@ultimat3/manifest 3.0.0 → 4.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 +28 -3
- package/README.md +13 -3
- package/package.json +6 -6
- package/src/diff-change.ts +67 -0
- package/src/diff-entities.ts +138 -0
- package/src/diff-fixtures.ts +95 -0
- package/src/diff-operations.ts +176 -0
- package/src/diff-rate-limit.ts +85 -0
- package/src/diff-registries.ts +116 -0
- package/src/diff-routes.ts +93 -0
- package/src/diff-work.ts +131 -0
- package/src/diff.ts +26 -413
- package/src/schema.ts +8 -3
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The two declaration registries: `policies` (a permission, where it is enforced) and
|
|
2
|
+
// `errorCodes` (a code, the package that owns it). Both are keyed by a string a consumer matches
|
|
3
|
+
// on — a permission it grants, a code it branches on — so losing an entry is breaking and the
|
|
4
|
+
// metadata beside it is internal.
|
|
5
|
+
|
|
6
|
+
import type { ManifestChange } from './diff-change';
|
|
7
|
+
import { diffScalar, index } from './diff-change';
|
|
8
|
+
import type { ErrorCodeFact, PolicyFact } from './schema';
|
|
9
|
+
|
|
10
|
+
export function diffPolicies(
|
|
11
|
+
before: readonly PolicyFact[],
|
|
12
|
+
after: readonly PolicyFact[],
|
|
13
|
+
): readonly ManifestChange[] {
|
|
14
|
+
const changes: ManifestChange[] = [];
|
|
15
|
+
const afterByName = index(after, (p) => p.permission);
|
|
16
|
+
const beforeByName = index(before, (p) => p.permission);
|
|
17
|
+
|
|
18
|
+
for (const policy of before) {
|
|
19
|
+
const next = afterByName.get(policy.permission);
|
|
20
|
+
const path = `policies.${policy.permission}`;
|
|
21
|
+
if (next === undefined) {
|
|
22
|
+
// The rule this permission names is enforced nowhere now; an actor holding the grant is
|
|
23
|
+
// the only thing left that still believes in it.
|
|
24
|
+
changes.push({ kind: 'breaking', path, detail: 'policy removed' });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
changes.push(
|
|
28
|
+
...diffScalar(
|
|
29
|
+
'internal',
|
|
30
|
+
`${path}.description`,
|
|
31
|
+
policy.description,
|
|
32
|
+
next.description,
|
|
33
|
+
(from, to) => `description "${from}" -> "${to}"`,
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
// Same direction as an operation's permissions: a NEW enforcement site refuses callers that
|
|
37
|
+
// reached that surface yesterday; one dropped widens access and is reported, never fatal.
|
|
38
|
+
changes.push(...diffEnforcedIn(path, policy.enforcedIn, next.enforcedIn));
|
|
39
|
+
}
|
|
40
|
+
for (const policy of after) {
|
|
41
|
+
if (!beforeByName.has(policy.permission)) {
|
|
42
|
+
changes.push({
|
|
43
|
+
kind: 'additive',
|
|
44
|
+
path: `policies.${policy.permission}`,
|
|
45
|
+
detail: 'policy added',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return changes;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function diffEnforcedIn(
|
|
53
|
+
path: string,
|
|
54
|
+
before: readonly string[],
|
|
55
|
+
after: readonly string[],
|
|
56
|
+
): readonly ManifestChange[] {
|
|
57
|
+
const beforeSet = new Set(before);
|
|
58
|
+
const afterSet = new Set(after);
|
|
59
|
+
return [
|
|
60
|
+
...after
|
|
61
|
+
.filter((site) => !beforeSet.has(site))
|
|
62
|
+
.map((site) => ({
|
|
63
|
+
kind: 'breaking' as const,
|
|
64
|
+
path: `${path}.enforcedIn.${site}`,
|
|
65
|
+
detail: 'now enforced here; callers that reached this surface are refused',
|
|
66
|
+
})),
|
|
67
|
+
...before
|
|
68
|
+
.filter((site) => !afterSet.has(site))
|
|
69
|
+
.map((site) => ({
|
|
70
|
+
kind: 'additive' as const,
|
|
71
|
+
path: `${path}.enforcedIn.${site}`,
|
|
72
|
+
detail: 'no longer enforced here; access widened',
|
|
73
|
+
})),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function diffErrorCodes(
|
|
78
|
+
before: readonly ErrorCodeFact[],
|
|
79
|
+
after: readonly ErrorCodeFact[],
|
|
80
|
+
): readonly ManifestChange[] {
|
|
81
|
+
const changes: ManifestChange[] = [];
|
|
82
|
+
const afterByCode = index(after, (e) => e.code);
|
|
83
|
+
const beforeByCode = index(before, (e) => e.code);
|
|
84
|
+
|
|
85
|
+
for (const fact of before) {
|
|
86
|
+
const next = afterByCode.get(fact.code);
|
|
87
|
+
const path = `errorCodes.${fact.code}`;
|
|
88
|
+
if (next === undefined) {
|
|
89
|
+
// A code is stable forever once shipped — every `catch` matching on it, every runbook and
|
|
90
|
+
// every `x errors explain` argument stops resolving the moment it leaves the file.
|
|
91
|
+
changes.push({ kind: 'breaking', path, detail: 'error code removed' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Which package declares it is a fact about the framework, not about the caller: the code
|
|
95
|
+
// string is what anyone matches on, and it did not move.
|
|
96
|
+
changes.push(
|
|
97
|
+
...diffScalar(
|
|
98
|
+
'internal',
|
|
99
|
+
`${path}.package`,
|
|
100
|
+
fact.package,
|
|
101
|
+
next.package,
|
|
102
|
+
(from, to) => `owner ${from} -> ${to}`,
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
for (const fact of after) {
|
|
107
|
+
if (!beforeByCode.has(fact.code)) {
|
|
108
|
+
changes.push({
|
|
109
|
+
kind: 'additive',
|
|
110
|
+
path: `errorCodes.${fact.code}`,
|
|
111
|
+
detail: 'error code added',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return changes;
|
|
116
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Routes: the URL and what it serves. `surface` is the contract — a URL that answered HTML and
|
|
2
|
+
// now answers JSON breaks every link to it — while the delivery facts (`render`, `offline`,
|
|
3
|
+
// `hydrate`, `budget`, `revalidateTags`) are internal and reported.
|
|
4
|
+
|
|
5
|
+
import { canonical } from './build';
|
|
6
|
+
import type { ManifestChange } from './diff-change';
|
|
7
|
+
import { diffScalar, index } from './diff-change';
|
|
8
|
+
import type { RouteFact } from './schema';
|
|
9
|
+
|
|
10
|
+
export function diffRoutes(
|
|
11
|
+
before: readonly RouteFact[],
|
|
12
|
+
after: readonly RouteFact[],
|
|
13
|
+
): readonly ManifestChange[] {
|
|
14
|
+
const changes: ManifestChange[] = [];
|
|
15
|
+
const afterByUrl = index(after, (r) => r.url);
|
|
16
|
+
const beforeByUrl = index(before, (r) => r.url);
|
|
17
|
+
|
|
18
|
+
for (const route of before) {
|
|
19
|
+
const next = afterByUrl.get(route.url);
|
|
20
|
+
const path = `routes.${route.url}`;
|
|
21
|
+
if (next === undefined) {
|
|
22
|
+
// A removed URL is a 404 for anyone holding a link to it.
|
|
23
|
+
changes.push({ kind: 'breaking', path, detail: 'route removed' });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// `site`, `app` and `api` are three different promises about one URL: a page becoming an API
|
|
27
|
+
// route stops rendering HTML for every crawler and bookmark pointing at it.
|
|
28
|
+
changes.push(
|
|
29
|
+
...diffScalar(
|
|
30
|
+
'breaking',
|
|
31
|
+
`${path}.surface`,
|
|
32
|
+
route.surface,
|
|
33
|
+
next.surface,
|
|
34
|
+
(from, to) => `surface ${from} -> ${to}`,
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
// The delivery facts, each with its own path so a reviewer sees WHICH one moved. Every one is
|
|
38
|
+
// optional in the file, and `diffScalar` skips a side that carries nothing: a manifest written
|
|
39
|
+
// before a field existed must not report every route as changed the first time it is diffed.
|
|
40
|
+
changes.push(
|
|
41
|
+
...diffScalar(
|
|
42
|
+
'internal',
|
|
43
|
+
`${path}.render`,
|
|
44
|
+
route.render,
|
|
45
|
+
next.render,
|
|
46
|
+
(from, to) => `render ${from} -> ${to}`,
|
|
47
|
+
),
|
|
48
|
+
...diffScalar(
|
|
49
|
+
'internal',
|
|
50
|
+
`${path}.offline`,
|
|
51
|
+
route.offline,
|
|
52
|
+
next.offline,
|
|
53
|
+
(from, to) => `offline ${from} -> ${to}`,
|
|
54
|
+
),
|
|
55
|
+
...diffScalar(
|
|
56
|
+
'internal',
|
|
57
|
+
`${path}.hydrate`,
|
|
58
|
+
route.hydrate,
|
|
59
|
+
next.hydrate,
|
|
60
|
+
(from, to) => `hydrate ${from} -> ${to}`,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
changes.push(...diffJson('internal', `${path}.budget`, route.budget, next.budget, 'changed'));
|
|
64
|
+
changes.push(
|
|
65
|
+
...diffJson(
|
|
66
|
+
'internal',
|
|
67
|
+
`${path}.revalidateTags`,
|
|
68
|
+
route.revalidateTags,
|
|
69
|
+
next.revalidateTags,
|
|
70
|
+
'revalidate tags changed',
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
for (const route of after) {
|
|
75
|
+
if (!beforeByUrl.has(route.url)) {
|
|
76
|
+
changes.push({ kind: 'additive', path: `routes.${route.url}`, detail: 'route added' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return changes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The structural sibling of `diffScalar`: absent on either side is still no evidence. */
|
|
83
|
+
function diffJson(
|
|
84
|
+
kind: ManifestChange['kind'],
|
|
85
|
+
path: string,
|
|
86
|
+
before: unknown,
|
|
87
|
+
after: unknown,
|
|
88
|
+
detail: string,
|
|
89
|
+
): readonly ManifestChange[] {
|
|
90
|
+
if (before === undefined || after === undefined) return [];
|
|
91
|
+
if (canonical(before) === canonical(after)) return [];
|
|
92
|
+
return [{ kind, path, detail }];
|
|
93
|
+
}
|
package/src/diff-work.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Background work: `jobs` (enqueued) and `tasks` (scheduled). Both fail the same silent way —
|
|
2
|
+
// nothing throws, the work simply stops happening — which is why a removal on either side is
|
|
3
|
+
// breaking rather than a note in the diff.
|
|
4
|
+
|
|
5
|
+
import { canonical } from './build';
|
|
6
|
+
import type { ManifestChange } from './diff-change';
|
|
7
|
+
import { diffScalar, index } from './diff-change';
|
|
8
|
+
import type { JobFact, TaskFact } from './schema';
|
|
9
|
+
|
|
10
|
+
export function diffJobs(
|
|
11
|
+
before: readonly JobFact[],
|
|
12
|
+
after: readonly JobFact[],
|
|
13
|
+
): readonly ManifestChange[] {
|
|
14
|
+
const changes: ManifestChange[] = [];
|
|
15
|
+
const afterByName = index(after, (j) => j.name);
|
|
16
|
+
const beforeByName = index(before, (j) => j.name);
|
|
17
|
+
|
|
18
|
+
for (const job of before) {
|
|
19
|
+
const next = afterByName.get(job.name);
|
|
20
|
+
const path = `jobs.${job.name}`;
|
|
21
|
+
if (next === undefined) {
|
|
22
|
+
// Enqueued-but-undeliverable work is silent data loss, so a removal is breaking.
|
|
23
|
+
changes.push({ kind: 'breaking', path, detail: 'job removed' });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (canonical(job.input) !== canonical(next.input)) {
|
|
27
|
+
changes.push({
|
|
28
|
+
kind: 'breaking',
|
|
29
|
+
path: `${path}.input`,
|
|
30
|
+
detail: 'input schema changed; in-flight payloads will not parse',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// The same failure as a removal, from the worker's side: a fleet subscribed to the old queue
|
|
34
|
+
// keeps running and stops receiving this job, and the rows pile up where nobody drains them.
|
|
35
|
+
changes.push(
|
|
36
|
+
...diffScalar(
|
|
37
|
+
'breaking',
|
|
38
|
+
`${path}.queue`,
|
|
39
|
+
job.queue,
|
|
40
|
+
next.queue,
|
|
41
|
+
(from, to) => `queue ${from} -> ${to}; workers bound to ${from} stop receiving it`,
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
changes.push(...diffRetry(path, job, next));
|
|
45
|
+
if (canonical(job.steps) !== canonical(next.steps)) {
|
|
46
|
+
changes.push({
|
|
47
|
+
kind: 'internal',
|
|
48
|
+
path: `${path}.steps`,
|
|
49
|
+
detail: 'steps changed; resumed runs may replay differently',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const job of after) {
|
|
54
|
+
if (!beforeByName.has(job.name)) {
|
|
55
|
+
changes.push({ kind: 'additive', path: `jobs.${job.name}`, detail: 'job added' });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return changes;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Attempts are a durability promise: work that survived a provider blip at 5 attempts
|
|
63
|
+
* dead-letters at 1, and nothing else in the manifest moves when that number does. Backoff only
|
|
64
|
+
* changes the spacing between the same attempts, so it is internal.
|
|
65
|
+
*/
|
|
66
|
+
function diffRetry(path: string, before: JobFact, after: JobFact): readonly ManifestChange[] {
|
|
67
|
+
const declared: unknown = before.retry;
|
|
68
|
+
const next: unknown = after.retry;
|
|
69
|
+
if (typeof declared !== 'object' || declared === null) return [];
|
|
70
|
+
if (typeof next !== 'object' || next === null) return [];
|
|
71
|
+
const from = (declared as Record<string, unknown>)['attempts'];
|
|
72
|
+
const to = (next as Record<string, unknown>)['attempts'];
|
|
73
|
+
const changes: ManifestChange[] = [];
|
|
74
|
+
|
|
75
|
+
if (typeof from === 'number' && typeof to === 'number' && from !== to) {
|
|
76
|
+
changes.push({
|
|
77
|
+
kind: to < from ? 'breaking' : 'additive',
|
|
78
|
+
path: `${path}.retry.attempts`,
|
|
79
|
+
detail:
|
|
80
|
+
to < from
|
|
81
|
+
? `attempts ${from} -> ${to}; a transient failure this survived now dead-letters`
|
|
82
|
+
: `attempts ${from} -> ${to}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
changes.push(
|
|
86
|
+
...diffScalar(
|
|
87
|
+
'internal',
|
|
88
|
+
`${path}.retry.backoff`,
|
|
89
|
+
(declared as Record<string, unknown>)['backoff'],
|
|
90
|
+
(next as Record<string, unknown>)['backoff'],
|
|
91
|
+
(a, b) => `backoff ${a} -> ${b}`,
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
return changes;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function diffTasks(
|
|
98
|
+
before: readonly TaskFact[],
|
|
99
|
+
after: readonly TaskFact[],
|
|
100
|
+
): readonly ManifestChange[] {
|
|
101
|
+
const changes: ManifestChange[] = [];
|
|
102
|
+
const afterByName = index(after, (t) => t.name);
|
|
103
|
+
const beforeByName = index(before, (t) => t.name);
|
|
104
|
+
|
|
105
|
+
for (const task of before) {
|
|
106
|
+
const next = afterByName.get(task.name);
|
|
107
|
+
const path = `tasks.${task.name}`;
|
|
108
|
+
if (next === undefined) {
|
|
109
|
+
// Nothing throws when a schedule disappears — the work simply never runs again.
|
|
110
|
+
changes.push({ kind: 'breaking', path, detail: 'task removed' });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// WHEN it runs is not a caller's contract, but it is the fact an operator reads this file
|
|
114
|
+
// for, and a zone change moves every fire time without touching the expression beside it.
|
|
115
|
+
changes.push(
|
|
116
|
+
...diffScalar('internal', `${path}.cron`, task.cron, next.cron, (a, b) => `${a} -> ${b}`),
|
|
117
|
+
);
|
|
118
|
+
changes.push(
|
|
119
|
+
...diffScalar('internal', `${path}.tz`, task.tz, next.tz, (a, b) => `${a} -> ${b}`),
|
|
120
|
+
);
|
|
121
|
+
if (canonical(task.enqueues) !== canonical(next.enqueues)) {
|
|
122
|
+
changes.push({ kind: 'internal', path: `${path}.enqueues`, detail: 'enqueued jobs changed' });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const task of after) {
|
|
126
|
+
if (!beforeByName.has(task.name)) {
|
|
127
|
+
changes.push({ kind: 'additive', path: `tasks.${task.name}`, detail: 'task added' });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return changes;
|
|
131
|
+
}
|