@ultimat3/manifest 20.2.1 → 22.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/README.md CHANGED
@@ -150,3 +150,15 @@ never writes, and it never synthesises a sentence.
150
150
  | `X_MANIFEST_BREAKING` | contract broke with no major bump | bump the version, or restore |
151
151
  | `X_AGENTS_MD_MISSING` | no `AGENTS.md` | write one by hand |
152
152
  | `X_AGENTS_MD_TOO_LARGE` | over the byte budget | move facts to `x.manifest.json` |
153
+
154
+ ### Error classes
155
+
156
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
157
+ a job boundary the class is gone and the `code` is what survives — match on that.
158
+
159
+ | Class | Code | Declared in |
160
+ |---|---|---|
161
+ | `AgentsMdMissingError` | `X_AGENTS_MD_MISSING` | `src/errors.ts` |
162
+ | `AgentsMdTooLargeError` | `X_AGENTS_MD_TOO_LARGE` | `src/errors.ts` |
163
+ | `ManifestBreakingError` | `X_MANIFEST_BREAKING` | `src/errors.ts` |
164
+ | `ManifestDriftError` | `X_MANIFEST_DRIFT` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/manifest",
3
- "version": "20.2.1",
3
+ "version": "22.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": "22.0.0",
35
+ "@ultimat3/core": "22.0.0",
36
+ "@ultimat3/entity": "22.0.0",
37
+ "@ultimat3/jobs": "22.0.0",
38
+ "@ultimat3/query": "22.0.0",
39
+ "@ultimat3/realtime": "22.0.0"
39
40
  }
40
41
  }
package/src/agents-md.ts CHANGED
@@ -81,13 +81,31 @@ export async function assertAgentsMd(input: CheckAgentsMdInput = {}): Promise<Ag
81
81
  return check;
82
82
  }
83
83
 
84
+ /** A header naming a column of generated facts. A conventions table (`| Rule | Detail |`) names none. */
85
+ const FACT_COLUMN = /\b(column|entity|entities|route|routes|endpoint|endpoints|table|type)\b/i;
86
+
87
+ /** A table separator row: `| --- | :-: |`, pipes, dashes, colons and spaces only. */
88
+ const SEPARATOR = /^\s*\|[\s|:-]+\|\s*$/;
89
+
90
+ /**
91
+ * The HEADER row of every markdown table — the line directly above a separator. The columns are
92
+ * what a table tabulates; a word in a cell is prose about a convention, which is what the file is
93
+ * for, and warning on it flagged the scaffold's own conventions table on every app (slice 11 i).
94
+ */
95
+ const tableHeaders = (text: string): readonly string[] => {
96
+ const lines = text.split('\n');
97
+ return lines.filter(
98
+ (line, index) => line.trim().startsWith('|') && SEPARATOR.test(lines[index + 1] ?? ''),
99
+ );
100
+ };
101
+
84
102
  /**
85
103
  * Warn when the file has started duplicating generated facts. Duplication is how a context
86
104
  * file goes stale: the copy and the source drift, and the agent believes the copy.
87
105
  */
