@pylonts/dsl 1.1.20 → 1.1.22

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 (54) hide show
  1. package/README.md +8 -5
  2. package/dist/action.d.ts +76 -33
  3. package/dist/action.js +19 -17
  4. package/dist/aggregate.d.ts +3 -13
  5. package/dist/aggregate.js +19 -22
  6. package/dist/component.d.ts +3 -3
  7. package/dist/curd.d.ts +2 -2
  8. package/dist/dto.d.ts +18 -2
  9. package/dist/dto.js +75 -9
  10. package/dist/flow-script.js +73 -7
  11. package/dist/flow.d.ts +20 -3
  12. package/dist/flow.js +82 -11
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.js +3 -0
  15. package/dist/journey.d.ts +23 -0
  16. package/dist/journey.js +26 -0
  17. package/dist/mermaid-driver.js +6 -1
  18. package/dist/navigation.d.ts +2 -2
  19. package/dist/page-action.d.ts +39 -0
  20. package/dist/page-action.js +17 -0
  21. package/dist/page-def.d.ts +9 -9
  22. package/dist/page-flow.d.ts +4 -4
  23. package/dist/popup.d.ts +3 -3
  24. package/dist/repository.d.ts +2 -2
  25. package/dist/task.d.ts +20 -0
  26. package/dist/task.js +21 -0
  27. package/dist/utils.d.ts +7 -0
  28. package/dist/utils.js +12 -4
  29. package/docs/aggregate-implementation.md +174 -0
  30. package/docs/aggregate.md +147 -110
  31. package/docs/concepts.md +109 -0
  32. package/docs/dto.md +130 -106
  33. package/docs/table.md +41 -0
  34. package/docs/task.md +81 -0
  35. package/docs/utils.md +19 -12
  36. package/package.json +1 -1
  37. package/src/action.ts +87 -52
  38. package/src/aggregate.ts +94 -103
  39. package/src/component.ts +3 -3
  40. package/src/curd.ts +2 -2
  41. package/src/dto.ts +87 -8
  42. package/src/flow-script.ts +68 -6
  43. package/src/flow.ts +99 -17
  44. package/src/index.ts +3 -0
  45. package/src/journey.ts +61 -0
  46. package/src/mermaid-driver.ts +5 -1
  47. package/src/navigation.ts +2 -2
  48. package/src/page-action.ts +59 -0
  49. package/src/page-def.ts +9 -9
  50. package/src/page-flow.ts +4 -4
  51. package/src/popup.ts +3 -3
  52. package/src/repository.ts +35 -35
  53. package/src/task.ts +52 -0
  54. package/src/utils.ts +18 -4
