@ultimat3/manifest 29.3.0 → 29.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/manifest",
3
- "version": "29.3.0",
3
+ "version": "29.3.2",
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": "29.3.0",
36
- "@ultimat3/core": "29.3.0",
37
- "@ultimat3/entity": "29.3.0",
38
- "@ultimat3/jobs": "29.3.0",
39
- "@ultimat3/query": "29.3.0",
40
- "@ultimat3/realtime": "29.3.0"
35
+ "@ultimat3/action": "29.3.2",
36
+ "@ultimat3/core": "29.3.2",
37
+ "@ultimat3/entity": "29.3.2",
38
+ "@ultimat3/jobs": "29.3.2",
39
+ "@ultimat3/query": "29.3.2",
40
+ "@ultimat3/realtime": "29.3.2"
41
41
  }
42
42
  }
@@ -0,0 +1,54 @@
1
+ // An input schema's change, classified (`diffActions`, `diffQueries`, `diffJobs`): byte-identical
2
+ // is no change; only ADDING optional top-level properties to an object schema is `additive`, since
3
+ // every payload the old (closed) schema accepted the new one accepts; anything else is `breaking`.
4
+ //
5
+ // Deliberately shallow: only the top level is compared, so an optional property added INSIDE a
6
+ // nested object still reads as breaking — over-reporting is the safe side of a contract check.
7
+
8
+ import { canonicalJson } from '@ultimat3/core';
9
+ import type { ManifestChange } from './diff-change';
10
+
11
+ type JsonObject = Readonly<Record<string, unknown>>;
12
+
13
+ const isObject = (value: unknown): value is JsonObject =>
14
+ typeof value === 'object' && value !== null && !Array.isArray(value);
15
+
16
+ const names = (value: unknown): readonly string[] =>
17
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
18
+
19
+ /** True when `after` accepts every payload `before` does, by only adding optional properties. */
20
+ export function onlyAddsOptionalProperties(before: unknown, after: unknown): boolean {
21
+ if (!isObject(before) || !isObject(after)) return false;
22
+ // Only a CLOSED schema: an open one (`additionalProperties` true or absent) already accepted any
23
+ // extra key, so a new typed property can refuse a payload it used to take (`{ trigger: 123 }`).
24
+ if (before['additionalProperties'] !== false) return false;
25
+ const beforeProps = before['properties'];
26
+ const afterProps = after['properties'];
27
+ if (!isObject(beforeProps) || !isObject(afterProps)) return false;
28
+ // Everything but `properties` and `required` must be the same (type, additionalProperties, …).
29
+ const { properties: _bp, required: beforeRequired, ...beforeRest } = before;
30
+ const { properties: _ap, required: afterRequired, ...afterRest } = after;
31
+ if (canonicalJson(beforeRest) !== canonicalJson(afterRest)) return false;
32
+ // No property removed or changed.
33
+ for (const [name, schema] of Object.entries(beforeProps)) {
34
+ if (!(name in afterProps)) return false;
35
+ if (canonicalJson(schema) !== canonicalJson(afterProps[name])) return false;
36
+ }
37
+ // Nothing newly required — an old property or a new one.
38
+ const wasRequired = new Set(names(beforeRequired));
39
+ return names(afterRequired).every((name) => wasRequired.has(name));
40
+ }
41
+
42
+ /** The change between two input schemas at `path`, or none. */
43
+ export function diffInput(
44
+ path: string,
45
+ before: unknown,
46
+ after: unknown,
47
+ breakingDetail: string,
48
+ ): readonly ManifestChange[] {
49
+ if (canonicalJson(before) === canonicalJson(after)) return [];
50
+ if (onlyAddsOptionalProperties(before, after)) {
51
+ return [{ kind: 'additive', path, detail: 'input gained optional properties' }];
52
+ }
53
+ return [{ kind: 'breaking', path, detail: breakingDetail }];
54
+ }
@@ -4,6 +4,7 @@
4
4
  import { canonicalJson, isMcpExposed } from '@ultimat3/core';
5
5
  import type { ManifestChange } from './diff-change';
6
6
  import { diffScalar, index } from './diff-change';
7
+ import { diffInput } from './diff-input';
7
8
  import { diffRateLimit } from './diff-rate-limit';
8
9
  import type { ActionFact, QueryFact } from './schema';
9
10
 
@@ -28,9 +29,7 @@ export function diffActions(
28
29
  changes.push({ kind: 'breaking', path, detail: 'action removed' });
29
30
  continue;
30
31
  }
31
- if (canonicalJson(action.input) !== canonicalJson(next.input)) {
32
- changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
33
- }
32
+ changes.push(...diffInput(`${path}.input`, action.input, next.input, 'input schema changed'));
34
33
  if (canonicalJson(action.output) !== canonicalJson(next.output)) {
35
34
  changes.push({ kind: 'breaking', path: `${path}.output`, detail: 'output schema changed' });
36
35
  }
@@ -119,12 +118,8 @@ export function diffQueries(
119
118
  }
120
119
  // Both sides or neither: a manifest committed before queries published their input carries
121
120
  // 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
- ) {
127
- changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
121
+ if (query.input !== undefined && next.input !== undefined) {
122
+ changes.push(...diffInput(`${path}.input`, query.input, next.input, 'input schema changed'));
128
123
  }
129
124
  if (query.policy !== next.policy) {
130
125
  changes.push({
package/src/diff-work.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { canonicalJson } from '@ultimat3/core';
6
6
  import type { ManifestChange } from './diff-change';
7
7
  import { diffScalar, index } from './diff-change';
8
+ import { diffInput } from './diff-input';
8
9
  import type { JobFact, TaskFact } from './schema';
9
10
 
10
11
  export function diffJobs(
@@ -23,13 +24,15 @@ export function diffJobs(
23
24
  changes.push({ kind: 'breaking', path, detail: 'job removed' });
24
25
  continue;
25
26
  }
26
- if (canonicalJson(job.input) !== canonicalJson(next.input)) {
27
- changes.push({
28
- kind: 'breaking',
29
- path: `${path}.input`,
30
- detail: 'input schema changed; in-flight payloads will not parse',
31
- });
32
- }
27
+ // An optional property added is additive: every in-flight payload still parses.
28
+ changes.push(
29
+ ...diffInput(
30
+ `${path}.input`,
31
+ job.input,
32
+ next.input,
33
+ 'input schema changed; in-flight payloads will not parse',
34
+ ),
35
+ );
33
36
  // The same failure as a removal, from the worker's side: a fleet subscribed to the old queue
34
37
  // keeps running and stops receiving this job, and the rows pile up where nobody drains them.
35
38
  changes.push(