@ultimat3/manifest 9.0.0 → 10.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 +15 -2
- package/README.md +3 -2
- package/package.json +6 -6
- package/src/build.ts +13 -1
- package/src/diff-entities.ts +11 -3
- package/src/emit.ts +14 -3
- package/src/errors.ts +6 -5
package/CLAUDE.md
CHANGED
|
@@ -32,7 +32,18 @@ by the CLI, not imported.
|
|
|
32
32
|
|
|
33
33
|
- **No nondeterminism.** No timestamp, git sha, hostname, counter, or unsorted iteration.
|
|
34
34
|
`buildManifest` is pure — it must never read a registry, a clock, or the filesystem.
|
|
35
|
-
- Top-level key order in the file is fixed by `KEY_ORDER` in `emit.ts
|
|
35
|
+
- Top-level key order in the file is fixed by `KEY_ORDER` in `emit.ts` — `as const satisfies
|
|
36
|
+
readonly (keyof Manifest)[]` AND walked by `emit.test.ts`, the treatment `ARRAY_SECTIONS` has,
|
|
37
|
+
because the annotation catches a key that is not on `Manifest` and only the walk catches one that
|
|
38
|
+
is MISSING. `manifestJson` writes those keys and no others while `contentHash` hashes the whole
|
|
39
|
+
body, so a 14th field would go into the hash and be dropped from the file — after which
|
|
40
|
+
`assertNoDrift` convicts the committed manifest as HAND_EDITED, a correct refusal with the wrong
|
|
41
|
+
diagnosis, about a file nobody touched.
|
|
42
|
+
- **Every author-declared inner list is sorted**, `routes[].revalidateTags` included (`As of
|
|
43
|
+
2026-08-23`). It was the one collection `buildManifest` left in declaration order, so reordering
|
|
44
|
+
`revalidate: [tag.b, tag.a]` in a route file churned `buildId` and emitted a
|
|
45
|
+
`routes.<url>.revalidateTags` change nothing had changed. `jobs[].steps` is the one exception and
|
|
46
|
+
says so at its own declaration: steps are a sequence, not a set.
|
|
36
47
|
- `buildId` = sha256 of `@ultimat3/core`'s `canonicalJson` over the body — the framework's one
|
|
37
48
|
INJECTIVE form, and the same one every `diff-*.ts` equality is taken over. It was a local
|
|
38
49
|
`JSON.stringify(sortKeys(v))` until 2026-08-22, exported from `index.ts` as `canonical`
|
|
@@ -98,7 +109,9 @@ by the CLI, not imported.
|
|
|
98
109
|
- **The axis is what a change refuses, not how it reads.** Something that rejects input that was
|
|
99
110
|
valid yesterday is breaking (an invariant added, a NOT NULL, a gained permission, a gained
|
|
100
111
|
enforcement site, a lowered `retry.attempts`); something that accepts more is additive and
|
|
101
|
-
still reported (an invariant dropped, a permission dropped, more attempts
|
|
112
|
+
still reported (an invariant dropped, a permission dropped, more attempts, **a column that lost
|
|
113
|
+
NOT NULL** — `diffColumns` implemented only the tightening half until 2026-08-23, so a loosened
|
|
114
|
+
column was reported as nothing at all). A removal is
|
|
102
115
|
breaking on every section, including the two that fail silently: a deleted task never runs
|
|
103
116
|
again and a job whose `queue` moved piles up where no worker is subscribed.
|
|
104
117
|
- **Absence is evidence only where absence has a meaning.** `primaryKey`/`references` absent IS
|
package/README.md
CHANGED
|
@@ -40,7 +40,8 @@ The file is committed and reviewed, so two builds of the same tree must produce
|
|
|
40
40
|
bytes. Enforced, not hoped for:
|
|
41
41
|
|
|
42
42
|
- **No timestamps, no git sha, no hostname, no build counter.**
|
|
43
|
-
- Every collection is sorted by a stable key before writing
|
|
43
|
+
- Every collection is sorted by a stable key before writing, inner lists included (a route's
|
|
44
|
+
`revalidateTags`, an action's `cacheInvalidates`, a task's `enqueues`) — `Map`/`Set` iteration order is
|
|
44
45
|
insertion order, and insertion order depends on module load order, which depends on the
|
|
45
46
|
filesystem.
|
|
46
47
|
- Object keys are written in a fixed order, not `JSON.stringify` order, so reordering a
|
|
@@ -59,7 +60,7 @@ whole mechanism.
|
|
|
59
60
|
| Class | Examples |
|
|
60
61
|
|---|---|
|
|
61
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 |
|
|
62
|
-
| **additive** | primitive added; nullable column added; 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 |
|
|
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 |
|
|
63
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` |
|
|
64
65
|
|
|
65
66
|
Every top-level section is classified, and that is checked rather than promised:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/manifest",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.0",
|
|
4
4
|
"description": "x.manifest.json: deterministic generated facts, contract diff, AGENTS.md budget",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/action": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/jobs": "
|
|
38
|
-
"@ultimat3/query": "
|
|
34
|
+
"@ultimat3/action": "10.0.0",
|
|
35
|
+
"@ultimat3/core": "10.0.0",
|
|
36
|
+
"@ultimat3/entity": "10.0.0",
|
|
37
|
+
"@ultimat3/jobs": "10.0.0",
|
|
38
|
+
"@ultimat3/query": "10.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/build.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface ManifestSources {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export function buildManifest(sources: ManifestSources): Manifest {
|
|
47
|
-
const routes = sortBy(sources.routes ?? [], (r) => r.url);
|
|
47
|
+
const routes = sortBy(sources.routes ?? [], (r) => r.url).map(normalizeRoute);
|
|
48
48
|
const entities = sortBy(sources.entities ?? [], (e) => e.name).map(normalizeEntity);
|
|
49
49
|
const actions = sortBy(sources.actions ?? [], (a) => a.name).map(normalizeAction);
|
|
50
50
|
const queries = sortBy(sources.queries ?? [], (q) => q.name).map(normalizeQuery);
|
|
@@ -117,6 +117,18 @@ function unique(values: readonly string[]): readonly string[] {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
// Inner collections are sorted too: a reordered column list is a spurious diff.
|
|
120
|
+
// `revalidateTags` is an author-declared SET — the same kind of list as an action's
|
|
121
|
+
// `cacheInvalidates`, which has always been sorted here. Left in declaration order it was the one
|
|
122
|
+
// inner collection `buildManifest` did not normalise, so reordering `revalidate: [tag.b, tag.a]`
|
|
123
|
+
// in a route file churned `buildId` and emitted a spurious `routes.<url>.revalidateTags` change.
|
|
124
|
+
// `jobs[].steps` is the one deliberate exception, and says why above itself.
|
|
125
|
+
const normalizeRoute = (route: RouteFact): RouteFact =>
|
|
126
|
+
route.revalidateTags === undefined
|
|
127
|
+
? route
|
|
128
|
+
: // Spread, never assigned: `exactOptionalPropertyTypes` makes an explicit `undefined` a
|
|
129
|
+
// different answer from an absent key, and `emit.ts` writes what it is given.
|
|
130
|
+
{ ...route, revalidateTags: [...route.revalidateTags].sort() };
|
|
131
|
+
|
|
120
132
|
const normalizeEntity = (entity: EntityFact): EntityFact => ({
|
|
121
133
|
...entity,
|
|
122
134
|
columns: sortBy(entity.columns, (c) => c.name),
|
package/src/diff-entities.ts
CHANGED
|
@@ -96,9 +96,17 @@ function diffColumns(
|
|
|
96
96
|
detail: `${column.type} -> ${next.type}`,
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
|
-
if (column.nullable
|
|
100
|
-
//
|
|
101
|
-
|
|
99
|
+
if (column.nullable !== next.nullable) {
|
|
100
|
+
// Both directions, the axis this file's header declares and `diffInvariants` already
|
|
101
|
+
// implements: tightening rejects rows that were valid a moment ago, loosening only widens
|
|
102
|
+
// what the table accepts — and a constraint that quietly stopped being enforced is what a
|
|
103
|
+
// reviewer of a data migration most needs to see. Only the tightening half was here, so
|
|
104
|
+
// dropping NOT NULL reported nothing at all.
|
|
105
|
+
changes.push(
|
|
106
|
+
next.nullable
|
|
107
|
+
? { kind: 'additive', path: `${at}.nullable`, detail: 'became nullable' }
|
|
108
|
+
: { kind: 'breaking', path: `${at}.nullable`, detail: 'became NOT NULL' },
|
|
109
|
+
);
|
|
102
110
|
}
|
|
103
111
|
changes.push(...diffKey(at, 'primaryKey', keyOf(column), keyOf(next)));
|
|
104
112
|
changes.push(...diffKey(at, 'references', column.references, next.references));
|
package/src/emit.ts
CHANGED
|
@@ -12,8 +12,19 @@ import { isManifest } from './schema';
|
|
|
12
12
|
|
|
13
13
|
export const MANIFEST_FILENAME = 'x.manifest.json';
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Top-level key order. Explicit so the file reads in a sensible order every time.
|
|
17
|
+
*
|
|
18
|
+
* `as const satisfies` and a test that WALKS it, the treatment `ARRAY_SECTIONS` already has
|
|
19
|
+
* (`schema.ts`) — the annotation catches a key that is not on `Manifest`, only a walk catches one
|
|
20
|
+
* that is missing. It was a bare annotation, and `manifestJson` writes these keys and no others
|
|
21
|
+
* while `contentHash` hashes the whole body: a 14th field added to `Manifest` would have gone into
|
|
22
|
+
* the hash and been dropped from the file, after which `assertNoDrift` convicts the committed
|
|
23
|
+
* manifest as HAND_EDITED — a correct refusal carrying the wrong diagnosis, about a file nobody
|
|
24
|
+
* touched. Exported for that test alone; deliberately NOT re-exported by `src/index.ts`, because
|
|
25
|
+
* the ORDER is this module's business and a public one would be semver-locked.
|
|
26
|
+
*/
|
|
27
|
+
export const KEY_ORDER = [
|
|
17
28
|
'manifestVersion',
|
|
18
29
|
'buildId',
|
|
19
30
|
'app',
|
|
@@ -27,7 +38,7 @@ const KEY_ORDER: readonly (keyof Manifest)[] = [
|
|
|
27
38
|
'permissions',
|
|
28
39
|
'locales',
|
|
29
40
|
'errorCodes',
|
|
30
|
-
];
|
|
41
|
+
] as const satisfies readonly (keyof Manifest)[];
|
|
31
42
|
|
|
32
43
|
/** The exact bytes written to disk. Deterministic for a given manifest. */
|
|
33
44
|
export function manifestJson(manifest: Manifest): string {
|
package/src/errors.ts
CHANGED
|
@@ -28,7 +28,12 @@ registerErrorCodes(
|
|
|
28
28
|
),
|
|
29
29
|
);
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
|
|
32
|
+
// which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
|
|
33
|
+
// `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
|
|
34
|
+
// which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
|
|
35
|
+
// answered 404, host included, on every error it has ever thrown; restating the replacement here
|
|
36
|
+
// would be the same constant in eight places waiting to drift again.
|
|
32
37
|
|
|
33
38
|
/**
|
|
34
39
|
* The committed `x.manifest.json` no longer matches the code. Drift means an agent reading
|
|
@@ -40,7 +45,6 @@ export class ManifestDriftError extends UltimateError {
|
|
|
40
45
|
code: 'X_MANIFEST_DRIFT',
|
|
41
46
|
cause: `${input.path} is stale: ${summarize(input.differences)}`,
|
|
42
47
|
fix: 'x manifest',
|
|
43
|
-
docs: docsFor('X_MANIFEST_DRIFT'),
|
|
44
48
|
});
|
|
45
49
|
}
|
|
46
50
|
}
|
|
@@ -54,7 +58,6 @@ export class ManifestBreakingError extends UltimateError {
|
|
|
54
58
|
`${input.changes.length} breaking change(s) from ${input.from} to ${input.to} ` +
|
|
55
59
|
`with no major version bump: ${summarize(input.changes)}`,
|
|
56
60
|
fix: 'bump the major version in app.config.ts, or restore the removed contract',
|
|
57
|
-
docs: docsFor('X_MANIFEST_BREAKING'),
|
|
58
61
|
});
|
|
59
62
|
}
|
|
60
63
|
}
|
|
@@ -69,7 +72,6 @@ export class AgentsMdMissingError extends UltimateError {
|
|
|
69
72
|
code: 'X_AGENTS_MD_MISSING',
|
|
70
73
|
cause: `${input.path} does not exist`,
|
|
71
74
|
fix: `create ${input.path} by hand: stack, commands, conventions. Keep it short; facts live in x.manifest.json`,
|
|
72
|
-
docs: docsFor('X_AGENTS_MD_MISSING'),
|
|
73
75
|
});
|
|
74
76
|
}
|
|
75
77
|
}
|
|
@@ -81,7 +83,6 @@ export class AgentsMdTooLargeError extends UltimateError {
|
|
|
81
83
|
code: 'X_AGENTS_MD_TOO_LARGE',
|
|
82
84
|
cause: `${input.path} is ${input.bytes}B, over the ${input.maxBytes}B budget`,
|
|
83
85
|
fix: 'move generated facts out of AGENTS.md and let x.manifest.json carry them',
|
|
84
|
-
docs: docsFor('X_AGENTS_MD_TOO_LARGE'),
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
88
|
}
|