@shortlink-org/portolan 0.2.0 → 0.2.2
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/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +56 -7
- 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/catalog-sources.mjs +12 -1
- package/scripts/catalog-sources.test.mjs +35 -0
- 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 +368 -34
- package/scripts/local-api.test.mjs +197 -2
- package/scripts/package-smoke.mjs +19 -2
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/schema.mjs +7 -0
- package/src/app/CatalogApp.tsx +19 -4
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/toast.tsx +11 -7
- package/src/catalog.test.ts +33 -0
- package/src/catalog.ts +190 -8
- 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.ts +24 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +124 -36
- 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 -0
- package/src/landing/LandingPage.tsx +12 -8
- 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/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 +17 -4
- package/src/lib/product.ts +7 -0
- 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 +55 -0
- package/src/merge.ts +30 -0
- 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 +382 -44
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +5 -1
|
@@ -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
|
@@ -1036,3 +1036,58 @@ describe("mergeCatalogs: maps", () => {
|
|
|
1036
1036
|
);
|
|
1037
1037
|
});
|
|
1038
1038
|
});
|
|
1039
|
+
|
|
1040
|
+
describe("mergeCatalogs: Redis persistence", () => {
|
|
1041
|
+
it("links a Redis value type to the one matching owned aggregate", () => {
|
|
1042
|
+
const books = context("library", ["library.book"]);
|
|
1043
|
+
books.services[0]!.aggregates = [
|
|
1044
|
+
{
|
|
1045
|
+
id: "library.book.book",
|
|
1046
|
+
slug: "book",
|
|
1047
|
+
name: "Book",
|
|
1048
|
+
readme: "",
|
|
1049
|
+
root: "Book",
|
|
1050
|
+
entities: [
|
|
1051
|
+
{
|
|
1052
|
+
id: "library.book.book.book",
|
|
1053
|
+
slug: "book",
|
|
1054
|
+
name: "Book",
|
|
1055
|
+
doc: "",
|
|
1056
|
+
fields: [],
|
|
1057
|
+
},
|
|
1058
|
+
],
|
|
1059
|
+
valueObjects: [],
|
|
1060
|
+
operations: [],
|
|
1061
|
+
events: [],
|
|
1062
|
+
},
|
|
1063
|
+
];
|
|
1064
|
+
|
|
1065
|
+
const merged = mergeCatalogs([
|
|
1066
|
+
source("domain.json", { contexts: [books] }),
|
|
1067
|
+
source("redis.json", {
|
|
1068
|
+
contexts: [context("library", ["library.book"])],
|
|
1069
|
+
stores: [
|
|
1070
|
+
{
|
|
1071
|
+
id: "library.book.redis",
|
|
1072
|
+
slug: "redis",
|
|
1073
|
+
name: "Book Redis",
|
|
1074
|
+
kind: "redis",
|
|
1075
|
+
owner: "library.book",
|
|
1076
|
+
tables: [],
|
|
1077
|
+
keyspaces: [
|
|
1078
|
+
{
|
|
1079
|
+
pattern: "{id}",
|
|
1080
|
+
operations: ["read"],
|
|
1081
|
+
value: "domain.Book",
|
|
1082
|
+
},
|
|
1083
|
+
],
|
|
1084
|
+
},
|
|
1085
|
+
],
|
|
1086
|
+
}),
|
|
1087
|
+
]);
|
|
1088
|
+
|
|
1089
|
+
expect(merged.catalog.stores?.[0]?.keyspaces?.[0]?.persists).toEqual({
|
|
1090
|
+
aggregate: "library.book.book",
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
});
|
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)
|
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. */}
|
package/src/pages/BlockPage.tsx
CHANGED
|
@@ -218,7 +218,13 @@ export function BlockPage({ kind }: { kind: BlockKind }) {
|
|
|
218
218
|
<PageHeader
|
|
219
219
|
kind={
|
|
220
220
|
<>
|
|
221
|
-
|
|
221
|
+
<span className="inline-flex items-center gap-1.5 font-semibold text-accent">
|
|
222
|
+
<KindIcon kind={kind} size={13} />
|
|
223
|
+
{KIND_LABEL[kind]}
|
|
224
|
+
</span>
|
|
225
|
+
<span aria-hidden className="mx-1.5 text-faint">
|
|
226
|
+
·
|
|
227
|
+
</span>
|
|
222
228
|
<Link
|
|
223
229
|
to={paths.aggregate(context.id, service.slug, aggregate.slug)}
|
|
224
230
|
className="rounded-control hover:text-ink hover:underline"
|
|
@@ -230,15 +236,18 @@ export function BlockPage({ kind }: { kind: BlockKind }) {
|
|
|
230
236
|
name={block.name}
|
|
231
237
|
id={block.id}
|
|
232
238
|
right={
|
|
233
|
-
<span className="flex items-center gap-2">
|
|
239
|
+
<span className="flex -translate-y-0.5 items-center gap-2">
|
|
234
240
|
{isRoot ? (
|
|
235
|
-
<span className="chip status-verified" title="aggregate root">
|
|
241
|
+
<span className="chip-lg status-verified" title="aggregate root">
|
|
236
242
|
<span aria-hidden className="dot" />
|
|
237
243
|
root
|
|
238
244
|
</span>
|
|
239
245
|
) : null}
|
|
240
246
|
{block.deprecated ? (
|
|
241
|
-
<span
|
|
247
|
+
<span
|
|
248
|
+
className="chip-lg"
|
|
249
|
+
title="marked @deprecated in the source"
|
|
250
|
+
>
|
|
242
251
|
deprecated
|
|
243
252
|
</span>
|
|
244
253
|
) : null}
|
|
@@ -252,34 +261,32 @@ export function BlockPage({ kind }: { kind: BlockKind }) {
|
|
|
252
261
|
</span>
|
|
253
262
|
}
|
|
254
263
|
>
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
) : null}
|
|
282
|
-
</div>
|
|
264
|
+
{block.doc ? (
|
|
265
|
+
<p className="mt-2 max-w-prose text-muted">{block.doc}</p>
|
|
266
|
+
) : null}
|
|
267
|
+
{block.ref ? (
|
|
268
|
+
<dl className="mt-2.5 grid grid-cols-1 items-start gap-x-8 gap-y-2 sm:grid-cols-[minmax(0,max-content)_max-content] sm:justify-start">
|
|
269
|
+
<div className="min-w-0">
|
|
270
|
+
<dt className="label mb-1">Type scope</dt>
|
|
271
|
+
<dd className="mono flex min-w-0 flex-wrap items-center gap-1.5 text-muted">
|
|
272
|
+
<span>shared</span>
|
|
273
|
+
<Ident value={block.ref} className="text-ink" />
|
|
274
|
+
</dd>
|
|
275
|
+
</div>
|
|
276
|
+
<div>
|
|
277
|
+
<dt className="label mb-1">References</dt>
|
|
278
|
+
<dd>
|
|
279
|
+
<a
|
|
280
|
+
href={`#${LINKS_HERE}`}
|
|
281
|
+
className="mono rounded-control text-ink hover:text-accent hover:underline"
|
|
282
|
+
>
|
|
283
|
+
<span className="tnum">{backlinkCount(links)}</span>{" "}
|
|
284
|
+
{plural(backlinkCount(links), "reference")}
|
|
285
|
+
</a>
|
|
286
|
+
</dd>
|
|
287
|
+
</div>
|
|
288
|
+
</dl>
|
|
289
|
+
) : null}
|
|
283
290
|
</PageHeader>
|
|
284
291
|
|
|
285
292
|
<div className="flex gap-section p-gutter">
|
package/src/pages/Changes.tsx
CHANGED
|
@@ -22,6 +22,8 @@ import type { ForgeRepo } from "../lib/github-catalog";
|
|
|
22
22
|
import { plural } from "../lib/format";
|
|
23
23
|
import { comparisonQuery, comparisonSide, forgeKeys, forgeRefsQuery } from "../lib/queries";
|
|
24
24
|
import { useForgeAccess } from "../app/forge-access";
|
|
25
|
+
import { CatEmptyState } from "../components/CatIllustration";
|
|
26
|
+
import { CommitLink } from "../components/CommitLink";
|
|
25
27
|
|
|
26
28
|
const LABEL: Record<Severity, string> = {
|
|
27
29
|
breaking: "Breaking",
|
|
@@ -35,10 +37,6 @@ const TONE: Record<Severity, string> = {
|
|
|
35
37
|
change: "text-accent border-accent/30 bg-accent/5",
|
|
36
38
|
};
|
|
37
39
|
|
|
38
|
-
function short(sha: string): string {
|
|
39
|
-
return sha.slice(0, 7) || "unknown";
|
|
40
|
-
}
|
|
41
|
-
|
|
42
40
|
function forgeName(repo: ForgeRepo): string {
|
|
43
41
|
return repo.provider === "gitlab" ? "GitLab" : "GitHub";
|
|
44
42
|
}
|
|
@@ -273,9 +271,9 @@ export function Changes() {
|
|
|
273
271
|
</div>
|
|
274
272
|
|
|
275
273
|
{loaded.changes.length === 0 ? (
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
</
|
|
274
|
+
<CatEmptyState scene="unchanged" title="No architectural changes" className="mt-4 max-w-prose">
|
|
275
|
+
These branch heads describe the same catalog.
|
|
276
|
+
</CatEmptyState>
|
|
279
277
|
) : shown.length === 0 ? (
|
|
280
278
|
<div className="empty mt-4">No changes match these filters.</div>
|
|
281
279
|
) : (
|
|
@@ -301,8 +299,8 @@ export function Changes() {
|
|
|
301
299
|
)}
|
|
302
300
|
|
|
303
301
|
<div className="mono mt-4 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs text-muted">
|
|
304
|
-
<span>{base} · {
|
|
305
|
-
<span>{head} · {
|
|
302
|
+
<span>{base} · <CommitLink commit={loaded.baseSha} repo={repo} /></span>
|
|
303
|
+
<span>{head} · <CommitLink commit={loaded.headSha} repo={repo} /></span>
|
|
306
304
|
<span className="ml-auto">loaded from {repo?.provider === "gitlab" ? "GitLab" : "GitHub"} at runtime</span>
|
|
307
305
|
</div>
|
|
308
306
|
</>
|