@shortlink-org/portolan 0.2.4 → 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 +174 -6
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1213 -0
- package/catalog/roundtrip_test.go +210 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +14 -1
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +21 -0
- package/go.sum +34 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +28 -15
- package/plugin/describe.go +156 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +404 -50
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- 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 +52 -19
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +30 -18
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +68 -2
- package/plugins/extract-django/lifecycle.py +4 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +20 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.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/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -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/pyplugin/protocol.py +1 -5
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +132 -4
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +905 -4
- package/scripts/README.md +21 -13
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +57 -14
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +150 -17
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +148 -118
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- 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 +78 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- 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 +287 -12
- package/scripts/local-api.test.mjs +150 -5
- package/scripts/local-discovery.mjs +94 -9
- package/scripts/manifest.mjs +14 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +45 -3
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +62 -7
- package/scripts/schema.mjs +167 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +167 -0
- package/scripts/warning-policy.test.mjs +93 -0
- 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/Sidebar.tsx +3 -3
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +293 -5
- package/src/catalog-validation.ts +113 -2
- package/src/catalog.test.ts +40 -1
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- 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.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +46 -7
- package/src/enrich.test.ts +459 -4
- package/src/enrich.ts +308 -7
- 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 +59 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -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 +68 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +17 -70
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/catalog-diff.ts +1 -1
- 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/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- 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 +116 -4
- 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/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -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 +63 -0
- package/src/lib/warnings.ts +260 -0
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +189 -18
- package/src/pages/AggregatePage.tsx +65 -14
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +8 -5
- 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 +62 -5
- package/src/pages/Settings.tsx +217 -43
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- 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/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
package/src/routes.test.ts
CHANGED
|
@@ -27,6 +27,13 @@ describe("routes", () => {
|
|
|
27
27
|
expect(isRoutable("/landing/")).toBe(true);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
it("routes the plugin index, and one plugin on it by manifest name", () => {
|
|
31
|
+
expect(paths.plugins()).toBe("/plugins");
|
|
32
|
+
expect(isRoutable(paths.plugins())).toBe(true);
|
|
33
|
+
expect(paths.plugin("go-nats")).toBe("/plugins#plugin-go-nats");
|
|
34
|
+
expect(isRoutable(paths.plugin("go-nats"))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
30
37
|
it("routes every URL the catalog can produce", () => {
|
|
31
38
|
const unroutable = allCatalogPaths(catalog).filter((p) => !isRoutable(p));
|
|
32
39
|
expect(unroutable).toEqual([]);
|
|
@@ -169,6 +176,8 @@ describe("routes", () => {
|
|
|
169
176
|
expect(paths.settingsProjects()).toBe("/settings/projects");
|
|
170
177
|
expect(paths.settingsPipeline()).toBe("/settings/pipeline");
|
|
171
178
|
expect(paths.settingsDelivery()).toBe("/settings/delivery");
|
|
179
|
+
expect(paths.settingsRecordings()).toBe("/settings/recordings");
|
|
180
|
+
expect(isRoutable(paths.settingsRecordings())).toBe(true);
|
|
172
181
|
expect(paths.settingsIntegrations()).toBe("/settings/integrations");
|
|
173
182
|
expect(paths.settingsPreferences()).toBe("/settings/preferences");
|
|
174
183
|
expect(paths.settingsAbout()).toBe("/settings/about");
|
package/src/routes.ts
CHANGED
|
@@ -39,12 +39,22 @@ export const paths = {
|
|
|
39
39
|
* page, and the reader is told what was narrowed and offered it back.
|
|
40
40
|
*/
|
|
41
41
|
term: (termId: string) => `/language?term=${encodeURIComponent(termId)}`,
|
|
42
|
+
/**
|
|
43
|
+
* Every plugin the package ships, as each describes itself. One page: the
|
|
44
|
+
* question a reader asks is "what can this read?", and the answer is the
|
|
45
|
+
* whole list grouped by what is read, not one plugin at a time.
|
|
46
|
+
*/
|
|
47
|
+
plugins: () => "/plugins",
|
|
48
|
+
/** One plugin on that page, by the name the manifest declares it under. */
|
|
49
|
+
plugin: (name: string) => `/plugins#plugin-${name}`,
|
|
42
50
|
problems: () => "/problems",
|
|
43
51
|
changes: () => "/changes",
|
|
44
52
|
settings: () => "/settings",
|
|
45
53
|
settingsProjects: () => "/settings/projects",
|
|
46
54
|
settingsPipeline: () => "/settings/pipeline",
|
|
47
55
|
settingsDelivery: () => "/settings/delivery",
|
|
56
|
+
settingsRecordings: () => "/settings/recordings",
|
|
57
|
+
settingsRules: () => "/settings/rules",
|
|
48
58
|
settingsIntegrations: () => "/settings/integrations",
|
|
49
59
|
settingsPreferences: () => "/settings/preferences",
|
|
50
60
|
settingsAbout: () => "/settings/about",
|
|
@@ -195,6 +205,7 @@ export const SERVICE_ANCHOR = {
|
|
|
195
205
|
aggregates: "svc-aggregates",
|
|
196
206
|
events: "svc-events",
|
|
197
207
|
commands: "svc-commands",
|
|
208
|
+
deployments: "svc-deployments",
|
|
198
209
|
} as const;
|
|
199
210
|
|
|
200
211
|
/** The section anchors on the context map. */
|
|
@@ -211,7 +222,9 @@ export function relationAnchor(relationId: string): string {
|
|
|
211
222
|
/** The section anchors on the overview, so "g d" has somewhere to land. */
|
|
212
223
|
export const OVERVIEW_ANCHOR = {
|
|
213
224
|
landscape: "landscape",
|
|
225
|
+
deployed: "deployed",
|
|
214
226
|
contexts: "contexts",
|
|
227
|
+
bridges: "bridges",
|
|
215
228
|
flows: "flows-by-reach",
|
|
216
229
|
problems: "problems",
|
|
217
230
|
} as const;
|
|
@@ -262,6 +275,12 @@ export function adrPath(adrId: string): string | null {
|
|
|
262
275
|
return adr ? paths.adr(adr.slug) : null;
|
|
263
276
|
}
|
|
264
277
|
|
|
278
|
+
/** Path to a flow's page, or null if the id is not a catalog flow. */
|
|
279
|
+
export function flowPath(flowId: string): string | null {
|
|
280
|
+
const flow = index.catalog.flows.find((candidate) => candidate.id === flowId);
|
|
281
|
+
return flow ? paths.flow(flow.slug) : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
265
284
|
/** Path to an aggregate page, or null if the id is not a catalog aggregate. */
|
|
266
285
|
export function aggregatePath(aggregateId: string): string | null {
|
|
267
286
|
const aggregate = index.aggregateById.get(aggregateId);
|
|
@@ -374,9 +393,10 @@ const ROUTES: RegExp[] = [
|
|
|
374
393
|
/^\/flows\/[^/]+$/,
|
|
375
394
|
/^\/adrs$/,
|
|
376
395
|
/^\/language$/,
|
|
396
|
+
/^\/plugins$/,
|
|
377
397
|
/^\/problems$/,
|
|
378
398
|
/^\/changes$/,
|
|
379
|
-
/^\/settings(?:\/(?:projects|pipeline|delivery|integrations|preferences|about))?$/,
|
|
399
|
+
/^\/settings(?:\/(?:projects|pipeline|delivery|recordings|rules|integrations|preferences|about))?$/,
|
|
380
400
|
/^\/externals\/[^/]+$/,
|
|
381
401
|
/^\/map$/,
|
|
382
402
|
/^\/adrs\/[^/]+$/,
|
|
@@ -408,6 +428,7 @@ export function allCatalogPaths(catalog: Catalog): string[] {
|
|
|
408
428
|
paths.map(),
|
|
409
429
|
paths.adrs(),
|
|
410
430
|
paths.language(),
|
|
431
|
+
paths.plugins(),
|
|
411
432
|
paths.problems(),
|
|
412
433
|
paths.changes(),
|
|
413
434
|
paths.settings(),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RelationEvidencePanel } from "../components/RelationEvidence";
|
|
1
2
|
// The right rail. One panel, one selection, every page that draws a diagram.
|
|
2
3
|
//
|
|
3
4
|
// It renders by kind rather than by page, so an event opened from a flow reads
|
|
@@ -238,6 +239,10 @@ function ViewBody({
|
|
|
238
239
|
return (
|
|
239
240
|
<>
|
|
240
241
|
{view.doc ? <p className="mt-2 text-muted">{view.doc}</p> : null}
|
|
242
|
+
<RelationEvidencePanel items={[
|
|
243
|
+
...(view.source ? [{ kind: "contract" as const, rule: "sql-view-definition", source: view.source, symbol: view.name }] : []),
|
|
244
|
+
...(view.persists?.evidence ?? []),
|
|
245
|
+
]} renderSource={(source) => <SchemaEvidenceSource source={source} owner={store.owner} />} />
|
|
241
246
|
|
|
242
247
|
<Label>Store</Label>
|
|
243
248
|
<SelectLink id={store.id}>{store.id}</SelectLink>
|
|
@@ -344,6 +349,7 @@ function TableBody({
|
|
|
344
349
|
return (
|
|
345
350
|
<>
|
|
346
351
|
{table.doc ? <p className="mt-2 text-muted">{table.doc}</p> : null}
|
|
352
|
+
<RelationEvidencePanel items={[...(table.evidence ?? []), ...(table.persists?.evidence ?? [])]} renderSource={(source) => <SchemaEvidenceSource source={source} owner={store.owner} />} />
|
|
347
353
|
|
|
348
354
|
<Label>Store</Label>
|
|
349
355
|
<SelectLink id={store.id}>{store.id}</SelectLink>
|
|
@@ -534,6 +540,11 @@ function ColumnBody({
|
|
|
534
540
|
return (
|
|
535
541
|
<>
|
|
536
542
|
{column.doc ? <p className="mt-2 text-muted">{column.doc}</p> : null}
|
|
543
|
+
{(column.fk || column.from?.length || column.maps) ? <RelationEvidencePanel items={[
|
|
544
|
+
...(table?.evidence ?? []),
|
|
545
|
+
...(table?.persists?.evidence ?? []),
|
|
546
|
+
...(view?.source ? [{ kind: "contract" as const, rule: "sql-view-definition", source: view.source, symbol: view.name }] : []),
|
|
547
|
+
]} renderSource={(source) => <SchemaEvidenceSource source={source} owner={resolved.store.owner} />} /> : null}
|
|
537
548
|
|
|
538
549
|
<Label>{view ? "View" : "Table"}</Label>
|
|
539
550
|
{view ? (
|
|
@@ -1550,3 +1561,7 @@ export function WithDetail({
|
|
|
1550
1561
|
</SavedGroup>
|
|
1551
1562
|
);
|
|
1552
1563
|
}
|
|
1564
|
+
|
|
1565
|
+
function SchemaEvidenceSource({ source, owner }: { source: string; owner: string }) {
|
|
1566
|
+
return <SourcePreviewLink location={sourceLocation(source, index.serviceById.get(owner), allRepos(catalog))} className="mono break-all text-muted hover:text-accent">{source}</SourcePreviewLink>;
|
|
1567
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The stamp of every catalog source, as the checkout's history says: served
|
|
2
|
+
// by `scripts/provenance.mjs` at build time (portolan.0010). Keyed by the
|
|
3
|
+
// path `src/data.ts` gives a source. A staged site imports its sources under
|
|
4
|
+
// flattened names; `source` is then the path the file has in the workspace.
|
|
5
|
+
declare module "virtual:portolan-provenance" {
|
|
6
|
+
const provenance: Record<
|
|
7
|
+
string,
|
|
8
|
+
{ commit: string; generatedAt: string; source?: string }
|
|
9
|
+
>;
|
|
10
|
+
export default provenance;
|
|
11
|
+
}
|
package/vite.config.ts
CHANGED
|
@@ -17,6 +17,10 @@ import { localApiPlugin } from "./scripts/local-api.mjs";
|
|
|
17
17
|
// generator wrote them; the build copies the same files into dist.
|
|
18
18
|
// @ts-expect-error plain JavaScript module intentionally has no browser types
|
|
19
19
|
import { siteDocsPlugin } from "./scripts/site-docs.mjs";
|
|
20
|
+
// When each catalog source last changed, read from git at build time and
|
|
21
|
+
// served to the app as one virtual module (portolan.0010).
|
|
22
|
+
// @ts-expect-error plain JavaScript module intentionally has no browser types
|
|
23
|
+
import { provenancePlugin } from "./scripts/provenance.mjs";
|
|
20
24
|
|
|
21
25
|
/** A git answer, or "" when there is nothing to answer with (no repo, no git). */
|
|
22
26
|
function git(args: string): string {
|
|
@@ -160,6 +164,7 @@ export default defineConfig({
|
|
|
160
164
|
// TypeScript below node_modules.
|
|
161
165
|
localApiPlugin(workspace, publicSetupFrom),
|
|
162
166
|
siteDocsPlugin(workspace),
|
|
167
|
+
provenancePlugin(workspace),
|
|
163
168
|
react(),
|
|
164
169
|
tailwindcss(),
|
|
165
170
|
// The AsyncAPI reference brings a parser written for Node, and it calls
|
package/scripts/vendor-lock.mjs
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// The commit a vendored copy is of.
|
|
2
|
-
//
|
|
3
|
-
// `stampFor` dates a fragment from the last commit that touched the directory
|
|
4
|
-
// it was read from, which is the right answer for code that lives here and the
|
|
5
|
-
// wrong one for code that does not. A service fetched out of another
|
|
6
|
-
// repository is stamped, without this, with the commit that VENDORED it: the
|
|
7
|
-
// estate then reports the service as fresh because somebody re-ran the fetch,
|
|
8
|
-
// and as unchanged when its own repository moved.
|
|
9
|
-
//
|
|
10
|
-
// The right answer is already on disk. `fetch-git` writes `git.lock.json`
|
|
11
|
-
// beside each copy, naming the commit it fetched, and a copy re-fetched at the
|
|
12
|
-
// same commit does not move it. Reading it here keeps the rule the host has
|
|
13
|
-
// always kept - the host works out the stamp, never the plugin - while making
|
|
14
|
-
// the subject of the stamp the code rather than the vendoring.
|
|
15
|
-
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
17
|
-
import { dirname, join } from "node:path";
|
|
18
|
-
|
|
19
|
-
/** The file `fetch-git` writes beside every copy. Its name is that plugin's. */
|
|
20
|
-
export const LOCK = "git.lock.json";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The commit `root` is a copy of, or null when it is not a copy of anything.
|
|
24
|
-
*
|
|
25
|
-
* Walks up from the directory the extractor was pointed at, because the lock
|
|
26
|
-
* sits at the root of the repository's copy and the step reads a service some
|
|
27
|
-
* way inside it: `vendor/repos/acme/shop/services/oms` is stamped by the lock
|
|
28
|
-
* three levels above it. It stops at the repository root, so a run in a tree
|
|
29
|
-
* with no vendored copies reads nothing and answers null.
|
|
30
|
-
*
|
|
31
|
-
* A lock naming other than exactly one repository is not one of these - the
|
|
32
|
-
* fetcher writes one per directory - and is left to the fetcher to complain
|
|
33
|
-
* about, which it does with the path and the count. Nothing here fails a run:
|
|
34
|
-
* the caller has a git history to fall back on, and a stamp is not worth
|
|
35
|
-
* refusing to describe an estate over.
|
|
36
|
-
*/
|
|
37
|
-
export function vendoredCommit(root, read = readFileSync) {
|
|
38
|
-
for (let at = root; at && at !== "." && at !== "/"; at = dirname(at)) {
|
|
39
|
-
let raw;
|
|
40
|
-
try {
|
|
41
|
-
raw = read(join(at, LOCK), "utf8");
|
|
42
|
-
} catch {
|
|
43
|
-
continue; // No lock here; the copy's root may still be above.
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
const lock = JSON.parse(raw);
|
|
48
|
-
const [only, ...rest] = lock.repos ?? [];
|
|
49
|
-
if (only?.commit && rest.length === 0) return only.commit;
|
|
50
|
-
} catch {
|
|
51
|
-
// Unreadable, which the fetcher reports against the copy itself.
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { vendoredCommit } from "./vendor-lock.mjs";
|
|
7
|
-
|
|
8
|
-
const COMMIT = "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0";
|
|
9
|
-
|
|
10
|
-
describe("vendoredCommit", () => {
|
|
11
|
-
let root;
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
root = mkdtempSync(join(tmpdir(), "portolan-vendor-"));
|
|
15
|
-
});
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
rmSync(root, { recursive: true, force: true });
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const copy = (at, lock) => {
|
|
21
|
-
mkdirSync(join(root, at), { recursive: true });
|
|
22
|
-
writeFileSync(join(root, at, "git.lock.json"), JSON.stringify(lock));
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// The case this exists for: the extractor is pointed at a service some way
|
|
26
|
-
// inside the copy, and the lock is at the copy's root.
|
|
27
|
-
it("reads the lock above the directory the extractor was given", () => {
|
|
28
|
-
copy("vendor/repos/acme/shop", { repos: [{ repo: "github.com/acme/shop", commit: COMMIT }] });
|
|
29
|
-
mkdirSync(join(root, "vendor/repos/acme/shop/services/oms"), { recursive: true });
|
|
30
|
-
|
|
31
|
-
expect(vendoredCommit(join(root, "vendor/repos/acme/shop/services/oms"))).toBe(COMMIT);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("answers null for a directory that is nobody's copy", () => {
|
|
35
|
-
mkdirSync(join(root, "examples/shop/oms"), { recursive: true });
|
|
36
|
-
|
|
37
|
-
expect(vendoredCommit(join(root, "examples/shop/oms"))).toBeNull();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// The fetcher writes one repository per directory. Anything else is its
|
|
41
|
-
// problem to report, with the path and the count; a stamp is not worth
|
|
42
|
-
// failing a run over, so this stops rather than guessing which one won.
|
|
43
|
-
it("answers null for a lock naming more than one repository", () => {
|
|
44
|
-
copy("vendor/repos/acme/shop", {
|
|
45
|
-
repos: [
|
|
46
|
-
{ repo: "github.com/acme/shop", commit: COMMIT },
|
|
47
|
-
{ repo: "github.com/acme/pay", commit: "0000000" },
|
|
48
|
-
],
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
expect(vendoredCommit(join(root, "vendor/repos/acme/shop"))).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("answers null for a lock that cannot be read", () => {
|
|
55
|
-
mkdirSync(join(root, "vendor/repos/acme/shop"), { recursive: true });
|
|
56
|
-
writeFileSync(join(root, "vendor/repos/acme/shop/git.lock.json"), "{ not json");
|
|
57
|
-
|
|
58
|
-
expect(vendoredCommit(join(root, "vendor/repos/acme/shop"))).toBeNull();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// A copy nested inside another copy takes the innermost lock: it is the one
|
|
62
|
-
// that names the files the extractor is about to read.
|
|
63
|
-
it("takes the nearest lock", () => {
|
|
64
|
-
copy("vendor/repos/acme", { repos: [{ repo: "github.com/acme/outer", commit: "9999999" }] });
|
|
65
|
-
copy("vendor/repos/acme/shop", { repos: [{ repo: "github.com/acme/shop", commit: COMMIT }] });
|
|
66
|
-
|
|
67
|
-
expect(vendoredCommit(join(root, "vendor/repos/acme/shop"))).toBe(COMMIT);
|
|
68
|
-
});
|
|
69
|
-
});
|
package/src/lib/data-problems.ts
DELETED
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
// Where the schema and the model disagree.
|
|
2
|
-
//
|
|
3
|
-
// The unresolved edges in derive.ts are about arrows that land nowhere. These
|
|
4
|
-
// are about arrows that land somewhere they should not, and about two claims
|
|
5
|
-
// in the catalog that cannot both be current. They share one shape and one
|
|
6
|
-
// page, because a reader scanning Problems asks the same question of all of
|
|
7
|
-
// them: what do I have to go and look at?
|
|
8
|
-
//
|
|
9
|
-
// Errors are boundary leaks — one service reaching into another's schema. They
|
|
10
|
-
// are errors because no amount of context makes them fine: a foreign key across
|
|
11
|
-
// a service boundary is a deployment order nobody wrote down, and a second
|
|
12
|
-
// writer in someone else's database is a bounded context with two owners.
|
|
13
|
-
//
|
|
14
|
-
// Warnings are staleness. A table that no longer carries the fields it claims,
|
|
15
|
-
// a column whose type has drifted from its field's — both are ordinary during a
|
|
16
|
-
// migration, so they are reported and not enforced.
|
|
17
|
-
|
|
18
|
-
import type {
|
|
19
|
-
Catalog,
|
|
20
|
-
CatalogIndex,
|
|
21
|
-
Column,
|
|
22
|
-
Store,
|
|
23
|
-
Table,
|
|
24
|
-
} from "../catalog";
|
|
25
|
-
import {
|
|
26
|
-
aggregateBlocks,
|
|
27
|
-
blockFields,
|
|
28
|
-
mapsFieldPath,
|
|
29
|
-
relationOfColumnId,
|
|
30
|
-
storeViews,
|
|
31
|
-
viewReads,
|
|
32
|
-
} from "../catalog";
|
|
33
|
-
import type { Problem } from "./derive";
|
|
34
|
-
import { payloadColumn, typesDisagree } from "./data-model";
|
|
35
|
-
|
|
36
|
-
/** The context a store sits in, via the service that owns it. */
|
|
37
|
-
function contextOf(index: CatalogIndex, serviceId: string): string {
|
|
38
|
-
return index.serviceContext.get(serviceId)?.id ?? "";
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Every persistence problem, stores in catalog order and tables in theirs.
|
|
43
|
-
* Errors first within each store, so the boundary leaks are not buried under
|
|
44
|
-
* a column-by-column type audit.
|
|
45
|
-
*/
|
|
46
|
-
export function dataProblems(
|
|
47
|
-
catalog: Catalog,
|
|
48
|
-
index: CatalogIndex,
|
|
49
|
-
): Problem[] {
|
|
50
|
-
const errors: Problem[] = [];
|
|
51
|
-
const warnings: Problem[] = [];
|
|
52
|
-
|
|
53
|
-
for (const store of catalog.stores ?? []) {
|
|
54
|
-
for (const table of store.tables) {
|
|
55
|
-
errors.push(...crossServiceKeys(index, store, table));
|
|
56
|
-
errors.push(...sharedStore(index, store, table));
|
|
57
|
-
warnings.push(...outboxWithoutPayload(index, store, table));
|
|
58
|
-
warnings.push(...drift(catalog, index, store, table));
|
|
59
|
-
warnings.push(...typeDrift(catalog, index, store, table));
|
|
60
|
-
warnings.push(
|
|
61
|
-
...crossServiceLineage(index, store, table.id, table.columns),
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
for (const view of storeViews(store)) {
|
|
65
|
-
warnings.push(...crossServiceLineage(index, store, view.id, view.columns));
|
|
66
|
-
// A view is defined over what it reads, so a `reads` entry pointing into
|
|
67
|
-
// someone else's store is the same coupling stated one level up.
|
|
68
|
-
for (const readId of viewReads(view)) {
|
|
69
|
-
const target =
|
|
70
|
-
index.tableById.get(readId) ?? index.viewById.get(readId);
|
|
71
|
-
if (!target || target.store.owner === store.owner) continue;
|
|
72
|
-
warnings.push({
|
|
73
|
-
kind: "cross-service-lineage",
|
|
74
|
-
severity: "warning",
|
|
75
|
-
context: contextOf(index, store.owner),
|
|
76
|
-
service: store.owner,
|
|
77
|
-
id: view.id,
|
|
78
|
-
peer: readId,
|
|
79
|
-
note: `${view.id} is defined over ${readId}, which ${target.store.owner} owns; a rename over there breaks this view with no error until it is read.`,
|
|
80
|
-
source: view.source ?? store.source,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return [...errors, ...warnings];
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* A foreign key into a table another service owns. The database will enforce
|
|
91
|
-
* it, which is the problem: two services are now one schema, and neither can
|
|
92
|
-
* migrate its own table without the other's release.
|
|
93
|
-
*/
|
|
94
|
-
function crossServiceKeys(
|
|
95
|
-
index: CatalogIndex,
|
|
96
|
-
store: Store,
|
|
97
|
-
table: Table,
|
|
98
|
-
): Problem[] {
|
|
99
|
-
const out: Problem[] = [];
|
|
100
|
-
for (const column of table.columns) {
|
|
101
|
-
if (!column.fk) continue;
|
|
102
|
-
const target = index.tableById.get(column.fk.table);
|
|
103
|
-
if (!target || target.store.owner === store.owner) continue;
|
|
104
|
-
out.push({
|
|
105
|
-
kind: "cross-service-fk",
|
|
106
|
-
severity: "error",
|
|
107
|
-
context: contextOf(index, store.owner),
|
|
108
|
-
service: store.owner,
|
|
109
|
-
id: `${table.id}.${column.name}`,
|
|
110
|
-
peer: column.fk.table,
|
|
111
|
-
note: `${store.owner} holds a foreign key into a table owned by ${target.store.owner}; neither service can migrate that table alone.`,
|
|
112
|
-
source: store.source,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
return out;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* A column copied from another service's schema.
|
|
120
|
-
*
|
|
121
|
-
* Not an error: copying is how a projection is built, and the alternative — a
|
|
122
|
-
* foreign key — is worse. It is a warning because the coupling is invisible
|
|
123
|
-
* from the other side. Nothing in the source database records that someone
|
|
124
|
-
* else's column is a copy of it, so the rename that breaks this is a rename
|
|
125
|
-
* that looked safe.
|
|
126
|
-
*/
|
|
127
|
-
function crossServiceLineage(
|
|
128
|
-
index: CatalogIndex,
|
|
129
|
-
store: Store,
|
|
130
|
-
relationId: string,
|
|
131
|
-
columns: Column[],
|
|
132
|
-
): Problem[] {
|
|
133
|
-
const out: Problem[] = [];
|
|
134
|
-
const seen = new Set<string>();
|
|
135
|
-
for (const column of columns) {
|
|
136
|
-
for (const ref of column.from ?? []) {
|
|
137
|
-
const source = relationOfColumnId(ref);
|
|
138
|
-
const target =
|
|
139
|
-
index.tableById.get(source) ?? index.viewById.get(source);
|
|
140
|
-
if (!target || target.store.owner === store.owner) continue;
|
|
141
|
-
if (seen.has(ref)) continue;
|
|
142
|
-
seen.add(ref);
|
|
143
|
-
out.push({
|
|
144
|
-
kind: "cross-service-lineage",
|
|
145
|
-
severity: "warning",
|
|
146
|
-
context: contextOf(index, store.owner),
|
|
147
|
-
service: store.owner,
|
|
148
|
-
id: `${relationId}.${column.name}`,
|
|
149
|
-
peer: ref,
|
|
150
|
-
note: `${store.owner} copies this value from ${ref}, which ${target.store.owner} owns; nothing on that side records that the copy exists.`,
|
|
151
|
-
source: store.source,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return out;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* A table in one service's store that holds another service's aggregate. An
|
|
160
|
-
* aggregate is written by the service that owns it, so this is a second writer
|
|
161
|
-
* in someone else's database — reading is allowed and is drawn ghosted, but
|
|
162
|
-
* this is not reading.
|
|
163
|
-
*/
|
|
164
|
-
function sharedStore(
|
|
165
|
-
index: CatalogIndex,
|
|
166
|
-
store: Store,
|
|
167
|
-
table: Table,
|
|
168
|
-
): Problem[] {
|
|
169
|
-
const aggregateId = table.persists?.aggregate;
|
|
170
|
-
if (!aggregateId) return [];
|
|
171
|
-
// A projection is a COPY of someone else's aggregate, maintained locally
|
|
172
|
-
// from their events. That is the pattern this rule exists to make room for,
|
|
173
|
-
// not the one it is looking for.
|
|
174
|
-
if (table.role === "projection") return [];
|
|
175
|
-
const writer = index.aggregateOwner.get(aggregateId);
|
|
176
|
-
if (!writer || writer.id === store.owner) return [];
|
|
177
|
-
return [
|
|
178
|
-
{
|
|
179
|
-
kind: "shared-store",
|
|
180
|
-
severity: "error",
|
|
181
|
-
context: contextOf(index, store.owner),
|
|
182
|
-
service: store.owner,
|
|
183
|
-
id: table.id,
|
|
184
|
-
peer: writer.id,
|
|
185
|
-
note: `${store.id} is owned by ${store.owner}, but this table holds ${aggregateId}, which ${writer.id} writes.`,
|
|
186
|
-
source: store.source,
|
|
187
|
-
},
|
|
188
|
-
];
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* An outbox with nothing to deliver. The whole point of the pattern is that the
|
|
193
|
-
* event body is committed in the same transaction as the state change, so an
|
|
194
|
-
* outbox with no payload column is either not an outbox or not finished.
|
|
195
|
-
*/
|
|
196
|
-
function outboxWithoutPayload(
|
|
197
|
-
index: CatalogIndex,
|
|
198
|
-
store: Store,
|
|
199
|
-
table: Table,
|
|
200
|
-
): Problem[] {
|
|
201
|
-
if (table.role !== "outbox" || payloadColumn(table)) return [];
|
|
202
|
-
return [
|
|
203
|
-
{
|
|
204
|
-
kind: "outbox-payload",
|
|
205
|
-
severity: "warning",
|
|
206
|
-
context: contextOf(index, store.owner),
|
|
207
|
-
service: store.owner,
|
|
208
|
-
id: table.id,
|
|
209
|
-
peer: store.id,
|
|
210
|
-
note: "an outbox carries the event body in the same transaction as the state change; this table declares no json column to hold one",
|
|
211
|
-
source: store.source,
|
|
212
|
-
},
|
|
213
|
-
];
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* A table that claims an aggregate none of its columns carry. Either the table
|
|
218
|
-
* was relabelled and the columns were not, or the aggregate moved on without
|
|
219
|
-
* it; both leave the `persists` claim asserting more than it can back up.
|
|
220
|
-
*/
|
|
221
|
-
function drift(
|
|
222
|
-
catalog: Catalog,
|
|
223
|
-
index: CatalogIndex,
|
|
224
|
-
store: Store,
|
|
225
|
-
table: Table,
|
|
226
|
-
): Problem[] {
|
|
227
|
-
const aggregateId = table.persists?.aggregate;
|
|
228
|
-
if (!aggregateId) return [];
|
|
229
|
-
// A derived table holds a copy shaped for reading, not the aggregate's own
|
|
230
|
-
// fields, so it has nothing to have drifted FROM. Only a table that claims
|
|
231
|
-
// to BE where the aggregate lives can contradict it.
|
|
232
|
-
if (table.role === "projection" || table.role === "outbox") return [];
|
|
233
|
-
const aggregate = index.aggregateById.get(aggregateId);
|
|
234
|
-
if (!aggregate) return [];
|
|
235
|
-
|
|
236
|
-
const declared = new Set<string>();
|
|
237
|
-
for (const { block } of aggregateBlocks(aggregate)) {
|
|
238
|
-
for (const field of blockFields(catalog, block)) declared.add(field.name);
|
|
239
|
-
}
|
|
240
|
-
// Nothing to drift from: an aggregate whose blocks are known by name only
|
|
241
|
-
// cannot contradict a schema.
|
|
242
|
-
if (declared.size === 0) return [];
|
|
243
|
-
|
|
244
|
-
const mapped = table.columns.filter((c) => {
|
|
245
|
-
if (!c.maps) return false;
|
|
246
|
-
const head = mapsFieldPath(c.maps).split(".")[0] ?? "";
|
|
247
|
-
return declared.has(head);
|
|
248
|
-
});
|
|
249
|
-
if (mapped.length > 0) return [];
|
|
250
|
-
|
|
251
|
-
const claimed = table.columns.filter((c) => c.maps).length;
|
|
252
|
-
return [
|
|
253
|
-
{
|
|
254
|
-
kind: "persistence-drift",
|
|
255
|
-
severity: "warning",
|
|
256
|
-
context: contextOf(index, store.owner),
|
|
257
|
-
service: store.owner,
|
|
258
|
-
id: table.id,
|
|
259
|
-
peer: aggregateId,
|
|
260
|
-
note:
|
|
261
|
-
claimed === 0
|
|
262
|
-
? `no column of ${table.name} maps to a field of ${aggregateId}`
|
|
263
|
-
: `${claimed} column(s) of ${table.name} map to fields ${aggregateId} no longer declares`,
|
|
264
|
-
source: store.source,
|
|
265
|
-
},
|
|
266
|
-
];
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* A column and the field it carries that no longer agree on a type. One of the
|
|
271
|
-
* two has been changed and the other has not; which one is the reader's call,
|
|
272
|
-
* so the problem names both and stops there.
|
|
273
|
-
*/
|
|
274
|
-
function typeDrift(
|
|
275
|
-
catalog: Catalog,
|
|
276
|
-
index: CatalogIndex,
|
|
277
|
-
store: Store,
|
|
278
|
-
table: Table,
|
|
279
|
-
): Problem[] {
|
|
280
|
-
const out: Problem[] = [];
|
|
281
|
-
const aggregateId = table.persists?.aggregate;
|
|
282
|
-
const aggregate = aggregateId
|
|
283
|
-
? index.aggregateById.get(aggregateId)
|
|
284
|
-
: undefined;
|
|
285
|
-
if (!aggregate) return out;
|
|
286
|
-
|
|
287
|
-
const fields = new Map<string, string>();
|
|
288
|
-
for (const { block } of aggregateBlocks(aggregate)) {
|
|
289
|
-
for (const field of blockFields(catalog, block)) {
|
|
290
|
-
// First declaration wins: two blocks of one aggregate naming the same
|
|
291
|
-
// field agree about it far more often than not, and a column mapping to
|
|
292
|
-
// "Id" is not made clearer by picking the second one.
|
|
293
|
-
if (!fields.has(field.name)) fields.set(field.name, field.type);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
for (const column of table.columns) {
|
|
298
|
-
if (!column.maps) continue;
|
|
299
|
-
const head = mapsFieldPath(column.maps).split(".")[0] ?? "";
|
|
300
|
-
const domainType = fields.get(head);
|
|
301
|
-
if (!domainType || !typesDisagree(column.type, domainType)) continue;
|
|
302
|
-
out.push({
|
|
303
|
-
kind: "column-type",
|
|
304
|
-
severity: "warning",
|
|
305
|
-
context: contextOf(index, store.owner),
|
|
306
|
-
service: store.owner,
|
|
307
|
-
id: `${table.id}.${column.name}`,
|
|
308
|
-
peer: column.maps,
|
|
309
|
-
note: `column is ${column.type}, ${column.maps} is ${domainType}`,
|
|
310
|
-
source: store.source,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
return out;
|
|
314
|
-
}
|