@ultimat3/manifest 20.2.0 → 21.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/package.json +7 -6
- package/src/build.ts +13 -0
- package/src/diff-channels.ts +74 -0
- package/src/diff-operations.ts +11 -5
- package/src/diff.ts +3 -0
- package/src/emit.ts +1 -0
- package/src/index.ts +1 -0
- package/src/schema.ts +24 -0
- package/src/sources.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/manifest",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.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,11 @@
|
|
|
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": "21.0.0",
|
|
35
|
+
"@ultimat3/core": "21.0.0",
|
|
36
|
+
"@ultimat3/entity": "21.0.0",
|
|
37
|
+
"@ultimat3/jobs": "21.0.0",
|
|
38
|
+
"@ultimat3/query": "21.0.0",
|
|
39
|
+
"@ultimat3/realtime": "21.0.0"
|
|
39
40
|
}
|
|
40
41
|
}
|
package/src/build.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { canonicalJson } from '@ultimat3/core';
|
|
20
20
|
import type {
|
|
21
21
|
ActionFact,
|
|
22
|
+
ChannelFact,
|
|
22
23
|
EntityFact,
|
|
23
24
|
ErrorCodeFact,
|
|
24
25
|
JobFact,
|
|
@@ -36,6 +37,7 @@ export interface ManifestSources {
|
|
|
36
37
|
readonly entities?: readonly EntityFact[];
|
|
37
38
|
readonly actions?: readonly ActionFact[];
|
|
38
39
|
readonly queries?: readonly QueryFact[];
|
|
40
|
+
readonly channels?: readonly ChannelFact[];
|
|
39
41
|
readonly jobs?: readonly JobFact[];
|
|
40
42
|
readonly tasks?: readonly TaskFact[];
|
|
41
43
|
readonly policies?: readonly PolicyFact[];
|
|
@@ -48,6 +50,7 @@ export function buildManifest(sources: ManifestSources): Manifest {
|
|
|
48
50
|
const entities = sortBy(sources.entities ?? [], (e) => e.name).map(normalizeEntity);
|
|
49
51
|
const actions = sortBy(sources.actions ?? [], (a) => a.name).map(normalizeAction);
|
|
50
52
|
const queries = sortBy(sources.queries ?? [], (q) => q.name).map(normalizeQuery);
|
|
53
|
+
const channels = sortBy(sources.channels ?? [], (c) => c.name).map(normalizeChannel);
|
|
51
54
|
const jobs = sortBy(sources.jobs ?? [], (j) => j.name).map(normalizeJob);
|
|
52
55
|
const tasks = sortBy(sources.tasks ?? [], (t) => t.name).map((t) => ({
|
|
53
56
|
...t,
|
|
@@ -70,6 +73,7 @@ export function buildManifest(sources: ManifestSources): Manifest {
|
|
|
70
73
|
...policies.map((p) => p.permission),
|
|
71
74
|
...actions.flatMap((a) => a.permissions),
|
|
72
75
|
...queries.flatMap((q) => q.permissions),
|
|
76
|
+
...channels.flatMap((c) => c.permissions),
|
|
73
77
|
]);
|
|
74
78
|
|
|
75
79
|
const body = {
|
|
@@ -79,6 +83,7 @@ export function buildManifest(sources: ManifestSources): Manifest {
|
|
|
79
83
|
entities,
|
|
80
84
|
actions,
|
|
81
85
|
queries,
|
|
86
|
+
channels,
|
|
82
87
|
jobs,
|
|
83
88
|
tasks,
|
|
84
89
|
policies,
|
|
@@ -152,6 +157,14 @@ const normalizeQuery = (query: QueryFact): QueryFact => ({
|
|
|
152
157
|
...(query.subscribes === undefined ? {} : { subscribes: [...query.subscribes].sort() }),
|
|
153
158
|
});
|
|
154
159
|
|
|
160
|
+
// `params` keep their DECLARED order — it is the order a topic's segments are spelled in, so it is
|
|
161
|
+
// part of the wire contract, not a set.
|
|
162
|
+
const normalizeChannel = (channel: ChannelFact): ChannelFact => ({
|
|
163
|
+
...channel,
|
|
164
|
+
records: [...channel.records].sort(),
|
|
165
|
+
permissions: [...channel.permissions].sort(),
|
|
166
|
+
});
|
|
167
|
+
|
|
155
168
|
// Job steps keep their DECLARED order — a job's steps are a sequence, not a set, and
|
|
156
169
|
// sorting them would misrepresent the program.
|
|
157
170
|
const normalizeJob = (job: JobFact): JobFact => ({ ...job, steps: [...job.steps] });
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Realtime channels in a contract diff. A client derives a channel's topic from its NAME and
|
|
2
|
+
// PARAMS, so either moving breaks every page built against the old ones; its subscribe policy is
|
|
3
|
+
// judged like any operation's, on the permissions it requires.
|
|
4
|
+
|
|
5
|
+
import { canonicalJson } from '@ultimat3/core';
|
|
6
|
+
import type { ManifestChange } from './diff-change';
|
|
7
|
+
import { index } from './diff-change';
|
|
8
|
+
import { diffPermissions } from './diff-operations';
|
|
9
|
+
import type { ChannelFact } from './schema';
|
|
10
|
+
|
|
11
|
+
export function diffChannels(
|
|
12
|
+
before: readonly ChannelFact[],
|
|
13
|
+
after: readonly ChannelFact[],
|
|
14
|
+
): readonly ManifestChange[] {
|
|
15
|
+
const changes: ManifestChange[] = [];
|
|
16
|
+
const afterByName = index(after, (c) => c.name);
|
|
17
|
+
const beforeByName = index(before, (c) => c.name);
|
|
18
|
+
|
|
19
|
+
for (const channel of before) {
|
|
20
|
+
const next = afterByName.get(channel.name);
|
|
21
|
+
const path = `channels.${channel.name}`;
|
|
22
|
+
if (next === undefined) {
|
|
23
|
+
changes.push({ kind: 'breaking', path, detail: 'channel removed' });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (canonicalJson(channel.params) !== canonicalJson(next.params)) {
|
|
27
|
+
changes.push({ kind: 'breaking', path: `${path}.params`, detail: 'params changed' });
|
|
28
|
+
}
|
|
29
|
+
if (channel.policy !== next.policy) {
|
|
30
|
+
changes.push({
|
|
31
|
+
kind: 'breaking',
|
|
32
|
+
path: `${path}.policy`,
|
|
33
|
+
detail: `policy ${channel.policy ?? 'none'} -> ${next.policy ?? 'none'}`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
changes.push(...diffPermissions(path, channel, next));
|
|
37
|
+
// Losing a record type or the events stream breaks a page that renders them; gaining one
|
|
38
|
+
// breaks nobody.
|
|
39
|
+
for (const type of channel.records) {
|
|
40
|
+
if (!next.records.includes(type)) {
|
|
41
|
+
changes.push({
|
|
42
|
+
kind: 'breaking',
|
|
43
|
+
path: `${path}.records.${type}`,
|
|
44
|
+
detail: 'no longer carried',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const type of next.records) {
|
|
49
|
+
if (!channel.records.includes(type)) {
|
|
50
|
+
changes.push({ kind: 'additive', path: `${path}.records.${type}`, detail: 'now carried' });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (channel.events !== next.events) {
|
|
54
|
+
changes.push({
|
|
55
|
+
kind: next.events ? 'additive' : 'breaking',
|
|
56
|
+
path: `${path}.events`,
|
|
57
|
+
detail: `events ${String(channel.events)} -> ${String(next.events)}`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (channel.catchUp !== next.catchUp) {
|
|
61
|
+
changes.push({
|
|
62
|
+
kind: 'internal',
|
|
63
|
+
path: `${path}.catchUp`,
|
|
64
|
+
detail: `catch-up read ${channel.catchUp} -> ${next.catchUp}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const channel of after) {
|
|
69
|
+
if (!beforeByName.has(channel.name)) {
|
|
70
|
+
changes.push({ kind: 'additive', path: `channels.${channel.name}`, detail: 'channel added' });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return changes;
|
|
74
|
+
}
|
package/src/diff-operations.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// The two callable surfaces — actions and queries — and the permissions they require.
|
|
1
|
+
// The two callable surfaces — actions and queries — and the permissions they require. The
|
|
2
|
+
// permission rule is exported: a channel's subscribe requires permissions the same way.
|
|
2
3
|
|
|
3
4
|
import { canonicalJson, isMcpExposed } from '@ultimat3/core';
|
|
4
5
|
import type { ManifestChange } from './diff-change';
|
|
@@ -6,6 +7,11 @@ import { diffScalar, index } from './diff-change';
|
|
|
6
7
|
import { diffRateLimit } from './diff-rate-limit';
|
|
7
8
|
import type { ActionFact, QueryFact } from './schema';
|
|
8
9
|
|
|
10
|
+
/** Any fact that names the permissions it requires — an action, a query, a channel. */
|
|
11
|
+
export interface PermissionBearing {
|
|
12
|
+
readonly permissions: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
export function diffActions(
|
|
10
16
|
before: readonly ActionFact[],
|
|
11
17
|
after: readonly ActionFact[],
|
|
@@ -165,10 +171,10 @@ export function diffQueries(
|
|
|
165
171
|
* (`and(post:publish, org:administer)`) equals no permission, so a rule reading it would call
|
|
166
172
|
* every non-trivially-guarded operation unchanged while both of its real grants moved.
|
|
167
173
|
*/
|
|
168
|
-
function diffPermissions(
|
|
174
|
+
export function diffPermissions(
|
|
169
175
|
path: string,
|
|
170
|
-
before:
|
|
171
|
-
after:
|
|
176
|
+
before: PermissionBearing,
|
|
177
|
+
after: PermissionBearing,
|
|
172
178
|
): readonly ManifestChange[] {
|
|
173
179
|
const declared = readPermissions(before);
|
|
174
180
|
const next = readPermissions(after);
|
|
@@ -204,7 +210,7 @@ function diffPermissions(
|
|
|
204
210
|
}
|
|
205
211
|
|
|
206
212
|
/** The list as the FILE carries it, or `undefined` when it carries nothing this can compare. */
|
|
207
|
-
function readPermissions(fact:
|
|
213
|
+
function readPermissions(fact: PermissionBearing): readonly string[] | undefined {
|
|
208
214
|
const value: unknown = fact.permissions;
|
|
209
215
|
if (!Array.isArray(value)) return undefined;
|
|
210
216
|
return value.every((entry) => typeof entry === 'string')
|
package/src/diff.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { ManifestChange } from './diff-change';
|
|
15
15
|
import { diffNamedSet } from './diff-change';
|
|
16
|
+
import { diffChannels } from './diff-channels';
|
|
16
17
|
import { diffEntities } from './diff-entities';
|
|
17
18
|
import { diffActions, diffQueries } from './diff-operations';
|
|
18
19
|
import { diffErrorCodes, diffPolicies } from './diff-registries';
|
|
@@ -46,6 +47,8 @@ export function diffManifest(before: Manifest, after: Manifest): ManifestDiff {
|
|
|
46
47
|
|
|
47
48
|
changes.push(...diffActions(before.actions, after.actions));
|
|
48
49
|
changes.push(...diffQueries(before.queries, after.queries));
|
|
50
|
+
// `?? []`: a file written before channels were projected carries none, which is not a removal.
|
|
51
|
+
changes.push(...diffChannels(before.channels ?? [], after.channels ?? []));
|
|
49
52
|
changes.push(...diffRoutes(before.routes, after.routes));
|
|
50
53
|
changes.push(...diffJobs(before.jobs, after.jobs));
|
|
51
54
|
changes.push(...diffTasks(before.tasks, after.tasks));
|
package/src/emit.ts
CHANGED
package/src/index.ts
CHANGED
package/src/schema.ts
CHANGED
|
@@ -110,6 +110,23 @@ export interface QueryFact {
|
|
|
110
110
|
readonly cacheTags: readonly string[];
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* A declared realtime `channel()`: what a client subscribes by, and what its policy requires.
|
|
115
|
+
* Its NAME and PARAMS are the wire contract — a client spells neither, it derives both from the
|
|
116
|
+
* declaration — so a change to either is breaking for every page built against the old ones.
|
|
117
|
+
*/
|
|
118
|
+
export interface ChannelFact {
|
|
119
|
+
readonly name: string;
|
|
120
|
+
readonly params: readonly string[];
|
|
121
|
+
/** The query a client re-reads on `replay-gap`. */
|
|
122
|
+
readonly catchUp: string;
|
|
123
|
+
/** Record types (entity names) the channel carries. */
|
|
124
|
+
readonly records: readonly string[];
|
|
125
|
+
readonly events: boolean;
|
|
126
|
+
readonly policy: string | null;
|
|
127
|
+
readonly permissions: readonly string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
113
130
|
export interface JobFact {
|
|
114
131
|
readonly name: string;
|
|
115
132
|
readonly input: JsonValue;
|
|
@@ -156,6 +173,11 @@ export interface Manifest {
|
|
|
156
173
|
readonly entities: readonly EntityFact[];
|
|
157
174
|
readonly actions: readonly ActionFact[];
|
|
158
175
|
readonly queries: readonly QueryFact[];
|
|
176
|
+
/**
|
|
177
|
+
* Optional to a READER only: a file written before channels were projected has none, and that
|
|
178
|
+
* is "no channels", never an unreadable manifest. `buildManifest` always writes it.
|
|
179
|
+
*/
|
|
180
|
+
readonly channels?: readonly ChannelFact[];
|
|
159
181
|
readonly jobs: readonly JobFact[];
|
|
160
182
|
readonly tasks: readonly TaskFact[];
|
|
161
183
|
readonly policies: readonly PolicyFact[];
|
|
@@ -212,6 +234,8 @@ export function isManifest(value: unknown): value is Manifest {
|
|
|
212
234
|
const m = value as Record<string, unknown>;
|
|
213
235
|
if (typeof m['manifestVersion'] !== 'number' || typeof m['buildId'] !== 'string') return false;
|
|
214
236
|
for (const section of ARRAY_SECTIONS) if (!Array.isArray(m[section])) return false;
|
|
237
|
+
// Absent is a pre-channel file and readable; present and not an array is a damaged one.
|
|
238
|
+
if (m['channels'] !== undefined && !Array.isArray(m['channels'])) return false;
|
|
215
239
|
return isAppIdentity(m['app']);
|
|
216
240
|
}
|
|
217
241
|
|
package/src/sources.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { describeActions } from '@ultimat3/action';
|
|
|
9
9
|
import { describeEntities } from '@ultimat3/entity';
|
|
10
10
|
import { describeJobs } from '@ultimat3/jobs';
|
|
11
11
|
import { describeQueries } from '@ultimat3/query';
|
|
12
|
+
import { describeChannels } from '@ultimat3/realtime/server';
|
|
12
13
|
import type { ManifestSources } from './build';
|
|
13
14
|
import type { ErrorCodeFact, JsonValue, PolicyFact, RouteFact, TaskFact } from './schema';
|
|
14
15
|
|
|
@@ -86,6 +87,17 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
|
|
|
86
87
|
...(query.subscribes === null ? {} : { subscribes: query.subscribes }),
|
|
87
88
|
cacheTags: query.tags,
|
|
88
89
|
})),
|
|
90
|
+
// Already the fact's shape, sorted and flattened by `describeChannels` — projected field by
|
|
91
|
+
// field all the same, for the reason above.
|
|
92
|
+
channels: describeChannels().map((channel) => ({
|
|
93
|
+
name: channel.name,
|
|
94
|
+
params: channel.params,
|
|
95
|
+
catchUp: channel.catchUp,
|
|
96
|
+
records: channel.records,
|
|
97
|
+
events: channel.events,
|
|
98
|
+
policy: channel.policy,
|
|
99
|
+
permissions: channel.permissions,
|
|
100
|
+
})),
|
|
89
101
|
jobs: describeJobs().map((job) => ({
|
|
90
102
|
name: job.name,
|
|
91
103
|
input: asJson(job.input),
|