@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.
Files changed (109) hide show
  1. package/cli/init.mjs +14 -6
  2. package/cli/init.test.mjs +8 -1
  3. package/cli/portolan.mjs +56 -7
  4. package/cli/portolan.test.mjs +47 -0
  5. package/package.json +7 -6
  6. package/plugins/extract-django/README.md +1 -1
  7. package/plugins/extract-django/extract_test.py +1 -1
  8. package/plugins/extract-django/openapi.py +4 -14
  9. package/plugins/extract-django/openapi_test.py +4 -4
  10. package/plugins/extract-go/options.schema.json +5 -0
  11. package/plugins/extract-project/options.schema.json +21 -0
  12. package/plugins/extract-ts/README.md +1 -1
  13. package/plugins/extract-ts/clients.ts +1 -1
  14. package/plugins/extract-ts/extract.test.ts +2 -2
  15. package/plugins/extract-ts/graphql.test.ts +2 -2
  16. package/plugins/extract-ts/openapi.test.ts +6 -7
  17. package/plugins/extract-ts/openapi.ts +3 -10
  18. package/plugins/portolan-go.wasm +0 -0
  19. package/public/404-cat-v1.webp +0 -0
  20. package/public/cat-about-v1.webp +0 -0
  21. package/public/cat-clear-skies-v1.webp +0 -0
  22. package/public/cat-no-changes-v1.webp +0 -0
  23. package/public/cat-onboarding-v1.webp +0 -0
  24. package/public/cat-trial-success-v1.webp +0 -0
  25. package/schema/portolan.schema.json +61 -0
  26. package/scripts/catalog-sources.mjs +12 -1
  27. package/scripts/catalog-sources.test.mjs +35 -0
  28. package/scripts/gen-likec4.mjs +242 -85
  29. package/scripts/gen-likec4.test.mjs +168 -14
  30. package/scripts/gen.mjs +5 -3
  31. package/scripts/host-plugins/fetch-git.mjs +22 -12
  32. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  33. package/scripts/local-api.mjs +368 -34
  34. package/scripts/local-api.test.mjs +197 -2
  35. package/scripts/package-smoke.mjs +19 -2
  36. package/scripts/plugin-host.mjs +14 -3
  37. package/scripts/plugin-host.test.mjs +8 -0
  38. package/scripts/schema.mjs +7 -0
  39. package/src/app/CatalogApp.tsx +19 -4
  40. package/src/app/CommandPalette.tsx +7 -2
  41. package/src/app/toast.tsx +11 -7
  42. package/src/catalog.test.ts +33 -0
  43. package/src/catalog.ts +190 -8
  44. package/src/components/CatIllustration.tsx +30 -0
  45. package/src/components/CommitLink.tsx +154 -0
  46. package/src/components/EditorLink.tsx +15 -8
  47. package/src/components/GrpcMethodReference.tsx +93 -0
  48. package/src/components/Markdown.tsx +17 -0
  49. package/src/components/MethodRows.tsx +8 -1
  50. package/src/components/PageHeader.test.ts +24 -0
  51. package/src/components/PageHeader.tsx +34 -0
  52. package/src/components/ShapeRows.tsx +12 -0
  53. package/src/components/SourceDoc.tsx +27 -12
  54. package/src/components/SourcePreview.tsx +124 -36
  55. package/src/enrich.test.ts +209 -2
  56. package/src/enrich.ts +284 -15
  57. package/src/er/RedisSchema.tsx +86 -5
  58. package/src/flow/FlowTable.tsx +19 -4
  59. package/src/flow/StepDetail.tsx +461 -101
  60. package/src/flow/StepRail.tsx +36 -13
  61. package/src/flow/answers-response.test.ts +100 -0
  62. package/src/flow/answers.ts +11 -8
  63. package/src/flow/chapters.ts +2 -1
  64. package/src/flow/labels.ts +10 -0
  65. package/src/flow/mermaid.test.ts +87 -3
  66. package/src/flow/mermaid.ts +26 -14
  67. package/src/index.css +132 -0
  68. package/src/landing/LandingPage.tsx +12 -8
  69. package/src/lib/api.test.ts +4 -6
  70. package/src/lib/build-info.test.ts +29 -0
  71. package/src/lib/build-info.ts +21 -0
  72. package/src/lib/data-model.ts +14 -0
  73. package/src/lib/flow-tree.ts +1 -1
  74. package/src/lib/github-catalog.test.ts +51 -0
  75. package/src/lib/github-catalog.ts +69 -0
  76. package/src/lib/grpc-reference.test.ts +72 -0
  77. package/src/lib/grpc-reference.ts +117 -0
  78. package/src/lib/local-api.ts +17 -4
  79. package/src/lib/product.ts +7 -0
  80. package/src/lib/queries.ts +16 -1
  81. package/src/lib/readme-assets.test.ts +19 -0
  82. package/src/lib/readme-assets.ts +34 -0
  83. package/src/lib/setup-info.test.ts +7 -0
  84. package/src/lib/setup-info.ts +5 -0
  85. package/src/lib/source-link.test.ts +106 -18
  86. package/src/lib/source-link.ts +22 -6
  87. package/src/merge.test.ts +55 -0
  88. package/src/merge.ts +30 -0
  89. package/src/pages/AdrDetail.tsx +3 -2
  90. package/src/pages/AdrIndex.tsx +10 -7
  91. package/src/pages/AggregatePage.tsx +34 -4
  92. package/src/pages/BlockPage.tsx +39 -32
  93. package/src/pages/Changes.tsx +7 -9
  94. package/src/pages/ContextMap.tsx +11 -2
  95. package/src/pages/ContextPage.tsx +11 -4
  96. package/src/pages/FlowIndex.tsx +16 -7
  97. package/src/pages/GraphPage.tsx +21 -7
  98. package/src/pages/Language.tsx +9 -9
  99. package/src/pages/NotFound.tsx +58 -15
  100. package/src/pages/Overview.tsx +6 -0
  101. package/src/pages/Problems.tsx +10 -8
  102. package/src/pages/RegistryIndex.tsx +10 -8
  103. package/src/pages/ServicePage.tsx +1 -1
  104. package/src/pages/Settings.tsx +382 -44
  105. package/src/routes.test.ts +2 -0
  106. package/src/routes.ts +2 -1
  107. package/src/selection/DetailPanel.tsx +213 -113
  108. package/src/testing/setup.ts +14 -0
  109. package/vite.config.ts +5 -1
