@pylonts/dsl 1.1.20 → 1.1.21

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/src/aggregate.ts CHANGED
@@ -1,22 +1,18 @@
1
1
  import type { SchemaBase } from './dsl.js';
2
- import type { TableSchema, ForeignKey } from './db.js';
2
+ import type { TableSchema } from './db.js';
3
3
 
4
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).
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)
8
13
 
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
- }
14
+ /** Member table(s) keyed by role name. Array = 1:N; non-array = 1:1 extension. */
15
+ export type AggregateMember = TableSchema | TableSchema[];
20
16
 
21
17
  /** A cross-member invariant, checked by generated repository code. */
22
18
  export interface AggregateInvariant {
@@ -38,7 +34,6 @@ export interface DomainAggregate extends SchemaBase {
38
34
  }
39
35
 
40
36
  export function defineAggregate(options: {
41
- name: string;
42
37
  root: TableSchema;
43
38
  members?: Record<string, AggregateMember>;
44
39
  invariants?: AggregateInvariant[];
@@ -47,7 +42,7 @@ export function defineAggregate(options: {
47
42
  }): DomainAggregate {
48
43
  const schema: DomainAggregate = {
49
44
  type: 'aggregate',
50
- name: options.name,
45
+ name: options.root.name,
51
46
  description: options.description,
52
47
  root: options.root,
53
48
  members: options.members ?? {},
@@ -57,46 +52,42 @@ export function defineAggregate(options: {
57
52
 
58
53
  // Root must have a primary key (aggregate identity).
59
54
  if (options.root.primaryKey === undefined) {
60
- throw new Error(`aggregate '${options.name}': root table '${options.root.name}' must have a primary key`);
55
+ throw new Error(`aggregate '${schema.name}': root table '${options.root.name}' must have a primary key`);
61
56
  }
62
57
 
63
- // Each member must attach to the root via an existing FK referencing the root.
64
58
  const rootPkRefs = Array.isArray(options.root.primaryKey)
65
59
  ? options.root.primaryKey
66
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.
67
65
  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)) {
66
+ if (Array.isArray(member)) {
67
+ if (member.length !== 1) {
82
68
  throw new Error(
83
- `aggregate '${options.name}': member '${role}' via must be one of table '${member.table.name}' foreignKeys — got a non-FK object`,
69
+ `aggregate '${schema.name}': member '${role}' array must contain exactly one table schema`,
84
70
  );
85
71
  }
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)))) {
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) {
88
78
  throw new Error(
89
- `aggregate '${options.name}': member '${role}' via must reference root '${options.root.name}' primary key columns`,
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`,
90
80
  );
91
81
  }
92
- } else {
93
- // Default: the (single) FK referencing the root. More than one → must declare via.
94
82
  if (fks.length > 1) {
95
83
  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`,
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`,
97
85
  );
98
86
  }
99
- (member as { via: ForeignKey }).via = fks[0];
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
+ );
100
91
  }
101
92
  }
102
93
 
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 };
package/src/flow.ts CHANGED
@@ -210,9 +210,49 @@ export interface Comparison {
210
210
  }
211
211
 
212
212
  /** The machine-readable condition behind a guard check or a branch edge
213
- * (optional): a utils predicate call (its boolean result decides) or a field
214
- * comparison. */
215
- export type GuardCondition = FlowCall | Comparison;
213
+ * (optional): a utils predicate call (its boolean result decides), a field
214
+ * comparison, or a composite (not/and/or over sub-conditions). */
215
+ export type GuardCondition = FlowCall | Comparison | ConditionGroup;
216
+
217
+ /** A composite condition: not (exactly one sub-condition), and/or (two or
218
+ * more). Renderers parenthesize sub-groups, so nesting stays unambiguous. */
219
+ export interface ConditionGroup {
220
+ kind: 'not' | 'and' | 'or';
221
+ conds: GuardCondition[];
222
+ }
223
+
224
+ export function isConditionGroup(c: unknown): c is ConditionGroup {
225
+ return (
226
+ typeof c === 'object' &&
227
+ c !== null &&
228
+ ((c as { kind?: unknown }).kind === 'not' || (c as { kind?: unknown }).kind === 'and' || (c as { kind?: unknown }).kind === 'or') &&
229
+ Array.isArray((c as { conds?: unknown }).conds)
230
+ );
231
+ }
232
+
233
+ /** Negation — the only way to invert a condition (e.g. !predicate). */
234
+ export function not(...conds: GuardCondition[]): ConditionGroup {
235
+ if (conds.length !== 1) {
236
+ throw new Error('not: takes exactly one condition');
237
+ }
238
+ return { kind: 'not', conds };
239
+ }
240
+
241
+ /** Conjunction — every sub-condition must hold. */
242
+ export function and(...conds: GuardCondition[]): ConditionGroup {
243
+ if (conds.length < 2) {
244
+ throw new Error('and: requires at least two conditions');
245
+ }
246
+ return { kind: 'and', conds };
247
+ }
248
+
249
+ /** Disjunction — at least one sub-condition holds. */
250
+ export function or(...conds: GuardCondition[]): ConditionGroup {
251
+ if (conds.length < 2) {
252
+ throw new Error('or: requires at least two conditions');
253
+ }
254
+ return { kind: 'or', conds };
255
+ }
216
256
 
217
257
  /** True when the condition operand is the slot itself (slot-level null check),
218
258
  * not a field access on it. Slot metadata (name) lives on the proxy target and
@@ -221,13 +261,28 @@ export function isFlowSlot(v: unknown): v is FlowSlot {
221
261
  return typeof v === 'object' && v !== null && typeof (v as FlowSlot).name === 'string';
222
262
  }
223
263
 
264
+ /** True when the slot carries a scalar Field (its declared type has a jsType)
265
+ * rather than a message — scalar slots support full comparisons
266
+ * (gt(slots.total, 100)); message slots only slot-level null checks. */
267
+ export function isScalarSlot(slot: FlowSlot): boolean {
268
+ const t = slot.type as { jsType?: unknown } | undefined;
269
+ return typeof t === 'object' && t !== null && t.jsType !== undefined;
270
+ }
271
+
224
272
  function comparison(op: CompareOp, field: unknown, value?: string | number | EnumValue): Comparison {
225
273
  if (isFlowSlot(field)) {
226
- if (op !== 'isNull' && op !== 'isNotNull') {
227
- throw new Error(`${op}: a slot-level check only supports isNull/isNotNull — field comparisons need slots.args.amt`);
228
- }
229
- if (value !== undefined) {
230
- throw new Error(`${op}: takes no value`);
274
+ const nullOp = op === 'isNull' || op === 'isNotNull';
275
+ if (nullOp) {
276
+ if (value !== undefined) {
277
+ throw new Error(`${op}: takes no value`);
278
+ }
279
+ } else {
280
+ if (!isScalarSlot(field)) {
281
+ throw new Error(`${op}: a slot-level check only supports isNull/isNotNull — field comparisons need slots.args.amt`);
282
+ }
283
+ if (value === undefined) {
284
+ throw new Error(`${op}: requires a value`);
285
+ }
231
286
  }
232
287
  return { kind: 'comparison', op, field, value };
233
288
  }
@@ -869,19 +924,37 @@ function validateGuardChecks(schema: FlowSchema): void {
869
924
  }
870
925
 
871
926
  // A condition must be a utils predicate call (boolean result, no result
872
- // slot) or a field comparison whose op is known and whose value presence and
873
- // type match the operator.
927
+ // slot), a field comparison whose op is known and whose value presence and
928
+ // type match the operator, or a composite (not/and/or) whose sub-conditions
929
+ // are each valid.
874
930
  const COMPARE_OPS: readonly CompareOp[] = ['lt', 'le', 'gt', 'ge', 'eq', 'ne', 'isNull', 'isNotNull'];
875
931
 
876
932
  function validateCondition(where: string, c: GuardCondition): void {
933
+ if (isConditionGroup(c)) {
934
+ if (c.kind === 'not') {
935
+ if (c.conds.length !== 1) {
936
+ throw new Error(`${where}: not() takes exactly one condition`);
937
+ }
938
+ } else if (c.conds.length < 2) {
939
+ throw new Error(`${where}: ${c.kind}() takes at least two conditions`);
940
+ }
941
+ for (const sub of c.conds) validateCondition(where, sub);
942
+ return;
943
+ }
877
944
  if (!isCall(c)) {
878
945
  if (isFlowSlot(c.field)) {
879
946
  const nullOp = c.op === 'isNull' || c.op === 'isNotNull';
880
- if (!nullOp) {
881
- throw new Error(`${where}: a slot-level check only supports isNull/isNotNull`);
882
- }
883
- if (c.value !== undefined) {
884
- throw new Error(`${where}: ${c.op} takes no value`);
947
+ if (nullOp) {
948
+ if (c.value !== undefined) {
949
+ throw new Error(`${where}: ${c.op} takes no value`);
950
+ }
951
+ } else {
952
+ if (!isScalarSlot(c.field)) {
953
+ throw new Error(`${where}: a slot-level check only supports isNull/isNotNull`);
954
+ }
955
+ if (c.value === undefined) {
956
+ throw new Error(`${where}: ${c.op} requires a value`);
957
+ }
885
958
  }
886
959
  return;
887
960
  }
@@ -908,18 +981,27 @@ function validateCondition(where: string, c: GuardCondition): void {
908
981
  }
909
982
  return;
910
983
  }
911
- const m = c.method as { type?: string; name: string };
984
+ const m = c.method as Partial<UtilsMethodSchema> & { type?: string; name: string };
912
985
  if (m.type !== 'utilsMethod') {
913
986
  throw new Error(`${where}: check call must be a utils predicate, got ${m.type ?? m.name}`);
914
987
  }
915
988
  if (c.result !== undefined) {
916
989
  throw new Error(`${where}: a predicate call cannot bind a result slot`);
917
990
  }
991
+ if (m.result === undefined || m.result.jsType !== 'boolean') {
992
+ const declared = m.result === undefined ? 'void' : m.result.jsType;
993
+ throw new Error(
994
+ `${where}: utils predicate "${m.name}" must declare a boolean result (got ${declared}) — ` +
995
+ `predicates (can/is/has) return boolean; defense guards (assert/validate/ensure) throw internally and are invoked, not used in IF`,
996
+ );
997
+ }
918
998
  }
919
999
 
920
1000
  /** Slots a condition reads: a comparison's field slot (or the slot itself for
921
- * slot-level checks) or a predicate call's arg slots. */
1001
+ * slot-level checks), a predicate call's arg slots, or every sub-condition's
1002
+ * slots for a composite. */
922
1003
  function conditionSlots(c: GuardCondition): FlowSlot[] {
1004
+ if (isConditionGroup(c)) return c.conds.flatMap(conditionSlots);
923
1005
  if (!isCall(c)) return [isFlowSlot(c.field) ? c.field : c.field.slot];
924
1006
  return c.args ?? [];
925
1007
  }
@@ -1,5 +1,5 @@
1
1
  import { FlowEdge, FlowEnd, FlowNode, FlowSchema, FlowStep, FlowNodeOrEnd, GuardNode, TryNode, IfNode } from './flow.js';
2
- import { isCall, isFlowSlot, methodOf } from './flow.js';
2
+ import { isCall, isConditionGroup, isFlowSlot, methodOf } from './flow.js';
3
3
  import type { FlowMethodRef, FlowNodeMethodRef, GuardCondition } from './flow.js';
4
4
  import { Page } from './page.js';
5
5
  import { PageEdge, PageFlow } from './page-flow.js';
@@ -219,6 +219,10 @@ function renderDataLine(n: FlowNode | GuardNode | IfNode, outgoing: FlowEdge[]):
219
219
  const writes = new Set<string>();
220
220
  const addCondition = (c: GuardCondition | undefined): void => {
221
221
  if (c === undefined) return;
222
+ if (isConditionGroup(c)) {
223
+ for (const s of c.conds) addCondition(s);
224
+ return;
225
+ }
222
226
  if (!isCall(c)) {
223
227
  reads.add(isFlowSlot(c.field) ? c.field.name : c.field.slot.name);
224
228
  return;
package/src/repository.ts CHANGED
@@ -6,8 +6,8 @@ import type { DomainAggregate } from './aggregate.js';
6
6
  * exposes the three fixed operation skeletons (load / save / delete) that the
7
7
  * generator expands from the aggregate structure:
8
8
  *
9
- * save(order) = tx { rootDao.upsert + memberDao cascade by via FK }
10
- * load(id) = rootDao.get + memberDao by via FK
9
+ * save(order) = tx { rootDao.upsert + memberDao cascade by FK / extends }
10
+ * load(id) = rootDao.get + memberDao by FK / extends
11
11
  * delete(id) = tx { memberDao delete + rootDao delete }
12
12
  *
13
13
  * Callers face the domain concept (Order), never the tables. DAO stays
package/src/utils.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { CollectionSchemaBase, Field, SchemaBase } from './dsl.js';
2
+ import { assertNoInlineContainers } from './dto.js';
2
3
  import type { DtoField } from './dto.js';
4
+ import type { ExceptionSchema } from './exception.js';
3
5
  import type { FrontAppSchema, ProjectApiSchema } from './project.js';
4
6
 
5
7
  // Base utility modules — business-agnostic helpers with full method
@@ -22,6 +24,12 @@ export interface UtilsMethodSchema extends SchemaBase {
22
24
  * computed amounts, ...). The method output is a fresh value, never a
23
25
  * shared table column. Omit for void methods (pure actions). */
24
26
  result?: Field;
27
+ /** Exceptions this method may throw — the failure contract of a defense
28
+ * guard (assert/validate/ensure: void, throws internally). Flows route
29
+ * invoked guards' throws into their escape set automatically, so a
30
+ * guard's throws must be declared by the calling service method (or
31
+ * caught in a TRY). Predicates (can/is/has, boolean) do not throw. */
32
+ throws?: ExceptionSchema[];
25
33
  }
26
34
 
27
35
  /** Method input for defineUtils: type/schema/name are set by the builder. */
@@ -62,6 +70,9 @@ export function defineUtils(options: {
62
70
  };
63
71
  for (const key of Object.keys(options.methods)) {
64
72
  const method = options.methods[key] as UtilsMethodDef;
73
+ // Same rule as DTO fields: args must not nest inline containers — use a
74
+ // DTO field reference (args: { items: SubmitRequest.fields.items }).
75
+ assertNoInlineContainers(options.name, method.args);
65
76
  const methodSchema: UtilsMethodSchema = {
66
77
  type: 'utilsMethod',
67
78
  name: key,
@@ -69,14 +80,17 @@ export function defineUtils(options: {
69
80
  schema,
70
81
  args: method.args,
71
82
  result: method.result,
83
+ throws: method.throws,
72
84
  };
73
85
  // Args: write back on the DtoField wrapper only (safe: wrappers are
74
- // created per method via dtoField(), never shared). Inline fields get
75
- // their underlying Field written back too; fields wrapping table columns
76
- // (domain rules) keep the shared column instance untouched — its
77
- // name/schema already point to the table.
86
+ // created per method via dtoField(), never shared). Shared instances
87
+ // DTO fields passed by reference (args: { items: OrderSubmitRequest.fields.items })
88
+ // and fields wrapping table columns (domain rules) stay untouched: the
89
+ // DTO owns its field name/schema, the shared column instance keeps its
90
+ // table identity.
78
91
  for (const argKey of Object.keys(methodSchema.args)) {
79
92
  const df = methodSchema.args[argKey] as DtoField;
93
+ if (df.schema !== undefined) continue;
80
94
  df.name = argKey;
81
95
  df.schema = schema;
82
96
  if (df.field.schema === undefined) {