@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.
Files changed (135) hide show
  1. package/README.md +7 -2
  2. package/cli/init.mjs +14 -6
  3. package/cli/init.test.mjs +8 -1
  4. package/cli/portolan.mjs +92 -13
  5. package/cli/portolan.test.mjs +47 -0
  6. package/package.json +7 -6
  7. package/plugins/extract-django/README.md +1 -1
  8. package/plugins/extract-django/extract_test.py +1 -1
  9. package/plugins/extract-django/openapi.py +4 -14
  10. package/plugins/extract-django/openapi_test.py +4 -4
  11. package/plugins/extract-go/options.schema.json +5 -0
  12. package/plugins/extract-project/options.schema.json +21 -0
  13. package/plugins/extract-ts/README.md +1 -1
  14. package/plugins/extract-ts/clients.ts +1 -1
  15. package/plugins/extract-ts/extract.test.ts +2 -2
  16. package/plugins/extract-ts/graphql.test.ts +2 -2
  17. package/plugins/extract-ts/openapi.test.ts +6 -7
  18. package/plugins/extract-ts/openapi.ts +3 -10
  19. package/plugins/portolan-go.wasm +0 -0
  20. package/public/404-cat-v1.webp +0 -0
  21. package/public/cat-about-v1.webp +0 -0
  22. package/public/cat-clear-skies-v1.webp +0 -0
  23. package/public/cat-no-changes-v1.webp +0 -0
  24. package/public/cat-onboarding-v1.webp +0 -0
  25. package/public/cat-trial-success-v1.webp +0 -0
  26. package/schema/portolan.schema.json +61 -0
  27. package/scripts/builtin-plugins.mjs +3 -2
  28. package/scripts/catalog-sources.mjs +14 -2
  29. package/scripts/catalog-sources.test.mjs +45 -0
  30. package/scripts/delivery-presets.mjs +207 -24
  31. package/scripts/diff.mjs +5 -1
  32. package/scripts/gen-likec4.mjs +242 -85
  33. package/scripts/gen-likec4.test.mjs +168 -14
  34. package/scripts/gen.mjs +5 -3
  35. package/scripts/host-plugins/fetch-git.mjs +22 -12
  36. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  37. package/scripts/local-api.mjs +330 -346
  38. package/scripts/local-api.test.mjs +245 -1
  39. package/scripts/local-discovery.mjs +378 -0
  40. package/scripts/manifest.mjs +42 -1
  41. package/scripts/manifest.test.mjs +24 -1
  42. package/scripts/plugin-host.mjs +14 -3
  43. package/scripts/plugin-host.test.mjs +8 -0
  44. package/scripts/run-builtin.mjs +4 -2
  45. package/scripts/schema.mjs +11 -0
  46. package/scripts/site-docs.mjs +2 -2
  47. package/src/app/CatalogApp.tsx +23 -8
  48. package/src/app/CommandPalette.tsx +7 -2
  49. package/src/app/Sidebar.tsx +13 -730
  50. package/src/app/SidebarFlowSections.tsx +251 -0
  51. package/src/app/SidebarFooter.tsx +160 -0
  52. package/src/app/SidebarTree.tsx +322 -0
  53. package/src/app/toast.tsx +11 -7
  54. package/src/catalog-index.ts +485 -0
  55. package/src/catalog-model.ts +1339 -0
  56. package/src/catalog-validation.ts +1570 -0
  57. package/src/catalog.test.ts +49 -0
  58. package/src/catalog.ts +6 -3124
  59. package/src/components/CatIllustration.tsx +30 -0
  60. package/src/components/CommitLink.tsx +154 -0
  61. package/src/components/EditorLink.tsx +15 -8
  62. package/src/components/GrpcMethodReference.tsx +93 -0
  63. package/src/components/Markdown.tsx +17 -0
  64. package/src/components/MethodRows.tsx +8 -1
  65. package/src/components/PageHeader.test.tsx +23 -0
  66. package/src/components/PageHeader.tsx +34 -0
  67. package/src/components/ProblemRow.tsx +3 -0
  68. package/src/components/ShapeRows.tsx +12 -0
  69. package/src/components/SourceDoc.tsx +27 -12
  70. package/src/components/SourcePreview.tsx +125 -37
  71. package/src/enrich.test.ts +209 -2
  72. package/src/enrich.ts +284 -15
  73. package/src/er/RedisSchema.tsx +86 -5
  74. package/src/flow/FlowTable.tsx +19 -4
  75. package/src/flow/StepDetail.tsx +461 -101
  76. package/src/flow/StepRail.tsx +36 -13
  77. package/src/flow/answers-response.test.ts +100 -0
  78. package/src/flow/answers.ts +11 -8
  79. package/src/flow/chapters.ts +2 -1
  80. package/src/flow/labels.ts +10 -0
  81. package/src/flow/mermaid.test.ts +87 -3
  82. package/src/flow/mermaid.ts +26 -14
  83. package/src/index.css +132 -17
  84. package/src/landing/LandingPage.tsx +16 -12
  85. package/src/lib/all-problems.ts +1 -1
  86. package/src/lib/api.test.ts +4 -6
  87. package/src/lib/build-info.test.ts +29 -0
  88. package/src/lib/build-info.ts +21 -0
  89. package/src/lib/data-model.ts +14 -0
  90. package/src/lib/derive.ts +1 -0
  91. package/src/lib/flow-tree.ts +1 -1
  92. package/src/lib/github-catalog.test.ts +51 -0
  93. package/src/lib/github-catalog.ts +69 -0
  94. package/src/lib/grpc-reference.test.ts +72 -0
  95. package/src/lib/grpc-reference.ts +117 -0
  96. package/src/lib/local-api.ts +36 -11
  97. package/src/lib/motion.test.ts +4 -2
  98. package/src/lib/motion.tsx +5 -4
  99. package/src/lib/product.ts +7 -0
  100. package/src/lib/proto-problems.test.ts +170 -3
  101. package/src/lib/proto-problems.ts +176 -4
  102. package/src/lib/queries.ts +16 -1
  103. package/src/lib/readme-assets.test.ts +19 -0
  104. package/src/lib/readme-assets.ts +34 -0
  105. package/src/lib/setup-info.test.ts +7 -0
  106. package/src/lib/setup-info.ts +5 -0
  107. package/src/lib/source-link.test.ts +106 -18
  108. package/src/lib/source-link.ts +22 -6
  109. package/src/merge.test.ts +101 -0
  110. package/src/merge.ts +65 -1
  111. package/src/pages/AdrDetail.tsx +3 -2
  112. package/src/pages/AdrIndex.tsx +10 -7
  113. package/src/pages/AggregatePage.tsx +34 -4
  114. package/src/pages/BlockPage.tsx +39 -32
  115. package/src/pages/Changes.tsx +7 -9
  116. package/src/pages/ContextMap.tsx +11 -2
  117. package/src/pages/ContextPage.tsx +11 -4
  118. package/src/pages/FlowIndex.tsx +16 -7
  119. package/src/pages/GraphPage.tsx +21 -7
  120. package/src/pages/Language.tsx +9 -9
  121. package/src/pages/NotFound.tsx +58 -15
  122. package/src/pages/Overview.tsx +6 -0
  123. package/src/pages/Problems.tsx +10 -8
  124. package/src/pages/RegistryIndex.tsx +10 -8
  125. package/src/pages/ServicePage.tsx +1 -1
  126. package/src/pages/Settings.tsx +257 -44
  127. package/src/pages/settings/AboutSettings.tsx +129 -0
  128. package/src/pages/settings/DeliverySettings.tsx +71 -15
  129. package/src/routes.test.ts +2 -0
  130. package/src/routes.ts +2 -1
  131. package/src/selection/DetailPanel.tsx +213 -113
  132. package/src/selection/pages.test.ts +14 -1
  133. package/src/selection/pages.ts +9 -3
  134. package/src/testing/setup.ts +14 -0
  135. package/vite.config.ts +3 -1
