@ultimat3/manifest 22.14.0 → 23.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
@@ -13,6 +13,7 @@ by the CLI, not imported.
13
13
  | `schema.ts` | the manifest's typed shape + `MANIFEST_VERSION` |
14
14
  | `build.ts` | `buildManifest` — pure, deterministic, stably sorted |
15
15
  | `sources.ts` | wires `describe*` from entity/action/query/jobs into `ManifestSources` |
16
+ | `sources-admin.ts` | the declared admins, read off `Symbol.for('ultimate.admin.mounts')` through each one's own `describe()` — never an import of `@ultimat3/admin`. `unknown` in, field by field: a description this build does not understand is skipped whole |
16
17
  | `diff.ts` | `diffManifest` — the orchestrator: one classifier per section, nothing else |
17
18
  | `diff-change.ts` | the shared vocabulary: `ManifestChange`, `index`, `diffNamedSet`, `diffScalar` |
18
19
  | `diff-operations.ts` | actions, queries and the permissions they require |
@@ -20,6 +21,7 @@ by the CLI, not imported.
20
21
  | `diff-entities.ts` | tables, columns, keys, invariants |
21
22
  | `diff-work.ts` | jobs and tasks — the two things that fail by silently not happening |
22
23
  | `diff-routes.ts` | a URL's surface and its delivery facts |
24
+ | `diff-admin.ts` | the `admin` section: a filter, sort, scope, resource or mounted route removed is breaking; a default scope moving or a row scope appearing is breaking; a route's permissions are judged like an operation's |
23
25
  | `diff-registries.ts` | policies and error codes |
24
26
  | `diff-fixtures.ts` | TEST-ONLY: one fully-populated `ManifestSources`. Never in `index.ts` |
25
27
  | `verify.ts` | `verifyContract` — the major-bump gate |
package/README.md CHANGED
@@ -20,7 +20,8 @@ verifyContract({ before: committed, after: manifest });
20
20
  | `entities` | table, columns (type, nullability, PK, FK), named invariants |
21
21
  | `actions` | input + output schema, policy label, **required permissions**, cache invalidations, declared rate limit, MCP exposure, `mutator` when it is one |
22
22
  | `queries` | input schema, policy label, **required permissions**, live, cache tags |
23
- | `jobs` | input schema, queue, retry policy, step names |
23
+ | `admin` | one entry per `defineAdmin()`: `basePath`; each resource's `filters`, `sorts`, `scopes` (`name`, `default`, `count`) and `rowScoped`; each mounted route's `url`, `view`, `entity` and `permissions`. `[]` for an app with no admin. Read off the process's own declarations — the one section the CLI does not inject |
24
+ | `jobs` | input schema, queue, retry policy, step names, and — only when the job declares a cap — `concurrency: { limit, keyed, whenBusy }` |
24
25
  | `tasks` | cron, tz, jobs enqueued |
25
26
  | `policies` | permission, where enforced |
26
27
  | `permissions` | **derived** from policies + each operation's own list, never declared twice |
@@ -59,9 +60,9 @@ whole mechanism.
59
60
 
60
61
  | Class | Examples |
61
62
  |---|---|
62
- | **breaking** | action/query/route/job/**task**/entity/**policy**/**error code** removed; input or output schema changed; policy changed; **an operation gained a required permission**; **a policy gained an enforcement site**; **a rate limit was tightened or introduced**; MCP exposure withdrawn; column removed, retyped, or made NOT NULL; **a column's `primaryKey` or `references` changed in either direction**; **an entity's `table` renamed**; **an invariant added**; **a job's `queue` moved or its `retry.attempts` lowered**; **a route's `surface` changed**; live query became non-live |
63
- | **additive** | primitive added; nullable column added; **a column that lost NOT NULL**; a required permission dropped; an enforcement site dropped; **an invariant dropped**; a rate limit loosened or removed; **more retry attempts**; MCP exposure granted; locale added |
64
- | **internal** | cache tags changed (actions **and queries**); render mode changed; **a route's `offline`/`hydrate`/`budget`/`revalidateTags`**; **a task's `cron`/`tz`/`enqueues`**; **a job's `retry.backoff`**; job steps reordered; **an error code's owning package**; `buildId` |
63
+ | **breaking** | action/query/route/job/**task**/entity/**policy**/**error code** removed; input or output schema changed; policy changed; **an operation gained a required permission**; **a policy gained an enforcement site**; **a rate limit was tightened or introduced**; MCP exposure withdrawn; column removed, retyped, or made NOT NULL; **a column sealed, unsealed, or moved from `lookup` to opaque** (sealing removes the field from every output; unsealing leaves stored values ciphertext); **a column's `primaryKey` or `references` changed in either direction**; **an entity's `table` renamed**; **an invariant added**; **a job's `queue` moved or its `retry.attempts` lowered**; **a route's `surface` changed**; live query became non-live; **an admin, an admin resource or an admin route removed; an admin list's filter, sort or scope removed; its default scope moved; a row scope introduced** |
64
+ | **additive** | primitive added; nullable column added; **a column that lost NOT NULL**; **a sealed column moved from opaque to `lookup`** (reported: equal values now store equal strings); a required permission dropped; an enforcement site dropped; **an invariant dropped**; a rate limit loosened or removed; **more retry attempts**; MCP exposure granted; locale added; an admin, resource, route, filter, sort or scope added; a row scope removed |
65
+ | **internal** | cache tags changed (actions **and queries**); render mode changed; **a route's `offline`/`hydrate`/`budget`/`revalidateTags`**; **a task's `cron`/`tz`/`enqueues`**; **a job's `retry.backoff`**; **a job's `concurrency`, declared, edited or removed**; job steps reordered; **an error code's owning package**; `buildId` |
65
66
 
