@pylonts/dsl 1.1.11 → 1.1.13

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 (50) hide show
  1. package/dist/convert.d.ts +6 -8
  2. package/dist/curd.js +1 -1
  3. package/dist/dao.d.ts +10 -7
  4. package/dist/dao.js +20 -7
  5. package/dist/dsl.d.ts +18 -1
  6. package/dist/dsl.js +40 -0
  7. package/dist/dto.d.ts +13 -8
  8. package/dist/dto.js +68 -13
  9. package/dist/entity.d.ts +4 -3
  10. package/dist/entity.js +1 -1
  11. package/dist/filter.d.ts +6 -4
  12. package/dist/filter.js +1 -1
  13. package/dist/flow-script.js +8 -2
  14. package/dist/flow.d.ts +10 -2
  15. package/dist/flow.js +44 -4
  16. package/dist/mermaid-driver.js +2 -2
  17. package/dist/project.d.ts +5 -2
  18. package/dist/project.js +21 -2
  19. package/dist/service.d.ts +13 -8
  20. package/dist/service.js +1 -1
  21. package/dist/third-service.d.ts +10 -53
  22. package/dist/third-service.js +3 -78
  23. package/dist/typebox-driver.d.ts +0 -6
  24. package/dist/typebox-driver.js +8 -36
  25. package/dist/utils.d.ts +2 -2
  26. package/docs/curd.md +55 -20
  27. package/docs/dao-generation.md +477 -477
  28. package/docs/project.md +32 -24
  29. package/docs/token.md +326 -326
  30. package/package.json +1 -1
  31. package/src/action.ts +51 -51
  32. package/src/controller.ts +53 -53
  33. package/src/convert.ts +76 -78
  34. package/src/curd.ts +104 -104
  35. package/src/dao.ts +504 -485
  36. package/src/dsl.ts +296 -257
  37. package/src/dto.ts +323 -266
  38. package/src/entity.ts +43 -42
  39. package/src/expr.ts +64 -64
  40. package/src/filter.ts +71 -69
  41. package/src/flow-script.ts +702 -695
  42. package/src/flow.ts +1272 -1226
  43. package/src/index.ts +46 -46
  44. package/src/mermaid-driver.ts +339 -339
  45. package/src/mysql-driver.ts +108 -108
  46. package/src/project.ts +138 -114
  47. package/src/service.ts +112 -107
  48. package/src/third-service.ts +68 -191
  49. package/src/typebox-driver.ts +234 -268
  50. package/src/utils.ts +74 -74