@@ -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({ path: "trace 9f2c1a../span 04", line: null });
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
- { repo: "github.com/acme/shop", commit: "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0" },
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(/README\.md$/);
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(sourceHref("a/b.go:1", { repo: "gitlab.com/acme/portolan" }, [], gl)).toBe(
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(sourceHref("internal/oms/app/checkout.go:104", { repo: "github.com/acme/shop" }, [], GH)).toBeNull();
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(sourceHref("vendor/repos/acme/shop/services/oms/app/checkout.go:104", shop, PINS, GH)).toBe(
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[] = [{ repo: "https://GitHub.com/acme/shop.git", commit: "abc1234" }];
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[] = [{ repo: "github.com/shortlink-org/portolan", commit: "0000000" }];
79
- expect(sourceHref("examples/auth/internal/x.go:20", auth, pins, GH)).toContain(GH.commit);
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(sourceHref("services/oms/app/checkout.go", shop, [{ repo: "github.com/acme/shop", commit: "" }], GH)).toBeNull();
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(sourceHref("examples/auth/internal/x.go:20", auth, [], { ...GH, repoUrl: "" })).toBeNull();
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(/\/docs\/x\.md$/);
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(sourceLocation("examples/auth/internal/x.go:20", { repo: "github.com/shortlink-org/portolan" }, [], GH)).toEqual({
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 = { ...GH, repoUrl: "https://git.example.test/acme/portolan", forge: "gitlab" as const };
115
- expect(sourceLocation("internal/x.go:3", { repo: "git.example.test/acme/portolan" }, [], gl)).toMatchObject({
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(sourceLocation("internal/x.go:3", null, [], { ...GH, repoUrl: "", commit: "" })).toEqual({
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(treeHref("examples/auth", { repo: "github.com/shortlink-org/portolan" }, [], GH)).toBe(
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(treeHref("vendor/repos/acme/shop/services/oms", shop, PINS, GH)).toBe(
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
  });
@@ -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): { path: string; line: number | null } {
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
- return bare(info.repoUrl) === bare(repo);
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 prefix = `vendor/repos/${segments.at(-2)}/${segments.at(-1)}/`;
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) return 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
- return `${at.url}${blobPath(at.url).replace("blob", "tree")}${at.ref}/${repositoryPath(path.replace(/\/$/, ""), service?.repo ?? "", info)}`;
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: [...incoming.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
 
@@ -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 title={`${label} in ${commit.commit}`}>
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
- <Ident value={commit.commit}>{commit.commit.slice(0, 7)}</Ident>
240
+ <CommitLink commit={commit.commit} />
240
241
  </span>
241
242
  );
242
243
  }
@@ -1,6 +1,7 @@
1
1
  import { useDocumentTitle } from "../app/title";
2
2
  import { useMemo } from "react";
3
- import { CATALOG_PATH, catalog } from "../data";
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 { Blank, Empty } from "../components/PageHeader";
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
- <Blank where={CATALOG_PATH}>
89
- Nothing on the record yet — an ADR is a decision as it was made,
90
- frozen. They are read from the repositories’ markdown and land in{" "}
91
- <span className="text-ink">adrs[]</span>.
92
- </Blank>
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 { tablesPersisting, viewsPresenting } from "../lib/data-model";
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
- one row per table that holds it
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` in migrations metadata
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. */}