package/src/enrich.ts CHANGED
@@ -59,7 +59,9 @@ export function enrichCatalog(input: Catalog): Enriched {
59
59
  // everything below - and every consumer derived from it - sees the event
60
60
  // rather than the name.
61
61
  const catalog = resolveForeignKeys(
62
- composeExecutionContinuations(resolveWireNames(input)),
62
+ resolveStoreAccesses(
63
+ composeExecutionContinuations(resolveWireNames(input)),
64
+ ),
63
65
  );
64
66
 
65
67
  const serviceById = new Map<string, Service>();
@@ -226,6 +228,80 @@ export function enrichCatalog(input: Catalog): Enriched {
226
228
  return { catalog: { ...catalog, contexts }, derived };
227
229
  }
228
230
 
231
+ /**
232
+ * Joins the use-case-side repository call emitted by extract-go to the
233
+ * concrete Redis client call emitted independently by extract-redis. Method
234
+ * name and store id are deliberately both required; a name such as `Get`
235
+ * alone is far too common to be evidence. Ambiguous matches stay unresolved.
236
+ */
237
+ function resolveStoreAccesses(input: Catalog): Catalog {
238
+ const byMethod = new Map<
239
+ string,
240
+ Array<{
241
+ operation: NonNullable<Step["storeAccess"]>["operation"];
242
+ keyspace: string;
243
+ source?: string;
244
+ }>
245
+ >();
246
+
247
+ for (const store of input.stores ?? []) {
248
+ for (const keyspace of store.keyspaces ?? []) {
249
+ for (const access of keyspace.accesses ?? []) {
250
+ if (!access.method) continue;
251
+ const method = access.method.split(".").pop() ?? access.method;
252
+ const key = `${store.id}\u0000${method}`;
253
+ const matches = byMethod.get(key) ?? [];
254
+ matches.push({
255
+ operation: access.operation,
256
+ keyspace: keyspace.pattern,
257
+ ...(access.source ? { source: access.source } : {}),
258
+ });
259
+ byMethod.set(key, matches);
260
+ }
261
+ }
262
+ }
263
+
264
+ let changed = false;
265
+ const mapNodes = (nodes: FlowNode[]): FlowNode[] =>
266
+ nodes.map((node): FlowNode => {
267
+ if (node.type === "step") {
268
+ const storeAccess = node.storeAccess;
269
+ if (
270
+ !storeAccess ||
271
+ !storeAccess.method ||
272
+ (storeAccess.operation && storeAccess.keyspace)
273
+ ) {
274
+ return node;
275
+ }
276
+ const method = storeAccess.method.split(".").pop() ?? storeAccess.method;
277
+ const matches = byMethod.get(`${storeAccess.store}\u0000${method}`) ?? [];
278
+ if (matches.length !== 1) return node;
279
+ changed = true;
280
+ return {
281
+ ...node,
282
+ storeAccess: { ...storeAccess, ...matches[0] },
283
+ };
284
+ }
285
+ if (node.type === "alt") {
286
+ const branches = node.branches.map((branch) => ({
287
+ ...branch,
288
+ steps: mapNodes(branch.steps),
289
+ }));
290
+ return { ...node, branches };
291
+ }
292
+ if (node.type === "parallel") {
293
+ return { ...node, branches: node.branches.map(mapNodes) };
294
+ }
295
+ return { ...node, steps: mapNodes(node.steps) };
296
+ });
297
+
298
+ const flows = input.flows.map((flow) => ({
299
+ ...flow,
300
+ steps: mapNodes(flow.steps),
301
+ }));
302
+ return changed ? { ...input, flows } : input;
303
+ }
304
+
229
305
  /**
230
306
  * Composes independently extracted protocol fragments into root-oriented
231
307
  * execution flows.
@@ -237,6 +313,7 @@ export function enrichCatalog(input: Catalog): Enriched {
237
313
  * participate. Ambiguous seams compose nothing.
238
314
  */
239
315
  function composeExecutionContinuations(input: Catalog): Catalog {
316
+ const responses = synchronousResponses(input);
240
317
  const byEntry = new Map<string, Flow | null>();
241
318
  const byHandoff = new Map<string, Flow | null>();
242
319
  for (const flow of input.flows) {
@@ -259,10 +336,6 @@ function composeExecutionContinuations(input: Catalog): Catalog {
259
336
  });
260
337
  }
261
338
  }
