@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 +2 -0
- package/README.md +6 -5
- package/package.json +8 -7
- package/src/build.ts +13 -0
- package/src/diff-admin.ts +199 -0
- package/src/diff-entities.ts +58 -0
- package/src/diff-work.ts +16 -0
- package/src/diff.ts +3 -0
- package/src/emit.ts +1 -0
- package/src/index.ts +4 -0
- package/src/schema.ts +98 -0
- package/src/sources-admin.ts +158 -0
- package/src/sources.ts +56 -2
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
|
-
| `
|
|
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/jobs": "
|
|
38
|
-
"@ultimat3/query": "
|
|
39
|
-
"@ultimat3/realtime": "
|
|
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
|
+
}
|
package/src/diff-entities.ts
CHANGED
|
@@ -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
package/src/index.ts
CHANGED
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 {
|
|
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
|
}
|