@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
@@ -6,7 +6,7 @@ import {
6
6
  Pin,
7
7
  X,
8
8
  } from "lucide-react";
9
- import { EditorLink } from "./EditorLink";
9
+ import { EditorLink, useEditorTarget } from "./EditorLink";
10
10
  import {
11
11
  useCallback,
12
12
  useEffect,
@@ -106,7 +106,7 @@ function AccessForm({
106
106
  <button
107
107
  type="submit"
108
108
  disabled={!draft.trim()}
109
- className="btn-accent shrink-0 disabled:opacity-40"
109
+ className="product-primary shrink-0 disabled:opacity-40"
110
110
  >
111
111
  {connected ? "Replace token" : "Connect"}
112
112
  </button>
@@ -140,7 +140,8 @@ function CodeWindow({
140
140
  [file.content, location.line],
141
141
  );
142
142
  const highlighted = useMemo(
143
- () => highlightSource(location.path, rows.map((row) => row.text).join("\n")),
143
+ () =>
144
+ highlightSource(location.path, rows.map((row) => row.text).join("\n")),
144
145
  [location.path, rows],
145
146
  );
146
147
  const focusedIndex = rows.findIndex((row) => row.focused);
@@ -149,7 +150,9 @@ function CodeWindow({
149
150
  if (node.type === "text") return node.value;
150
151
  const names = node.properties?.className;
151
152
  const className = Array.isArray(names)
152
- ? names.filter((name): name is string => typeof name === "string").join(" ")
153
+ ? names
154
+ .filter((name): name is string => typeof name === "string")
155
+ .join(" ")
153
156
  : typeof names === "string"
154
157
  ? names
155
158
  : undefined;
@@ -187,7 +190,9 @@ function CodeWindow({
187
190
  ))}
188
191
  </div>
189
192
  <pre className="source-code relative z-10 min-w-max pl-3 pr-4 text-ink">
190
- <code>{highlighted.map((node, index) => renderNode(node, String(index)))}</code>
193
+ <code>
194
+ {highlighted.map((node, index) => renderNode(node, String(index)))}
195
+ </code>
191
196
  </pre>
192
197
  </div>
193
198
  </div>
@@ -315,18 +320,25 @@ function PreviewPanel({
315
320
  );
316
321
  }
317
322
 
318
- /** Hover/focus for a glance; click pins the same preview for scrolling and copying. */
319
- export function SourcePreviewButton({
323
+ function SourcePreviewTrigger({
320
324
  location,
325
+ children,
321
326
  className = "",
327
+ href,
328
+ external = false,
329
+ title,
322
330
  }: {
323
- location: SourceLocation | null;
331
+ location: SourceLocation;
332
+ children: ReactNode;
324
333
  className?: string;
334
+ href?: string;
335
+ external?: boolean;
336
+ title: string;
325
337
  }) {
326
338
  const [open, setOpen] = useState(false);
327
339
  const [pinned, setPinned] = useState(false);
328
340
  const [position, setPosition] = useState<Position | null>(null);
329
- const triggerRef = useRef<HTMLButtonElement>(null);
341
+ const triggerRef = useRef<HTMLElement | null>(null);
330
342
  const panelRef = useRef<HTMLDivElement>(null);
331
343
  const pinnedRef = useRef(false);
332
344
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -386,41 +398,59 @@ export function SourcePreviewButton({
386
398
  return () => document.removeEventListener("pointerdown", outside);
387
399
  }, [close, pinned]);
388
400
 
389
- if (!location) return null;
390
-
391
401
  const hover = (event: ReactPointerEvent) => {
392
402
  if (event.pointerType === "mouse" || event.pointerType === "pen")
393
403
  openSoon();
394
404
  };
395
405
 
406
+ const triggerProps = {
407
+ "aria-haspopup": "dialog" as const,
408
+ "aria-expanded": open,
409
+ "aria-controls": open ? id : undefined,
410
+ className: `mono inline-flex items-center gap-1 rounded-control text-accent hover:underline focus-visible:outline focus-visible:outline-1 focus-visible:outline-accent ${className}`,
411
+ title,
412
+ onPointerEnter: hover,
413
+ onPointerLeave: closeSoon,
414
+ onFocus: () => setOpen(true),
415
+ onBlur: closeSoon,
416
+ };
417
+
396
418
  return (
397
419
  <>
398
- <button
399
- ref={triggerRef}
400
- type="button"
401
- aria-haspopup="dialog"
402
- aria-expanded={open}
403
- aria-controls={open ? id : undefined}
404
- className={`mono inline-flex items-center gap-1 rounded-control text-accent hover:underline focus-visible:outline focus-visible:outline-1 focus-visible:outline-accent ${className}`}
405
- title="Preview source · click to pin"
406
- onPointerEnter={hover}
407
- onPointerLeave={closeSoon}
408
- onFocus={() => setOpen(true)}
409
- onBlur={closeSoon}
410
- onClick={() => {
411
- cancelTimer();
412
- if (open && pinned) {
413
- close();
414
- return;
415
- }
416
- setOpen(true);
417
- pinnedRef.current = true;
418
- setPinned(true);
419
- setTimeout(() => panelRef.current?.focus(), 0);
420
- }}
421
- >
422
- <Code2 size={13} aria-hidden /> preview
423
- </button>
420
+ {href ? (
421
+ <a
422
+ ref={(node) => {
423
+ triggerRef.current = node;
424
+ }}
425
+ href={href}
426
+ target={external ? "_blank" : undefined}
427
+ rel={external ? "noreferrer" : undefined}
428
+ {...triggerProps}
429
+ >
430
+ {children}
431
+ </a>
432
+ ) : (
433
+ <button
434
+ ref={(node) => {
435
+ triggerRef.current = node;
436
+ }}
437
+ type="button"
438
+ {...triggerProps}
439
+ onClick={() => {
440
+ cancelTimer();
441
+ if (open && pinned) {
442
+ close();
443
+ return;
444
+ }
445
+ setOpen(true);
446
+ pinnedRef.current = true;
447
+ setPinned(true);
448
+ setTimeout(() => panelRef.current?.focus(), 0);
449
+ }}
450
+ >
451
+ {children}
452
+ </button>
453
+ )}
424
454
  {open && position && typeof document !== "undefined"
425
455
  ? createPortal(
426
456
  <PreviewPanel
@@ -439,3 +469,61 @@ export function SourcePreviewButton({
439
469
  </>
440
470
  );
441
471
  }
472
+
473
+ /** Hover/focus for a glance; click pins the same preview for scrolling and copying. */
474
+ export function SourcePreviewButton({
475
+ location,
476
+ className = "",
477
+ }: {
478
+ location: SourceLocation | null;
479
+ className?: string;
480
+ }) {
481
+ if (!location) return null;
482
+ return (
483
+ <SourcePreviewTrigger
484
+ location={location}
485
+ className={className}
486
+ title="Preview source · click to pin"
487
+ >
488
+ <Code2 size={13} aria-hidden /> preview
489
+ </SourcePreviewTrigger>
490
+ );
491
+ }
492
+
493
+ /**
494
+ * A compact source path: preview on hover, then open the configured editor in
495
+ * local mode or the immutable forge location in a static build.
496
+ */
497
+ export function SourcePreviewLink({
498
+ location,
499
+ children,
500
+ className = "",
501
+ }: {
502
+ location: SourceLocation | null;
503
+ children: ReactNode;
504
+ className?: string;
505
+ }) {
506
+ const editor = useEditorTarget(location);
507
+ if (!location) return <span className={className}>{children}</span>;
508
+ const href = editor?.href ?? location.href ?? undefined;
509
+ const destination = editor
510
+ ? editor.name
511
+ : location.href
512
+ ? "repository"
513
+ : null;
514
+ return (
515
+ <SourcePreviewTrigger
516
+ location={location}
517
+ className={className}
518
+ href={href}
519
+ external={!editor && Boolean(location.href)}
520
+ title={
521
+ destination
522
+ ? `Preview on hover · click to open in ${destination}`
523
+ : "Preview source · click to pin"
524
+ }
525
+ >
526
+ {children}
527
+ </SourcePreviewTrigger>
528
+ );
529
+ }
@@ -88,7 +88,14 @@ function pricing(): Service {
88
88
  provides: [
89
89
  {
90
90
  id: "pricing.v1.Pricing",
91
- methods: [{ name: "Quote", doc: "" }],
91
+ methods: [
92
+ {
93
+ name: "Quote",
94
+ doc: "",
95
+ request: "QuoteRequest",
96
+ response: "QuoteResponse",
97
+ },
98
+ ],
92
99
  source: "pricing.proto",
93
100
  },
94
101
  ],
@@ -113,7 +120,7 @@ beforeEach(() => {
113
120
  function step(
114
121
  from: string,
115
122
  to: string,
116
- kind: "rpc" | "event" | "call",
123
+ kind: "rpc" | "event" | "call" | "response",
117
124
  extra: Partial<Extract<FlowNode, { type: "step" }>> = {},
118
125
  ): FlowNode {
119
126
  n += 1;
@@ -287,6 +294,7 @@ describe("enrichCatalog: asynchronous outbound continuations", () => {
287
294
  "enqueue void",
288
295
  "VoidWorker.Work",
289
296
  "POST /cancel",
297
+ "HTTP response",
290
298
  ]);
291
299
  expect(root.includes).toEqual(["void-job", "void-http"]);
292
300
  expect(once.flows.map((item) => item.slug)).toEqual([
@@ -345,6 +353,7 @@ describe("enrichCatalog: asynchronous outbound continuations", () => {
345
353
  "POST /invoices/{id}/issue",
346
354
  "enqueue send_invoice_email",
347
355
  "send_invoice_email",
356
+ "HTTP response",
348
357
  ]);
349
358
  expect(root.includes).toEqual(["send-invoice-email"]);
350
359
  expect(enrichCatalog(result).catalog).toEqual(result);
@@ -404,6 +413,127 @@ describe("enrichCatalog: asynchronous outbound continuations", () => {
404
413
  expect(enrichCatalog(once).catalog).toEqual(once);
405
414
  });
406
415
 
416
+ it("synthesizes returns for nested unary rpc and the HTTP root", () => {
417
+ const entrypoint = "pricing/rpc:Server.Quote";
418
+ const api = {
419
+ ...flow("quote-api", [
420
+ step("client", "shop.oms", "rpc", { label: "GET /quote" }),
421
+ step("shop.oms", "shop.pricing", "rpc", {
422
+ ref: METHOD,
423
+ label: "Quote",
424
+ continuesAt: entrypoint,
425
+ }),
426
+ ]),
427
+ trigger: {
428
+ kind: "http",
429
+ label: "GET /quote",
430
+ confidence: "high",
431
+ } as const,
432
+ };
433
+ const provider = {
434
+ ...flow("quote-rpc", [
435
+ step("shop.pricing", "shop.pricing", "call", {
436
+ label: "load quote",
437
+ }),
438
+ ]),
439
+ entrypoint,
440
+ trigger: {
441
+ kind: "unproven",
442
+ label: METHOD,
443
+ confidence: "high",
444
+ } as const,
445
+ };
446
+
447
+ const once = enrichCatalog(estate([api, provider])).catalog;
448
+ const root = once.flows.find((item) => item.slug === "quote-api")!;
449
+ const steps = walkSteps(root.steps);
450
+
451
+ expect(
452
+ steps.map((item) => [item.kind, item.from, item.to, item.label]),
453
+ ).toEqual([
454
+ ["rpc", "client", "shop.oms", "GET /quote"],
455
+ ["rpc", "shop.oms", "shop.pricing", "Quote"],
456
+ ["call", "shop.pricing", "shop.pricing", "load quote"],
457
+ ["response", "shop.pricing", "shop.oms", "QuoteResponse"],
458
+ ["response", "shop.oms", "client", "HTTP response"],
459
+ ]);
460
+ expect(steps[3]!.replyTo).toBe(steps[1]!.id);
461
+ expect(steps[4]!.replyTo).toBe(steps[0]!.id);
462
+ expect(enrichCatalog(once).catalog).toEqual(once);
463
+ });
464
+
465
+ it("hydrates a source-proven HTTP body from the serialized rpc response", () => {
466
+ const api = {
467
+ ...flow("quote-api", [
468
+ step("client", "shop.oms", "rpc", { label: "GET /quote" }),
469
+ step("shop.oms", "shop.pricing", "rpc", {
470
+ ref: METHOD,
471
+ label: "Quote",
472
+ }),
473
+ step("shop.oms", "client", "response", {
474
+ label: "200 · HTTP response",
475
+ replyTo: "s1",
476
+ http: {
477
+ status: 200,
478
+ contentType: "application/json",
479
+ bodyRef: METHOD,
480
+ encoding: "protojson",
481
+ outcome: "success",
482
+ },
483
+ }),
484
+ ]),
485
+ trigger: {
486
+ kind: "http",
487
+ label: "GET /quote",
488
+ confidence: "high",
489
+ } as const,
490
+ };
491
+
492
+ const once = enrichCatalog(estate([api])).catalog;
493
+ const response = walkSteps(once.flows[0]!.steps).at(-1)!;
494
+
495
+ expect(response.label).toBe("200 · QuoteResponse");
496
+ expect(response.http?.body).toBe("QuoteResponse");
497
+ expect(response.http?.bodyRef).toBe(METHOD);
498
+ expect(enrichCatalog(once).catalog).toEqual(once);
499
+ });
500
+
501
+ it("does not synthesize a unary return for a streaming rpc", () => {
502
+ const entrypoint = "pricing/rpc:Server.Watch";
503
+ const api = {
504
+ ...flow("watch-api", [
505
+ step("client", "shop.oms", "rpc", { label: "GET /watch" }),
506
+ step("shop.oms", "shop.pricing", "rpc", {
507
+ ref: METHOD,
508
+ label: "Quote",
509
+ continuesAt: entrypoint,
510
+ }),
511
+ ]),
512
+ trigger: {
513
+ kind: "http",
514
+ label: "GET /watch",
515
+ confidence: "high",
516
+ } as const,
517
+ };
518
+ const provider = {
519
+ ...flow("watch-rpc", [
520
+ step("shop.pricing", "shop.pricing", "call", { label: "watch" }),
521
+ ]),
522
+ entrypoint,
523
+ };
524
+ const streaming = pricing();
525
+ streaming.provides[0]!.methods[0]!.streaming = "server";
526
+
527
+ const result = enrichCatalog(
528
+ estate([api, provider], [oms(), streaming]),
529
+ ).catalog;
530
+ const responses = walkSteps(result.flows[0]!.steps).filter(
531
+ (item) => item.kind === "response",
532
+ );
533
+
534
+ expect(responses.map((item) => item.label)).toEqual(["HTTP response"]);
535
+ });
536
+
407
537
  it("refuses an ambiguous message handoff", () => {
408
538
  const publisher = flow("publisher", [
409
539
  step("shop.oms", "bus", "event", {
@@ -892,6 +1022,83 @@ describe("enrichCatalog: invariants", () => {
892
1022
  });
893
1023
  });
894
1024
 
1025
+ describe("enrichCatalog: Redis store accesses", () => {
1026
+ it("joins a repository call to one concrete Redis key access", () => {
1027
+ const c = estate([
1028
+ flow("load-order", [
1029
+ step("shop.oms", "oms-db", "call", {
1030
+ label: "Get",
1031
+ storeAccess: { store: "shop.oms.redis", method: "Get" },
1032
+ }),
1033
+ ]),
1034
+ ]);
1035
+ c.stores = [
1036
+ {
1037
+ id: "shop.oms.redis",
1038
+ slug: "redis",
1039
+ name: "OMS Redis",
1040
+ kind: "redis",
1041
+ owner: "shop.oms",
1042
+ tables: [],
1043
+ keyspaces: [
1044
+ {
1045
+ pattern: "order:{id}",
1046
+ operations: ["read"],
1047
+ accesses: [
1048
+ {
1049
+ operation: "read",
1050
+ method: "Store.Get",
1051
+ source: "repository/redis.go:20",
1052
+ },
1053
+ ],
1054
+ },
1055
+ ],
1056
+ },
1057
+ ];
1058
+
1059
+ const result = enrichCatalog(c).catalog;
1060
+ expect(walkSteps(result.flows[0]!.steps)[0]!.storeAccess).toEqual({
1061
+ store: "shop.oms.redis",
1062
+ method: "Get",
1063
+ operation: "read",
1064
+ keyspace: "order:{id}",
1065
+ source: "repository/redis.go:20",
1066
+ });
1067
+ expect(enrichCatalog(result).catalog).toBe(result);
1068
+ });
1069
+
1070
+ it("leaves an ambiguous repository method unresolved", () => {
1071
+ const c = estate([
1072
+ flow("load-order", [
1073
+ step("shop.oms", "oms-db", "call", {
1074
+ storeAccess: { store: "shop.oms.redis", method: "Get" },
1075
+ }),
1076
+ ]),
1077
+ ]);
1078
+ c.stores = [
1079
+ {
1080
+ id: "shop.oms.redis",
1081
+ slug: "redis",
1082
+ name: "OMS Redis",
1083
+ kind: "redis",
1084
+ owner: "shop.oms",
1085
+ tables: [],
1086
+ keyspaces: ["order:{id}", "order-by-number:{number}"].map(
1087
+ (pattern) => ({
1088
+ pattern,
1089
+ operations: ["read" as const],
1090
+ accesses: [{ operation: "read" as const, method: "Store.Get" }],
1091
+ }),
1092
+ ),
1093
+ },
1094
+ ];
1095
+
1096
+ expect(
1097
+ walkSteps(enrichCatalog(c).catalog.flows[0]!.steps)[0]!.storeAccess,
1098
+ ).toEqual({ store: "shop.oms.redis", method: "Get" });
1099
+ });
1100
+ });
1101
+
895
1102
  describe("enrichCatalog: the auth fragment", () => {
896
1103
  // The three fragments together, the way the app reads them: the domain one
897
1104
  // alone names endpoints the api one declares.