262
- if (byEntry.size === 0 && byHandoff.size === 0) {
263
- return input;
264
- }
265
-
266
339
  const consumed = new Set<string>();
267
340
  let any = false;
268
341
  const flows = input.flows.map((flow) => {
@@ -275,8 +348,27 @@ function composeExecutionContinuations(input: Catalog): Catalog {
275
348
  byEntry,
276
349
  byHandoff,
277
350
  consumed,
351
+ responses,
278
352
  );
279
- if (expansion.includes.length === 0) return flow;
353
+ let steps = expansion.nodes;
354
+ const opening = walkSteps(steps)[0];
355
+ if (
356
+ flow.trigger?.kind === "http" &&
357
+ opening?.kind === "rpc" &&
358
+ !hasResponseFor(steps, opening.id)
359
+ ) {
360
+ steps = appendResponseAtExits(
361
+ steps,
362
+ responseStep(
363
+ opening,
364
+ responses.get(opening.ref ?? "")?.label || "HTTP response",
365
+ "HTTP handler return",
366
+ ),
367
+ );
368
+ }
369
+ steps = hydrateHTTPResponseContracts(steps, responses);
370
+ if (expansion.includes.length === 0 && steps === expansion.nodes)
371
+ return flow;
280
372
  any = true;
281
373
 
282
374
  const participants = [...flow.participants];
@@ -295,7 +387,7 @@ function composeExecutionContinuations(input: Catalog): Catalog {
295
387
  " Source-backed cross-protocol continuations are included.",
296
388
  includes: unique([...(flow.includes ?? []), ...expansion.includes]),
297
389
  participants,
298
- steps: expansion.nodes,
390
+ steps,
299
391
  };
300
392
  });
