@ultimat3/manifest 23.0.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/manifest",
3
- "version": "23.0.0",
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",
@@ -32,11 +32,11 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "23.0.0",
36
- "@ultimat3/core": "23.0.0",
37
- "@ultimat3/entity": "23.0.0",
38
- "@ultimat3/jobs": "23.0.0",
39
- "@ultimat3/query": "23.0.0",
40
- "@ultimat3/realtime": "23.0.0"
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"
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 { canonicalJson } from '@ultimat3/core';
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,22 @@ export function buildManifest(sources: ManifestSources): Manifest {
96
97
  errorCodes,
97
98
  };
98
99
 
100
+ // Before the hash: a fact the written file cannot hold must never get a buildId at all.
101
+ assertFiniteFacts(body, '');
99
102
  return { ...body, buildId: contentHash(body) };
100
103
  }
101
104
 
102
105
  /**
103
- * Content hash of the manifest body. Deliberately excludes `buildId` itself, and is taken over
104
- * `@ultimat3/core`'s `canonicalJson` — the same INJECTIVE form the diff compares on, so a fact
105
- * that changed cannot hash the same as the fact it replaced.
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.
106
110
  *
107
111
  * This is a HASH, never the published document: `manifestJson` in `emit.ts` is what reaches disk,
108
112
  * and it is `JSON.stringify` with a fixed key order for exactly that reason.
109
113
  */
110
114
  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);
115
+ return fingerprint(body);
114
116
  }
115
117
 
116
118
  function sortBy<T>(items: readonly T[], key: (item: T) => string): readonly T[] {
@@ -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
@@ -49,7 +49,7 @@ export const FIXTURE: ManifestSources = {
49
49
  offline: 'precache',
50
50
  hydrate: 'idle',
51
51
  revalidateTags: ['post'],
52
- budget: { js: '40kb', lcp: 2000 },
52
+ budget: { js: '40kb' },
53
53
  surface: 'site',
54
54
  },
55
55
  ],
@@ -117,7 +117,13 @@ export function diffQueries(
117
117
  changes.push({ kind: 'breaking', path, detail: 'query removed' });
118
118
  continue;
119
119
  }
120
- if (canonicalJson(query.input) !== canonicalJson(next.input)) {
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/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,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; readonly lcp?: number };
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
  }
@@ -173,7 +173,11 @@ export interface ActionFact {
173
173
 
174
174
  export interface QueryFact {
175
175
  readonly name: string;
176
- /** Optional: `QueryDescriptor` is schema-erased, so a live query may not expose one. */
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
+ */
177
181
  readonly input?: JsonValue;
178
182
  /** The policy's DISPLAY label — see `ActionFact.policy`, and read `permissions` to match on. */
179
183
  readonly policy: string | null;
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 {
@@ -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,