88
106
  function warningsFor(text: string): readonly string[] {
89
107
  const warnings: string[] = [];
90
- if (/^\s*\|.*\|\s*$/m.test(text) && /\b(column|entity|route|endpoint)\b/i.test(text)) {
108
+ if (tableHeaders(text).some((header) => FACT_COLUMN.test(header))) {
91
109
  warnings.push('looks like it tabulates schema or route facts; those live in x.manifest.json');
92
110
  }
93
111
  if (text.split('\n').length > 200) {
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} -> ${next.policy}`,
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
+ }
@@ -115,10 +115,17 @@ function diffColumns(
115
115
  const beforeColumns = index(before.columns, (c) => c.name);
116
116
  for (const column of after.columns) {
117
117
  if (!beforeColumns.has(column.name)) {
118
+ // A default is what makes a NOT NULL column safe to add: every existing row takes it and no
119
+ // writer that omits the column is refused.
120
+ const additive = column.nullable || column.hasDefault === true;
118
121
  changes.push({
119
- kind: column.nullable ? 'additive' : 'breaking',
122
+ kind: additive ? 'additive' : 'breaking',
120
123
  path: `${path}.columns.${column.name}`,
121
- detail: column.nullable ? 'column added' : 'NOT NULL column added with no default',
124
+ detail: column.nullable
125
+ ? 'column added'
126
+ : additive
127
+ ? 'column added with a default'
128
+ : 'NOT NULL column added with no default',
122
129
  });
123
130
  }
124
131
  }
@@ -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
@@ -16,8 +16,6 @@ export { diffManifest, formatDiff } from './diff';
16
16
  export type { DocEntry, DocEntryKind } from './docs-scan';
17
17
  export {
18
18
  headerComment,
19
- parseGuideSections,
20
- parseReExports,
21
19
  scanInstalledDocs,
22
20
  scanPackageDocs,
23
21
  shortName,
@@ -44,6 +42,7 @@ export {
44
42
  } from './errors';
45
43
  export type {
46
44
  ActionFact,
45
+ ChannelFact,
47
46
  ColumnFact,
48
47
  EntityFact,
49
48
  ErrorCodeFact,
package/src/schema.ts CHANGED
@@ -47,6 +47,12 @@ export interface ColumnFact {
47
47
  readonly nullable: boolean;
48
48
  readonly primaryKey?: boolean;
49
49
  readonly references?: string;
50
+ /**
51
+ * The column has a declared default (a value, `defaultNow()`, a generated key). Written only when
52
+ * true, so a column without one reads as it always did. Adding a NOT NULL column WITH a default
53
+ * is additive — every existing row takes the default — and was classed breaking without this.
54
+ */
55
+ readonly hasDefault?: boolean;
50
56
  }
51
57
 
52
58
  export interface EntityFact {
@@ -110,6 +116,24 @@ export interface QueryFact {
110
116
  readonly cacheTags: readonly string[];
111
117
  }
112
118
 
119
+ /**
120
+ * A declared realtime `channel()`: what a client subscribes by, and what its policy requires.
121
+ * Its NAME and PARAMS are the wire contract — a client spells neither, it derives both from the
122
+ * declaration — so a change to either is breaking for every page built against the old ones.
123
+ */
124
+ export interface ChannelFact {
125
+ readonly name: string;
126
+ readonly params: readonly string[];
127
+ /** The query a client re-reads on `replay-gap`. */
128
+ readonly catchUp: string;
129
+ /** Record types (entity names) the channel carries. */
130
+ readonly records: readonly string[];
131
+ readonly events: boolean;
132
+ /** The policy's label. Never null since 22.0.0 — `channel()` requires a policy. */
133
+ readonly policy: string;
134
+ readonly permissions: readonly string[];
135
+ }
136
+
113
137
  export interface JobFact {
114
138
  readonly name: string;
115
139
  readonly input: JsonValue;
@@ -156,6 +180,11 @@ export interface Manifest {
156
180
  readonly entities: readonly EntityFact[];
157
181
  readonly actions: readonly ActionFact[];
158
182
  readonly queries: readonly QueryFact[];
183
+ /**
184
+ * Optional to a READER only: a file written before channels were projected has none, and that
185
+ * is "no channels", never an unreadable manifest. `buildManifest` always writes it.
186
+ */
187
+ readonly channels?: readonly ChannelFact[];
159
188
  readonly jobs: readonly JobFact[];
160
189
  readonly tasks: readonly TaskFact[];
161
190
  readonly policies: readonly PolicyFact[];
@@ -212,6 +241,8 @@ export function isManifest(value: unknown): value is Manifest {
212
241
  const m = value as Record<string, unknown>;
213
242
  if (typeof m['manifestVersion'] !== 'number' || typeof m['buildId'] !== 'string') return false;
214
243
  for (const section of ARRAY_SECTIONS) if (!Array.isArray(m[section])) return false;
244
+ // Absent is a pre-channel file and readable; present and not an array is a damaged one.
245
+ if (m['channels'] !== undefined && !Array.isArray(m['channels'])) return false;
215
246
  return isAppIdentity(m['app']);
216
247
  }
217
248
 
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
 
@@ -53,6 +54,7 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
53
54
  type: column.kind,
54
55
  nullable: !column.notNull,
55
56
  primaryKey: column.primaryKey,
57
+ ...(column.hasDefault ? { hasDefault: true } : {}),
56
58
  ...(column.references === null ? {} : { references: column.references }),
57
59
  })),
58
60
  invariants: entity.invariants.map((invariant) => invariant.name),
@@ -86,6 +88,17 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
86
88
  ...(query.subscribes === null ? {} : { subscribes: query.subscribes }),
87
89
  cacheTags: query.tags,
88
90
  })),
91
+ // Already the fact's shape, sorted and flattened by `describeChannels` — projected field by
92
+ // field all the same, for the reason above.
93
+ channels: describeChannels().map((channel) => ({
94
+ name: channel.name,
95
+ params: channel.params,
96
+ catchUp: channel.catchUp,
97
+ records: channel.records,
98
+ events: channel.events,
99
+ policy: channel.policy,
100
+ permissions: channel.permissions,
101
+ })),
89
102
  jobs: describeJobs().map((job) => ({
90
103
  name: job.name,
91
104
  input: asJson(job.input),