301
393
 
@@ -324,6 +416,7 @@ function expandExecution(
324
416
  byEntry: ReadonlyMap<string, Flow | null>,
325
417
  byHandoff: ReadonlyMap<string, Flow | null>,
326
418
  consumed: Set<string>,
419
+ responses: ReadonlyMap<string, SynchronousResponse>,
327
420
  ): ExecutionExpansion {
328
421
  const out: FlowNode[] = [];
329
422
  const includes: string[] = [];
@@ -333,12 +426,15 @@ function expandExecution(
333
426
  case "step": {
334
427
  out.push(node);
335
428
  if (path.length > 12) break;
336
- for (const continuation of continuationsFor(
429
+ let continuationNodes: FlowNode[] = [];
430
+ let synchronous = false;
431
+ for (const continuationMatch of continuationsFor(
337
432
  root,
338
433
  node,
339
434
  byEntry,
340
435
  byHandoff,
341
436
  )) {
437
+ const continuation = continuationMatch.flow;
342
438
  if (
343
439
  path.includes(continuation.slug) ||
344
440
  existing.has(continuation.slug)
@@ -354,9 +450,11 @@ function expandExecution(
354
450
  byEntry,
355
451
  byHandoff,
356
452
  consumed,
453
+ responses,
357
454
  );
358
455
  const prefix = `continuation-${continuation.slug}-${node.id}`;
359
- out.push(...prefixFlowNodes(nested.nodes, prefix));
456
+ continuationNodes.push(...prefixFlowNodes(nested.nodes, prefix));
457
+ synchronous ||= continuationMatch.synchronous;
360
458
  includes.push(
361
459
  continuation.slug,
362
460
  ...(continuation.includes ?? []),
@@ -370,6 +468,19 @@ function expandExecution(
370
468
  consumed.add(continuation.slug);
371
469
  }
372
470
  }
471
+ const response = responses.get(node.ref ?? "");
472
+ if (
473
+ synchronous &&
474
+ node.kind === "rpc" &&
475
+ response &&
476
+ !hasResponseFor(nodes, node.id)
477
+ ) {
478
+ continuationNodes = appendResponseAtExits(
479
+ continuationNodes,
480
+ responseStep(node, response.label, "unary gRPC return"),
481
+ );
482
+ }
483
+ out.push(...continuationNodes);
373
484
  break;
374
485
  }
375
486
  case "alt":
@@ -383,6 +494,7 @@ function expandExecution(
383
494
  byEntry,
384
495
  byHandoff,
385
496
  consumed,
497
+ responses,
386
498
  );
387
499
  includes.push(...expanded.includes);
388
500
  fragments.push(...expanded.fragments);
@@ -405,6 +517,7 @@ function expandExecution(
405
517
  byEntry,
406
518
  byHandoff,
407
519
  consumed,
520
+ responses,
408
521
  );
409
522
  includes.push(...expanded.includes);
410
523
  fragments.push(...expanded.fragments);
@@ -426,6 +539,7 @@ function expandExecution(
426
539
  byEntry,
427
540
  byHandoff,
428
541
  consumed,
542
+ responses,
429
543
  );
430
544
  includes.push(...expanded.includes);
431
545
  fragments.push(...expanded.fragments);
@@ -444,26 +558,36 @@ function expandExecution(
444
558
  };
445
559
  }
446
560
 
561
+ interface ContinuationMatch {
562
+ flow: Flow;
563
+ synchronous: boolean;
564
+ }
565
+
447
566
  function continuationsFor(
448
567
  root: Flow,
449
568
  step: Step,
450
569
  byEntry: ReadonlyMap<string, Flow | null>,
451
570
  byHandoff: ReadonlyMap<string, Flow | null>,
452
- ): Flow[] {
453
- const found: Flow[] = [];
571
+ ): ContinuationMatch[] {
572
+ const found: ContinuationMatch[] = [];
454
573
  const sourceEntries = [step.continuesAt, ...(step.reaches ?? [])].filter(
455
574
  (entry): entry is string => Boolean(entry),
456
575
  );
457
576
  for (const entry of sourceEntries) {
458
577
  const continuation = byEntry.get(entry);
459
578
  if (!continuation || continuation.owner !== root.owner) continue;
460
- found.push(continuation);
579
+ found.push({ flow: continuation, synchronous: true });
461
580
  }
462
581
  if (step.handoff?.direction === "send") {
463
582
  const continuation = byHandoff.get(handoffKey(step.handoff));
464
- if (continuation) found.push(continuation);
583
+ if (continuation) found.push({ flow: continuation, synchronous: false });
584
+ }
585
+ const matches = new Map<string, ContinuationMatch>();
586
+ for (const match of found) {
587
+ const previous = matches.get(match.flow.slug);
588
+ if (!previous || match.synchronous) matches.set(match.flow.slug, match);
465
589
  }
466
- return uniqueFlows(found);
590
+ return [...matches.values()];
467
591
  }
468
592
 
469
593
  function handoffKey(handoff: NonNullable<Step["handoff"]>): string {
@@ -501,7 +625,11 @@ function prefixFlowNodes(nodes: FlowNode[], prefix: string): FlowNode[] {
501
625
  const id = `${prefix}-${node.id}`;
502
626
  switch (node.type) {
503
627
  case "step":
504
- return { ...node, id };
628
+ return {
629
+ ...node,
630
+ id,
631
+ ...(node.replyTo ? { replyTo: `${prefix}-${node.replyTo}` } : {}),
632
+ };
505
633
  case "alt":
506
634
  return {
507
635
  ...node,
@@ -525,6 +653,147 @@ function prefixFlowNodes(nodes: FlowNode[], prefix: string): FlowNode[] {
525
653
  });
526
654
  }
527
655
 
656
+ interface SynchronousResponse {
657
+ label: string;
658
+ }
659
+
660
+ /** Resolve a source-proven serialized RPC result to its response message. */
661
+ function hydrateHTTPResponseContracts(
662
+ nodes: FlowNode[],
663
+ responses: ReadonlyMap<string, SynchronousResponse>,
664
+ ): FlowNode[] {
665
+ let changed = false;
666
+ const visit = (list: FlowNode[]): FlowNode[] =>
667
+ list.map((node): FlowNode => {
668
+ if (node.type === "step") {
669
+ if (node.kind !== "response" || !node.http) return node;
670
+ const contract = node.http.bodyRef
671
+ ? responses.get(node.http.bodyRef)
672
+ : undefined;
673
+ const body =
674
+ node.http.body ||
675
+ contract?.label ||
676
+ node.label?.replace(/^\d{3}\s*·\s*/, "") ||
677
+ "HTTP response";
678
+ const label = node.http.status ? `${node.http.status} · ${body}` : body;
679
+ if (node.http.body === body && node.label === label) return node;
680
+ changed = true;
681
+ return { ...node, label, http: { ...node.http, body } };
682
+ }
683
+ if (node.type === "alt") {
684
+ const branches = node.branches.map((branch) => ({
685
+ ...branch,
686
+ steps: visit(branch.steps),
687
+ }));
688
+ return branches.some(
689
+ (branch, index) => branch.steps !== node.branches[index]!.steps,
690
+ )
691
+ ? { ...node, branches }
692
+ : node;
693
+ }
694
+ if (node.type === "parallel") {
695
+ const branches = node.branches.map(visit);
696
+ return branches.some((branch, index) => branch !== node.branches[index])
697
+ ? { ...node, branches }
698
+ : node;
699
+ }
700
+ const nested = visit(node.steps);
701
+ return nested === node.steps ? node : { ...node, steps: nested };
702
+ });
703
+
704
+ const hydrated = visit(nodes);
705
+ return changed ? hydrated : nodes;
706
+ }
707
+
708
+ /** Unary contract responses keyed by `<interface>/<method>`. */
709
+ function synchronousResponses(
710
+ catalog: Catalog,
711
+ ): Map<string, SynchronousResponse> {
712
+ const out = new Map<string, SynchronousResponse>();
713
+ const collect = (provided: Service["provides"][number]): void => {
714
+ for (const method of provided.methods) {
715
+ if (method.streaming || !method.response) continue;
716
+ out.set(`${provided.id}/${method.name}`, { label: method.response });
717
+ }
718
+ };
719
+ for (const context of catalog.contexts) {
720
+ for (const service of context.services) {
721
+ for (const provided of service.provides) collect(provided);
722
+ }
723
+ }
724
+ for (const external of catalog.externals ?? []) {
725
+ for (const provided of external.provides) collect(provided);
726
+ }
727
+ return out;
728
+ }
729
+
730
+ function responseStep(request: Step, label: string, protocol: string): Step {
731
+ return {
732
+ type: "step",
733
+ id: `response-${request.id}`,
734
+ from: request.to,
735
+ to: request.from,
736
+ kind: "response",
737
+ label,
738
+ status: request.status,
739
+ note: `Synthesized from the proven synchronous ${protocol}.`,
740
+ replyTo: request.id,
741
+ };
742
+ }
743
+
744
+ function hasResponseFor(nodes: FlowNode[], requestId: string): boolean {
745
+ return walkSteps(nodes).some(
746
+ (step) => step.kind === "response" && step.replyTo === requestId,
747
+ );
748
+ }
749
+
750
+ /**
751
+ * Adds a response to every way a synchronous execution can return. A terminal
752
+ * alt branch gets its own copy inside the branch; the normal fallthrough gets
753
+ * the unsuffixed response after the nested fragment.
754
+ */
755
+ function appendResponseAtExits(nodes: FlowNode[], response: Step): FlowNode[] {
756
+ let emitted = 0;
757
+ const nextResponse = (): Step => {
758
+ emitted += 1;
759
+ return emitted === 1
760
+ ? response
761
+ : { ...response, id: `${response.id}-exit-${emitted}` };
762
+ };
763
+
764
+ const visit = (
765
+ list: FlowNode[],
766
+ respondAtEnd: boolean,
767
+ ): { nodes: FlowNode[]; fallsThrough: boolean } => {
768
+ let fallsThrough = true;
769
+ const out = list.map((node): FlowNode => {
770
+ if (node.type === "step") return node;
771
+ if (node.type === "parallel") {
772
+ return {
773
+ ...node,
774
+ branches: node.branches.map((branch) => visit(branch, false).nodes),
775
+ };
776
+ }
777
+ if (node.type === "loop") {
778
+ return { ...node, steps: visit(node.steps, false).nodes };
779
+ }
780
+
781
+ const branches = node.branches.map((branch) => {
782
+ const nested = visit(branch.steps, Boolean(branch.terminal));
783
+ return { ...branch, steps: nested.nodes };
784
+ });
785
+ if (branches.length > 0 && branches.every((branch) => branch.terminal)) {
786
+ fallsThrough = false;
787
+ }
788
+ return { ...node, branches };
789
+ });
790
+ if (respondAtEnd && fallsThrough) out.push(nextResponse());
791
+ return { nodes: out, fallsThrough };
792
+ };
793
+
794
+ return visit(nodes, true).nodes;
795
+ }
796
+
528
797
  /**
529
798
  * Steps that name an event by the name it travels under, resolved to the event
530
799
  * that travels under it.
@@ -1,6 +1,11 @@
1
- import type { RedisKeyspace, Store } from "../catalog";
1
+ import { allRepos, type RedisKeyspace, type Store } from "../catalog";
2
+ import { Link } from "react-router";
2
3
  import { Ident } from "../components/Ident";
4
+ import { SourcePreviewLink } from "../components/SourcePreview";
5
+ import { catalog, index } from "../data";
3
6
  import { plural } from "../lib/format";
7
+ import { sourceLocation } from "../lib/source-link";
8
+ import { aggregatePath } from "../routes";
4
9
 
5
10
  const OPERATION_TONE: Record<string, string> = {
6
11
  read: "text-accent",
@@ -11,7 +16,16 @@ const OPERATION_TONE: Record<string, string> = {
11
16
  count: "text-ink",
12
17
  };
13
18
 
14
- function KeyspaceCard({ keyspace }: { keyspace: RedisKeyspace }) {
19
+ function KeyspaceCard({
20
+ keyspace,
21
+ store,
22
+ }: {
23
+ keyspace: RedisKeyspace;
24
+ store: Store;
25
+ }) {
26
+ const aggregateId = keyspace.persists?.aggregate;
27
+ const aggregateTo = aggregateId ? aggregatePath(aggregateId) : null;
28
+ const accesses = keyspace.accesses ?? [];
15
29
  return (
16
30
  <article className="overflow-hidden rounded-card border border-line bg-canvas shadow-xs">
17
31
  <div className="flex flex-wrap items-center gap-2 border-b border-line bg-surface px-3 py-2">
@@ -42,15 +56,78 @@ function KeyspaceCard({ keyspace }: { keyspace: RedisKeyspace }) {
42
56
  <dd className="mono text-ink">{keyspace.ttl}</dd>
43
57
  </>
44
58
  ) : null}
45
- {keyspace.source ? (
59
+ {aggregateId ? (
60
+ <>
61
+ <dt className="label">persists</dt>
62
+ <dd className="min-w-0">
63
+ {aggregateTo ? (
64
+ <Link
65
+ to={aggregateTo}
66
+ className="mono text-accent hover:underline"
67
+ >
68
+ {aggregateId} →
69
+ </Link>
70
+ ) : (
71
+ <Ident value={aggregateId} className="max-w-full text-muted" />
72
+ )}
73
+ </dd>
74
+ </>
75
+ ) : null}
76
+ {keyspace.source && accesses.length === 0 ? (
46
77
  <>
47
78
  <dt className="label">source</dt>
48
79
  <dd className="min-w-0">
49
- <Ident value={keyspace.source} className="max-w-full text-muted" />
80
+ <Ident
81
+ value={keyspace.source}
82
+ className="max-w-full text-muted"
83
+ />
50
84
  </dd>
51
85
  </>
52
86
  ) : null}
53
87
  </dl>
88
+ {accesses.length > 0 ? (
89
+ <div className="border-t border-line">
90
+ <div className="label bg-surface px-3 py-1.5">
91
+ Accesses · <span className="tnum">{accesses.length}</span>
92
+ </div>
93
+ <div className="divide-y divide-line">
94
+ {accesses.map((access, accessIndex) => {
95
+ const location = access.source
96
+ ? sourceLocation(
97
+ access.source,
98
+ index.serviceById.get(store.owner),
99
+ allRepos(catalog),
100
+ )
101
+ : null;
102
+ return (
103
+ <div
104
+ key={`${access.operation}:${access.method}:${access.source}:${accessIndex}`}
105
+ className="grid gap-x-3 gap-y-1 px-3 py-2 sm:grid-cols-[auto_minmax(0,1fr)]"
106
+ >
107
+ <span
108
+ className={`chip self-start uppercase ${OPERATION_TONE[access.operation] ?? "text-muted"}`}
109
+ >
110
+ {access.operation}
111
+ </span>
112
+ <div className="min-w-0">
113
+ <div className="mono break-all text-ink">
114
+ {access.method ?? "Redis client call"}
115
+ </div>
116
+ {access.source ? (
117
+ <SourcePreviewLink
118
+ location={location}
119
+ className="mt-1 max-w-full break-all text-muted hover:text-accent"
120
+ >
121
+ {access.source}
122
+ </SourcePreviewLink>
123
+ ) : null}
124
+ </div>
125
+ </div>
126
+ );
127
+ })}
128
+ </div>
129
+ </div>
130
+ ) : null}
54
131
  </article>
55
132
  );
56
133
  }
@@ -66,7 +143,11 @@ export function RedisSchema({ store }: { store: Store }) {
66
143
  </div>
67
144
  <div className="grid gap-3 xl:grid-cols-2">
68
145
  {keyspaces.map((keyspace) => (
69
- <KeyspaceCard key={keyspace.pattern} keyspace={keyspace} />
146
+ <KeyspaceCard
147
+ key={keyspace.pattern}
148
+ keyspace={keyspace}
149
+ store={store}
150
+ />
70
151
  ))}
71
152
  </div>
72
153
  </section>
@@ -13,6 +13,7 @@ import { flowStepId } from "../selection/model";
13
13
  import { DataTable } from "../table/DataTable";
14
14
  import type { ColumnSpec } from "../table/types";
15
15
  import type { Chapter } from "./chapters";
16
+ import { stepLabel } from "./labels";
16
17
  import type { OutlineStep } from "./outline";
17
18
 
18
19
  interface Row {
@@ -29,7 +30,13 @@ interface Row {
29
30
  }
30
31
 
31
32
  const COLUMNS: ColumnSpec<Row>[] = [
32
- { id: "number", header: "#", type: "number", value: (r) => r.number, size: 56 },
33
+ {
34
+ id: "number",
35
+ header: "#",
36
+ type: "number",
37
+ value: (r) => r.number,
38
+ size: 56,
39
+ },
33
40
  {
34
41
  id: "step",
35
42
  header: "step",
@@ -45,7 +52,14 @@ const COLUMNS: ColumnSpec<Row>[] = [
45
52
  value: (r) => r.route,
46
53
  size: 300,
47
54
  },
48
- { id: "kind", header: "kind", type: "text", value: (r) => r.kind, facet: true, size: 96 },
55
+ {
56
+ id: "kind",
57
+ header: "kind",
58
+ type: "text",
59
+ value: (r) => r.kind,
60
+ facet: true,
61
+ size: 96,
62
+ },
49
63
  {
50
64
  id: "status",
51
65
  header: "status",
@@ -93,12 +107,13 @@ export function FlowTable({
93
107
  const data = useMemo<Row[]>(() => {
94
108
  const chapterOf = new Map<string, string>();
95
109
  for (const chapter of chapters) {
96
- for (const stepId of chapter.stepIds) chapterOf.set(stepId, chapter.title);
110
+ for (const stepId of chapter.stepIds)
111
+ chapterOf.set(stepId, chapter.title);
97
112
  }
98
113
  return rows.map(({ step, number }) => ({
99
114
  id: step.id,
100
115
  number,
101
- step: step.label ?? step.ref ?? step.kind,
116
+ step: stepLabel(step),
102
117
  route: route(step, contextOf),
103
118
  kind: step.kind,
104
119
  status: step.status,