@shortlink-org/portolan 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +1 -0
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +53 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +96 -2
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// What a switch or a re-grade does to the manifest's entries.
|
|
2
|
+
//
|
|
3
|
+
// The Rules page and the Problems page both flip a rule; the entry they
|
|
4
|
+
// write is one shape, kept here so the two cannot write it differently.
|
|
5
|
+
|
|
6
|
+
import type { ProblemRule, ProblemRuleEntry, RuleSeverity } from "./problem-rules";
|
|
7
|
+
|
|
8
|
+
/** The entries with one rule switched. A shipped rule back on with nothing else to say loses its entry. */
|
|
9
|
+
export function switched(entries: ProblemRuleEntry[], rule: ProblemRule, enabled: boolean, reason: string): ProblemRuleEntry[] {
|
|
10
|
+
const existing = entries.find((entry) => entry.id === rule.id);
|
|
11
|
+
if (rule.builtin) {
|
|
12
|
+
const next: ProblemRuleEntry = { id: rule.id };
|
|
13
|
+
if (!enabled) next.enabled = false;
|
|
14
|
+
if (existing?.severity) next.severity = existing.severity;
|
|
15
|
+
const why = enabled ? existing?.reason : reason || existing?.reason;
|
|
16
|
+
if (why && (!enabled || next.severity)) next.reason = why;
|
|
17
|
+
if (enabled && !next.severity) return entries.filter((entry) => entry.id !== rule.id);
|
|
18
|
+
return existing ? entries.map((entry) => (entry.id === rule.id ? next : entry)) : [...entries, next];
|
|
19
|
+
}
|
|
20
|
+
return entries.map((entry) => {
|
|
21
|
+
if (entry.id !== rule.id) return entry;
|
|
22
|
+
const { enabled: _enabled, ...rest } = entry;
|
|
23
|
+
return enabled ? rest : { ...rest, enabled: false, ...(reason ? { reason } : {}) };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The entries with one rule's severity set; a shipped rule back at its own severity loses the field. */
|
|
28
|
+
export function regraded(entries: ProblemRuleEntry[], rule: ProblemRule, severity: RuleSeverity): ProblemRuleEntry[] {
|
|
29
|
+
const existing = entries.find((entry) => entry.id === rule.id);
|
|
30
|
+
if (rule.builtin) {
|
|
31
|
+
const next: ProblemRuleEntry = { id: rule.id };
|
|
32
|
+
if (existing?.enabled === false) next.enabled = false;
|
|
33
|
+
if (severity !== rule.defaultSeverity) next.severity = severity;
|
|
34
|
+
if (existing?.reason) next.reason = existing.reason;
|
|
35
|
+
if (next.enabled === undefined && !next.severity) return entries.filter((entry) => entry.id !== rule.id);
|
|
36
|
+
return existing ? entries.map((entry) => (entry.id === rule.id ? next : entry)) : [...entries, next];
|
|
37
|
+
}
|
|
38
|
+
return entries.map((entry) => (entry.id === rule.id ? { ...entry, severity } : entry));
|
|
39
|
+
}
|
|
@@ -6,36 +6,38 @@ import { rawCatalog } from "../test-catalog";
|
|
|
6
6
|
import { buildIndex, validateCatalog } from "../catalog";
|
|
7
7
|
import type { Catalog } from "../catalog";
|
|
8
8
|
import type { Problem } from "./derive";
|
|
9
|
-
import {
|
|
9
|
+
import { builtinProblems } from "./problem-rules";
|
|
10
|
+
|
|
11
|
+
const DATA = ["cross-service-fk", "cross-service-lineage", "cross-service-view", "shared-store", "persistence-drift", "column-type", "outbox-payload"];
|
|
10
12
|
|
|
11
13
|
const clone = (): Catalog =>
|
|
12
14
|
JSON.parse(JSON.stringify(rawCatalog)) as unknown as Catalog;
|
|
13
15
|
|
|
14
16
|
function found(catalog: Catalog) {
|
|
15
|
-
return
|
|
17
|
+
return builtinProblems(catalog, buildIndex(validateCatalog(catalog)), DATA);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
const kinds = (list: {
|
|
20
|
+
const kinds = (list: { rule: string }[]) => list.map((p) => p.rule);
|
|
19
21
|
|
|
20
22
|
describe("the sample estate", () => {
|
|
21
23
|
const problems = found(clone());
|
|
22
24
|
|
|
23
25
|
it("reports the intentional cross-service foreign key", () => {
|
|
24
|
-
const fk = problems.find((p) => p.
|
|
26
|
+
const fk = problems.find((p) => p.rule === "cross-service-fk");
|
|
25
27
|
expect(fk?.id).toBe("delivery.core.pg.packages.order_id");
|
|
26
28
|
expect(fk?.peer).toBe("shop.oms.pg.orders");
|
|
27
29
|
expect(fk?.severity).toBe("error");
|
|
28
30
|
});
|
|
29
31
|
|
|
30
32
|
it("reports the table one service writes in another's database", () => {
|
|
31
|
-
const shared = problems.find((p) => p.
|
|
33
|
+
const shared = problems.find((p) => p.rule === "shared-store");
|
|
32
34
|
expect(shared?.id).toBe("shop.oms.pg.price_snapshots");
|
|
33
35
|
expect(shared?.peer).toBe("shop.pricing");
|
|
34
36
|
expect(shared?.severity).toBe("error");
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
it("reports no persistence drift: every table's columns follow its aggregate", () => {
|
|
38
|
-
expect(problems.filter((p) => p.
|
|
40
|
+
expect(problems.filter((p) => p.rule === "persistence-drift")).toEqual([]);
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
it("reports a renamed table whose columns did not follow", () => {
|
|
@@ -45,13 +47,13 @@ describe("the sample estate", () => {
|
|
|
45
47
|
?.tables.find((t) => t.name === "orders");
|
|
46
48
|
if (!orders) throw new Error("fixture has no orders table");
|
|
47
49
|
for (const column of orders.columns) if (column.maps) column.maps = `Order.${column.name}Gone`;
|
|
48
|
-
const drift = found(drifted).find((p) => p.
|
|
50
|
+
const drift = found(drifted).find((p) => p.rule === "persistence-drift");
|
|
49
51
|
expect(drift?.id).toBe("shop.oms.pg.orders");
|
|
50
52
|
expect(drift?.severity).toBe("warning");
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
it("reports every type disagreement and nothing else", () => {
|
|
54
|
-
const types = problems.filter((p) => p.
|
|
56
|
+
const types = problems.filter((p) => p.rule === "column-type");
|
|
55
57
|
expect(types.map((p) => p.id)).toEqual([
|
|
56
58
|
"shop.oms.pg.orders.id",
|
|
57
59
|
"shop.oms.pg.order_items.quantity",
|
|
@@ -64,7 +66,7 @@ describe("the sample estate", () => {
|
|
|
64
66
|
});
|
|
65
67
|
|
|
66
68
|
it("reports the value delivery copies out of the OMS schema", () => {
|
|
67
|
-
const copied = problems.filter((p) => p.
|
|
69
|
+
const copied = problems.filter((p) => p.rule === "cross-service-lineage");
|
|
68
70
|
expect(copied.map((p) => p.id)).toEqual([
|
|
69
71
|
"delivery.core.pg.packages.ship_to",
|
|
70
72
|
]);
|
|
@@ -78,7 +80,7 @@ describe("the sample estate", () => {
|
|
|
78
80
|
it("says nothing about lineage that stays inside one service", () => {
|
|
79
81
|
const inside = problems.filter(
|
|
80
82
|
(p) =>
|
|
81
|
-
p.
|
|
83
|
+
p.rule === "cross-service-lineage" &&
|
|
82
84
|
p.id.startsWith("shop.oms.pg.outbox"),
|
|
83
85
|
);
|
|
84
86
|
expect(inside).toEqual([]);
|
|
@@ -104,7 +106,7 @@ describe("cases that only look like problems", () => {
|
|
|
104
106
|
const problems = found(clone());
|
|
105
107
|
const inside = problems.filter(
|
|
106
108
|
(p) =>
|
|
107
|
-
p.
|
|
109
|
+
p.rule === "cross-service-fk" &&
|
|
108
110
|
p.id.startsWith("shop.oms.pg.order_items"),
|
|
109
111
|
);
|
|
110
112
|
expect(inside).toEqual([]);
|
|
@@ -120,7 +122,7 @@ describe("cases that only look like problems", () => {
|
|
|
120
122
|
stops.persists = { aggregate: "shop.oms.order" };
|
|
121
123
|
|
|
122
124
|
const shared = found(catalog).filter(
|
|
123
|
-
(p) => p.
|
|
125
|
+
(p) => p.rule === "shared-store" && p.id === stops.id,
|
|
124
126
|
);
|
|
125
127
|
expect(shared).toEqual([]);
|
|
126
128
|
});
|
|
@@ -129,7 +131,7 @@ describe("cases that only look like problems", () => {
|
|
|
129
131
|
const problems = found(clone());
|
|
130
132
|
const derived = problems.filter(
|
|
131
133
|
(p) =>
|
|
132
|
-
p.
|
|
134
|
+
p.rule === "persistence-drift" &&
|
|
133
135
|
p.id === "delivery.core.pg.route_stops",
|
|
134
136
|
);
|
|
135
137
|
expect(derived).toEqual([]);
|
|
@@ -139,7 +141,7 @@ describe("cases that only look like problems", () => {
|
|
|
139
141
|
const problems = found(clone());
|
|
140
142
|
expect(
|
|
141
143
|
problems.filter(
|
|
142
|
-
(p) => p.
|
|
144
|
+
(p) => p.rule === "persistence-drift" && p.id === "shop.oms.pg.orders",
|
|
143
145
|
),
|
|
144
146
|
).toEqual([]);
|
|
145
147
|
});
|
|
@@ -154,7 +156,7 @@ describe("an outbox with nothing in it", () => {
|
|
|
154
156
|
if (!outbox) throw new Error("fixture has no outbox");
|
|
155
157
|
outbox.columns = outbox.columns.filter((c) => c.type !== "jsonb");
|
|
156
158
|
|
|
157
|
-
const problem = found(catalog).find((p) => p.
|
|
159
|
+
const problem = found(catalog).find((p) => p.rule === "outbox-payload");
|
|
158
160
|
expect(problem?.severity).toBe("warning");
|
|
159
161
|
expect(problem?.id).toBe("shop.oms.pg.outbox");
|
|
160
162
|
expect(problem?.peer).toBe("shop.oms.pg");
|
|
@@ -174,5 +176,5 @@ describe("a catalog with no stores", () => {
|
|
|
174
176
|
|
|
175
177
|
// A compile-time check that the shared shape really is shared: the page renders
|
|
176
178
|
// one row component over both lists, so the two must stay one type.
|
|
177
|
-
const _shape: (p: Problem) => string = (p) => `${p.severity}:${p.
|
|
179
|
+
const _shape: (p: Problem) => string = (p) => `${p.severity}:${p.rule}`;
|
|
178
180
|
void _shape;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildIndex } from "../catalog";
|
|
3
|
+
import type { Catalog, Deployment } from "../catalog";
|
|
4
|
+
import { builtinProblems } from "./problem-rules";
|
|
5
|
+
|
|
6
|
+
const deployProblems = (catalog: Catalog) => builtinProblems(catalog, buildIndex(catalog), ["deployment-unclaimed", "deployment-drift"]);
|
|
7
|
+
|
|
8
|
+
const service = (id: string, path: string) => ({
|
|
9
|
+
id,
|
|
10
|
+
slug: id.split(".")[1] ?? id,
|
|
11
|
+
name: id,
|
|
12
|
+
repo: "github.com/acme/shop",
|
|
13
|
+
path,
|
|
14
|
+
readme: "",
|
|
15
|
+
provides: [],
|
|
16
|
+
consumes: [],
|
|
17
|
+
aggregates: [],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const placed = (
|
|
21
|
+
name: string,
|
|
22
|
+
repo: string,
|
|
23
|
+
path: string,
|
|
24
|
+
extra: Partial<Deployment> = {},
|
|
25
|
+
): Deployment => ({
|
|
26
|
+
id: `argocd/${name}`,
|
|
27
|
+
name,
|
|
28
|
+
project: "shop",
|
|
29
|
+
environment: "prod",
|
|
30
|
+
cluster: "in-cluster",
|
|
31
|
+
namespace: "shop",
|
|
32
|
+
repo,
|
|
33
|
+
path,
|
|
34
|
+
targetRevision: "main",
|
|
35
|
+
revision: "a".repeat(40),
|
|
36
|
+
tool: "kustomize",
|
|
37
|
+
url: `https://argocd.example.com/applications/argocd/${name}`,
|
|
38
|
+
...extra,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const estate = (deployments: Deployment[]): Catalog => ({
|
|
42
|
+
generatedAt: "",
|
|
43
|
+
commit: "",
|
|
44
|
+
contexts: [
|
|
45
|
+
{
|
|
46
|
+
id: "shop",
|
|
47
|
+
slug: "shop",
|
|
48
|
+
name: "Shop",
|
|
49
|
+
summary: "",
|
|
50
|
+
services: [service("shop.cart", "services/cart"), service("shop.oms", "")],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
defs: {},
|
|
54
|
+
flows: [],
|
|
55
|
+
adrs: [],
|
|
56
|
+
deployments,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("deployments the catalog cannot place", () => {
|
|
60
|
+
it("is quiet when every Application deploys from a directory a service lives at", () => {
|
|
61
|
+
const catalog = estate([
|
|
62
|
+
placed("cart", "github.com/acme/shop", "services/cart/deploy"),
|
|
63
|
+
// A service whose path is the whole repository claims any directory of it.
|
|
64
|
+
placed("oms", "github.com/acme/shop", "anything/at/all"),
|
|
65
|
+
]);
|
|
66
|
+
expect(deployProblems(catalog)).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("names the Application and where it deploys from, as a warning with no context", () => {
|
|
70
|
+
const catalog = estate([
|
|
71
|
+
placed("grafana", "github.com/acme/platform", "charts/grafana", {
|
|
72
|
+
namespace: "monitoring",
|
|
73
|
+
}),
|
|
74
|
+
]);
|
|
75
|
+
const [problem, ...rest] = deployProblems(catalog);
|
|
76
|
+
expect(rest).toEqual([]);
|
|
77
|
+
expect(problem).toMatchObject({
|
|
78
|
+
rule: "deployment-unclaimed",
|
|
79
|
+
severity: "warning",
|
|
80
|
+
context: "",
|
|
81
|
+
service: "",
|
|
82
|
+
id: "argocd/grafana",
|
|
83
|
+
peer: "github.com/acme/platform/charts/grafana",
|
|
84
|
+
});
|
|
85
|
+
expect(problem?.note).toContain("grafana runs in prod, namespace monitoring");
|
|
86
|
+
expect(problem?.note).toContain("https://argocd.example.com/applications/argocd/grafana");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("places by the labels when the Application carries them, and says so when they name nobody", () => {
|
|
90
|
+
const placedByLabel = estate([
|
|
91
|
+
placed("cart-prod", "github.com/acme/other", "envs/prod/shop/cart", { service: "shop.cart" }),
|
|
92
|
+
]);
|
|
93
|
+
expect(deployProblems(placedByLabel)).toEqual([]);
|
|
94
|
+
|
|
95
|
+
const nobody = estate([
|
|
96
|
+
placed("grafana", "github.com/acme/platform", "charts/grafana", { service: "platform.grafana" }),
|
|
97
|
+
]);
|
|
98
|
+
expect(deployProblems(nobody)[0]?.note).toContain("its labels name platform.grafana, which is no service of the estate");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("reports drift on the service's row, in words, and stays quiet when the tree and the deployer agree", () => {
|
|
102
|
+
const agreed = estate([placed("cart-prod", "github.com/acme/shop", "services/cart/deploy", { basis: "both" })]);
|
|
103
|
+
expect(deployProblems(agreed)).toEqual([]);
|
|
104
|
+
|
|
105
|
+
const drifted = estate([
|
|
106
|
+
placed("cart-prod", "github.com/acme/shop", "services/cart/deploy", {
|
|
107
|
+
basis: "both",
|
|
108
|
+
images: ["ghcr.io/acme/cart:2.0.9"],
|
|
109
|
+
drift: { targetRevision: "release-2.1", images: ["ghcr.io/acme/cart:2.1.0"] },
|
|
110
|
+
}),
|
|
111
|
+
]);
|
|
112
|
+
const [problem, ...rest] = deployProblems(drifted);
|
|
113
|
+
expect(rest).toEqual([]);
|
|
114
|
+
expect(problem).toMatchObject({ rule: "deployment-drift", severity: "warning", context: "shop", service: "shop.cart", id: "argocd/cart-prod", peer: "prod" });
|
|
115
|
+
expect(problem?.note).toBe(
|
|
116
|
+
"tracks release-2.1 in the tree, main deployed; pins ghcr.io/acme/cart:2.1.0 in the tree; running ghcr.io/acme/cart:2.0.9. https://argocd.example.com/applications/argocd/cart-prod",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("says the chart when the Application deploys one from a registry", () => {
|
|
121
|
+
const catalog = estate([
|
|
122
|
+
placed("redis", "charts.bitnami.com/bitnami", "", { chart: "redis" }),
|
|
123
|
+
]);
|
|
124
|
+
expect(deployProblems(catalog)[0]?.peer).toBe("chart redis");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("does not place a deployment on a service in another repository at the same path", () => {
|
|
128
|
+
const catalog = estate([
|
|
129
|
+
placed("cart", "github.com/acme/other", "services/cart/deploy"),
|
|
130
|
+
]);
|
|
131
|
+
expect(deployProblems(catalog)).toHaveLength(1);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { catalog } from "../data";
|
|
3
|
-
import { contextStats, edgeCount
|
|
2
|
+
import { catalog, index } from "../data";
|
|
3
|
+
import { contextStats, edgeCount } from "./derive";
|
|
4
|
+
import { builtinProblems } from "./problem-rules";
|
|
5
|
+
|
|
6
|
+
import { buildIndex } from "../catalog";
|
|
4
7
|
import type { Catalog } from "../catalog";
|
|
5
8
|
|
|
9
|
+
const problems = (of: Catalog) => builtinProblems(of, of === catalog ? index : buildIndex(of), ["rpc", "consumer"]);
|
|
10
|
+
|
|
6
11
|
describe("problems", () => {
|
|
7
12
|
it("finds every unresolved edge and nothing else", () => {
|
|
8
13
|
for (const problem of problems(catalog)) {
|
|
@@ -13,7 +18,7 @@ describe("problems", () => {
|
|
|
13
18
|
`${problem.id} names a service not in the catalog`,
|
|
14
19
|
).toBeDefined();
|
|
15
20
|
|
|
16
|
-
if (problem.
|
|
21
|
+
if (problem.rule === "rpc") {
|
|
17
22
|
const call = service?.consumes.find((c) => c.id === problem.id);
|
|
18
23
|
expect(call?.status).toBe("unresolved");
|
|
19
24
|
expect(call?.peer).toBe(problem.peer);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { buildIndex } from "../catalog";
|
|
3
|
+
import type { Catalog, Service } from "../catalog";
|
|
4
|
+
import { builtinProblems } from "./problem-rules";
|
|
5
|
+
|
|
6
|
+
const PROTO = ["proto-missing", "proto-drift"];
|
|
4
7
|
|
|
5
8
|
function service(id: string, overrides: Partial<Service> = {}): Service {
|
|
6
9
|
const slug = id.slice(id.indexOf(".") + 1);
|
|
@@ -141,36 +144,17 @@ const copiedPricing = (overrides = {}) =>
|
|
|
141
144
|
});
|
|
142
145
|
|
|
143
146
|
function found(catalog: Catalog) {
|
|
144
|
-
|
|
145
|
-
const serviceContext = new Map<string, Catalog["contexts"][number]>();
|
|
146
|
-
const rpcProviderByMethod = new Map<string, Service>();
|
|
147
|
-
for (const context of catalog.contexts) {
|
|
148
|
-
for (const item of context.services) {
|
|
149
|
-
serviceById.set(item.id, item);
|
|
150
|
-
serviceContext.set(item.id, context);
|
|
151
|
-
for (const provided of item.provides) {
|
|
152
|
-
for (const method of provided.methods) {
|
|
153
|
-
rpcProviderByMethod.set(`${provided.id}/${method.name}`, item);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
const index = {
|
|
159
|
-
serviceById,
|
|
160
|
-
serviceContext,
|
|
161
|
-
rpcProviderByMethod,
|
|
162
|
-
} as CatalogIndex;
|
|
163
|
-
return protoProblems(catalog, index);
|
|
147
|
+
return builtinProblems(catalog, buildIndex(catalog), PROTO);
|
|
164
148
|
}
|
|
165
149
|
|
|
166
|
-
describe("
|
|
150
|
+
describe("the proto rules", () => {
|
|
167
151
|
it("reports a call whose provider answers on no such method", () => {
|
|
168
152
|
const problems = found(
|
|
169
153
|
catalogWith([pricing(), calling("pricing.v1.Pricing/ListPriceLists")]),
|
|
170
154
|
);
|
|
171
155
|
|
|
172
156
|
expect(problems).toHaveLength(1);
|
|
173
|
-
expect(problems[0]?.
|
|
157
|
+
expect(problems[0]?.rule).toBe("proto-missing");
|
|
174
158
|
expect(problems[0]?.severity).toBe("error");
|
|
175
159
|
// The near end is the CALLER: that is the copy to go and look at.
|
|
176
160
|
expect(problems[0]?.service).toBe("shop.oms");
|
|
@@ -238,7 +222,7 @@ describe("protoProblems", () => {
|
|
|
238
222
|
|
|
239
223
|
expect(problems).toHaveLength(1);
|
|
240
224
|
expect(problems[0]).toMatchObject({
|
|
241
|
-
|
|
225
|
+
rule: "proto-drift",
|
|
242
226
|
severity: "warning",
|
|
243
227
|
service: "shop.oms",
|
|
244
228
|
id: "pricing.v1.Pricing",
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
type Service,
|
|
10
10
|
} from "../catalog";
|
|
11
11
|
import { catalog as estate } from "../data";
|
|
12
|
-
import {
|
|
12
|
+
import { builtinProblems } from "./problem-rules";
|
|
13
|
+
|
|
14
|
+
const WIRE = ["shared-channel", "channel-undeclared", "channel-unpublished", "message-encoding", "subscription-unresolved"];
|
|
13
15
|
|
|
14
16
|
function event(id: string, channel?: string): Event {
|
|
15
17
|
const name = id.slice(id.lastIndexOf(".") + 1);
|
|
@@ -84,7 +86,7 @@ function catalogWith(services: Service[]): Catalog {
|
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
function found(catalog: Catalog) {
|
|
87
|
-
return
|
|
89
|
+
return builtinProblems(catalog, buildIndex(catalog), WIRE);
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
const cart = () =>
|
|
@@ -95,7 +97,7 @@ const cart = () =>
|
|
|
95
97
|
]),
|
|
96
98
|
]);
|
|
97
99
|
|
|
98
|
-
describe("
|
|
100
|
+
describe("the wire rules", () => {
|
|
99
101
|
it("reports each side of a channel two services publish on", () => {
|
|
100
102
|
const oms = service("shop.oms", [
|
|
101
103
|
aggregate("shop.oms.order", [
|
|
@@ -109,7 +111,7 @@ describe("wireProblems", () => {
|
|
|
109
111
|
["shop.oms", "shop.oms.order.OrderPlaced", "shop.cart"],
|
|
110
112
|
]);
|
|
111
113
|
const [first] = problems;
|
|
112
|
-
expect(first?.
|
|
114
|
+
expect(first?.rule).toBe("shared-channel");
|
|
113
115
|
expect(first?.severity).toBe("error");
|
|
114
116
|
expect(first?.note).toContain("OrderPlaced");
|
|
115
117
|
expect(first?.source).toBe("BasketCreated.ts");
|
|
@@ -150,7 +152,7 @@ describe("wireProblems", () => {
|
|
|
150
152
|
);
|
|
151
153
|
const problems = found(catalogWith([cart(), oms]));
|
|
152
154
|
|
|
153
|
-
expect(problems.filter((p) => p.
|
|
155
|
+
expect(problems.filter((p) => p.rule === "shared-channel")).toHaveLength(2);
|
|
154
156
|
const theirs = problems.find((p) => p.service === "shop.oms");
|
|
155
157
|
// No event of theirs to point at, so the row leads to the service.
|
|
156
158
|
expect(theirs?.id).toBe("shop.oms");
|
|
@@ -166,7 +168,7 @@ describe("wireProblems", () => {
|
|
|
166
168
|
);
|
|
167
169
|
const problems = found(catalogWith([one]));
|
|
168
170
|
|
|
169
|
-
const undeclared = problems.filter((p) => p.
|
|
171
|
+
const undeclared = problems.filter((p) => p.rule === "channel-undeclared");
|
|
170
172
|
expect(undeclared.map((p) => [p.id, p.peer])).toEqual([
|
|
171
173
|
["shop.cart.basket.BasketCreated", "shop.cart.basket"],
|
|
172
174
|
["shop.cart.basket.BasketCheckedOut", "shop.cart.basket"],
|
|
@@ -182,7 +184,7 @@ describe("wireProblems", () => {
|
|
|
182
184
|
);
|
|
183
185
|
const problems = found(catalogWith([one]));
|
|
184
186
|
|
|
185
|
-
const promised = problems.filter((p) => p.
|
|
187
|
+
const promised = problems.filter((p) => p.rule === "channel-unpublished");
|
|
186
188
|
expect(promised.map((p) => p.peer)).toEqual(["shop.cart.wishlist"]);
|
|
187
189
|
expect(promised[0]?.id).toBe("shop.cart");
|
|
188
190
|
expect(promised[0]?.note).toContain("cart.ItemWished");
|
|
@@ -202,7 +204,7 @@ describe("wireProblems", () => {
|
|
|
202
204
|
channel("payments.ledger.payment", "receive payments.PaymentAuthorized"),
|
|
203
205
|
);
|
|
204
206
|
const problems = found(catalogWith([one])).filter(
|
|
205
|
-
(p) => p.
|
|
207
|
+
(p) => p.rule === "channel-undeclared" || p.rule === "channel-unpublished",
|
|
206
208
|
);
|
|
207
209
|
|
|
208
210
|
expect(problems).toEqual([]);
|
|
@@ -240,7 +242,7 @@ describe("wireProblems", () => {
|
|
|
240
242
|
);
|
|
241
243
|
const problems = found(catalogWith([cart(), oms]));
|
|
242
244
|
|
|
243
|
-
expect(problems.filter((p) => p.
|
|
245
|
+
expect(problems.filter((p) => p.rule === "subscription-unresolved")).toEqual(
|
|
244
246
|
[],
|
|
245
247
|
);
|
|
246
248
|
});
|
|
@@ -253,7 +255,7 @@ describe("wireProblems", () => {
|
|
|
253
255
|
const problems = found(catalogWith([cart(), oms]));
|
|
254
256
|
|
|
255
257
|
const unresolved = problems.filter(
|
|
256
|
-
(p) => p.
|
|
258
|
+
(p) => p.rule === "subscription-unresolved",
|
|
257
259
|
);
|
|
258
260
|
expect(unresolved.map((p) => [p.service, p.peer])).toEqual([
|
|
259
261
|
["shop.oms", "billing.InvoiceRaised"],
|
|
@@ -270,7 +272,7 @@ describe("wireProblems", () => {
|
|
|
270
272
|
const subscriber = speaking(service("shop.search", []), subscriberChannel);
|
|
271
273
|
|
|
272
274
|
const mismatch = found(catalogWith([publisher, subscriber])).find(
|
|
273
|
-
(problem) => problem.
|
|
275
|
+
(problem) => problem.rule === "message-encoding",
|
|
274
276
|
);
|
|
275
277
|
expect(mismatch).toMatchObject({
|
|
276
278
|
severity: "error",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Field } from "../catalog";
|
|
3
|
+
import { ruleMark, ruleMarks } from "./rules";
|
|
4
|
+
|
|
5
|
+
describe("ruleMark", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
["min_len", "1", "≥ 1 chars"],
|
|
8
|
+
["max_len", "64", "≤ 64 chars"],
|
|
9
|
+
["len", "3", "3 chars"],
|
|
10
|
+
["min_items", "1", "≥ 1 items"],
|
|
11
|
+
["gt", "0", "> 0"],
|
|
12
|
+
["gte", "-1", "≥ -1"],
|
|
13
|
+
["lte", "9007199254740993", "≤ 9007199254740993"],
|
|
14
|
+
["const", "USD", "= USD"],
|
|
15
|
+
["in", "USD, EUR", "one of USD, EUR"],
|
|
16
|
+
["not_in", "XXX", "none of XXX"],
|
|
17
|
+
["pattern", "^[A-Z]{3}$", "matches ^[A-Z]{3}$"],
|
|
18
|
+
["format", "email", "email"],
|
|
19
|
+
["multiple_of", "5", "multiple of 5"],
|
|
20
|
+
["items.max_len", "64", "each ≤ 64 chars"],
|
|
21
|
+
["keys.min_len", "1", "each key ≥ 1 chars"],
|
|
22
|
+
["values.gte", "0", "each value ≥ 0"],
|
|
23
|
+
["cel", "this.sku != ''", "cel"],
|
|
24
|
+
// A custom option is shown as written; `true` is the whole of a flag.
|
|
25
|
+
["(acme.pii)", "true", "(acme.pii)"],
|
|
26
|
+
["(acme.mask).style", "LAST_FOUR", "(acme.mask).style LAST_FOUR"],
|
|
27
|
+
// A rule the page has no words for yet is still shown, spelled as it came.
|
|
28
|
+
["well_formed", "true", "well_formed true"],
|
|
29
|
+
])("%s %s reads as %s", (name, value, text) => {
|
|
30
|
+
expect(ruleMark({ name, value })).toEqual({ text, title: `${name} = ${value}` });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("a bare flag has no value in the title either", () => {
|
|
34
|
+
expect(ruleMark({ name: "unique" })).toEqual({ text: "unique", title: "unique" });
|
|
35
|
+
expect(ruleMark({ name: "lt_now" })).toEqual({ text: "in the past", title: "lt_now" });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("ruleMarks", () => {
|
|
40
|
+
const field = (extra: Partial<Field>): Field => ({ name: "x", type: "string", doc: "", ...extra });
|
|
41
|
+
|
|
42
|
+
it("puts required first and keeps the rules in the source's order", () => {
|
|
43
|
+
const marks = ruleMarks(field({ required: true, rules: [{ name: "max_len", value: "16" }, { name: "format", value: "uuid" }] }));
|
|
44
|
+
expect(marks.map((m) => m.text)).toEqual(["required", "≤ 16 chars", "uuid"]);
|
|
45
|
+
expect(marks[0]?.title).toBe("must be sent");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is empty for a field the source says nothing about", () => {
|
|
49
|
+
expect(ruleMarks(field({}))).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/lib/rules.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// What a field's rules say, in words a reader scans.
|
|
2
|
+
//
|
|
3
|
+
// A rule arrives as the catalog spells it - `min_len 1`, `items.max_len 64`,
|
|
4
|
+
// `format email` - the same spelling whether a Protovalidate option or a JSON
|
|
5
|
+
// Schema keyword put it there. The page shows each as a short mark beside the
|
|
6
|
+
// type, "≥ 1 chars", and keeps the spelling for the tooltip, which is what
|
|
7
|
+
// a reader copies into a search of the source. A rule the catalog has no
|
|
8
|
+
// words for, a custom option, is shown as it was written rather than hidden.
|
|
9
|
+
|
|
10
|
+
import type { Field, FieldRule } from "../catalog";
|
|
11
|
+
|
|
12
|
+
export interface RuleMark {
|
|
13
|
+
/** The chip: a few words, or the value alone when the value is the point. */
|
|
14
|
+
text: string;
|
|
15
|
+
/** The rule as the fragment carries it: `min_len = 1`. */
|
|
16
|
+
title: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What a rule on the members of a list or map is about, by its prefix. */
|
|
20
|
+
const OF: Record<string, string> = {
|
|
21
|
+
items: "each",
|
|
22
|
+
keys: "each key",
|
|
23
|
+
values: "each value",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const WORDS: Record<string, (v: string) => string> = {
|
|
27
|
+
min_len: (v) => `≥ ${v} chars`,
|
|
28
|
+
max_len: (v) => `≤ ${v} chars`,
|
|
29
|
+
len: (v) => `${v} chars`,
|
|
30
|
+
min_items: (v) => `≥ ${v} items`,
|
|
31
|
+
max_items: (v) => `≤ ${v} items`,
|
|
32
|
+
min_pairs: (v) => `≥ ${v} entries`,
|
|
33
|
+
max_pairs: (v) => `≤ ${v} entries`,
|
|
34
|
+
gt: (v) => `> ${v}`,
|
|
35
|
+
gte: (v) => `≥ ${v}`,
|
|
36
|
+
lt: (v) => `< ${v}`,
|
|
37
|
+
lte: (v) => `≤ ${v}`,
|
|
38
|
+
const: (v) => `= ${v}`,
|
|
39
|
+
multiple_of: (v) => `multiple of ${v}`,
|
|
40
|
+
in: (v) => `one of ${v}`,
|
|
41
|
+
not_in: (v) => `none of ${v}`,
|
|
42
|
+
pattern: (v) => `matches ${v}`,
|
|
43
|
+
prefix: (v) => `starts with ${v}`,
|
|
44
|
+
suffix: (v) => `ends with ${v}`,
|
|
45
|
+
contains: (v) => `contains ${v}`,
|
|
46
|
+
not_contains: (v) => `never contains ${v}`,
|
|
47
|
+
format: (v) => v,
|
|
48
|
+
unique: () => "unique",
|
|
49
|
+
defined_only: () => "a defined value",
|
|
50
|
+
lt_now: () => "in the past",
|
|
51
|
+
gt_now: () => "in the future",
|
|
52
|
+
cel: () => "cel",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The mark for one rule. */
|
|
56
|
+
export function ruleMark(rule: FieldRule): RuleMark {
|
|
57
|
+
const title = rule.value === undefined ? rule.name : `${rule.name} = ${rule.value}`;
|
|
58
|
+
const value = rule.value ?? "";
|
|
59
|
+
|
|
60
|
+
// A custom option keeps its own name; `(acme.pii)` says more than any
|
|
61
|
+
// words the catalog could put to it.
|
|
62
|
+
if (rule.name.startsWith("(")) {
|
|
63
|
+
return { text: value && value !== "true" ? `${rule.name} ${value}` : rule.name, title };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const dot = rule.name.indexOf(".");
|
|
67
|
+
const of = dot > 0 ? OF[rule.name.slice(0, dot)] : undefined;
|
|
68
|
+
const base = of ? rule.name.slice(dot + 1) : rule.name;
|
|
69
|
+
const words = WORDS[base];
|
|
70
|
+
const text = words ? words(value) : value ? `${base} ${value}` : base;
|
|
71
|
+
|
|
72
|
+
return { text: of ? `${of} ${text}` : text, title };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every mark a field wears: `required` first, then its rules as written. */
|
|
76
|
+
export function ruleMarks(field: Field): RuleMark[] {
|
|
77
|
+
const marks: RuleMark[] = [];
|
|
78
|
+
if (field.required) {
|
|
79
|
+
marks.push({ text: "required", title: "must be sent" });
|
|
80
|
+
}
|
|
81
|
+
for (const rule of field.rules ?? []) {
|
|
82
|
+
marks.push(ruleMark(rule));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return marks;
|
|
86
|
+
}
|
package/src/lib/shape.test.ts
CHANGED
|
@@ -211,6 +211,38 @@ describe("schemaChanges", () => {
|
|
|
211
211
|
expect(removed.map((f) => f.name)).toEqual(["gone"]);
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
+
it("a field whose rules moved is changed, with the rules it had", () => {
|
|
215
|
+
const rules = {
|
|
216
|
+
id: "x.y.R",
|
|
217
|
+
versions: [
|
|
218
|
+
{
|
|
219
|
+
version: "v1",
|
|
220
|
+
fields: [
|
|
221
|
+
{ name: "code", type: "string", doc: "", rules: [{ name: "min_len", value: "8" }] },
|
|
222
|
+
{ name: "who", type: "string", doc: "" },
|
|
223
|
+
{ name: "same", type: "string", doc: "", rules: [{ name: "in", value: "a, b" }, { name: "unique" }] },
|
|
224
|
+
{ name: "both", type: "int32", doc: "", required: true },
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
version: "v2",
|
|
229
|
+
fields: [
|
|
230
|
+
{ name: "code", type: "string", doc: "", rules: [{ name: "min_len", value: "12" }] },
|
|
231
|
+
{ name: "who", type: "string", doc: "", required: true, rules: [{ name: "format", value: "uuid" }] },
|
|
232
|
+
// The same rules in another order are the same rules.
|
|
233
|
+
{ name: "same", type: "string", doc: "", rules: [{ name: "unique" }, { name: "in", value: "a, b" }] },
|
|
234
|
+
{ name: "both", type: "int64", doc: "" },
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
} as unknown as Event;
|
|
239
|
+
const { byField } = schemaChanges(rules, "v2");
|
|
240
|
+
expect(byField.get("code")).toEqual({ change: "changed", rulesFrom: "≥ 8 chars" });
|
|
241
|
+
expect(byField.get("who")).toEqual({ change: "changed", rulesFrom: "" });
|
|
242
|
+
expect(byField.get("same")).toBeUndefined();
|
|
243
|
+
expect(byField.get("both")).toEqual({ change: "changed", from: "int32", rulesFrom: "required" });
|
|
244
|
+
});
|
|
245
|
+
|
|
214
246
|
it("agrees with the fixture: OrderPlaced v2 added channel", () => {
|
|
215
247
|
const placed = eventById("shop.oms.order.OrderPlaced");
|
|
216
248
|
const { byField, removed } = schemaChanges(placed, "v2");
|