@shortlink-org/portolan 0.2.1 → 0.2.3
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 +7 -2
- package/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +92 -13
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +14 -2
- package/scripts/catalog-sources.test.mjs +45 -0
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +330 -346
- package/scripts/local-api.test.mjs +245 -1
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +11 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +23 -8
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/app/toast.tsx +11 -7
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1339 -0
- package/src/catalog-validation.ts +1570 -0
- package/src/catalog.test.ts +49 -0
- package/src/catalog.ts +6 -3124
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.tsx +23 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +125 -37
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -17
- package/src/landing/LandingPage.tsx +16 -12
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/derive.ts +1 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +36 -11
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +101 -0
- package/src/merge.ts +65 -1
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +257 -44
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +3 -1
package/src/lib/setup-info.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface SetupProject {
|
|
|
17
17
|
service?: string;
|
|
18
18
|
group?: string;
|
|
19
19
|
component?: string;
|
|
20
|
+
components?: string[];
|
|
20
21
|
groupKind?: string;
|
|
21
22
|
componentKind?: string;
|
|
22
23
|
repository?: string;
|
|
@@ -99,6 +100,7 @@ interface ManifestProject {
|
|
|
99
100
|
service?: unknown;
|
|
100
101
|
group?: unknown;
|
|
101
102
|
component?: unknown;
|
|
103
|
+
components?: unknown;
|
|
102
104
|
groupKind?: unknown;
|
|
103
105
|
componentKind?: unknown;
|
|
104
106
|
repository?: unknown;
|
|
@@ -289,6 +291,9 @@ function projectFrom(value: unknown): SetupProject | null {
|
|
|
289
291
|
...(typeof item.service === "string" ? { service: item.service } : {}),
|
|
290
292
|
...(typeof item.group === "string" ? { group: item.group } : {}),
|
|
291
293
|
...(typeof item.component === "string" ? { component: item.component } : {}),
|
|
294
|
+
...(array(item.components).every((component) => typeof component === "string") && array(item.components).length > 0
|
|
295
|
+
? { components: array(item.components) as string[] }
|
|
296
|
+
: {}),
|
|
292
297
|
...(typeof item.groupKind === "string" ? { groupKind: item.groupKind } : {}),
|
|
293
298
|
...(typeof item.componentKind === "string" ? { componentKind: item.componentKind } : {}),
|
|
294
299
|
...(repository ? { repository } : {}),
|
|
@@ -23,7 +23,10 @@ describe("splitLine", () => {
|
|
|
23
23
|
});
|
|
24
24
|
});
|
|
25
25
|
it("leaves anything else whole", () => {
|
|
26
|
-
expect(splitLine("trace 9f2c1a../span 04")).toEqual({
|
|
26
|
+
expect(splitLine("trace 9f2c1a../span 04")).toEqual({
|
|
27
|
+
path: "trace 9f2c1a../span 04",
|
|
28
|
+
line: null,
|
|
29
|
+
});
|
|
27
30
|
expect(splitLine("services/oms/test/e2e/checkout_test.go").line).toBeNull();
|
|
28
31
|
});
|
|
29
32
|
});
|
|
@@ -31,7 +34,10 @@ describe("splitLine", () => {
|
|
|
31
34
|
const shop = { repo: "github.com/acme/shop" };
|
|
32
35
|
|
|
33
36
|
const PINS: RepoPin[] = [
|
|
34
|
-
{
|
|
37
|
+
{
|
|
38
|
+
repo: "github.com/acme/shop",
|
|
39
|
+
commit: "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0",
|
|
40
|
+
},
|
|
35
41
|
];
|
|
36
42
|
|
|
37
43
|
describe("sourceHref", () => {
|
|
@@ -41,18 +47,37 @@ describe("sourceHref", () => {
|
|
|
41
47
|
expect(sourceHref("examples/auth/internal/x.go:20", auth, [], GH)).toBe(
|
|
42
48
|
"https://github.com/shortlink-org/portolan/blob/4f1c9ae0c1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6/examples/auth/internal/x.go#L20",
|
|
43
49
|
);
|
|
44
|
-
expect(sourceHref("examples/auth/README.md", auth, [], GH)).toMatch(
|
|
50
|
+
expect(sourceHref("examples/auth/README.md", auth, [], GH)).toMatch(
|
|
51
|
+
/README\.md$/,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("recognizes the checkout's short name as the repository being built", () => {
|
|
56
|
+
expect(
|
|
57
|
+
sourceHref("internal/book/store.go:59", { repo: "portolan" }, [], GH),
|
|
58
|
+
).toBe(
|
|
59
|
+
`https://github.com/shortlink-org/portolan/blob/${GH.commit}/internal/book/store.go#L59`,
|
|
60
|
+
);
|
|
45
61
|
});
|
|
46
62
|
|
|
47
63
|
it("uses GitLab's /-/blob/ when that is the forge", () => {
|
|
48
64
|
const gl = { ...GH, repoUrl: "https://gitlab.com/acme/portolan" };
|
|
49
|
-
expect(
|
|
65
|
+
expect(
|
|
66
|
+
sourceHref("a/b.go:1", { repo: "gitlab.com/acme/portolan" }, [], gl),
|
|
67
|
+
).toBe(
|
|
50
68
|
"https://gitlab.com/acme/portolan/-/blob/4f1c9ae0c1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6/a/b.go#L1",
|
|
51
69
|
);
|
|
52
70
|
});
|
|
53
71
|
|
|
54
72
|
it("does not link a service that lives in another repository, unpinned", () => {
|
|
55
|
-
expect(
|
|
73
|
+
expect(
|
|
74
|
+
sourceHref(
|
|
75
|
+
"internal/oms/app/checkout.go:104",
|
|
76
|
+
{ repo: "github.com/acme/shop" },
|
|
77
|
+
[],
|
|
78
|
+
GH,
|
|
79
|
+
),
|
|
80
|
+
).toBeNull();
|
|
56
81
|
});
|
|
57
82
|
|
|
58
83
|
it("links a service in another repository at the commit it was fetched at", () => {
|
|
@@ -62,29 +87,54 @@ describe("sourceHref", () => {
|
|
|
62
87
|
});
|
|
63
88
|
|
|
64
89
|
it("removes the local vendoring prefix from an external source", () => {
|
|
65
|
-
expect(
|
|
90
|
+
expect(
|
|
91
|
+
sourceHref(
|
|
92
|
+
"vendor/repos/acme/shop/services/oms/app/checkout.go:104",
|
|
93
|
+
shop,
|
|
94
|
+
PINS,
|
|
95
|
+
GH,
|
|
96
|
+
),
|
|
97
|
+
).toBe(
|
|
66
98
|
"https://github.com/acme/shop/blob/c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0/services/oms/app/checkout.go#L104",
|
|
67
99
|
);
|
|
68
100
|
});
|
|
69
101
|
|
|
70
102
|
it("reads a pin however the repository was spelled", () => {
|
|
71
|
-
const pins: RepoPin[] = [
|
|
103
|
+
const pins: RepoPin[] = [
|
|
104
|
+
{ repo: "https://GitHub.com/acme/shop.git", commit: "abc1234" },
|
|
105
|
+
];
|
|
72
106
|
expect(sourceHref("services/oms/app/checkout.go", shop, pins, GH)).toBe(
|
|
73
107
|
"https://github.com/acme/shop/blob/abc1234/services/oms/app/checkout.go",
|
|
74
108
|
);
|
|
75
109
|
});
|
|
76
110
|
|
|
77
111
|
it("prefers the build over a pin naming the repository this was built from", () => {
|
|
78
|
-
const pins: RepoPin[] = [
|
|
79
|
-
|
|
112
|
+
const pins: RepoPin[] = [
|
|
113
|
+
{ repo: "github.com/shortlink-org/portolan", commit: "0000000" },
|
|
114
|
+
];
|
|
115
|
+
expect(
|
|
116
|
+
sourceHref("examples/auth/internal/x.go:20", auth, pins, GH),
|
|
117
|
+
).toContain(GH.commit);
|
|
80
118
|
});
|
|
81
119
|
|
|
82
120
|
it("does not link a pin with no commit", () => {
|
|
83
|
-
expect(
|
|
121
|
+
expect(
|
|
122
|
+
sourceHref(
|
|
123
|
+
"services/oms/app/checkout.go",
|
|
124
|
+
shop,
|
|
125
|
+
[{ repo: "github.com/acme/shop", commit: "" }],
|
|
126
|
+
GH,
|
|
127
|
+
),
|
|
128
|
+
).toBeNull();
|
|
84
129
|
});
|
|
85
130
|
|
|
86
131
|
it("does not link when the build does not know its repository", () => {
|
|
87
|
-
expect(
|
|
132
|
+
expect(
|
|
133
|
+
sourceHref("examples/auth/internal/x.go:20", auth, [], {
|
|
134
|
+
...GH,
|
|
135
|
+
repoUrl: "",
|
|
136
|
+
}),
|
|
137
|
+
).toBeNull();
|
|
88
138
|
});
|
|
89
139
|
|
|
90
140
|
it("does not link a place that is not a path", () => {
|
|
@@ -92,13 +142,22 @@ describe("sourceHref", () => {
|
|
|
92
142
|
});
|
|
93
143
|
|
|
94
144
|
it("links a service that names no repository, since there is only the one", () => {
|
|
95
|
-
expect(sourceHref("docs/x.md", { repo: "" }, [], GH)).toMatch(
|
|
145
|
+
expect(sourceHref("docs/x.md", { repo: "" }, [], GH)).toMatch(
|
|
146
|
+
/\/docs\/x\.md$/,
|
|
147
|
+
);
|
|
96
148
|
});
|
|
97
149
|
});
|
|
98
150
|
|
|
99
151
|
describe("sourceLocation", () => {
|
|
100
152
|
it("keeps the immutable repository, commit, path, and line needed by a preview", () => {
|
|
101
|
-
expect(
|
|
153
|
+
expect(
|
|
154
|
+
sourceLocation(
|
|
155
|
+
"examples/auth/internal/x.go:20",
|
|
156
|
+
{ repo: "github.com/shortlink-org/portolan" },
|
|
157
|
+
[],
|
|
158
|
+
GH,
|
|
159
|
+
),
|
|
160
|
+
).toEqual({
|
|
102
161
|
kind: "remote",
|
|
103
162
|
provider: "github",
|
|
104
163
|
origin: "https://github.com",
|
|
@@ -111,8 +170,19 @@ describe("sourceLocation", () => {
|
|
|
111
170
|
});
|
|
112
171
|
|
|
113
172
|
it("uses the explicit forge hint for a self-hosted GitLab repository", () => {
|
|
114
|
-
const gl = {
|
|
115
|
-
|
|
173
|
+
const gl = {
|
|
174
|
+
...GH,
|
|
175
|
+
repoUrl: "https://git.example.test/acme/portolan",
|
|
176
|
+
forge: "gitlab" as const,
|
|
177
|
+
};
|
|
178
|
+
expect(
|
|
179
|
+
sourceLocation(
|
|
180
|
+
"internal/x.go:3",
|
|
181
|
+
{ repo: "git.example.test/acme/portolan" },
|
|
182
|
+
[],
|
|
183
|
+
gl,
|
|
184
|
+
),
|
|
185
|
+
).toMatchObject({
|
|
116
186
|
kind: "remote",
|
|
117
187
|
provider: "gitlab",
|
|
118
188
|
origin: "https://git.example.test",
|
|
@@ -123,7 +193,13 @@ describe("sourceLocation", () => {
|
|
|
123
193
|
});
|
|
124
194
|
|
|
125
195
|
it("falls back to the localhost source service when no forge is known", () => {
|
|
126
|
-
expect(
|
|
196
|
+
expect(
|
|
197
|
+
sourceLocation("internal/x.go:3", null, [], {
|
|
198
|
+
...GH,
|
|
199
|
+
repoUrl: "",
|
|
200
|
+
commit: "",
|
|
201
|
+
}),
|
|
202
|
+
).toEqual({
|
|
127
203
|
kind: "local",
|
|
128
204
|
path: "internal/x.go",
|
|
129
205
|
line: 3,
|
|
@@ -134,7 +210,14 @@ describe("sourceLocation", () => {
|
|
|
134
210
|
|
|
135
211
|
describe("treeHref", () => {
|
|
136
212
|
it("opens the service's directory", () => {
|
|
137
|
-
expect(
|
|
213
|
+
expect(
|
|
214
|
+
treeHref(
|
|
215
|
+
"examples/auth",
|
|
216
|
+
{ repo: "github.com/shortlink-org/portolan" },
|
|
217
|
+
[],
|
|
218
|
+
GH,
|
|
219
|
+
),
|
|
220
|
+
).toBe(
|
|
138
221
|
"https://github.com/shortlink-org/portolan/tree/4f1c9ae0c1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6/examples/auth",
|
|
139
222
|
);
|
|
140
223
|
});
|
|
@@ -146,8 +229,13 @@ describe("treeHref", () => {
|
|
|
146
229
|
});
|
|
147
230
|
|
|
148
231
|
it("removes the local vendoring prefix from an external tree", () => {
|
|
149
|
-
expect(
|
|
232
|
+
expect(
|
|
233
|
+
treeHref("vendor/repos/acme/shop/services/oms", shop, PINS, GH),
|
|
234
|
+
).toBe(
|
|
150
235
|
"https://github.com/acme/shop/tree/c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0/services/oms",
|
|
151
236
|
);
|
|
237
|
+
expect(treeHref("vendor/repos/acme/shop", shop, PINS, GH)).toBe(
|
|
238
|
+
"https://github.com/acme/shop/tree/c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0",
|
|
239
|
+
);
|
|
152
240
|
});
|
|
153
241
|
});
|
package/src/lib/source-link.ts
CHANGED
|
@@ -23,7 +23,10 @@ import { buildInfo } from "./build-info";
|
|
|
23
23
|
import type { BuildInfo } from "./build-info";
|
|
24
24
|
|
|
25
25
|
/** A `file:line` split, or the whole thing as a path when it is not one. */
|
|
26
|
-
export function splitLine(where: string): {
|
|
26
|
+
export function splitLine(where: string): {
|
|
27
|
+
path: string;
|
|
28
|
+
line: number | null;
|
|
29
|
+
} {
|
|
27
30
|
const m = /^(\S+?):(\d+)$/.exec(where);
|
|
28
31
|
if (m) return { path: m[1]!, line: Number(m[2]) };
|
|
29
32
|
return { path: where, line: null };
|
|
@@ -52,8 +55,13 @@ export function looksLikePath(path: string): boolean {
|
|
|
52
55
|
function sameRepo(repo: string, info: BuildInfo): boolean {
|
|
53
56
|
if (!info.repoUrl) return false;
|
|
54
57
|
if (!repo) return true;
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
const built = bare(info.repoUrl);
|
|
59
|
+
const declared = bare(repo);
|
|
60
|
+
if (built === declared) return true;
|
|
61
|
+
// Project extractors sometimes only know the checkout's directory name.
|
|
62
|
+
// It still identifies this build when it is a single segment matching the
|
|
63
|
+
// repository name at the end of the forge URL.
|
|
64
|
+
return !declared.includes("/") && built.split("/").at(-1) === declared;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
/** A repository as `host/owner/name`, however it was spelled. */
|
|
@@ -70,7 +78,9 @@ function repositoryPath(path: string, repo: string, info: BuildInfo): string {
|
|
|
70
78
|
if (!repo || sameRepo(repo, info)) return path;
|
|
71
79
|
const segments = bare(repo).split("/").filter(Boolean);
|
|
72
80
|
if (segments.length < 3) return path;
|
|
73
|
-
const
|
|
81
|
+
const root = `vendor/repos/${segments.at(-2)}/${segments.at(-1)}`;
|
|
82
|
+
if (path === root) return "";
|
|
83
|
+
const prefix = `${root}/`;
|
|
74
84
|
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
75
85
|
}
|
|
76
86
|
|
|
@@ -133,7 +143,8 @@ function providerFor(url: string, info: BuildInfo): "github" | "gitlab" | null {
|
|
|
133
143
|
} catch {
|
|
134
144
|
return null;
|
|
135
145
|
}
|
|
136
|
-
if (info.repoUrl && bare(info.repoUrl) === bare(url) && info.forge)
|
|
146
|
+
if (info.repoUrl && bare(info.repoUrl) === bare(url) && info.forge)
|
|
147
|
+
return info.forge;
|
|
137
148
|
if (host === "github.com" || host.endsWith(".github.com")) return "github";
|
|
138
149
|
if (host === "gitlab.com" || host.includes("gitlab")) return "gitlab";
|
|
139
150
|
return null;
|
|
@@ -197,5 +208,10 @@ export function treeHref(
|
|
|
197
208
|
const at = whereFor(service?.repo ?? "", pins, info);
|
|
198
209
|
if (!at) return null;
|
|
199
210
|
|
|
200
|
-
|
|
211
|
+
const remotePath = repositoryPath(
|
|
212
|
+
path.replace(/\/$/, ""),
|
|
213
|
+
service?.repo ?? "",
|
|
214
|
+
info,
|
|
215
|
+
);
|
|
216
|
+
return `${at.url}${blobPath(at.url).replace("blob", "tree")}${at.ref}${remotePath ? `/${remotePath}` : ""}`;
|
|
201
217
|
}
|
package/src/merge.test.ts
CHANGED
|
@@ -181,6 +181,52 @@ describe("mergeCatalogs", () => {
|
|
|
181
181
|
expect(service?.provides.map((p) => p.id)).toEqual(["auth.v1.Users"]);
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
+
it("retains and unions vendored proto descriptors across service fragments", () => {
|
|
185
|
+
const first = context("shop", ["shop.oms"]);
|
|
186
|
+
first.services[0]!.copies = [
|
|
187
|
+
{
|
|
188
|
+
id: "pricing.v1.Pricing",
|
|
189
|
+
source: "pricing.proto",
|
|
190
|
+
methods: [{ name: "GetQuote" }],
|
|
191
|
+
enums: [
|
|
192
|
+
{
|
|
193
|
+
name: "Status",
|
|
194
|
+
values: [{ name: "STATUS_UNSPECIFIED", number: 0 }],
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
const second = context("shop", ["shop.oms"]);
|
|
200
|
+
second.services[0]!.copies = [
|
|
201
|
+
{
|
|
202
|
+
id: "pricing.v1.Pricing",
|
|
203
|
+
source: "pricing.proto",
|
|
204
|
+
methods: [{ name: "WatchPrices" }],
|
|
205
|
+
enums: [
|
|
206
|
+
{
|
|
207
|
+
name: "Status",
|
|
208
|
+
values: [{ name: "STATUS_READY", number: 1 }],
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const merged = mergeCatalogs([
|
|
215
|
+
source("a.json", { contexts: [first] }),
|
|
216
|
+
source("b.json", { contexts: [second] }),
|
|
217
|
+
]);
|
|
218
|
+
const copy = merged.catalog.contexts[0]?.services[0]?.copies?.[0];
|
|
219
|
+
|
|
220
|
+
expect(copy?.methods.map((method) => method.name)).toEqual([
|
|
221
|
+
"GetQuote",
|
|
222
|
+
"WatchPrices",
|
|
223
|
+
]);
|
|
224
|
+
expect(copy?.enums?.[0]?.values.map((value) => value.name)).toEqual([
|
|
225
|
+
"STATUS_UNSPECIFIED",
|
|
226
|
+
"STATUS_READY",
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
|
|
184
230
|
it("unions a service's commands by the line to type", () => {
|
|
185
231
|
const domain = context("shop", ["shop.cart"]);
|
|
186
232
|
domain.services = domain.services.map((s) => ({
|
|
@@ -1036,3 +1082,58 @@ describe("mergeCatalogs: maps", () => {
|
|
|
1036
1082
|
);
|
|
1037
1083
|
});
|
|
1038
1084
|
});
|
|
1085
|
+
|
|
1086
|
+
describe("mergeCatalogs: Redis persistence", () => {
|
|
1087
|
+
it("links a Redis value type to the one matching owned aggregate", () => {
|
|
1088
|
+
const books = context("library", ["library.book"]);
|
|
1089
|
+
books.services[0]!.aggregates = [
|
|
1090
|
+
{
|
|
1091
|
+
id: "library.book.book",
|
|
1092
|
+
slug: "book",
|
|
1093
|
+
name: "Book",
|
|
1094
|
+
readme: "",
|
|
1095
|
+
root: "Book",
|
|
1096
|
+
entities: [
|
|
1097
|
+
{
|
|
1098
|
+
id: "library.book.book.book",
|
|
1099
|
+
slug: "book",
|
|
1100
|
+
name: "Book",
|
|
1101
|
+
doc: "",
|
|
1102
|
+
fields: [],
|
|
1103
|
+
},
|
|
1104
|
+
],
|
|
1105
|
+
valueObjects: [],
|
|
1106
|
+
operations: [],
|
|
1107
|
+
events: [],
|
|
1108
|
+
},
|
|
1109
|
+
];
|
|
1110
|
+
|
|
1111
|
+
const merged = mergeCatalogs([
|
|
1112
|
+
source("domain.json", { contexts: [books] }),
|
|
1113
|
+
source("redis.json", {
|
|
1114
|
+
contexts: [context("library", ["library.book"])],
|
|
1115
|
+
stores: [
|
|
1116
|
+
{
|
|
1117
|
+
id: "library.book.redis",
|
|
1118
|
+
slug: "redis",
|
|
1119
|
+
name: "Book Redis",
|
|
1120
|
+
kind: "redis",
|
|
1121
|
+
owner: "library.book",
|
|
1122
|
+
tables: [],
|
|
1123
|
+
keyspaces: [
|
|
1124
|
+
{
|
|
1125
|
+
pattern: "{id}",
|
|
1126
|
+
operations: ["read"],
|
|
1127
|
+
value: "domain.Book",
|
|
1128
|
+
},
|
|
1129
|
+
],
|
|
1130
|
+
},
|
|
1131
|
+
],
|
|
1132
|
+
}),
|
|
1133
|
+
]);
|
|
1134
|
+
|
|
1135
|
+
expect(merged.catalog.stores?.[0]?.keyspaces?.[0]?.persists).toEqual({
|
|
1136
|
+
aggregate: "library.book.book",
|
|
1137
|
+
});
|
|
1138
|
+
});
|
|
1139
|
+
});
|
package/src/merge.ts
CHANGED
|
@@ -403,8 +403,38 @@ function foldMaps(catalog: Catalog, stores: Store[]): Store[] {
|
|
|
403
403
|
return spelled.join(".");
|
|
404
404
|
};
|
|
405
405
|
|
|
406
|
+
const aggregateForValue = (
|
|
407
|
+
store: Store,
|
|
408
|
+
value: string | undefined,
|
|
409
|
+
): Aggregate | undefined => {
|
|
410
|
+
const name = value?.match(/[A-Za-z_][A-Za-z0-9_]*$/)?.[0];
|
|
411
|
+
if (!name) return undefined;
|
|
412
|
+
const matches = [...aggregates.values()].filter(
|
|
413
|
+
(aggregate) =>
|
|
414
|
+
aggregate.id.startsWith(`${store.owner}.`) &&
|
|
415
|
+
(aggregate.root === name ||
|
|
416
|
+
aggregate.entities.some((entity) => entity.name === name)),
|
|
417
|
+
);
|
|
418
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
419
|
+
};
|
|
420
|
+
|
|
406
421
|
return stores.map((store) => ({
|
|
407
422
|
...store,
|
|
423
|
+
...(store.keyspaces
|
|
424
|
+
? {
|
|
425
|
+
keyspaces: store.keyspaces.map((keyspace) => {
|
|
426
|
+
if (keyspace.persists?.aggregate || keyspace.persists?.block)
|
|
427
|
+
return keyspace;
|
|
428
|
+
const aggregate = aggregateForValue(store, keyspace.value);
|
|
429
|
+
return aggregate
|
|
430
|
+
? {
|
|
431
|
+
...keyspace,
|
|
432
|
+
persists: { aggregate: aggregate.id },
|
|
433
|
+
}
|
|
434
|
+
: keyspace;
|
|
435
|
+
}),
|
|
436
|
+
}
|
|
437
|
+
: {}),
|
|
408
438
|
tables: store.tables.map((table) => {
|
|
409
439
|
const aggregate = table.persists?.aggregate
|
|
410
440
|
? aggregates.get(table.persists.aggregate)
|
|
@@ -443,8 +473,11 @@ function mergeService(
|
|
|
443
473
|
if (!existing) {
|
|
444
474
|
services.set(incoming.id, {
|
|
445
475
|
...incoming,
|
|
446
|
-
provides:
|
|
476
|
+
provides: incoming.provides.map(copyInterface),
|
|
447
477
|
consumes: [...incoming.consumes],
|
|
478
|
+
...(incoming.copies
|
|
479
|
+
? { copies: incoming.copies.map(copyInterface) }
|
|
480
|
+
: {}),
|
|
448
481
|
aggregates: incoming.aggregates.map(copyAggregate),
|
|
449
482
|
...(incoming.stores ? { stores: [...incoming.stores] } : {}),
|
|
450
483
|
...(incoming.modules ? { modules: [...incoming.modules] } : {}),
|
|
@@ -504,6 +537,11 @@ function mergeService(
|
|
|
504
537
|
|
|
505
538
|
mergeInterfaces(existing.provides, incoming.provides);
|
|
506
539
|
appendNew(existing.consumes, incoming.consumes, (c) => c.id, raise);
|
|
540
|
+
if (incoming.copies?.length) {
|
|
541
|
+
const copies = existing.copies ?? [];
|
|
542
|
+
mergeInterfaces(copies, incoming.copies);
|
|
543
|
+
existing.copies = copies;
|
|
544
|
+
}
|
|
507
545
|
mergeAggregates(existing.aggregates, incoming.aggregates);
|
|
508
546
|
|
|
509
547
|
if (incoming.stores?.length) {
|
|
@@ -683,6 +721,24 @@ function mergeInterfaces(target: RpcService[], incoming: RpcService[]): void {
|
|
|
683
721
|
}
|
|
684
722
|
}
|
|
685
723
|
if (messages.length > 0) mine.messages = messages;
|
|
724
|
+
|
|
725
|
+
const enums = mine.enums ?? [];
|
|
726
|
+
for (const item of theirs.enums ?? []) {
|
|
727
|
+
const existing = enums.find((candidate) => candidate.name === item.name);
|
|
728
|
+
if (!existing) {
|
|
729
|
+
enums.push({
|
|
730
|
+
...item,
|
|
731
|
+
values: item.values.map((value) => ({ ...value })),
|
|
732
|
+
});
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
appendNew(
|
|
736
|
+
existing.values,
|
|
737
|
+
item.values.map((value) => ({ ...value })),
|
|
738
|
+
(value) => value.name,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
if (enums.length > 0) mine.enums = enums;
|
|
686
742
|
}
|
|
687
743
|
}
|
|
688
744
|
|
|
@@ -706,6 +762,14 @@ function copyInterface(provided: RpcService): RpcService {
|
|
|
706
762
|
})),
|
|
707
763
|
}
|
|
708
764
|
: {}),
|
|
765
|
+
...(provided.enums
|
|
766
|
+
? {
|
|
767
|
+
enums: provided.enums.map((item) => ({
|
|
768
|
+
...item,
|
|
769
|
+
values: item.values.map((value) => ({ ...value })),
|
|
770
|
+
})),
|
|
771
|
+
}
|
|
772
|
+
: {}),
|
|
709
773
|
};
|
|
710
774
|
}
|
|
711
775
|
|
package/src/pages/AdrDetail.tsx
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { eventPath, paths, servicePath } from "../routes";
|
|
15
15
|
import { PinButton } from "../app/pins";
|
|
16
16
|
import { NotFound } from "./NotFound";
|
|
17
|
+
import { CommitLink } from "../components/CommitLink";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The body's own H1 repeats the title in the header of this page; MADR files
|
|
@@ -231,12 +232,12 @@ export function AdrDetail() {
|
|
|
231
232
|
/** Who wrote the record down and when, from git: "committed by Ada Lovelace · 2026-01-01 · 0a1b2c3". */
|
|
232
233
|
function CommitLine({ label, commit }: { label: string; commit: AdrCommit }) {
|
|
233
234
|
return (
|
|
234
|
-
<span
|
|
235
|
+
<span>
|
|
235
236
|
{label} by <span className="text-ink">{commit.author || "unknown"}</span>
|
|
236
237
|
{" · "}
|
|
237
238
|
{commit.date.slice(0, 10)}
|
|
238
239
|
{" · "}
|
|
239
|
-
<
|
|
240
|
+
<CommitLink commit={commit.commit} />
|
|
240
241
|
</span>
|
|
241
242
|
);
|
|
242
243
|
}
|
package/src/pages/AdrIndex.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useDocumentTitle } from "../app/title";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { Link } from "react-router";
|
|
4
|
+
import { catalog } from "../data";
|
|
4
5
|
import type { Adr } from "../catalog";
|
|
5
6
|
import { scopeLabel, sortAdrs } from "../lib/adr";
|
|
6
7
|
import {
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
AdrScopePill,
|
|
9
10
|
AdrStatusChip,
|
|
10
11
|
} from "../components/primitives";
|
|
11
|
-
import {
|
|
12
|
+
import { CapabilityEmpty, Empty } from "../components/PageHeader";
|
|
12
13
|
import { RowActions } from "../components/RowActions";
|
|
13
14
|
import { DataTable } from "../table/DataTable";
|
|
14
15
|
import type { ColumnSpec } from "../table/types";
|
|
@@ -85,11 +86,13 @@ export function AdrIndex() {
|
|
|
85
86
|
|
|
86
87
|
{bare ? (
|
|
87
88
|
<div className="mt-section">
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
<
|
|
92
|
-
|
|
89
|
+
<CapabilityEmpty
|
|
90
|
+
title="No architecture decisions on the record yet"
|
|
91
|
+
signal="Signal: ADR markdown discovered by the project extractor → adrs[]."
|
|
92
|
+
actions={<Link className="product-primary" to={catalog.contexts.length === 0 ? paths.settingsProjects() : paths.settingsPipeline()}>{catalog.contexts.length === 0 ? "Connect a project" : "Review extraction"}</Link>}
|
|
93
|
+
>
|
|
94
|
+
An ADR preserves a decision as it was made. Add or expose the repository’s decision markdown so Portolan can place it beside the affected architecture.
|
|
95
|
+
</CapabilityEmpty>
|
|
93
96
|
</div>
|
|
94
97
|
) : (
|
|
95
98
|
<div className="mt-section max-w-table">
|
|
@@ -12,7 +12,11 @@ import type {
|
|
|
12
12
|
Enum,
|
|
13
13
|
} from "../catalog";
|
|
14
14
|
import { markdownOutline } from "../lib/derive";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
redisKeyspacesPersisting,
|
|
17
|
+
tablesPersisting,
|
|
18
|
+
viewsPresenting,
|
|
19
|
+
} from "../lib/data-model";
|
|
16
20
|
import { plural } from "../lib/format";
|
|
17
21
|
import { KIND_LABEL, KIND_PLURAL } from "../lib/kinds";
|
|
18
22
|
import type { LeafKind } from "../lib/kinds";
|
|
@@ -352,6 +356,7 @@ export function AggregatePage() {
|
|
|
352
356
|
const root = rootEntity(aggregate);
|
|
353
357
|
const persistence = tablesPersisting(index, aggregate.id);
|
|
354
358
|
const presented = viewsPresenting(index, aggregate.id);
|
|
359
|
+
const redisKeyspaces = redisKeyspacesPersisting(index, aggregate.id);
|
|
355
360
|
|
|
356
361
|
// The readme's own headings first, then the five sections the page adds
|
|
357
362
|
// under it. One rail, in the order the page is actually written in.
|
|
@@ -618,17 +623,17 @@ export function AggregatePage() {
|
|
|
618
623
|
anchor={AGGREGATE_SECTION.persistence}
|
|
619
624
|
right={
|
|
620
625
|
<span>
|
|
621
|
-
|
|
626
|
+
tables and Redis keys that hold it
|
|
622
627
|
{presented.length > 0 ? ", then the views over it" : ""}
|
|
623
628
|
</span>
|
|
624
629
|
}
|
|
625
630
|
>
|
|
626
631
|
Persistence
|
|
627
632
|
</SectionTitle>
|
|
628
|
-
{persistence.length === 0 ? (
|
|
633
|
+
{persistence.length === 0 && redisKeyspaces.length === 0 ? (
|
|
629
634
|
<Empty>
|
|
630
635
|
No persistence found for {aggregate.name} — the extractor maps
|
|
631
|
-
tables via `persists`
|
|
636
|
+
tables and Redis key families via `persists`
|
|
632
637
|
</Empty>
|
|
633
638
|
) : (
|
|
634
639
|
/* icon, name, what it is, store, width, actions. Tables and
|
|
@@ -673,6 +678,31 @@ export function AggregatePage() {
|
|
|
673
678
|
</div>
|
|
674
679
|
);
|
|
675
680
|
})}
|
|
681
|
+
{redisKeyspaces.map(({ keyspace, store }) => (
|
|
682
|
+
<div
|
|
683
|
+
key={`${store.id}:${keyspace.pattern}`}
|
|
684
|
+
className="row px-2 py-1.5"
|
|
685
|
+
>
|
|
686
|
+
<KindIcon kind="store" />
|
|
687
|
+
<Link
|
|
688
|
+
to={paths.store(context.id, service.slug, store.slug)}
|
|
689
|
+
data-nav-item
|
|
690
|
+
className="mono min-w-0 break-all rounded-control"
|
|
691
|
+
>
|
|
692
|
+
{keyspace.pattern}
|
|
693
|
+
</Link>
|
|
694
|
+
<span className="chip">redis key</span>
|
|
695
|
+
<span className="mono text-muted">{store.slug}</span>
|
|
696
|
+
<span className="mono text-muted">
|
|
697
|
+
{keyspace.operations.join("/")}
|
|
698
|
+
</span>
|
|
699
|
+
<RowActions
|
|
700
|
+
copy={keyspace.pattern}
|
|
701
|
+
reveal={store.id}
|
|
702
|
+
label={keyspace.pattern}
|
|
703
|
+
/>
|
|
704
|
+
</div>
|
|
705
|
+
))}
|
|
676
706
|
{/* A view is not persistence — it holds nothing — but it is
|
|
677
707
|
how this aggregate is actually read back, and a reader who
|
|
678
708
|
has found the tables wants the reports over them next. */}
|