@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
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
453
|
-
const found:
|
|
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
|
|
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 {
|
|
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.
|
package/src/er/RedisSchema.tsx
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import type
|
|
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({
|
|
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
|
-
{
|
|
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
|
|
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
|
|
146
|
+
<KeyspaceCard
|
|
147
|
+
key={keyspace.pattern}
|
|
148
|
+
keyspace={keyspace}
|
|
149
|
+
store={store}
|
|
150
|
+
/>
|
|
70
151
|
))}
|
|
71
152
|
</div>
|
|
72
153
|
</section>
|
package/src/flow/FlowTable.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
{
|
|
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)
|
|
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
|
|
116
|
+
step: stepLabel(step),
|
|
102
117
|
route: route(step, contextOf),
|
|
103
118
|
kind: step.kind,
|
|
104
119
|
status: step.status,
|