66
67
  Every top-level section is classified, and that is checked rather than promised:
67
68
  `diff.test.ts` walks `ARRAY_SECTIONS` from `schema.ts` and fails on a section nothing reads. Two
@@ -70,7 +71,7 @@ every scheduled task reported `internal buildId: content changed` and passed the
70
71
 
71
72
  One classifier per section, each in its own file: `diff-operations.ts` (actions, queries,
72
73
  permissions), `diff-rate-limit.ts`, `diff-entities.ts`, `diff-work.ts` (jobs, tasks),
73
- `diff-routes.ts`, `diff-registries.ts` (policies, error codes), over the shared vocabulary in
74
+ `diff-routes.ts`, `diff-admin.ts`, `diff-registries.ts` (policies, error codes), over the shared vocabulary in
74
75
  `diff-change.ts`. `diff.ts` is the orchestrator and nothing else.
75
76
 
76
77
  `verifyContract()` is the gate: a breaking change fails unless the app's **major** version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/manifest",
3
- "version": "22.14.0",
3
+ "version": "23.0.0",
4
4
  "description": "x.manifest.json: deterministic generated facts, contract diff, AGENTS.md budget",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
22
23
  "CLAUDE.md",
23
24
  "README.md",
24
25
  "LICENSE"
@@ -31,11 +32,11 @@
31
32
  "test": "bun test"
32
33
  },
33
34
  "dependencies": {
34
- "@ultimat3/action": "22.14.0",
35
- "@ultimat3/core": "22.14.0",
36
- "@ultimat3/entity": "22.14.0",
37
- "@ultimat3/jobs": "22.14.0",
38
- "@ultimat3/query": "22.14.0",
39
- "@ultimat3/realtime": "22.14.0"
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"
40
41
  }
41
42
  }