@@ -1,340 +1,340 @@
1
- import { FlowEdge, FlowEnd, FlowNode, FlowSchema, FlowStep, FlowNodeOrEnd, GuardNode, TryNode, IfNode } from './flow.js';
2
- import { isCall, methodOf } from './flow.js';
3
- import type { FlowMethodRef, FlowNodeMethodRef, GuardCondition } from './flow.js';
4
- import { Page } from './page.js';
5
- import { PageEdge, PageFlow } from './page-flow.js';
6
-
7
- // Mermaid driver: converts a FlowSchema into a Mermaid flowchart (TD).
8
- // Node ids are hierarchical (n0, n0_0, n0_0_0, ...) so nested sub-flows stay
9
- // globally unique. A node with a sub-flow renders as a subgraph block whose
10
- // internals are rendered recursively. A TryNode renders as a subgraph for its
11
- // body plus one per catch handler (and finally): body exception ends route to
12
- // handlers as dashed `catch X` edges, fall-through paths (body return end and
13
- // handler return ends, through finally when present) continue via the
14
- // TryNode's outgoing edges, and handler exception ends rethrow via the
15
- // TryNode's typed throws edges. Guard nodes render as diamonds, FlowEnd nodes
16
- // as stadium shapes. Normal edges render as -->, conditional edges as
17
- // -->|"WHEN"|, typed throws and catch routes as dashed edges.
18
-
19
- function escapeLabel(s: string): string {
20
- return s.replace(/"/g, '\\"').replace(/\n/g, '<br/>').replace(/\|/g, '&#124;');
21
- }
22
-
23
- export function renderFlowMermaid(schema: FlowSchema): string {
24
- const lines: string[] = ['flowchart TD'];
25
- const ids = new Map<FlowNodeOrEnd, string>();
26
- renderFlow(schema, 'n', lines, ids);
27
- lines.push('');
28
- lines.push(' classDef start fill:#e6f4ea,stroke:#333,stroke-width:1px;');
29
- lines.push(` class ${ids.get(schema.start)} start;`);
30
- return lines.join('\n');
31
- }
32
-
33
- function renderFlow(schema: FlowSchema, prefix: string, lines: string[], ids: Map<FlowNodeOrEnd, string>): void {
34
- schema.nodes.forEach((n, i) => ids.set(n, `${prefix}${i}`));
35
- const outgoing = new Map<FlowNodeOrEnd, FlowEdge[]>();
36
- for (const n of schema.nodes) outgoing.set(n, []);
37
- for (const e of schema.edges) outgoing.get(e.start)!.push(e);
38
-
39
- for (const n of schema.nodes) {
40
- const id = ids.get(n)!;
41
- if (isTryNode(n)) {
42
- lines.push(` subgraph ${id}["${escapeLabel(n.name)}"]`);
43
- renderFlow(n.body, `${id}_b`, lines, ids);
44
- lines.push(' end');
45
- const handlers: FlowSchema[] = [];
46
- const handlerIdx = new Map<FlowSchema, number>();
47
- for (const c of n.catches) {
48
- if (handlerIdx.has(c.handler)) continue; // one handler may serve many catches
49
- handlerIdx.set(c.handler, handlers.length);
50
- handlers.push(c.handler);
51
- }
52
- handlers.forEach((h, i) => {
53
- lines.push(` subgraph ${id}_h${i}["${escapeLabel(h.name)}"]`);
54
- renderFlow(h, `${id}_h${i}_`, lines, ids);
55
- lines.push(' end');
56
- });
57
- if (n.finally) {
58
- lines.push(` subgraph ${id}_f["${escapeLabel(n.finally.name)}"]`);
59
- renderFlow(n.finally, `${id}_f_`, lines, ids);
60
- lines.push(' end');
61
- }
62
- } else if (isFlowNode(n) && n.flow) {
63
- lines.push(` subgraph ${id}["${escapeLabel(renderNodeLabel(n, outgoing.get(n)!))}"]`);
64
- renderFlow(n.flow, `${id}_`, lines, ids);
65
- lines.push(' end');
66
- } else {
67
- const shape = renderShape(n, schema, outgoing.get(n)!);
68
- lines.push(` ${id}${shape.open}${escapeLabel(renderNodeLabel(n, outgoing.get(n)!))}${shape.close}`);
69
- }
70
- }
71
-
72
- for (const e of schema.edges) {
73
- if (isTryNode(e.start)) continue; // rendered by renderTryRoutes
74
- if (isTryNode(e.end)) {
75
- // entering a TryNode means entering its body
76
- lines.push(renderEdgeLine(e, ids.get(e.start)!, ids.get(e.end.body.start)!));
77
- continue;
78
- }
79
- lines.push(renderEdgeLine(e, ids.get(e.start)!, ids.get(e.end)!));
80
- }
81
-
82
- // guard checks: implicit branch edges of the guard itself
83
- for (const n of schema.nodes) {
84
- if (!isGuard(n)) continue;
85
- const id = ids.get(n)!;
86
- for (const c of n.checks) {
87
- if (c.return) {
88
- lines.push(` ${id} -->|"${escapeLabel(c.when)}"| ${ids.get(schema.returnEnd)}`);
89
- } else if (c.exception) {
90
- const t = findExceptionEnd(schema, c.exception.name);
91
- lines.push(
92
- ` ${id} -.->|"${escapeLabel(c.when)} · throw ${escapeLabel(c.exception.name)}"| ${ids.get(t)}`,
93
- );
94
- }
95
- }
96
- }
97
-
98
- // ifNode cases: implicit branch edges of the decision node
99
- const branchTarget = (t: FlowNodeOrEnd): FlowNodeOrEnd => (isTryNode(t) ? t.body.start : t);
100
- for (const n of schema.nodes) {
101
- if (!isIfNode(n)) continue;
102
- const id = ids.get(n)!;
103
- for (const c of n.cases) {
104
- lines.push(` ${id} -->|"${escapeLabel(c.when)}"| ${ids.get(branchTarget(c.to))}`);
105
- }
106
- lines.push(` ${id} -->|"else"| ${ids.get(branchTarget(n.else))}`);
107
- }
108
-
109
- // TryNode internal routes
110
- for (const n of schema.nodes) {
111
- if (isTryNode(n)) renderTryRoutes(n, schema, ids, lines);
112
- }
113
- }
114
-
115
- /** Routes of a TryNode: catch edges, fall-through continuation (through
116
- * finally when present), and handler rethrows (also through finally). */
117
- function renderTryRoutes(n: TryNode, schema: FlowSchema, ids: Map<FlowNodeOrEnd, string>, lines: string[]): void {
118
- const next = schema.edges.filter((e) => e.start === n && e.throws === undefined && e.exception !== true);
119
- const rethrows = schema.edges.filter((e) => e.start === n && e.throws !== undefined);
120
-
121
- for (const c of n.catches) {
122
- const end = findExceptionEnd(n.body, c.exception.name);
123
- lines.push(` ${ids.get(end)} -.->|"catch ${escapeLabel(c.exception.name)}"| ${ids.get(c.handler.start)}`);
124
- }
125
-
126
- const continueFrom = (sourceId: string): void => {
127
- if (n.finally) {
128
- lines.push(` ${sourceId} --> ${ids.get(n.finally.start)}`);
129
- return;
130
- }
131
- for (const e of next) {
132
- lines.push(renderEdgeLine(e, sourceId, ids.get(e.end)!));
133
- }
134
- };
135
-
136
- continueFrom(ids.get(n.body.returnEnd)!);
137
- const doneHandlers = new Set<FlowSchema>();
138
- for (const c of n.catches) {
139
- if (doneHandlers.has(c.handler)) continue; // one handler may serve many catches
140
- doneHandlers.add(c.handler);
141
- // A handler whose every path throws has no fall-through — its return end
142
- // is never targeted and stays out of the handler's node list.
143
- const handlerReturn = ids.get(c.handler.returnEnd);
144
- if (handlerReturn !== undefined) continueFrom(handlerReturn);
145
- }
146
- if (n.finally) {
147
- for (const e of next) {
148
- lines.push(renderEdgeLine(e, ids.get(n.finally.returnEnd)!, ids.get(e.end)!));
149
- }
150
- }
151
-
152
- const rethrown = new Map<FlowSchema, Set<string>>();
153
- for (const e of rethrows) {
154
- for (const c of n.catches) {
155
- let names = rethrown.get(c.handler);
156
- if (!names) {
157
- names = new Set<string>();
158
- rethrown.set(c.handler, names);
159
- }
160
- if (names.has(e.throws!.name)) continue; // one handler may serve many catches
161
- names.add(e.throws!.name);
162
- const end = findExceptionEnd(c.handler, e.throws!.name);
163
- if (!end) continue;
164
- if (n.finally) {
165
- lines.push(` ${ids.get(end)} -.->|"rethrow ${escapeLabel(e.throws!.name)}"| ${ids.get(n.finally.start)}`);
166
- lines.push(` ${ids.get(n.finally.returnEnd)} -.->|"rethrow ${escapeLabel(e.throws!.name)}"| ${ids.get(e.end)}`);
167
- } else {
168
- lines.push(` ${ids.get(end)} -.->|"rethrow ${escapeLabel(e.throws!.name)}"| ${ids.get(e.end)}`);
169
- }
170
- }
171
- }
172
- }
173
-
174
- /** The exception end of a flow carrying the given exception name. */
175
- function findExceptionEnd(flow: FlowSchema, exceptionName: string): FlowEnd {
176
- const end = flow.nodes.find(
177
- (n): n is FlowEnd => isEnd(n) && n.type === 'exception' && n.exception?.name === exceptionName,
178
- );
179
- if (!end) {
180
- throw new Error(`flow ${flow.name}: no exception end for ${exceptionName}`);
181
- }
182
- return end;
183
- }
184
-
185
- /** Node label: name on the first line, method references (including utils
186
- * predicates of guard checks) on the second, and the data line (slots
187
- * consumed `r:` / produced `w:`) last. */
188
- function renderNodeLabel(n: FlowNodeOrEnd, outgoing: FlowEdge[]): string {
189
- if (isEnd(n) || isTryNode(n)) return n.name;
190
- const lines: string[] = [n.name];
191
- if (isFlowNode(n) && n.publish) {
192
- const payload = n.publish.payload ? ` (${n.publish.payload.name})` : '';
193
- lines.push(`publish ${n.publish.event.name}${payload}`);
194
- }
195
- const refs: FlowNodeMethodRef[] = [];
196
- if (!isIfNode(n)) {
197
- refs.push(...(n.methods ?? []));
198
- if (isGuard(n)) {
199
- for (const c of n.checks) {
200
- if (c.check !== undefined && isCall(c.check)) refs.push(c.check);
201
- }
202
- }
203
- }
204
- if (refs.length > 0) {
205
- const rendered = refs.map((m) => renderMethodRef(methodOf(m))).join(' | ');
206
- // Script-compiled nodes carry the full method name themselves.
207
- if (rendered !== n.name) lines.push(rendered);
208
- }
209
- const data = renderDataLine(n, outgoing);
210
- if (data !== '') lines.push(data);
211
- return lines.join('\n');
212
- }
213
-
214
- /** Data line: `r:` lists slots the node consumes (reads, call args, decision
215
- * condition slots, and branch-edge conditions decided here), `w:` lists
216
- * slots it produces (writes and call results). */
217
- function renderDataLine(n: FlowNode | GuardNode | IfNode, outgoing: FlowEdge[]): string {
218
- const reads = new Set<string>();
219
- const writes = new Set<string>();
220
- const addCondition = (c: GuardCondition | undefined): void => {
221
- if (c === undefined) return;
222
- if (!isCall(c)) {
223
- reads.add(c.field.slot.name);
224
- return;
225
- }
226
- for (const t of c.args ?? []) reads.add(t.name);
227
- };
228
- if (isGuard(n)) {
229
- for (const c of n.checks) {
230
- for (const t of c.reads ?? []) reads.add(t.name);
231
- addCondition(c.check);
232
- }
233
- } else if (isIfNode(n)) {
234
- for (const c of n.cases) addCondition(c.check);
235
- } else {
236
- for (const t of n.reads ?? []) reads.add(t.name);
237
- for (const t of n.writes ?? []) writes.add(t.name);
238
- if (isFlowNode(n) && n.publish?.payload) reads.add(n.publish.payload.name);
239
- }
240
- for (const e of outgoing) addCondition(e.check);
241
- if (!isIfNode(n)) {
242
- for (const ref of n.methods ?? []) {
243
- if (!isCall(ref)) continue;
244
- for (const t of ref.args ?? []) reads.add(t.name);
245
- if (ref.result) writes.add(ref.result.name);
246
- }
247
- }
248
- const parts: string[] = [];
249
- if (reads.size > 0) parts.push(`r:${[...reads].join(',')}`);
250
- if (writes.size > 0) parts.push(`w:${[...writes].join(',')}`);
251
- return parts.join(' ');
252
- }
253
-
254
- /** Display form: owner.name for descriptors, schema.name for container methods. */
255
- function renderMethodRef(m: FlowMethodRef): string {
256
- if ('owner' in m) return `${m.owner}.${m.name}`;
257
- return `${m.schema.name}.${m.name}`;
258
- }
259
-
260
- /** Shape derived from topology: guards are diamonds, ends rounded, other
261
- * branching nodes diamonds, else rect. Exception edges (typed or not) do not
262
- * count as branches. The start keeps its rounded shape unless it branches. */
263
- function renderShape(n: FlowNodeOrEnd, schema: FlowSchema, outgoing: FlowEdge[]): { open: string; close: string } {
264
- if (isGuard(n)) return { open: '{"', close: '"}' };
265
- if (isIfNode(n)) return { open: '{"', close: '"}' };
266
- if (isEnd(n) || outgoing.length === 0) return { open: '(["', close: '"])' };
267
- const normal = outgoing.filter((e) => e.exception !== true && e.throws === undefined).length;
268
- if (normal >= 2) return { open: '{"', close: '"}' };
269
- if (n === schema.start) return { open: '(["', close: '"])' };
270
- return { open: '["', close: '"]' };
271
- }
272
-
273
- function renderEdgeLine(e: FlowEdge, startId: string, endId: string): string {
274
- const isException = e.exception === true || e.throws !== undefined;
275
- const arrow = isException ? '-.->' : '-->';
276
- const throwLabel = e.throws ? `throw ${escapeLabel(e.throws.name)}` : '';
277
- const labelParts = [e.when ? escapeLabel(e.when) : '', throwLabel].filter((s) => s !== '');
278
- const label = labelParts.length > 0 ? `|"${labelParts.join(' · ')}"|` : '';
279
- return ` ${startId} ${arrow}${label} ${endId}`;
280
- }
281
-
282
- function isEnd(n: FlowNodeOrEnd): n is FlowEnd {
283
- return 'type' in n && (n.type === 'return' || n.type === 'exception');
284
- }
285
-
286
- function isFlowNode(n: FlowNodeOrEnd): n is FlowNode {
287
- return !('type' in n);
288
- }
289
-
290
- function isGuard(n: FlowNodeOrEnd): n is GuardNode {
291
- return 'type' in n && n.type === 'guard';
292
- }
293
-
294
- function isTryNode(n: FlowNodeOrEnd): n is TryNode {
295
- return 'type' in n && n.type === 'try';
296
- }
297
-
298
- function isIfNode(n: FlowNodeOrEnd): n is IfNode {
299
- return 'type' in n && n.type === 'if';
300
- }
301
-
302
- // Page-driven flow renderer: groups pages by their app into swimlane
303
- // subgraphs, then renders edges across the whole flow.
304
-
305
- export function renderPageFlowMermaid(schema: PageFlow): string {
306
- const lines: string[] = ['flowchart TD'];
307
- const ids = new Map<Page, string>();
308
-
309
- const byApp = new Map<string, Page[]>();
310
- for (const p of schema.pages) {
311
- const list = byApp.get(p.app.name);
312
- if (!list) {
313
- byApp.set(p.app.name, [p]);
314
- } else {
315
- list.push(p);
316
- }
317
- }
318
-
319
- let appIdx = 0;
320
- for (const [appName, pages] of byApp) {
321
- lines.push(` subgraph app${appIdx}["${escapeLabel(appName)}"]`);
322
- pages.forEach((p, i) => ids.set(p, `app${appIdx}_p${i}`));
323
- for (const p of pages) {
324
- const display = p.label ?? p.name;
325
- lines.push(` ${ids.get(p)}["${escapeLabel(display)}"]`);
326
- }
327
- appIdx++;
328
- lines.push(' end');
329
- }
330
-
331
- for (const e of schema.edges) {
332
- const label = e.when ? `|"${escapeLabel(e.when.name)}"|` : '';
333
- lines.push(` ${ids.get(e.start)} -->${label} ${ids.get(e.end)}`);
334
- }
335
-
336
- lines.push('');
337
- lines.push(' classDef start fill:#e6f4ea,stroke:#333,stroke-width:1px;');
338
- lines.push(` class ${ids.get(schema.start)} start;`);
339
- return lines.join('\n');
1
+ import { FlowEdge, FlowEnd, FlowNode, FlowSchema, FlowStep, FlowNodeOrEnd, GuardNode, TryNode, IfNode } from './flow.js';
2
+ import { isCall, isFlowSlot, methodOf } from './flow.js';
3
+ import type { FlowMethodRef, FlowNodeMethodRef, GuardCondition } from './flow.js';
4
+ import { Page } from './page.js';
5
+ import { PageEdge, PageFlow } from './page-flow.js';
6
+
7
+ // Mermaid driver: converts a FlowSchema into a Mermaid flowchart (TD).
8
+ // Node ids are hierarchical (n0, n0_0, n0_0_0, ...) so nested sub-flows stay
9
+ // globally unique. A node with a sub-flow renders as a subgraph block whose
10
+ // internals are rendered recursively. A TryNode renders as a subgraph for its
11
+ // body plus one per catch handler (and finally): body exception ends route to
12
+ // handlers as dashed `catch X` edges, fall-through paths (body return end and
13
+ // handler return ends, through finally when present) continue via the
14
+ // TryNode's outgoing edges, and handler exception ends rethrow via the
15
+ // TryNode's typed throws edges. Guard nodes render as diamonds, FlowEnd nodes
16
+ // as stadium shapes. Normal edges render as -->, conditional edges as
17
+ // -->|"WHEN"|, typed throws and catch routes as dashed edges.
18
+
19
+ function escapeLabel(s: string): string {
20
+ return s.replace(/"/g, '\\"').replace(/\n/g, '<br/>').replace(/\|/g, '&#124;');
21
+ }
22
+
23
+ export function renderFlowMermaid(schema: FlowSchema): string {
24
+ const lines: string[] = ['flowchart TD'];
25
+ const ids = new Map<FlowNodeOrEnd, string>();
26
+ renderFlow(schema, 'n', lines, ids);
27
+ lines.push('');
28
+ lines.push(' classDef start fill:#e6f4ea,stroke:#333,stroke-width:1px;');
29
+ lines.push(` class ${ids.get(schema.start)} start;`);
30
+ return lines.join('\n');
31
+ }
32
+
33
+ function renderFlow(schema: FlowSchema, prefix: string, lines: string[], ids: Map<FlowNodeOrEnd, string>): void {
34
+ schema.nodes.forEach((n, i) => ids.set(n, `${prefix}${i}`));
35
+ const outgoing = new Map<FlowNodeOrEnd, FlowEdge[]>();
36
+ for (const n of schema.nodes) outgoing.set(n, []);
37
+ for (const e of schema.edges) outgoing.get(e.start)!.push(e);
38
+
39
+ for (const n of schema.nodes) {
40
+ const id = ids.get(n)!;
41
+ if (isTryNode(n)) {
42
+ lines.push(` subgraph ${id}["${escapeLabel(n.name)}"]`);
43
+ renderFlow(n.body, `${id}_b`, lines, ids);
44
+ lines.push(' end');
45
+ const handlers: FlowSchema[] = [];
46
+ const handlerIdx = new Map<FlowSchema, number>();
47
+ for (const c of n.catches) {
48
+ if (handlerIdx.has(c.handler)) continue; // one handler may serve many catches
49
+ handlerIdx.set(c.handler, handlers.length);
50
+ handlers.push(c.handler);
51
+ }
52
+ handlers.forEach((h, i) => {
53
+ lines.push(` subgraph ${id}_h${i}["${escapeLabel(h.name)}"]`);
54
+ renderFlow(h, `${id}_h${i}_`, lines, ids);
55
+ lines.push(' end');
56
+ });
57
+ if (n.finally) {
58
+ lines.push(` subgraph ${id}_f["${escapeLabel(n.finally.name)}"]`);
59
+ renderFlow(n.finally, `${id}_f_`, lines, ids);
60
+ lines.push(' end');
61
+ }
62
+ } else if (isFlowNode(n) && n.flow) {
63
+ lines.push(` subgraph ${id}["${escapeLabel(renderNodeLabel(n, outgoing.get(n)!))}"]`);
64
+ renderFlow(n.flow, `${id}_`, lines, ids);
65
+ lines.push(' end');
66
+ } else {
67
+ const shape = renderShape(n, schema, outgoing.get(n)!);
68
+ lines.push(` ${id}${shape.open}${escapeLabel(renderNodeLabel(n, outgoing.get(n)!))}${shape.close}`);
69
+ }
70
+ }
71
+
72
+ for (const e of schema.edges) {
73
+ if (isTryNode(e.start)) continue; // rendered by renderTryRoutes
74
+ if (isTryNode(e.end)) {
75
+ // entering a TryNode means entering its body
76
+ lines.push(renderEdgeLine(e, ids.get(e.start)!, ids.get(e.end.body.start)!));
77
+ continue;
78
+ }
79
+ lines.push(renderEdgeLine(e, ids.get(e.start)!, ids.get(e.end)!));
80
+ }
81
+
82
+ // guard checks: implicit branch edges of the guard itself
83
+ for (const n of schema.nodes) {
84
+ if (!isGuard(n)) continue;
85
+ const id = ids.get(n)!;
86
+ for (const c of n.checks) {
87
+ if (c.return) {
88
+ lines.push(` ${id} -->|"${escapeLabel(c.when)}"| ${ids.get(schema.returnEnd)}`);
89
+ } else if (c.exception) {
90
+ const t = findExceptionEnd(schema, c.exception.name);
91
+ lines.push(
92
+ ` ${id} -.->|"${escapeLabel(c.when)} · throw ${escapeLabel(c.exception.name)}"| ${ids.get(t)}`,
93
+ );
94
+ }
95
+ }
96
+ }
97
+
98
+ // ifNode cases: implicit branch edges of the decision node
99
+ const branchTarget = (t: FlowNodeOrEnd): FlowNodeOrEnd => (isTryNode(t) ? t.body.start : t);
100
+ for (const n of schema.nodes) {
101
+ if (!isIfNode(n)) continue;
102
+ const id = ids.get(n)!;
103
+ for (const c of n.cases) {
104
+ lines.push(` ${id} -->|"${escapeLabel(c.when)}"| ${ids.get(branchTarget(c.to))}`);
105
+ }
106
+ lines.push(` ${id} -->|"else"| ${ids.get(branchTarget(n.else))}`);
107
+ }
108
+
109
+ // TryNode internal routes
110
+ for (const n of schema.nodes) {
111
+ if (isTryNode(n)) renderTryRoutes(n, schema, ids, lines);
112
+ }
113
+ }
114
+
115
+ /** Routes of a TryNode: catch edges, fall-through continuation (through
116
+ * finally when present), and handler rethrows (also through finally). */
117
+ function renderTryRoutes(n: TryNode, schema: FlowSchema, ids: Map<FlowNodeOrEnd, string>, lines: string[]): void {
118
+ const next = schema.edges.filter((e) => e.start === n && e.throws === undefined && e.exception !== true);
119
+ const rethrows = schema.edges.filter((e) => e.start === n && e.throws !== undefined);
120
+
121
+ for (const c of n.catches) {
122
+ const end = findExceptionEnd(n.body, c.exception.name);
123
+ lines.push(` ${ids.get(end)} -.->|"catch ${escapeLabel(c.exception.name)}"| ${ids.get(c.handler.start)}`);
124
+ }
125
+
126
+ const continueFrom = (sourceId: string): void => {
127
+ if (n.finally) {
128
+ lines.push(` ${sourceId} --> ${ids.get(n.finally.start)}`);
129
+ return;
130
+ }
131
+ for (const e of next) {
132
+ lines.push(renderEdgeLine(e, sourceId, ids.get(e.end)!));
133
+ }
134
+ };
135
+
136
+ continueFrom(ids.get(n.body.returnEnd)!);
137
+ const doneHandlers = new Set<FlowSchema>();
138
+ for (const c of n.catches) {
139
+ if (doneHandlers.has(c.handler)) continue; // one handler may serve many catches
140
+ doneHandlers.add(c.handler);
141
+ // A handler whose every path throws has no fall-through — its return end
142
+ // is never targeted and stays out of the handler's node list.
143
+ const handlerReturn = ids.get(c.handler.returnEnd);
144
+ if (handlerReturn !== undefined) continueFrom(handlerReturn);
145
+ }
146
+ if (n.finally) {
147
+ for (const e of next) {
148
+ lines.push(renderEdgeLine(e, ids.get(n.finally.returnEnd)!, ids.get(e.end)!));
149
+ }
150
+ }
151
+
152
+ const rethrown = new Map<FlowSchema, Set<string>>();
153
+ for (const e of rethrows) {
154
+ for (const c of n.catches) {
155
+ let names = rethrown.get(c.handler);
156
+ if (!names) {
157
+ names = new Set<string>();
158
+ rethrown.set(c.handler, names);
159
+ }
160
+ if (names.has(e.throws!.name)) continue; // one handler may serve many catches
161
+ names.add(e.throws!.name);
162
+ const end = findExceptionEnd(c.handler, e.throws!.name);
163
+ if (!end) continue;
164
+ if (n.finally) {
165
+ lines.push(` ${ids.get(end)} -.->|"rethrow ${escapeLabel(e.throws!.name)}"| ${ids.get(n.finally.start)}`);
166
+ lines.push(` ${ids.get(n.finally.returnEnd)} -.->|"rethrow ${escapeLabel(e.throws!.name)}"| ${ids.get(e.end)}`);
167
+ } else {
168
+ lines.push(` ${ids.get(end)} -.->|"rethrow ${escapeLabel(e.throws!.name)}"| ${ids.get(e.end)}`);
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ /** The exception end of a flow carrying the given exception name. */
175
+ function findExceptionEnd(flow: FlowSchema, exceptionName: string): FlowEnd {
176
+ const end = flow.nodes.find(
177
+ (n): n is FlowEnd => isEnd(n) && n.type === 'exception' && n.exception?.name === exceptionName,
178
+ );
179
+ if (!end) {
180
+ throw new Error(`flow ${flow.name}: no exception end for ${exceptionName}`);
181
+ }
182
+ return end;
183
+ }
184
+
185
+ /** Node label: name on the first line, method references (including utils
186
+ * predicates of guard checks) on the second, and the data line (slots
187
+ * consumed `r:` / produced `w:`) last. */
188
+ function renderNodeLabel(n: FlowNodeOrEnd, outgoing: FlowEdge[]): string {
189
+ if (isEnd(n) || isTryNode(n)) return n.name;
190
+ const lines: string[] = [n.name];
191
+ if (isFlowNode(n) && n.publish) {
192
+ const payload = n.publish.payload ? ` (${n.publish.payload.name})` : '';
193
+ lines.push(`publish ${n.publish.event.name}${payload}`);
194
+ }
195
+ const refs: FlowNodeMethodRef[] = [];
196
+ if (!isIfNode(n)) {
197
+ refs.push(...(n.methods ?? []));
198
+ if (isGuard(n)) {
199
+ for (const c of n.checks) {
200
+ if (c.check !== undefined && isCall(c.check)) refs.push(c.check);
201
+ }
202
+ }
203
+ }
204
+ if (refs.length > 0) {
205
+ const rendered = refs.map((m) => renderMethodRef(methodOf(m))).join(' | ');
206
+ // Script-compiled nodes carry the full method name themselves.
207
+ if (rendered !== n.name) lines.push(rendered);
208
+ }
209
+ const data = renderDataLine(n, outgoing);
210
+ if (data !== '') lines.push(data);
211
+ return lines.join('\n');
212
+ }
213
+
214
+ /** Data line: `r:` lists slots the node consumes (reads, call args, decision
215
+ * condition slots, and branch-edge conditions decided here), `w:` lists
216
+ * slots it produces (writes and call results). */
217
+ function renderDataLine(n: FlowNode | GuardNode | IfNode, outgoing: FlowEdge[]): string {
218
+ const reads = new Set<string>();
219
+ const writes = new Set<string>();
220
+ const addCondition = (c: GuardCondition | undefined): void => {
221
+ if (c === undefined) return;
222
+ if (!isCall(c)) {
223
+ reads.add(isFlowSlot(c.field) ? c.field.name : c.field.slot.name);
224
+ return;
225
+ }
226
+ for (const t of c.args ?? []) reads.add(t.name);
227
+ };
228
+ if (isGuard(n)) {
229
+ for (const c of n.checks) {
230
+ for (const t of c.reads ?? []) reads.add(t.name);
231
+ addCondition(c.check);
232
+ }
233
+ } else if (isIfNode(n)) {
234
+ for (const c of n.cases) addCondition(c.check);
235
+ } else {
236
+ for (const t of n.reads ?? []) reads.add(t.name);
237
+ for (const t of n.writes ?? []) writes.add(t.name);
238
+ if (isFlowNode(n) && n.publish?.payload) reads.add(n.publish.payload.name);
239
+ }
240
+ for (const e of outgoing) addCondition(e.check);
241
+ if (!isIfNode(n)) {
242
+ for (const ref of n.methods ?? []) {
243
+ if (!isCall(ref)) continue;
244
+ for (const t of ref.args ?? []) reads.add(t.name);
245
+ if (ref.result) writes.add(ref.result.name);
246
+ }
247
+ }
248
+ const parts: string[] = [];
249
+ if (reads.size > 0) parts.push(`r:${[...reads].join(',')}`);
250
+ if (writes.size > 0) parts.push(`w:${[...writes].join(',')}`);
251
+ return parts.join(' ');
252
+ }
253
+
254
+ /** Display form: owner.name for descriptors, schema.name for container methods. */
255
+ function renderMethodRef(m: FlowMethodRef): string {
256
+ if ('owner' in m) return `${m.owner}.${m.name}`;
257
+ return `${m.schema.name}.${m.name}`;
258
+ }
259
+
260
+ /** Shape derived from topology: guards are diamonds, ends rounded, other
261
+ * branching nodes diamonds, else rect. Exception edges (typed or not) do not
262
+ * count as branches. The start keeps its rounded shape unless it branches. */
263
+ function renderShape(n: FlowNodeOrEnd, schema: FlowSchema, outgoing: FlowEdge[]): { open: string; close: string } {
264
+ if (isGuard(n)) return { open: '{"', close: '"}' };
265
+ if (isIfNode(n)) return { open: '{"', close: '"}' };
266
+ if (isEnd(n) || outgoing.length === 0) return { open: '(["', close: '"])' };
267
+ const normal = outgoing.filter((e) => e.exception !== true && e.throws === undefined).length;
268
+ if (normal >= 2) return { open: '{"', close: '"}' };
269
+ if (n === schema.start) return { open: '(["', close: '"])' };
270
+ return { open: '["', close: '"]' };
271
+ }
272
+
273
+ function renderEdgeLine(e: FlowEdge, startId: string, endId: string): string {
274
+ const isException = e.exception === true || e.throws !== undefined;
275
+ const arrow = isException ? '-.->' : '-->';
276
+ const throwLabel = e.throws ? `throw ${escapeLabel(e.throws.name)}` : '';
277
+ const labelParts = [e.when ? escapeLabel(e.when) : '', throwLabel].filter((s) => s !== '');
278
+ const label = labelParts.length > 0 ? `|"${labelParts.join(' · ')}"|` : '';
279
+ return ` ${startId} ${arrow}${label} ${endId}`;
280
+ }
281
+
282
+ function isEnd(n: FlowNodeOrEnd): n is FlowEnd {
283
+ return 'type' in n && (n.type === 'return' || n.type === 'exception');
284
+ }
285
+
286
+ function isFlowNode(n: FlowNodeOrEnd): n is FlowNode {
287
+ return !('type' in n);
288
+ }
289
+
290
+ function isGuard(n: FlowNodeOrEnd): n is GuardNode {
291
+ return 'type' in n && n.type === 'guard';
292
+ }
293
+
294
+ function isTryNode(n: FlowNodeOrEnd): n is TryNode {
295
+ return 'type' in n && n.type === 'try';
296
+ }
297
+
298
+ function isIfNode(n: FlowNodeOrEnd): n is IfNode {
299
+ return 'type' in n && n.type === 'if';
300
+ }
301
+
302
+ // Page-driven flow renderer: groups pages by their app into swimlane
303
+ // subgraphs, then renders edges across the whole flow.
304
+
305
+ export function renderPageFlowMermaid(schema: PageFlow): string {
306
+ const lines: string[] = ['flowchart TD'];
307
+ const ids = new Map<Page, string>();
308
+
309
+ const byApp = new Map<string, Page[]>();
310
+ for (const p of schema.pages) {
311
+ const list = byApp.get(p.app.name);
312
+ if (!list) {
313
+ byApp.set(p.app.name, [p]);
314
+ } else {
315
+ list.push(p);
316
+ }
317
+ }
318
+
319
+ let appIdx = 0;
320
+ for (const [appName, pages] of byApp) {
321
+ lines.push(` subgraph app${appIdx}["${escapeLabel(appName)}"]`);
322
+ pages.forEach((p, i) => ids.set(p, `app${appIdx}_p${i}`));
323
+ for (const p of pages) {
324
+ const display = p.label ?? p.name;
325
+ lines.push(` ${ids.get(p)}["${escapeLabel(display)}"]`);
326
+ }
327
+ appIdx++;
328
+ lines.push(' end');
329
+ }
330
+
331
+ for (const e of schema.edges) {
332
+ const label = e.when ? `|"${escapeLabel(e.when.name)}"|` : '';
333
+ lines.push(` ${ids.get(e.start)} -->${label} ${ids.get(e.end)}`);
334
+ }
335
+
336
+ lines.push('');
337
+ lines.push(' classDef start fill:#e6f4ea,stroke:#333,stroke-width:1px;');
338
+ lines.push(` class ${ids.get(schema.start)} start;`);
339
+ return lines.join('\n');
340
340
  }