@ultimat3/manifest 20.2.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/manifest",
3
- "version": "20.2.1",
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": "20.2.1",
35
- "@ultimat3/core": "20.2.1",
36
- "@ultimat3/entity": "20.2.1",
37
- "@ultimat3/jobs": "20.2.1",
38
- "@ultimat3/query": "20.2.1"
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
+ }
@@ -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: ActionFact | QueryFact,
171
- after: ActionFact | QueryFact,
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: ActionFact | QueryFact): readonly string[] | undefined {
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
@@ -32,6 +32,7 @@ export const KEY_ORDER = [
32
32
  'entities',
33
33
  'actions',
34
34
  'queries',
35
+ 'channels',
35
36
  'jobs',
36
37
  'tasks',
37
38
  'policies',
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export {
44
44
  } from './errors';
45
45
  export type {
46
46
  ActionFact,
47
+ ChannelFact,
47
48
  ColumnFact,
48
49
  EntityFact,
49
50
  ErrorCodeFact,
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),