package/src/build.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  import { canonicalJson } from '@ultimat3/core';
20
20
  import type {
21
21
  ActionFact,
22
+ AdminFact,
22
23
  ChannelFact,
23
24
  EntityFact,
24
25
  ErrorCodeFact,
@@ -38,6 +39,7 @@ export interface ManifestSources {
38
39
  readonly actions?: readonly ActionFact[];
39
40
  readonly queries?: readonly QueryFact[];
40
41
  readonly channels?: readonly ChannelFact[];
42
+ readonly admin?: readonly AdminFact[];
41
43
  readonly jobs?: readonly JobFact[];
42
44
  readonly tasks?: readonly TaskFact[];
43
45
  readonly policies?: readonly PolicyFact[];
@@ -51,6 +53,7 @@ export function buildManifest(sources: ManifestSources): Manifest {
51
53
  const actions = sortBy(sources.actions ?? [], (a) => a.name).map(normalizeAction);
52
54
  const queries = sortBy(sources.queries ?? [], (q) => q.name).map(normalizeQuery);
53
55
  const channels = sortBy(sources.channels ?? [], (c) => c.name).map(normalizeChannel);
56
+ const admin = sortBy(sources.admin ?? [], (a) => a.basePath).map(normalizeAdmin);
54
57
  const jobs = sortBy(sources.jobs ?? [], (j) => j.name).map(normalizeJob);
55
58
  const tasks = sortBy(sources.tasks ?? [], (t) => t.name).map((t) => ({
56
59
  ...t,
@@ -84,6 +87,7 @@ export function buildManifest(sources: ManifestSources): Manifest {
84
87
  actions,
85
88
  queries,
86
89
  channels,
90
+ admin,
87
91
  jobs,
88
92
  tasks,
89
93
  policies,
@@ -134,6 +138,15 @@ const normalizeRoute = (route: RouteFact): RouteFact =>
134
138
  // different answer from an absent key, and `emit.ts` writes what it is given.
135
139
  { ...route, revalidateTags: [...route.revalidateTags].sort() };
136
140
 
141
+ // Resources and routes are sets, keyed by entity and by URL. What is INSIDE them is not: filters
142
+ // and scopes are drawn in declaration order, and a route's permissions are a pair with the coarse
143
+ // gate first — sorting either would publish an order the admin does not have.
144
+ const normalizeAdmin = (admin: AdminFact): AdminFact => ({
145
+ ...admin,
146
+ resources: sortBy(admin.resources, (r) => r.entity),
147
+ routes: sortBy(admin.routes, (r) => r.url),
148
+ });
149
+
137
150
  const normalizeEntity = (entity: EntityFact): EntityFact => ({
138
151
  ...entity,
139
152
  columns: sortBy(entity.columns, (c) => c.name),
@@ -0,0 +1,199 @@
1
+ // The admin in a contract diff. An admin's list URLs are bookmarked and its MCP tools are called
2
+ // by agents, so what a list ANSWERS is contract: a filter, a sort or a scope that goes away
3
+ // refuses a caller it served, a default scope that moves changes what a bare URL lists, and a row
4
+ // scope that appears hides rows from actors who saw them. An action is an MCP tool: one that goes
5
+ // away, stops taking `ids`, or starts excluding rows (`when`) refuses a call it answered. A
6
+ // route's permissions are judged like any operation's; layout and related lists are internal.
7
+
8
+ import type { ManifestChange } from './diff-change';
9
+ import { diffNamedSet, index } from './diff-change';
10
+ import { diffPermissions } from './diff-operations';
11
+ import type { AdminActionFact, AdminFact, AdminResourceFact } from './schema';
12
+
13
+ /** A change of one boolean fact, breaking in one direction and additive in the other. */
14
+ const flag = (
15
+ path: string,
16
+ before: boolean,
17
+ after: boolean,
18
+ breaksWhen: boolean,
19
+ detail: string,
20
+ ): readonly ManifestChange[] =>
21
+ before === after
22
+ ? []
23
+ : [
24
+ {
25
+ kind: after === breaksWhen ? 'breaking' : 'additive',
26
+ path,
27
+ detail: `${detail} ${String(before)} -> ${String(after)}`,
28
+ },
29
+ ];
30
+
31
+ function diffActions(
32
+ path: string,
33
+ before: readonly AdminActionFact[],
34
+ after: readonly AdminActionFact[],
35
+ ): readonly ManifestChange[] {
36
+ const changes: ManifestChange[] = [
37
+ ...diffNamedSet(
38
+ path,
39
+ before.map((action) => action.name),
40
+ after.map((action) => action.name),
41
+ ),
42
+ ];
43
+ const next = index(after, (action) => action.name);
44
+ for (const action of before) {
45
+ const now = next.get(action.name);
46
+ if (now === undefined) continue;
47
+ const at = `${path}.${action.name}`;
48
+ changes.push(
49
+ ...diffPermissions(
50
+ at,
51
+ { permissions: [action.permission] },
52
+ { permissions: [now.permission] },
53
+ ),
54
+ // `when` appearing refuses rows the tool ran on; the batch path going away refuses `ids`;
55
+ // a schema appearing refuses a call that sent nothing; destructive starts asking for a token.
56
+ ...flag(`${at}.when`, action.when, now.when, true, 'when'),
57
+ ...flag(`${at}.batch`, action.batch, now.batch, false, 'batch'),
58
+ ...flag(`${at}.input`, action.input, now.input, true, 'input schema'),
59
+ ...flag(`${at}.destructive`, action.destructive, now.destructive, true, 'destructive'),
60
+ );
61
+ if (action.threshold !== now.threshold) {
62
+ changes.push({
63
+ kind: 'internal',
64
+ path: `${at}.threshold`,
65
+ detail: `threshold ${String(action.threshold)} -> ${String(now.threshold)}`,
66
+ });
67
+ }
68
+ }
69
+ return changes;
70
+ }
71
+
72
+ const layout = (sections: AdminResourceFact['sections']): string =>
73
+ JSON.stringify(sections.map((section) => [section.title, section.fields]));
74
+
75
+ function diffResource(
76
+ path: string,
77
+ before: AdminResourceFact,
78
+ after: AdminResourceFact,
79
+ ): readonly ManifestChange[] {
80
+ const changes: ManifestChange[] = [
81
+ ...diffNamedSet(`${path}.filters`, before.filters, after.filters),
82
+ ...diffNamedSet(`${path}.sorts`, before.sorts, after.sorts),
83
+ ...diffNamedSet(
84
+ `${path}.scopes`,
85
+ before.scopes.map((scope) => scope.name),
86
+ after.scopes.map((scope) => scope.name),
87
+ ),
88
+ ];
89
+ const next = index(after.scopes, (scope) => scope.name);
90
+ for (const scope of before.scopes) {
91
+ const now = next.get(scope.name);
92
+ if (now === undefined) continue;
93
+ if (scope.default !== now.default) {
94
+ changes.push({
95
+ kind: 'breaking',
96
+ path: `${path}.scopes.${scope.name}.default`,
97
+ detail: `default ${String(scope.default)} -> ${String(now.default)}; a bare list URL reads other rows`,
98
+ });
99
+ }
100
+ if (scope.count !== now.count) {
101
+ changes.push({
102
+ kind: 'internal',
103
+ path: `${path}.scopes.${scope.name}.count`,
104
+ detail: `count ${String(scope.count)} -> ${String(now.count)}`,
105
+ });
106
+ }
107
+ }
108
+ changes.push(...diffActions(`${path}.actions`, before.actions, after.actions));
109
+ // A related card is the related resource's own list, reachable on its own: neither direction
110
+ // refuses a caller.
111
+ changes.push(...diffNamedSet(`${path}.related`, before.related, after.related, 'internal'));
112
+ for (const key of ['sections', 'formGroups'] as const) {
113
+ if (layout(before[key]) !== layout(after[key])) {
114
+ changes.push({ kind: 'internal', path: `${path}.${key}`, detail: `${key} rearranged` });
115
+ }
116
+ }
117
+ if (before.rowScoped !== after.rowScoped) {
118
+ changes.push({
119
+ // Appearing narrows what every actor sees; going away widens it — which breaks nobody, and
120
+ // is still a change a reviewer has to see.
121
+ kind: after.rowScoped ? 'breaking' : 'additive',
122
+ path: `${path}.rowScoped`,
123
+ detail: after.rowScoped ? 'rows are now scoped per actor' : 'rows are no longer scoped',
124
+ });
125
+ }
126
+ return changes;
127
+ }
128
+
129
+ function diffAdmin(before: AdminFact, after: AdminFact): readonly ManifestChange[] {
130
+ const root = `admin.${before.basePath}`;
131
+ const changes: ManifestChange[] = [];
132
+ if (before.audit !== after.audit) {
133
+ changes.push({
134
+ kind: 'internal',
135
+ path: `${root}.audit`,
136
+ detail: `audit log ${before.audit} -> ${after.audit}`,
137
+ });
138
+ }
139
+
140
+ const resources = index(after.resources, (resource) => resource.entity);
141
+ for (const resource of before.resources) {
142
+ const path = `${root}.resources.${resource.entity}`;
143
+ const next = resources.get(resource.entity);
144
+ if (next === undefined) changes.push({ kind: 'breaking', path, detail: 'resource removed' });
145
+ else changes.push(...diffResource(path, resource, next));
146
+ }
147
+ const had = index(before.resources, (resource) => resource.entity);
148
+ for (const resource of after.resources) {
149
+ if (!had.has(resource.entity)) {
150
+ changes.push({
151
+ kind: 'additive',
152
+ path: `${root}.resources.${resource.entity}`,
153
+ detail: 'resource added',
154
+ });
155
+ }
156
+ }
157
+
158
+ const routes = index(after.routes, (route) => route.url);
159
+ for (const route of before.routes) {
160
+ const path = `${root}.routes.${route.url}`;
161
+ const next = routes.get(route.url);
162
+ if (next === undefined) changes.push({ kind: 'breaking', path, detail: 'route removed' });
163
+ else changes.push(...diffPermissions(path, route, next));
164
+ }
165
+ const served = index(before.routes, (route) => route.url);
166
+ for (const route of after.routes) {
167
+ if (!served.has(route.url)) {
168
+ changes.push({
169
+ kind: 'additive',
170
+ path: `${root}.routes.${route.url}`,
171
+ detail: 'route added',
172
+ });
173
+ }
174
+ }
175
+ return changes;
176
+ }
177
+
178
+ export function diffAdmins(
179
+ before: readonly AdminFact[],
180
+ after: readonly AdminFact[],
181
+ ): readonly ManifestChange[] {
182
+ const changes: ManifestChange[] = [];
183
+ const next = index(after, (admin) => admin.basePath);
184
+ const had = index(before, (admin) => admin.basePath);
185
+ for (const admin of before) {
186
+ const now = next.get(admin.basePath);
187
+ if (now === undefined) {
188
+ changes.push({ kind: 'breaking', path: `admin.${admin.basePath}`, detail: 'admin removed' });
189
+ } else {
190
+ changes.push(...diffAdmin(admin, now));
191
+ }
192
+ }
193
+ for (const admin of after) {
194
+ if (!had.has(admin.basePath)) {
195
+ changes.push({ kind: 'additive', path: `admin.${admin.basePath}`, detail: 'admin added' });
196
+ }
197
+ }
198
+ return changes;
199
+ }
@@ -110,6 +110,7 @@ function diffColumns(
110
110
  }
111
111
  changes.push(...diffKey(at, 'primaryKey', keyOf(column), keyOf(next)));
112
112
  changes.push(...diffKey(at, 'references', column.references, next.references));
113
+ changes.push(...diffSealed(`${at}.sealed`, column.sealed, next.sealed));
113
114
  }
114
115
 
115
116
  const beforeColumns = index(before.columns, (c) => c.name);
@@ -132,6 +133,63 @@ function diffColumns(
132
133
  return changes;
133
134
  }
134
135
 
136
+ /**
137
+ * How a column is sealed is a WIRE fact as much as a storage one: `.sealed()` emits no DDL and
138
+ * leaves the column's type alone, so nothing else in the file moves — while the field leaves every
139
+ * action output, query row, record and live frame the app sends. Absence is a statement here, not
140
+ * missing evidence: the field is written only for a sealed column, and a manifest older than the
141
+ * feature had none.
142
+ *
143
+ * Three of the four moves break a consumer. Sealing removes a field every client read. Unsealing
144
+ * puts it back AND leaves every stored value a ciphertext no read opens any more. `lookup` to
145
+ * opaque refuses the equality filters and the unique rule that were legal. Opaque to `lookup` is
146
+ * the one that only widens — reported all the same, because equal values now store equal strings,
147
+ * and rows written before are found only once they are re-sealed.
148
+ */
149
+ function diffSealed(
150
+ path: string,
151
+ before: ColumnFact['sealed'],
152
+ after: ColumnFact['sealed'],
153
+ ): readonly ManifestChange[] {
154
+ if (before === after) return [];
155
+ if (before === undefined) {
156
+ return [
157
+ {
158
+ kind: 'breaking',
159
+ path,
160
+ detail: `became sealed (${after}); the field is removed from every output`,
161
+ },
162
+ ];
163
+ }
164
+ if (after === undefined) {
165
+ return [
166
+ {
167
+ kind: 'breaking',
168
+ path,
169
+ detail:
170
+ 'no longer sealed; stored values stay ciphertext until rewritten, and the field joins every output',
171
+ },
172
+ ];
173
+ }
174
+ return after === 'lookup'
175
+ ? [
176
+ {
177
+ kind: 'additive',
178
+ path,
179
+ detail:
180
+ 'sealed opaque -> lookup; equal values now store equal strings, and existing rows match only after a re-seal',
181
+ },
182
+ ]
183
+ : [
184
+ {
185
+ kind: 'breaking',
186
+ path,
187
+ detail:
188
+ 'sealed lookup -> opaque; an equality filter or a unique rule on it is now refused',
189
+ },
190
+ ];
191
+ }
192
+
135
193
  /** `primaryKey` is optional in the file, so absence is the same statement as `false`. */
136
194
  const keyOf = (column: ColumnFact): string => (column.primaryKey === true ? 'yes' : 'no');
137
195
 
package/src/diff-work.ts CHANGED
@@ -42,6 +42,22 @@ export function diffJobs(
42
42
  ),
43
43
  );
44
44
  changes.push(...diffRetry(path, job, next));
45
+ // A cap decides how much runs at once and what a claim over it does; neither is the shape an
46
+ // enqueuer or a queued payload depends on. Declared, removed and edited alike.
47
+ if (canonicalJson(job.concurrency ?? null) !== canonicalJson(next.concurrency ?? null)) {
48
+ changes.push({
49
+ kind: 'internal',
50
+ path: `${path}.concurrency`,
51
+ detail: 'concurrency changed; runs already in flight keep the slots they hold',
52
+ });
53
+ }
54
+ if ((job.onSettled ?? false) !== (next.onSettled ?? false)) {
55
+ changes.push({
56
+ kind: 'internal',
57
+ path: `${path}.onSettled`,
58
+ detail: next.onSettled === true ? 'onSettled hook declared' : 'onSettled hook removed',
59
+ });
60
+ }
45
61
  if (canonicalJson(job.steps) !== canonicalJson(next.steps)) {
46
62
  changes.push({
47
63
  kind: 'internal',
package/src/diff.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // `[{ kind: 'internal', path: 'buildId' }]` and passed. `diff.test.ts` walks `ARRAY_SECTIONS` and
12
12
  // fails on a section nothing here classifies.
13
13
 
14
+ import { diffAdmins } from './diff-admin';
14
15
  import type { ManifestChange } from './diff-change';
15
16
  import { diffNamedSet } from './diff-change';
16
17
  import { diffChannels } from './diff-channels';
@@ -49,6 +50,8 @@ export function diffManifest(before: Manifest, after: Manifest): ManifestDiff {
49
50
  changes.push(...diffQueries(before.queries, after.queries));
50
51
  // `?? []`: a file written before channels were projected carries none, which is not a removal.
51
52
  changes.push(...diffChannels(before.channels ?? [], after.channels ?? []));
53
+ // The same `?? []`, for the same reason: no `admin` key is a file older than the section.
54
+ changes.push(...diffAdmins(before.admin ?? [], after.admin ?? []));
52
55
  changes.push(...diffRoutes(before.routes, after.routes));
53
56
  changes.push(...diffJobs(before.jobs, after.jobs));
54
57
  changes.push(...diffTasks(before.tasks, after.tasks));
package/src/emit.ts CHANGED
@@ -33,6 +33,7 @@ export const KEY_ORDER = [
33
33
  'actions',
34
34
  'queries',
35
35
  'channels',
36
+ 'admin',
36
37
  'jobs',
37
38
  'tasks',
38
39
  'policies',
package/src/index.ts CHANGED
@@ -42,6 +42,10 @@ export {
42
42
  } from './errors';
43
43
  export type {
44
44
  ActionFact,
45
+ AdminFact,
46
+ AdminResourceFact,
47
+ AdminRouteFact,
48
+ AdminScopeFact,
45
49
  ChannelFact,
46
50
  ColumnFact,
47
51
  EntityFact,
package/src/schema.ts CHANGED
@@ -41,6 +41,77 @@ export interface RouteFact {
41
41
  readonly surface?: 'site' | 'app' | 'api';
42
42
  }
43
43
 
44
+ /** One scope of an admin list: a tab, by name. */
45
+ export interface AdminScopeFact {
46
+ readonly name: string;
47
+ /** What a bare list URL reads. At most one per resource. */
48
+ readonly default: boolean;
49
+ /** The tab shows a row count — one extra query per list page. */
50
+ readonly count: boolean;
51
+ }
52
+
53
+ /** One titled group of an admin detail page or form. `title` is an i18n key; `null` untitled. */
54
+ export interface AdminSectionFact {
55
+ readonly title: string | null;
56
+ readonly fields: readonly string[];
57
+ }
58
+
59
+ /**
60
+ * One admin action: its button, its batch bar entry and its ONE MCP tool. `when` narrows which rows
61
+ * it applies to; `batch` puts it in the bar and gives the tool `ids`; past `threshold` rows a batch
62
+ * is queued as `admin.batch` jobs instead of run in the request.
63
+ */
64
+ export interface AdminActionFact {
65
+ readonly name: string;
66
+ readonly permission: string;
67
+ readonly destructive: boolean;
68
+ readonly input: boolean;
69
+ readonly when: boolean;
70
+ readonly batch: boolean;
71
+ readonly threshold: number | null;
72
+ }
73
+
74
+ /** One resource of a generated admin: what its list answers, and whether a row scope narrows it. */
75
+ export interface AdminResourceFact {
76
+ readonly entity: string;
77
+ /** Mount-relative: `/posts`. */
78
+ readonly path: string;
79
+ /** The fields a list URL's `f.<field>` and the MCP list tool's `where` may name, in bar order. */
80
+ readonly filters: readonly string[];
81
+ /** The fields `?sort=` may name. */
82
+ readonly sorts: readonly string[];
83
+ /** Tab order. */
84
+ readonly scopes: readonly AdminScopeFact[];
85
+ /** `rows` is declared: every read of the resource is narrowed per actor. */
86
+ readonly rowScoped: boolean;
87
+ /** The detail page's groups, drawing order; undeclared fields are the last, default one. */
88
+ readonly sections: readonly AdminSectionFact[];
89
+ /** The create and edit form's groups, the same way. */
90
+ readonly formGroups: readonly AdminSectionFact[];
91
+ /** `hasMany` relations drawn on the detail page as the related resource's own list. */
92
+ readonly related: readonly string[];
93
+ /** Declaration order — the order the buttons are drawn in. */
94
+ readonly actions: readonly AdminActionFact[];
95
+ }
96
+
97
+ /** One route `defineAdmin()` mounts — no page file declares it. */
98
+ export interface AdminRouteFact {
99
+ readonly url: string;
100
+ readonly view: string;
101
+ readonly entity: string | null;
102
+ /** Every permission the screen decides on, coarse gate first. A pair, not a set. */
103
+ readonly permissions: readonly string[];
104
+ }
105
+
106
+ /** One generated admin, as `defineAdmin()` derived it. */
107
+ export interface AdminFact {
108
+ readonly basePath: string;
109
+ /** The audit log the admin writes: `memory` forgets at every restart; `postgres` is the record. */
110
+ readonly audit: string;
111
+ readonly resources: readonly AdminResourceFact[];
112
+ readonly routes: readonly AdminRouteFact[];
113
+ }
114
+
44
115
  export interface ColumnFact {
45
116
  readonly name: string;
46
117
  readonly type: string;
@@ -53,6 +124,13 @@ export interface ColumnFact {
53
124
  * is additive — every existing row takes the default — and was classed breaking without this.
54
125
  */
55
126
  readonly hasDefault?: boolean;
127
+ /**
128
+ * The column is `.sealed()`: stored as ciphertext, absent from every output. `'lookup'` is the
129
+ * deterministic form, matchable by equality. Written only when sealed, so a column that is not
130
+ * reads as it always did — and an agent reading the manifest can tell a field it will never be
131
+ * sent from one that is merely missing.
132
+ */
133
+ readonly sealed?: 'opaque' | 'lookup';
56
134
  }
57
135
 
58
136
  export interface EntityFact {
@@ -140,6 +218,19 @@ export interface JobFact {
140
218
  readonly queue: string;
141
219
  readonly retry: { readonly attempts: number; readonly backoff: string };
142
220
  readonly steps: readonly string[];
221
+ /**
222
+ * The job's fleet-wide cap, present only when it declares one. `keyed: true` says `limit` holds
223
+ * per `concurrency.key(input)` — "one run per account" — rather than for the whole job, and
224
+ * `whenBusy` is what a claim over it does (`null` for a plain number, which always waits).
225
+ * Optional for `channels`' reason: a manifest written before this existed is "no cap known".
226
+ */
227
+ readonly concurrency?: {
228
+ readonly limit: number;
229
+ readonly keyed: boolean;
230
+ readonly whenBusy: string | null;
231
+ };
232
+ /** Present, and `true`, only when the job declares an `onSettled` hook. */
233
+ readonly onSettled?: true;
143
234
  }
144
235
 
145
236
  export interface TaskFact {
@@ -185,6 +276,12 @@ export interface Manifest {
185
276
  * is "no channels", never an unreadable manifest. `buildManifest` always writes it.
186
277
  */
187
278
  readonly channels?: readonly ChannelFact[];
279
+ /**
280
+ * The generated admins the app declares, each with its resources and mounted routes. Optional to
281
+ * a READER only, exactly as `channels` is: a file written before admins were projected has none.
282
+ * `buildManifest` always writes it — `[]` for an app with no admin.
283
+ */
284
+ readonly admin?: readonly AdminFact[];
188
285
  readonly jobs: readonly JobFact[];
189
286
  readonly tasks: readonly TaskFact[];
190
287
  readonly policies: readonly PolicyFact[];
@@ -243,6 +340,7 @@ export function isManifest(value: unknown): value is Manifest {
243
340
  for (const section of ARRAY_SECTIONS) if (!Array.isArray(m[section])) return false;
244
341
  // Absent is a pre-channel file and readable; present and not an array is a damaged one.
245
342
  if (m['channels'] !== undefined && !Array.isArray(m['channels'])) return false;
343
+ if (m['admin'] !== undefined && !Array.isArray(m['admin'])) return false;
246
344
  return isAppIdentity(m['app']);
247
345
  }
248
346
 
@@ -0,0 +1,158 @@
1
+ // The admins this process declared, as manifest facts. `@ultimat3/admin` is a tier above this
2
+ // package and may not be imported, so its mount registry is read off the global symbol registry —
3
+ // the same seam `@ultimat3/cli` mounts the screens through — and each admin is asked to describe
4
+ // ITSELF. What comes back is `unknown` and is read field by field: a description this build does
5
+ // not understand is skipped whole, never half-published.
6
+
7
+ import type {
8
+ AdminActionFact,
9
+ AdminFact,
10
+ AdminResourceFact,
11
+ AdminRouteFact,
12
+ AdminScopeFact,
13
+ AdminSectionFact,
14
+ } from './schema';
15
+
16
+ /** `@ultimat3/admin`'s `ADMIN_MOUNTS`. Restated — the package cannot be imported from here. */
17
+ export const ADMIN_MOUNTS_KEY: symbol = Symbol.for('ultimate.admin.mounts');
18
+
19
+ type Bag = Readonly<Record<string, unknown>>;
20
+
21
+ const bagOf = (value: unknown): Bag | undefined =>
22
+ typeof value === 'object' && value !== null ? (value as Bag) : undefined;
23
+
24
+ const text = (value: unknown): string | undefined =>
25
+ typeof value === 'string' ? value : undefined;
26
+
27
+ const texts = (value: unknown): readonly string[] | undefined =>
28
+ Array.isArray(value) && value.every((one) => typeof one === 'string')
29
+ ? (value as readonly string[])
30
+ : undefined;
31
+
32
+ /** Every element read, or the whole list refused: a half-read list is a manifest that lies. */
33
+ const each = <T>(
34
+ value: unknown,
35
+ read: (one: unknown) => T | undefined,
36
+ ): readonly T[] | undefined => {
37
+ if (!Array.isArray(value)) return undefined;
38
+ const out: T[] = [];
39
+ for (const one of value) {
40
+ const fact = read(one);
41
+ if (fact === undefined) return undefined;
42
+ out.push(fact);
43
+ }
44
+ return out;
45
+ };
46
+
47
+ const scopeOf = (value: unknown): AdminScopeFact | undefined => {
48
+ const bag = bagOf(value);
49
+ const name = text(bag?.['name']);
50
+ if (bag === undefined || name === undefined) return undefined;
51
+ return { name, default: bag['default'] === true, count: bag['count'] === true };
52
+ };
53
+
54
+ const sectionOf = (value: unknown): AdminSectionFact | undefined => {
55
+ const bag = bagOf(value);
56
+ const fields = texts(bag?.['fields']);
57
+ const title = bag?.['title'];
58
+ if (bag === undefined || fields === undefined) return undefined;
59
+ if (title !== null && typeof title !== 'string') return undefined;
60
+ return { title, fields };
61
+ };
62
+
63
+ const actionOf = (value: unknown): AdminActionFact | undefined => {
64
+ const bag = bagOf(value);
65
+ const name = text(bag?.['name']);
66
+ const permission = text(bag?.['permission']);
67
+ const threshold = bag?.['threshold'];
68
+ if (bag === undefined || name === undefined || permission === undefined) return undefined;
69
+ if (threshold !== null && typeof threshold !== 'number') return undefined;
70
+ return {
71
+ name,
72
+ permission,
73
+ destructive: bag['destructive'] === true,
74
+ input: bag['input'] === true,
75
+ when: bag['when'] === true,
76
+ batch: bag['batch'] === true,
77
+ threshold,
78
+ };
79
+ };
80
+
81
+ const resourceOf = (value: unknown): AdminResourceFact | undefined => {
82
+ const bag = bagOf(value);
83
+ const entity = text(bag?.['entity']);
84
+ const path = text(bag?.['path']);
85
+ const filters = texts(bag?.['filters']);
86
+ const sorts = texts(bag?.['sorts']);
87
+ const scopes = each(bag?.['scopes'], scopeOf);
88
+ const sections = each(bag?.['sections'], sectionOf);
89
+ const formGroups = each(bag?.['formGroups'], sectionOf);
90
+ const related = texts(bag?.['related']);
91
+ const actions = each(bag?.['actions'], actionOf);
92
+ if (
93
+ bag === undefined ||
94
+ entity === undefined ||
95
+ path === undefined ||
96
+ filters === undefined ||
97
+ sorts === undefined ||
98
+ scopes === undefined ||
99
+ sections === undefined ||
100
+ formGroups === undefined ||
101
+ related === undefined ||
102
+ actions === undefined
103
+ ) {
104
+ return undefined;
105
+ }
106
+ return {
107
+ entity,
108
+ path,
109
+ filters,
110
+ sorts,
111
+ scopes,
112
+ rowScoped: bag['rowScoped'] === true,
113
+ sections,
114
+ formGroups,
115
+ related,
116
+ actions,
117
+ };
118
+ };
119
+
120
+ const routeOf = (value: unknown): AdminRouteFact | undefined => {
121
+ const bag = bagOf(value);
122
+ const url = text(bag?.['url']);
123
+ const view = text(bag?.['view']);
124
+ const permissions = texts(bag?.['permissions']);
125
+ if (bag === undefined || url === undefined || view === undefined || permissions === undefined) {
126
+ return undefined;
127
+ }
128
+ return { url, view, entity: text(bag['entity']) ?? null, permissions };
129
+ };
130
+
131
+ const adminOf = (mount: unknown): AdminFact | undefined => {
132
+ const describe = bagOf(mount)?.['describe'];
133
+ if (typeof describe !== 'function') return undefined;
134
+ const described = bagOf((describe as () => unknown).call(mount));
135
+ const basePath = text(described?.['basePath']);
136
+ const audit = text(described?.['audit']);
137
+ const resources = each(described?.['resources'], resourceOf);
138
+ const routes = each(described?.['routes'], routeOf);
139
+ if (
140
+ basePath === undefined ||
141
+ audit === undefined ||
142
+ resources === undefined ||
143
+ routes === undefined
144
+ ) {
145
+ return undefined;
146
+ }
147
+ return { basePath, audit, resources, routes };
148
+ };
149
+
150
+ /** Every admin `defineAdmin()` declared in this process. Empty for an app that declares none. */
151
+ export function declaredAdmins(): readonly AdminFact[] {
152
+ const held = (globalThis as { [key: symbol]: unknown })[ADMIN_MOUNTS_KEY];
153
+ if (!(held instanceof Map)) return [];
154
+ return [...held.values()].flatMap((mount) => {
155
+ const fact = adminOf(mount);
156
+ return fact === undefined ? [] : [fact];
157
+ });
158
+ }
package/src/sources.ts CHANGED
@@ -6,12 +6,20 @@
6
6
  // in `@ultimat3/render`, which is this same tier.
7
7
 
8
8
  import { describeActions } from '@ultimat3/action';
9
- import { describeEntities } from '@ultimat3/entity';
9
+ import { describeEntities, registeredEntities, sealedFields } from '@ultimat3/entity';
10
10
  import { describeJobs } from '@ultimat3/jobs';
11
11
  import { describeQueries } from '@ultimat3/query';
12
12
  import { describeChannels } from '@ultimat3/realtime/server';
13
13
  import type { ManifestSources } from './build';
14
- import type { ErrorCodeFact, JsonValue, PolicyFact, RouteFact, TaskFact } from './schema';
14
+ import type {
15
+ AdminFact,
16
+ ErrorCodeFact,
17
+ JsonValue,
18
+ PolicyFact,
19
+ RouteFact,
20
+ TaskFact,
21
+ } from './schema';
22
+ import { declaredAdmins } from './sources-admin';
15
23
 
16
24
  export interface FrameworkSourcesInput {
17
25
  readonly app: { readonly name: string; readonly version: string };
@@ -23,6 +31,11 @@ export interface FrameworkSourcesInput {
23
31
  readonly locales?: readonly string[];
24
32
  /** Each package's `*_ERROR_CODES`, flattened by the CLI. */
25
33
  readonly errorCodes?: readonly ErrorCodeFact[];
34
+ /**
35
+ * The app's generated admins. Omitted, they are read off the process's own declarations
36
+ * (`sources-admin.ts`) — the one section a caller does not have to inject.
37
+ */
38
+ readonly admin?: readonly AdminFact[];
26
39
  }
27
40
 
28
41
  /**
@@ -36,7 +49,33 @@ export interface FrameworkSourcesInput {
36
49
  */
37
50
  const asJson = (value: object): JsonValue => value as JsonValue;
38
51
 
52
+ /** Absent stays absent: only a sealed column carries the field. */
53
+ const sealedFact = (
54
+ how: 'opaque' | 'lookup' | undefined,
55
+ ): { readonly sealed?: 'opaque' | 'lookup' } => (how === undefined ? {} : { sealed: how });
56
+
57
+ /**
58
+ * `<entity name>` -> `<physical column>` -> how it is sealed. Read off the declarations rather than
59
+ * `$describe()`, which is the DDL's projection: sealing changes no DDL, so it is not on it, and a
60
+ * column becoming sealed must not move the schema hash a migration is checked against.
61
+ */
62
+ function sealedColumns(): ReadonlyMap<string, ReadonlyMap<string, 'opaque' | 'lookup'>> {
63
+ return new Map(
64
+ registeredEntities().map((entry) => [
65
+ entry.name,
66
+ new Map(
67
+ // `core` is absent on a hand-registered entry, which declares no columns to seal.
68
+ (entry.core === undefined ? [] : sealedFields(entry.core)).map((field) => [
69
+ field.column,
70
+ field.lookup ? ('lookup' as const) : ('opaque' as const),
71
+ ]),
72
+ ),
73
+ ]),
74
+ );
75
+ }
76
+
39
77
  export function frameworkSources(input: FrameworkSourcesInput): ManifestSources {
78
+ const sealed = sealedColumns();
40
79
  return {
41
80
  app: input.app,
42
81
  routes: input.routes ?? [],
@@ -44,6 +83,7 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
44
83
  tasks: input.tasks ?? [],
45
84
  locales: input.locales ?? [],
46
85
  errorCodes: input.errorCodes ?? [],
86
+ admin: input.admin ?? declaredAdmins(),
47
87
  // Projected field by field, never cast: the primitive registries own richer shapes
48
88
  // than the manifest publishes, and a cast would silently rot when either side moves.
49
89
  entities: describeEntities().map((entity) => ({
@@ -56,6 +96,7 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
56
96
  primaryKey: column.primaryKey,
57
97
  ...(column.hasDefault ? { hasDefault: true } : {}),
58
98
  ...(column.references === null ? {} : { references: column.references }),
99
+ ...sealedFact(sealed.get(entity.name)?.get(column.column)),
59
100
  })),
60
101
  invariants: entity.invariants.map((invariant) => invariant.name),
61
102
  })),
@@ -108,6 +149,19 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
108
149
  // `run()` at execution time, so no static reader can know it. `x jobs show` reports the
109
150
  // steps an actual run recorded.
110
151
  steps: job.steps,
152
+ // Absent, never `null`, for a job with no cap: a row gains the key the day its job declares
153
+ // one, so adding this fact moved no committed manifest but the ones it is a fact about.
154
+ ...(job.concurrency === null
155
+ ? {}
156
+ : {
157
+ concurrency: {
158
+ limit: job.concurrency.limit,
159
+ keyed: job.concurrency.keyed,
160
+ whenBusy: job.concurrency.whenBusy,
161
+ },
162
+ }),
163
+ // The same rule: only a job that declares the hook carries the key.
164
+ ...(job.onSettled ? { onSettled: true as const } : {}),
111
165
  })),
112
166
  };
113
167
  }