package/src/aggregate.ts CHANGED
@@ -1,104 +1,95 @@
1
- import type { SchemaBase } from './dsl.js';
2
- import type { TableSchema, ForeignKey } from './db.js';
3
-
4
- // Aggregate declaration: groups multiple tables into one domain concept with
5
- // a root table, member attachment rules, cross-member invariants and
6
- // inter-aggregate reference rules. This turns "multi-table consistency" from a
7
- // convention (hand-written in flows) into a constraint (lintable, codegen-able).
8
-
9
- /** How a member table attaches to the aggregate root. */
10
- export interface AggregateMember {
11
- /** The member table (shared instance from schema/*.table.ts). */
12
- table: TableSchema;
13
- /** The foreign key on the member table pointing to the root table.
14
- * Must exist in table.foreignKeys and its references must be the root's PK
15
- * columns. Defaults to the (unique) FK referencing the root table. */
16
- via?: ForeignKey;
17
- /** 1:1 member (unique constraint on via.columns) vs 1:N (default). */
18
- one?: boolean;
19
- }
20
-
21
- /** A cross-member invariant, checked by generated repository code. */
22
- export interface AggregateInvariant {
23
- name: string;
24
- /** Expression in the aggregate's field vocabulary (e.g. 'total == sum(items.price * items.qty)'). */
25
- check: string;
26
- }
27
-
28
- export interface DomainAggregate extends SchemaBase {
29
- type: 'aggregate';
30
- /** The aggregate root table. */
31
- root: TableSchema;
32
- /** Member tables keyed by role name (e.g. 'items', 'address'). */
33
- members: Record<string, AggregateMember>;
34
- /** Cross-member invariants; optional. */
35
- invariants?: AggregateInvariant[];
36
- /** Inter-aggregate references: only by root ID, keyed by referenced role. */
37
- references?: Record<string, string>;
38
- }
39
-
40
- export function defineAggregate(options: {
41
- name: string;
42
- root: TableSchema;
43
- members?: Record<string, AggregateMember>;
44
- invariants?: AggregateInvariant[];
45
- references?: Record<string, string>;
46
- description?: string;
47
- }): DomainAggregate {
48
- const schema: DomainAggregate = {
49
- type: 'aggregate',
50
- name: options.name,
51
- description: options.description,
52
- root: options.root,
53
- members: options.members ?? {},
54
- invariants: options.invariants,
55
- references: options.references,
56
- };
57
-
58
- // Root must have a primary key (aggregate identity).
59
- if (options.root.primaryKey === undefined) {
60
- throw new Error(`aggregate '${options.name}': root table '${options.root.name}' must have a primary key`);
61
- }
62
-
63
- // Each member must attach to the root via an existing FK referencing the root.
64
- const rootPkRefs = Array.isArray(options.root.primaryKey)
65
- ? options.root.primaryKey
66
- : [options.root.primaryKey];
67
- for (const [role, member] of Object.entries(schema.members)) {
68
- const fks = Object.values(member.table.foreignKeys ?? {}).filter(
69
- (fk) => {
70
- const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
71
- return refs.every((r) => rootPkRefs.includes(r)) && refs.length === rootPkRefs.length;
72
- },
73
- );
74
- if (fks.length === 0) {
75
- throw new Error(
76
- `aggregate '${options.name}': member '${role}' table '${member.table.name}' has no foreign key referencing root '${options.root.name}' — declare one in the table's foreignKeys`,
77
- );
78
- }
79
- if (member.via !== undefined) {
80
- const viaKeys = Object.values(member.table.foreignKeys ?? {});
81
- if (!viaKeys.includes(member.via)) {
82
- throw new Error(
83
- `aggregate '${options.name}': member '${role}' via must be one of table '${member.table.name}' foreignKeys — got a non-FK object`,
84
- );
85
- }
86
- const refs = Array.isArray(member.via.references) ? member.via.references : [member.via.references];
87
- if (!(refs.length === rootPkRefs.length && refs.every((r) => rootPkRefs.includes(r)))) {
88
- throw new Error(
89
- `aggregate '${options.name}': member '${role}' via must reference root '${options.root.name}' primary key columns`,
90
- );
91
- }
92
- } else {
93
- // Default: the (single) FK referencing the root. More than one → must declare via.
94
- if (fks.length > 1) {
95
- throw new Error(
96
- `aggregate '${options.name}': member '${role}' table '${member.table.name}' has ${fks.length} foreign keys referencing root '${options.root.name}' — declare via explicitly`,
97
- );
98
- }
99
- (member as { via: ForeignKey }).via = fks[0];
100
- }
101
- }
102
-
103
- return schema;
1
+ import type { SchemaBase } from './dsl.js';
2
+ import type { TableSchema } from './db.js';
3
+
4
+ // Aggregate declaration: groups multiple tables into one domain concept with
5
+ // a root table, member tables, cross-member invariants and inter-aggregate
6
+ // reference rules. This turns "multi-table consistency" from a convention
7
+ // (hand-written in flows) into a constraint (lintable, codegen-able).
8
+ //
9
+ // Members are intentionally minimal:
10
+ // members: { items: [orderItem] } -> array = 1:N (normal table)
11
+ // members: { address: orderAddress } -> single = 1:1 (extension table;
12
+ // designed but not implemented yet)
13
+
14
+ /** Member table(s) keyed by role name. Array = 1:N; non-array = 1:1 extension. */
15
+ export type AggregateMember = TableSchema | TableSchema[];
16
+
17
+ /** A cross-member invariant, checked by generated repository code. */
18
+ export interface AggregateInvariant {
19
+ name: string;
20
+ /** Expression in the aggregate's field vocabulary (e.g. 'total == sum(items.price * items.qty)'). */
21
+ check: string;
22
+ }
23
+
24
+ export interface DomainAggregate extends SchemaBase {
25
+ type: 'aggregate';
26
+ /** The aggregate root table. */
27
+ root: TableSchema;
28
+ /** Member tables keyed by role name (e.g. 'items', 'address'). */
29
+ members: Record<string, AggregateMember>;
30
+ /** Cross-member invariants; optional. */
31
+ invariants?: AggregateInvariant[];
32
+ /** Inter-aggregate references: only by root ID, keyed by referenced role. */
33
+ references?: Record<string, string>;
34
+ }
35
+
36
+ export function defineAggregate(options: {
37
+ root: TableSchema;
38
+ members?: Record<string, AggregateMember>;
39
+ invariants?: AggregateInvariant[];
40
+ references?: Record<string, string>;
41
+ description?: string;
42
+ }): DomainAggregate {
43
+ const schema: DomainAggregate = {
44
+ type: 'aggregate',
45
+ name: options.root.name,
46
+ description: options.description,
47
+ root: options.root,
48
+ members: options.members ?? {},
49
+ invariants: options.invariants,
50
+ references: options.references,
51
+ };
52
+
53
+ // Root must have a primary key (aggregate identity).
54
+ if (options.root.primaryKey === undefined) {
55
+ throw new Error(`aggregate '${schema.name}': root table '${options.root.name}' must have a primary key`);
56
+ }
57
+
58
+ const rootPkRefs = Array.isArray(options.root.primaryKey)
59
+ ? options.root.primaryKey
60
+ : [options.root.primaryKey];
61
+
62
+ // Each member must attach to the root. Array members are normal 1:N tables
63
+ // and must have exactly one FK referencing the root. Non-array (1:1 extension)
64
+ // members are designed but not implemented yet.
65
+ for (const [role, member] of Object.entries(schema.members)) {
66
+ if (Array.isArray(member)) {
67
+ if (member.length !== 1) {
68
+ throw new Error(
69
+ `aggregate '${schema.name}': member '${role}' array must contain exactly one table schema`,
70
+ );
71
+ }
72
+ const table = member[0];
73
+ const fks = Object.values(table.foreignKeys ?? {}).filter((fk) => {
74
+ const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
75
+ return refs.length === rootPkRefs.length && refs.every((r) => rootPkRefs.includes(r));
76
+ });
77
+ if (fks.length === 0) {
78
+ throw new Error(
79
+ `aggregate '${schema.name}': member '${role}' table '${table.name}' has no foreign key referencing root '${options.root.name}' — declare one in the table's foreignKeys`,
80
+ );
81
+ }
82
+ if (fks.length > 1) {
83
+ throw new Error(
84
+ `aggregate '${schema.name}': member '${role}' table '${table.name}' has ${fks.length} foreign keys referencing root '${options.root.name}' — reduce to one FK for minimal aggregate declarations`,
85
+ );
86
+ }
87
+ } else {
88
+ throw new Error(
89
+ `aggregate '${schema.name}': member '${role}' is a non-array table 1:1 extension tables are designed but not implemented yet; use an array for 1:N members`,
90
+ );
91
+ }
92
+ }
93
+
94
+ return schema;
104
95
  }
package/src/component.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import type { SchemaBase } from './dsl.js';
2
2
  import type { RefSchema } from './ref.js';
3
- import type { ActionSchema } from './action.js';
3
+ import type { PageActionSchema } from './page-action.js';
4
4
  import type { EventDataSchema } from './event.js';
5
5
 
6
6
  /** A component event trigger declaration. */
7
7
  export interface TriggerSchema extends SchemaBase {
8
8
  /** Data the event carries (e.g. e.detail). */
9
9
  eventData?: EventDataSchema;
10
- /** Actions that fire when the event occurs. */
11
- actions?: ActionSchema[];
10
+ /** Page actions that fire when the event occurs. */
11
+ actions?: PageActionSchema[];
12
12
  }
13
13
 
14
14
  /** A UI component declaration — a virtual schema that describes props and
package/src/curd.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { SchemaBase, Field } from './dsl.js';
2
2
  import { TableSchema } from './db.js';
3
3
  import { FrontAppSchema } from './project.js';
4
- import { ActionSchema } from './action.js';
4
+ import { PageActionSchema } from './page-action.js';
5
5
  import type { FilterSchema } from './filter.js';
6
6
  import { toKebabCase } from '@pylonts/core';
7
7
 
@@ -51,7 +51,7 @@ export interface CurdSchema extends SchemaBase {
51
51
  /** Sidebar menu section (group) this CRUD page belongs to. */
52
52
  section: string;
53
53
  /** Extra user actions on this page (beyond the standard CRUD). */
54
- actions?: ActionSchema[];
54
+ actions?: PageActionSchema[];
55
55
  /** Add/update/detail action pages. */
56
56
  actionPages?: {
57
57
  add?: ActionPage;
package/src/dto.ts CHANGED
@@ -200,13 +200,55 @@ export function resolveDtoRefChain(f: DtoField): DtoField {
200
200
  return cur;
201
201
  }
202
202
 
203
- /** TS type of a DtoField in generated code — unwraps the wrapped field
204
- * (enum its JS name, date/datetime string, containers jsType). */
203
+ /** TS type of a DtoField in generated code.
204
+ * A field shared by reference (its schema is the owning DTO utils args
205
+ * like `args: { items: OrderSubmitRequest.fields.items }`) renders as an
206
+ * indexed access on the DTO's generated type (the DTO owns the structure).
207
+ * Array elements render by name (`ItemDto[]` — named DTO) or by recursion
208
+ * (`Array<string>` — scalar). Plain wire objects (objectField) render
209
+ * their property shape (`{ key: type }`); DtoField-class containers are
210
+ * rejected at build time (DTOs must not nest inline structures).
211
+ * Enum → its JS name, date/datetime → string. */
205
212
  export function dtoFieldJsType(df: DtoField): string {
206
- const f = df.field;
207
- if (f.type === 'enum') return f.enum.jsName;
208
- if (f.type === 'date' || f.type === 'datetime') return 'string';
209
- return f.jsType;
213
+ const owner = df.schema as { type?: string; name?: string } | undefined;
214
+ if (owner?.type === 'dto' && owner.name !== undefined && owner.name !== '' && df.name !== '') {
215
+ return `${owner.name}['${df.name}']`;
216
+ }
217
+ return dtoFieldJsTypeInner(df.field);
218
+ }
219
+
220
+ /** Type of a raw field object — unwraps DtoField-class wrappers
221
+ * (dtoField(dtoArrayField(...)) stores the def inside the instance's
222
+ * .field) and recurses: named-DTO elements (Name[]), scalar elements
223
+ * (Array<T>), plain wire objects ({ key: type }), enums, and scalars. */
224
+ function dtoFieldJsTypeInner(field: Field | DtoArrayFieldDef | DtoObjectFieldDef): string {
225
+ const f = (field as { field?: unknown }).field ?? field;
226
+ const inner = f as {
227
+ type?: string;
228
+ jsType?: string;
229
+ enum?: { jsName: string };
230
+ items?: DtoField | DtoMessage;
231
+ properties?: Record<string, DtoField | Field>;
232
+ };
233
+ if (inner.type === 'enum') return inner.enum!.jsName;
234
+ if (inner.type === 'date' || inner.type === 'datetime') return 'string';
235
+ if (inner.type === 'array') {
236
+ const items = inner.items!;
237
+ return isDtoMessage(items) ? `${items.name}[]` : `Array<${dtoFieldJsType(items)}>`;
238
+ }
239
+ if (inner.type === 'object') {
240
+ // Plain objectField properties are bare Fields; DtoObjectFieldDef
241
+ // properties are DtoFields. Recurse through both.
242
+ const props = Object.entries(inner.properties ?? {})
243
+ .map(([k, v]) => {
244
+ const optional = isDtoField(v) ? v.isOptional() : (v as Field).optional ?? false;
245
+ const type = isDtoField(v) ? dtoFieldJsType(v) : dtoFieldJsTypeInner(v as Field);
246
+ return `${k}${optional ? '?' : ''}: ${type}`;
247
+ })
248
+ .join('; ');
249
+ return `{ ${props} }`;
250
+ }
251
+ return inner.jsType ?? '';
210
252
  }
211
253
 
212
254
  /** Enum JS names referenced by a DtoField, recursing into inline array/object
@@ -225,8 +267,10 @@ export function dtoCollectEnumRefs(df: DtoField, out: string[] = []): string[] {
225
267
  }
226
268
 
227
269
  export function dtoArrayField(def: { items: DtoField | DtoMessage } & Omit<BaseField, 'name'>): DtoArrayField {
228
- // Items stay as-is: an inline DtoField is rendered inline, a DtoMessage is
229
- // referenced by name (the driver renders Type.Array(<DtoName>)).
270
+ // Items stay as-is: a DtoMessage is referenced by name (the driver renders
271
+ // Type.Array(<DtoName>)); a scalar DtoField element renders its primitive
272
+ // type. Inline container elements are rejected by buildMessage/defineUtils
273
+ // (DTOs must not nest inline structures — every object needs a name).
230
274
  return new DtoArrayField({ name: '', type: 'array', jsType: 'array', ...def });
231
275
  }
232
276
 
@@ -234,7 +278,42 @@ export function dtoObjectField(def: { properties: Record<string, DtoField> } & O
234
278
  return new DtoObjectField({ name: '', type: 'object', jsType: 'object', ...def });
235
279
  }
236
280
 
281
+ /** DTOs must not nest DtoField-class containers inline: dtoObjectField /
282
+ * dtoArrayField instances (and dtoField(dtoObjectField(...))-style wraps)
283
+ * have no reusable name — extract a named DTO and reference it as an array
284
+ * element (dtoArrayField({ items: namedDto })), and array items must be a
285
+ * named DTO or a scalar field. Plain Field containers (objectField /
286
+ * arrayField — wire-format nesting) stay legal and render inline.
287
+ * DtoField-class wrappers (dtoField(dtoArrayField(...))) carry the def
288
+ * inside the instance's .field, so both layers are unwrapped. */
289
+ export function assertNoInlineContainers(dtoName: string, fields: Record<string, DtoField>): void {
290
+ for (const [key, df] of Object.entries(fields)) {
291
+ const f = (df.field as { field?: unknown }).field ?? df.field;
292
+ const field = f as { type?: string; items?: DtoField | DtoMessage };
293
+ // Only DtoField-class containers are banned (they need a name). Plain
294
+ // Field objects (objectField — wire-format nesting) are legal and render
295
+ // inline: dtoField(objectField({...})) stays allowed.
296
+ const isDtoClassContainer =
297
+ isDtoField(df.field) || typeof (df as { properties?: unknown }).properties === 'function';
298
+ if (isDtoClassContainer && field.type === 'object') {
299
+ throw new Error(
300
+ `[dto] "${dtoName}" field "${key}": inline object is not allowed — dtoObjectField containers must be named: extract a named DTO and reference it (dtoArrayField({ items: itemDto })) or use a plain objectField for wire-format nesting`,
301
+ );
302
+ }
303
+ if (field.type === 'array' && isDtoField(field.items)) {
304
+ const items = (field.items.field as { field?: unknown }).field ?? field.items.field;
305
+ const item = items as { type?: string };
306
+ if (item.type === 'object' || item.type === 'array') {
307
+ throw new Error(
308
+ `[dto] "${dtoName}" field "${key}": inline container elements are not allowed — array items must be a named DTO or a scalar field`,
309
+ );
310
+ }
311
+ }
312
+ }
313
+ }
314
+
237
315
  function buildMessage(name: string, direction: DtoDirection, fields: Record<string, DtoField>, description?: string): DtoMessage {
316
+ assertNoInlineContainers(name, fields);
238
317
  const message = new DtoMessage(name, direction, fields, description);
239
318
  // Write back the DTO field name from the map key (safe: DtoField instances
240
319
  // are created per DTO, never shared).
@@ -21,6 +21,7 @@ import {
21
21
  guard,
22
22
  ifNode,
23
23
  isCall,
24
+ isConditionGroup,
24
25
  isEnd,
25
26
  isFlowNode,
26
27
  isFlowSlot,
@@ -297,6 +298,10 @@ function addUsed(ctx: FlowCompile, slot: FlowSlot | undefined): void {
297
298
  }
298
299
 
299
300
  function addConditionUsed(ctx: FlowCompile, c: GuardCondition): void {
301
+ if (isConditionGroup(c)) {
302
+ for (const s of c.conds) addConditionUsed(ctx, s);
303
+ return;
304
+ }
300
305
  if (!isCall(c)) {
301
306
  addUsed(ctx, isFlowSlot(c.field) ? c.field : c.field.slot);
302
307
  return;
@@ -310,6 +315,8 @@ function isInvokeStep(c: GuardCondition | InvokeStep): c is InvokeStep {
310
315
  return (c as InvokeStep).kind === 'invoke';
311
316
  }
312
317
 
318
+ /** Lower an IF condition: a top-level invoke is a predicate call, and
319
+ * composites convert invoke steps nested inside them recursively. */
313
320
  function toCondition(c: GuardCondition | InvokeStep): GuardCondition {
314
321
  if (isInvokeStep(c)) {
315
322
  if (c.result !== undefined) {
@@ -317,6 +324,9 @@ function toCondition(c: GuardCondition | InvokeStep): GuardCondition {
317
324
  }
318
325
  return { method: c.method, args: c.args === undefined ? [] : Array.isArray(c.args) ? c.args : [c.args] };
319
326
  }
327
+ if (isConditionGroup(c)) {
328
+ return { kind: c.kind, conds: c.conds.map((sub) => toCondition(sub)) };
329
+ }
320
330
  return c;
321
331
  }
322
332
 
@@ -341,11 +351,35 @@ function methodThrows(m: FlowMethodRef): ExceptionSchema[] {
341
351
  }
342
352
 
343
353
  /** Readable condition text used as node/branch labels (and as the throw
344
- * label when THROW carries no message). */
354
+ * label when THROW carries no message). Composites render parenthesized
355
+ * sub-conditions: !(a), (a && b), (a || b). */
345
356
  function renderCondition(c: GuardCondition): string {
357
+ if (isConditionGroup(c)) {
358
+ const inner = c.conds.map(renderCondition).join(c.kind === 'and' ? ' && ' : c.kind === 'or' ? ' || ' : '');
359
+ return c.kind === 'not' ? `!(${inner})` : `(${inner})`;
360
+ }
346
361
  if (!isCall(c)) {
347
362
  if (isFlowSlot(c.field)) {
348
- return c.op === 'isNull' ? `${c.field.name} is null` : `${c.field.name} is not null`;
363
+ const nullOp = c.op === 'isNull' || c.op === 'isNotNull';
364
+ if (nullOp) return c.op === 'isNull' ? `${c.field.name} is null` : `${c.field.name} is not null`;
365
+ // scalar slot comparison: total > 100
366
+ const ref = c.field.name;
367
+ switch (c.op) {
368
+ case 'lt':
369
+ return `${ref} < ${renderValue(c.value)}`;
370
+ case 'le':
371
+ return `${ref} <= ${renderValue(c.value)}`;
372
+ case 'gt':
373
+ return `${ref} > ${renderValue(c.value)}`;
374
+ case 'ge':
375
+ return `${ref} >= ${renderValue(c.value)}`;
376
+ case 'eq':
377
+ return `${ref} = ${renderValue(c.value)}`;
378
+ case 'ne':
379
+ return `${ref} ≠ ${renderValue(c.value)}`;
380
+ default:
381
+ throw new Error(`unsupported comparison op '${c.op}'`);
382
+ }
349
383
  }
350
384
  const field = c.field.field as { name: string };
351
385
  const ref = `${c.field.slot.name}.${field.name}`;
@@ -546,15 +580,21 @@ function compileTry(step: TryStep, ctx: FlowCompile, cont: FlowNodeOrEnd, inheri
546
580
  const ordinal = ctx.tryTotal - ++ctx.tryCount + 1;
547
581
  const suffix = ordinal === 1 ? '' : `${ordinal}`;
548
582
  const body = compileFlowBody(`${ctx.name}.tryBody${suffix}`, undefined, step.body, ctx, inherit);
583
+ // Catch handlers and finally may read what the body produced before the
584
+ // failure point (Java semantics: try { row = dao.get() } catch { use(row) }).
585
+ // The body's productions seed their entry availability alongside the
586
+ // enclosing flow's inherited slots.
587
+ const bodyProduced = flowProducedSlots(body);
588
+ const catchInherit = [...inherit, ...bodyProduced];
549
589
  const catches = step.catches.map(([ex, steps]) => ({
550
590
  exception: ex,
551
- handler: compileFlowBody(`${ctx.name}.catch${ex.name}${suffix}`, undefined, steps, ctx, inherit),
591
+ handler: compileFlowBody(`${ctx.name}.catch${ex.name}${suffix}`, undefined, steps, ctx, catchInherit),
552
592
  }));
553
593
  const t = tryNode(step.name ?? 'try', {
554
594
  body,
555
595
  catches,
556
596
  finally: step.finally
557
- ? compileFlowBody(`${ctx.name}.finally${suffix}`, undefined, step.finally, ctx, inherit)
597
+ ? compileFlowBody(`${ctx.name}.finally${suffix}`, undefined, step.finally, ctx, catchInherit)
558
598
  : undefined,
559
599
  });
560
600
  ctx.seen.add(t);
@@ -585,6 +625,22 @@ function compileSub(step: SubStep, ctx: FlowCompile, cont: FlowNodeOrEnd, inheri
585
625
  return n;
586
626
  }
587
627
 
628
+ /** Slots a flow's own nodes produce (call results and writes) — the try
629
+ * body's productions become visible to its catch handlers and finally. */
630
+ function flowProducedSlots(f: FlowSchema): FlowSlot[] {
631
+ const out = new Set<FlowSlot>();
632
+ for (const n of f.nodes) {
633
+ if (isEnd(n)) continue;
634
+ if (isGuard(n) || isFlowNode(n)) {
635
+ for (const m of n.methods ?? []) {
636
+ if (isCall(m) && m.result !== undefined) out.add(m.result);
637
+ }
638
+ if (isFlowNode(n)) for (const w of n.writes ?? []) out.add(w);
639
+ }
640
+ }
641
+ return [...out];
642
+ }
643
+
588
644
  /** Compile one flow (top-level or sub-flow): its own slots registry (args
589
645
  * plus the named slots actually used inside), its own exception ends, and
590
646
  * its own return end (linked through DANGLE). */
@@ -628,8 +684,11 @@ function buildFlow(
628
684
  const nodes = [...ctx.seen];
629
685
  const registry = defineSlots(buildSlots(ctx));
630
686
  // Entry inheritance only for slots the flow actually consumes; unused
631
- // productions of the enclosing flow are not this flow's concern.
632
- const entrySlots = rewriteSlots(nodes, ctx.edges, registry, ctx.entrySlots.filter((s) => ctx.usedSlots.has(s)));
687
+ // productions of the enclosing flow are not this flow's concern. Name-based
688
+ // matching: inherited instances may come from another flow's registry (the
689
+ // try body's re-bound productions), so identity comparison would drop them.
690
+ const usedNames = new Set([...ctx.usedSlots].map((s) => s.name));
691
+ const entrySlots = rewriteSlots(nodes, ctx.edges, registry, ctx.entrySlots.filter((s) => usedNames.has(s.name)));
633
692
  return defineFlow(name, {
634
693
  start,
635
694
  description,
@@ -671,6 +730,9 @@ function rewriteSlots(nodes: FlowNodeOrEnd[], edges: FlowEdge[], slots: FlowSlot
671
730
  return { method: m.method, args: m.args?.map(map), result: m.result ? map(m.result) : undefined };
672
731
  };
673
732
  const cond = (c: GuardCondition): GuardCondition => {
733
+ if (isConditionGroup(c)) {
734
+ return { kind: c.kind, conds: c.conds.map(cond) };
735
+ }
674
736
  if (!isCall(c)) {
675
737
  if (isFlowSlot(c.field)) {
676
738
  return { kind: 'comparison', op: c.op, field: map(c.field), value: c.value };