@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.
@@ -1,16 +1,7 @@
1
1
  import type { SchemaBase } from './dsl.js';
2
- import type { TableSchema, ForeignKey } from './db.js';
3
- /** How a member table attaches to the aggregate root. */
4
- export interface AggregateMember {
5
- /** The member table (shared instance from schema/*.table.ts). */
6
- table: TableSchema;
7
- /** The foreign key on the member table pointing to the root table.
8
- * Must exist in table.foreignKeys and its references must be the root's PK
9
- * columns. Defaults to the (unique) FK referencing the root table. */
10
- via?: ForeignKey;
11
- /** 1:1 member (unique constraint on via.columns) vs 1:N (default). */
12
- one?: boolean;
13
- }
2
+ import type { TableSchema } from './db.js';
3
+ /** Member table(s) keyed by role name. Array = 1:N; non-array = 1:1 extension. */
4
+ export type AggregateMember = TableSchema | TableSchema[];
14
5
  /** A cross-member invariant, checked by generated repository code. */
15
6
  export interface AggregateInvariant {
16
7
  name: string;
@@ -29,7 +20,6 @@ export interface DomainAggregate extends SchemaBase {
29
20
  references?: Record<string, string>;
30
21
  }
31
22
  export declare function defineAggregate(options: {
32
- name: string;
33
23
  root: TableSchema;
34
24
  members?: Record<string, AggregateMember>;
35
25
  invariants?: AggregateInvariant[];
package/dist/aggregate.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export function defineAggregate(options) {
2
2
  const schema = {
3
3
  type: 'aggregate',
4
- name: options.name,
4
+ name: options.root.name,
5
5
  description: options.description,
6
6
  root: options.root,
7
7
  members: options.members ?? {},
@@ -10,36 +10,33 @@ export function defineAggregate(options) {
10
10
  };
11
11
  // Root must have a primary key (aggregate identity).
12
12
  if (options.root.primaryKey === undefined) {
13
- throw new Error(`aggregate '${options.name}': root table '${options.root.name}' must have a primary key`);
13
+ throw new Error(`aggregate '${schema.name}': root table '${options.root.name}' must have a primary key`);
14
14
  }
15
- // Each member must attach to the root via an existing FK referencing the root.
16
15
  const rootPkRefs = Array.isArray(options.root.primaryKey)
17
16
  ? options.root.primaryKey
18
17
  : [options.root.primaryKey];
18
+ // Each member must attach to the root. Array members are normal 1:N tables
19
+ // and must have exactly one FK referencing the root. Non-array (1:1 extension)
20
+ // members are designed but not implemented yet.
19
21
  for (const [role, member] of Object.entries(schema.members)) {
20
- const fks = Object.values(member.table.foreignKeys ?? {}).filter((fk) => {
21
- const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
22
- return refs.every((r) => rootPkRefs.includes(r)) && refs.length === rootPkRefs.length;
23
- });
24
- if (fks.length === 0) {
25
- throw new Error(`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`);
26
- }
27
- if (member.via !== undefined) {
28
- const viaKeys = Object.values(member.table.foreignKeys ?? {});
29
- if (!viaKeys.includes(member.via)) {
30
- throw new Error(`aggregate '${options.name}': member '${role}' via must be one of table '${member.table.name}' foreignKeys — got a non-FK object`);
22
+ if (Array.isArray(member)) {
23
+ if (member.length !== 1) {
24
+ throw new Error(`aggregate '${schema.name}': member '${role}' array must contain exactly one table schema`);
31
25
  }
32
- const refs = Array.isArray(member.via.references) ? member.via.references : [member.via.references];
33
- if (!(refs.length === rootPkRefs.length && refs.every((r) => rootPkRefs.includes(r)))) {
34
- throw new Error(`aggregate '${options.name}': member '${role}' via must reference root '${options.root.name}' primary key columns`);
26
+ const table = member[0];
27
+ const fks = Object.values(table.foreignKeys ?? {}).filter((fk) => {
28
+ const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
29
+ return refs.length === rootPkRefs.length && refs.every((r) => rootPkRefs.includes(r));
30
+ });
31
+ if (fks.length === 0) {
32
+ throw new Error(`aggregate '${schema.name}': member '${role}' table '${table.name}' has no foreign key referencing root '${options.root.name}' — declare one in the table's foreignKeys`);
35
33
  }
36
- }
37
- else {
38
- // Default: the (single) FK referencing the root. More than one → must declare via.
39
34
  if (fks.length > 1) {
40
- throw new Error(`aggregate '${options.name}': member '${role}' table '${member.table.name}' has ${fks.length} foreign keys referencing root '${options.root.name}' — declare via explicitly`);
35
+ throw new Error(`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`);
41
36
  }
42
- member.via = fks[0];
37
+ }
38
+ else {
39
+ throw new Error(`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`);
43
40
  }
44
41
  }
45
42
  return schema;
package/dist/dto.d.ts CHANGED
@@ -106,8 +106,15 @@ export declare function isDtoField(v: unknown): v is DtoField;
106
106
  /** Resolve a ref chain to its terminal DtoField (the one without .ref).
107
107
  * Cycles are a DSL definition error — fail loudly at render time. */
108
108
  export declare function resolveDtoRefChain(f: DtoField): DtoField;
109
- /** TS type of a DtoField in generated code — unwraps the wrapped field
110
- * (enum its JS name, date/datetime string, containers jsType). */
109
+ /** TS type of a DtoField in generated code.
110
+ * A field shared by reference (its schema is the owning DTO utils args
111
+ * like `args: { items: OrderSubmitRequest.fields.items }`) renders as an
112
+ * indexed access on the DTO's generated type (the DTO owns the structure).
113
+ * Array elements render by name (`ItemDto[]` — named DTO) or by recursion
114
+ * (`Array<string>` — scalar). Plain wire objects (objectField) render
115
+ * their property shape (`{ key: type }`); DtoField-class containers are
116
+ * rejected at build time (DTOs must not nest inline structures).
117
+ * Enum → its JS name, date/datetime → string. */
111
118
  export declare function dtoFieldJsType(df: DtoField): string;
112
119
  /** Enum JS names referenced by a DtoField, recursing into inline array/object
113
120
  * wrappers; DtoMessage item references stop the walk. First-occurrence order. */
@@ -118,6 +125,15 @@ export declare function dtoArrayField(def: {
118
125
  export declare function dtoObjectField(def: {
119
126
  properties: Record<string, DtoField>;
120
127
  } & Omit<BaseField, 'name'>): DtoObjectField;
128
+ /** DTOs must not nest DtoField-class containers inline: dtoObjectField /
129
+ * dtoArrayField instances (and dtoField(dtoObjectField(...))-style wraps)
130
+ * have no reusable name — extract a named DTO and reference it as an array
131
+ * element (dtoArrayField({ items: namedDto })), and array items must be a
132
+ * named DTO or a scalar field. Plain Field containers (objectField /
133
+ * arrayField — wire-format nesting) stay legal and render inline.
134
+ * DtoField-class wrappers (dtoField(dtoArrayField(...))) carry the def
135
+ * inside the instance's .field, so both layers are unwrapped. */
136
+ export declare function assertNoInlineContainers(dtoName: string, fields: Record<string, DtoField>): void;
121
137
  export declare function buildInput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage;
122
138
  export declare function buildOutput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage;
123
139
  export declare function buildQuery(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage;
package/dist/dto.js CHANGED
@@ -133,15 +133,50 @@ export function resolveDtoRefChain(f) {
133
133
  }
134
134
  return cur;
135
135
  }
136
- /** TS type of a DtoField in generated code — unwraps the wrapped field
137
- * (enum its JS name, date/datetime string, containers jsType). */
136
+ /** TS type of a DtoField in generated code.
137
+ * A field shared by reference (its schema is the owning DTO utils args
138
+ * like `args: { items: OrderSubmitRequest.fields.items }`) renders as an
139
+ * indexed access on the DTO's generated type (the DTO owns the structure).
140
+ * Array elements render by name (`ItemDto[]` — named DTO) or by recursion
141
+ * (`Array<string>` — scalar). Plain wire objects (objectField) render
142
+ * their property shape (`{ key: type }`); DtoField-class containers are
143
+ * rejected at build time (DTOs must not nest inline structures).
144
+ * Enum → its JS name, date/datetime → string. */
138
145
  export function dtoFieldJsType(df) {
139
- const f = df.field;
140
- if (f.type === 'enum')
141
- return f.enum.jsName;
142
- if (f.type === 'date' || f.type === 'datetime')
146
+ const owner = df.schema;
147
+ if (owner?.type === 'dto' && owner.name !== undefined && owner.name !== '' && df.name !== '') {
148
+ return `${owner.name}['${df.name}']`;
149
+ }
150
+ return dtoFieldJsTypeInner(df.field);
151
+ }
152
+ /** Type of a raw field object — unwraps DtoField-class wrappers
153
+ * (dtoField(dtoArrayField(...)) stores the def inside the instance's
154
+ * .field) and recurses: named-DTO elements (Name[]), scalar elements
155
+ * (Array<T>), plain wire objects ({ key: type }), enums, and scalars. */
156
+ function dtoFieldJsTypeInner(field) {
157
+ const f = field.field ?? field;
158
+ const inner = f;
159
+ if (inner.type === 'enum')
160
+ return inner.enum.jsName;
161
+ if (inner.type === 'date' || inner.type === 'datetime')
143
162
  return 'string';
144
- return f.jsType;
163
+ if (inner.type === 'array') {
164
+ const items = inner.items;
165
+ return isDtoMessage(items) ? `${items.name}[]` : `Array<${dtoFieldJsType(items)}>`;
166
+ }
167
+ if (inner.type === 'object') {
168
+ // Plain objectField properties are bare Fields; DtoObjectFieldDef
169
+ // properties are DtoFields. Recurse through both.
170
+ const props = Object.entries(inner.properties ?? {})
171
+ .map(([k, v]) => {
172
+ const optional = isDtoField(v) ? v.isOptional() : v.optional ?? false;
173
+ const type = isDtoField(v) ? dtoFieldJsType(v) : dtoFieldJsTypeInner(v);
174
+ return `${k}${optional ? '?' : ''}: ${type}`;
175
+ })
176
+ .join('; ');
177
+ return `{ ${props} }`;
178
+ }
179
+ return inner.jsType ?? '';
145
180
  }
146
181
  /** Enum JS names referenced by a DtoField, recursing into inline array/object
147
182
  * wrappers; DtoMessage item references stop the walk. First-occurrence order. */
@@ -162,14 +197,45 @@ export function dtoCollectEnumRefs(df, out = []) {
162
197
  return out;
163
198
  }
164
199
  export function dtoArrayField(def) {
165
- // Items stay as-is: an inline DtoField is rendered inline, a DtoMessage is
166
- // referenced by name (the driver renders Type.Array(<DtoName>)).
200
+ // Items stay as-is: a DtoMessage is referenced by name (the driver renders
201
+ // Type.Array(<DtoName>)); a scalar DtoField element renders its primitive
202
+ // type. Inline container elements are rejected by buildMessage/defineUtils
203
+ // (DTOs must not nest inline structures — every object needs a name).
167
204
  return new DtoArrayField({ name: '', type: 'array', jsType: 'array', ...def });
168
205
  }
169
206
  export function dtoObjectField(def) {
170
207
  return new DtoObjectField({ name: '', type: 'object', jsType: 'object', ...def });
171
208
  }
209
+ /** DTOs must not nest DtoField-class containers inline: dtoObjectField /
210
+ * dtoArrayField instances (and dtoField(dtoObjectField(...))-style wraps)
211
+ * have no reusable name — extract a named DTO and reference it as an array
212
+ * element (dtoArrayField({ items: namedDto })), and array items must be a
213
+ * named DTO or a scalar field. Plain Field containers (objectField /
214
+ * arrayField — wire-format nesting) stay legal and render inline.
215
+ * DtoField-class wrappers (dtoField(dtoArrayField(...))) carry the def
216
+ * inside the instance's .field, so both layers are unwrapped. */
217
+ export function assertNoInlineContainers(dtoName, fields) {
218
+ for (const [key, df] of Object.entries(fields)) {
219
+ const f = df.field.field ?? df.field;
220
+ const field = f;
221
+ // Only DtoField-class containers are banned (they need a name). Plain
222
+ // Field objects (objectField — wire-format nesting) are legal and render
223
+ // inline: dtoField(objectField({...})) stays allowed.
224
+ const isDtoClassContainer = isDtoField(df.field) || typeof df.properties === 'function';
225
+ if (isDtoClassContainer && field.type === 'object') {
226
+ throw new Error(`[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`);
227
+ }
228
+ if (field.type === 'array' && isDtoField(field.items)) {
229
+ const items = field.items.field.field ?? field.items.field;
230
+ const item = items;
231
+ if (item.type === 'object' || item.type === 'array') {
232
+ throw new Error(`[dto] "${dtoName}" field "${key}": inline container elements are not allowed — array items must be a named DTO or a scalar field`);
233
+ }
234
+ }
235
+ }
236
+ }
172
237
  function buildMessage(name, direction, fields, description) {
238
+ assertNoInlineContainers(name, fields);
173
239
  const message = new DtoMessage(name, direction, fields, description);
174
240
  // Write back the DTO field name from the map key (safe: DtoField instances
175
241
  // are created per DTO, never shared).
@@ -9,7 +9,7 @@
9
9
  // The IR stays the single executable model (mermaid, must-analysis, throws
10
10
  // coverage, service contracts all consume it); this layer is a lowering, not
11
11
  // a parallel model.
12
- import { defineFlow, defineSlots, edge, guard, ifNode, isCall, isEnd, isFlowNode, isFlowSlot, isGuard, isIfNode, methodOf, node, tryNode, } from './flow.js';
12
+ import { defineFlow, defineSlots, edge, guard, ifNode, isCall, isConditionGroup, isEnd, isFlowNode, isFlowSlot, isGuard, isIfNode, methodOf, node, tryNode, } from './flow.js';
13
13
  /** Call a method as a statement or (in IF position) as a utils predicate. */
14
14
  export function invoke(method, args, result) {
15
15
  return { kind: 'invoke', method, args, result };
@@ -132,6 +132,11 @@ function addUsed(ctx, slot) {
132
132
  ctx.usedNames.add(slot.name);
133
133
  }
134
134
  function addConditionUsed(ctx, c) {
135
+ if (isConditionGroup(c)) {
136
+ for (const s of c.conds)
137
+ addConditionUsed(ctx, s);
138
+ return;
139
+ }
135
140
  if (!isCall(c)) {
136
141
  addUsed(ctx, isFlowSlot(c.field) ? c.field : c.field.slot);
137
142
  return;
@@ -144,6 +149,8 @@ function addConditionUsed(ctx, c) {
144
149
  function isInvokeStep(c) {
145
150
  return c.kind === 'invoke';
146
151
  }
152
+ /** Lower an IF condition: a top-level invoke is a predicate call, and
153
+ * composites convert invoke steps nested inside them recursively. */
147
154
  function toCondition(c) {
148
155
  if (isInvokeStep(c)) {
149
156
  if (c.result !== undefined) {
@@ -151,6 +158,9 @@ function toCondition(c) {
151
158
  }
152
159
  return { method: c.method, args: c.args === undefined ? [] : Array.isArray(c.args) ? c.args : [c.args] };
153
160
  }
161
+ if (isConditionGroup(c)) {
162
+ return { kind: c.kind, conds: c.conds.map((sub) => toCondition(sub)) };
163
+ }
154
164
  return c;
155
165
  }
156
166
  /** Display form mirrors the mermaid driver: owner.name / schema.name. */
@@ -172,11 +182,36 @@ function methodThrows(m) {
172
182
  return 'throws' in m && m.throws !== undefined ? m.throws : [];
173
183
  }
174
184
  /** Readable condition text used as node/branch labels (and as the throw
175
- * label when THROW carries no message). */
185
+ * label when THROW carries no message). Composites render parenthesized
186
+ * sub-conditions: !(a), (a && b), (a || b). */
176
187
  function renderCondition(c) {
188
+ if (isConditionGroup(c)) {
189
+ const inner = c.conds.map(renderCondition).join(c.kind === 'and' ? ' && ' : c.kind === 'or' ? ' || ' : '');
190
+ return c.kind === 'not' ? `!(${inner})` : `(${inner})`;
191
+ }
177
192
  if (!isCall(c)) {
178
193
  if (isFlowSlot(c.field)) {
179
- return c.op === 'isNull' ? `${c.field.name} is null` : `${c.field.name} is not null`;
194
+ const nullOp = c.op === 'isNull' || c.op === 'isNotNull';
195
+ if (nullOp)
196
+ return c.op === 'isNull' ? `${c.field.name} is null` : `${c.field.name} is not null`;
197
+ // scalar slot comparison: total > 100
198
+ const ref = c.field.name;
199
+ switch (c.op) {
200
+ case 'lt':
201
+ return `${ref} < ${renderValue(c.value)}`;
202
+ case 'le':
203
+ return `${ref} <= ${renderValue(c.value)}`;
204
+ case 'gt':
205
+ return `${ref} > ${renderValue(c.value)}`;
206
+ case 'ge':
207
+ return `${ref} >= ${renderValue(c.value)}`;
208
+ case 'eq':
209
+ return `${ref} = ${renderValue(c.value)}`;
210
+ case 'ne':
211
+ return `${ref} ≠ ${renderValue(c.value)}`;
212
+ default:
213
+ throw new Error(`unsupported comparison op '${c.op}'`);
214
+ }
180
215
  }
181
216
  const field = c.field.field;
182
217
  const ref = `${c.field.slot.name}.${field.name}`;
@@ -363,15 +398,21 @@ function compileTry(step, ctx, cont, inherit) {
363
398
  const ordinal = ctx.tryTotal - ++ctx.tryCount + 1;
364
399
  const suffix = ordinal === 1 ? '' : `${ordinal}`;
365
400
  const body = compileFlowBody(`${ctx.name}.tryBody${suffix}`, undefined, step.body, ctx, inherit);
401
+ // Catch handlers and finally may read what the body produced before the
402
+ // failure point (Java semantics: try { row = dao.get() } catch { use(row) }).
403
+ // The body's productions seed their entry availability alongside the
404
+ // enclosing flow's inherited slots.
405
+ const bodyProduced = flowProducedSlots(body);
406
+ const catchInherit = [...inherit, ...bodyProduced];
366
407
  const catches = step.catches.map(([ex, steps]) => ({
367
408
  exception: ex,
368
- handler: compileFlowBody(`${ctx.name}.catch${ex.name}${suffix}`, undefined, steps, ctx, inherit),
409
+ handler: compileFlowBody(`${ctx.name}.catch${ex.name}${suffix}`, undefined, steps, ctx, catchInherit),
369
410
  }));
370
411
  const t = tryNode(step.name ?? 'try', {
371
412
  body,
372
413
  catches,
373
414
  finally: step.finally
374
- ? compileFlowBody(`${ctx.name}.finally${suffix}`, undefined, step.finally, ctx, inherit)
415
+ ? compileFlowBody(`${ctx.name}.finally${suffix}`, undefined, step.finally, ctx, catchInherit)
375
416
  : undefined,
376
417
  });
377
418
  ctx.seen.add(t);
@@ -401,6 +442,25 @@ function compileSub(step, ctx, cont, inherit) {
401
442
  ctx.edges.push(edge(n, cont));
402
443
  return n;
403
444
  }
445
+ /** Slots a flow's own nodes produce (call results and writes) — the try
446
+ * body's productions become visible to its catch handlers and finally. */
447
+ function flowProducedSlots(f) {
448
+ const out = new Set();
449
+ for (const n of f.nodes) {
450
+ if (isEnd(n))
451
+ continue;
452
+ if (isGuard(n) || isFlowNode(n)) {
453
+ for (const m of n.methods ?? []) {
454
+ if (isCall(m) && m.result !== undefined)
455
+ out.add(m.result);
456
+ }
457
+ if (isFlowNode(n))
458
+ for (const w of n.writes ?? [])
459
+ out.add(w);
460
+ }
461
+ }
462
+ return [...out];
463
+ }
404
464
  /** Compile one flow (top-level or sub-flow): its own slots registry (args
405
465
  * plus the named slots actually used inside), its own exception ends, and
406
466
  * its own return end (linked through DANGLE). */
@@ -432,8 +492,11 @@ function buildFlow(name, description, ctx, start) {
432
492
  const nodes = [...ctx.seen];
433
493
  const registry = defineSlots(buildSlots(ctx));
434
494
  // Entry inheritance only for slots the flow actually consumes; unused
435
- // productions of the enclosing flow are not this flow's concern.
436
- const entrySlots = rewriteSlots(nodes, ctx.edges, registry, ctx.entrySlots.filter((s) => ctx.usedSlots.has(s)));
495
+ // productions of the enclosing flow are not this flow's concern. Name-based
496
+ // matching: inherited instances may come from another flow's registry (the
497
+ // try body's re-bound productions), so identity comparison would drop them.
498
+ const usedNames = new Set([...ctx.usedSlots].map((s) => s.name));
499
+ const entrySlots = rewriteSlots(nodes, ctx.edges, registry, ctx.entrySlots.filter((s) => usedNames.has(s.name)));
437
500
  return defineFlow(name, {
438
501
  start,
439
502
  description,
@@ -475,6 +538,9 @@ function rewriteSlots(nodes, edges, slots, entrySlots) {
475
538
  return { method: m.method, args: m.args?.map(map), result: m.result ? map(m.result) : undefined };
476
539
  };
477
540
  const cond = (c) => {
541
+ if (isConditionGroup(c)) {
542
+ return { kind: c.kind, conds: c.conds.map(cond) };
543
+ }
478
544
  if (!isCall(c)) {
479
545
  if (isFlowSlot(c.field)) {
480
546
  return { kind: 'comparison', op: c.op, field: map(c.field), value: c.value };
package/dist/flow.d.ts CHANGED
@@ -94,13 +94,30 @@ export interface Comparison {
94
94
  value?: string | number | EnumValue;
95
95
  }
96
96
  /** The machine-readable condition behind a guard check or a branch edge
97
- * (optional): a utils predicate call (its boolean result decides) or a field
98
- * comparison. */
99
- export type GuardCondition = FlowCall | Comparison;
97
+ * (optional): a utils predicate call (its boolean result decides), a field
98
+ * comparison, or a composite (not/and/or over sub-conditions). */
99
+ export type GuardCondition = FlowCall | Comparison | ConditionGroup;
100
+ /** A composite condition: not (exactly one sub-condition), and/or (two or
101
+ * more). Renderers parenthesize sub-groups, so nesting stays unambiguous. */
102
+ export interface ConditionGroup {
103
+ kind: 'not' | 'and' | 'or';
104
+ conds: GuardCondition[];
105
+ }
106
+ export declare function isConditionGroup(c: unknown): c is ConditionGroup;
107
+ /** Negation — the only way to invert a condition (e.g. !predicate). */
108
+ export declare function not(...conds: GuardCondition[]): ConditionGroup;
109
+ /** Conjunction — every sub-condition must hold. */
110
+ export declare function and(...conds: GuardCondition[]): ConditionGroup;
111
+ /** Disjunction — at least one sub-condition holds. */
112
+ export declare function or(...conds: GuardCondition[]): ConditionGroup;
100
113
  /** True when the condition operand is the slot itself (slot-level null check),
101
114
  * not a field access on it. Slot metadata (name) lives on the proxy target and
102
115
  * reads without field interception; a field access resolves to { slot, field }. */
103
116
  export declare function isFlowSlot(v: unknown): v is FlowSlot;
117
+ /** True when the slot carries a scalar Field (its declared type has a jsType)
118
+ * rather than a message — scalar slots support full comparisons
119
+ * (gt(slots.total, 100)); message slots only slot-level null checks. */
120
+ export declare function isScalarSlot(slot: FlowSlot): boolean;
104
121
  export declare function lt(field: unknown, value: string | number): Comparison;
105
122
  export declare function le(field: unknown, value: string | number): Comparison;
106
123
  export declare function gt(field: unknown, value: string | number): Comparison;
package/dist/flow.js CHANGED
@@ -80,19 +80,61 @@ export function isCall(m) {
80
80
  export function methodOf(m) {
81
81
  return isCall(m) ? m.method : m;
82
82
  }
83
+ export function isConditionGroup(c) {
84
+ return (typeof c === 'object' &&
85
+ c !== null &&
86
+ (c.kind === 'not' || c.kind === 'and' || c.kind === 'or') &&
87
+ Array.isArray(c.conds));
88
+ }
89
+ /** Negation — the only way to invert a condition (e.g. !predicate). */
90
+ export function not(...conds) {
91
+ if (conds.length !== 1) {
92
+ throw new Error('not: takes exactly one condition');
93
+ }
94
+ return { kind: 'not', conds };
95
+ }
96
+ /** Conjunction — every sub-condition must hold. */
97
+ export function and(...conds) {
98
+ if (conds.length < 2) {
99
+ throw new Error('and: requires at least two conditions');
100
+ }
101
+ return { kind: 'and', conds };
102
+ }
103
+ /** Disjunction — at least one sub-condition holds. */
104
+ export function or(...conds) {
105
+ if (conds.length < 2) {
106
+ throw new Error('or: requires at least two conditions');
107
+ }
108
+ return { kind: 'or', conds };
109
+ }
83
110
  /** True when the condition operand is the slot itself (slot-level null check),
84
111
  * not a field access on it. Slot metadata (name) lives on the proxy target and
85
112
  * reads without field interception; a field access resolves to { slot, field }. */
86
113
  export function isFlowSlot(v) {
87
114
  return typeof v === 'object' && v !== null && typeof v.name === 'string';
88
115
  }
116
+ /** True when the slot carries a scalar Field (its declared type has a jsType)
117
+ * rather than a message — scalar slots support full comparisons
118
+ * (gt(slots.total, 100)); message slots only slot-level null checks. */
119
+ export function isScalarSlot(slot) {
120
+ const t = slot.type;
121
+ return typeof t === 'object' && t !== null && t.jsType !== undefined;
122
+ }
89
123
  function comparison(op, field, value) {
90
124
  if (isFlowSlot(field)) {
91
- if (op !== 'isNull' && op !== 'isNotNull') {
92
- throw new Error(`${op}: a slot-level check only supports isNull/isNotNull — field comparisons need slots.args.amt`);
125
+ const nullOp = op === 'isNull' || op === 'isNotNull';
126
+ if (nullOp) {
127
+ if (value !== undefined) {
128
+ throw new Error(`${op}: takes no value`);
129
+ }
93
130
  }
94
- if (value !== undefined) {
95
- throw new Error(`${op}: takes no value`);
131
+ else {
132
+ if (!isScalarSlot(field)) {
133
+ throw new Error(`${op}: a slot-level check only supports isNull/isNotNull — field comparisons need slots.args.amt`);
134
+ }
135
+ if (value === undefined) {
136
+ throw new Error(`${op}: requires a value`);
137
+ }
96
138
  }
97
139
  return { kind: 'comparison', op, field, value };
98
140
  }
@@ -495,18 +537,39 @@ function validateGuardChecks(schema) {
495
537
  }
496
538
  }
497
539
  // A condition must be a utils predicate call (boolean result, no result
498
- // slot) or a field comparison whose op is known and whose value presence and
499
- // type match the operator.
540
+ // slot), a field comparison whose op is known and whose value presence and
541
+ // type match the operator, or a composite (not/and/or) whose sub-conditions
542
+ // are each valid.
500
543
  const COMPARE_OPS = ['lt', 'le', 'gt', 'ge', 'eq', 'ne', 'isNull', 'isNotNull'];
501
544
  function validateCondition(where, c) {
545
+ if (isConditionGroup(c)) {
546
+ if (c.kind === 'not') {
547
+ if (c.conds.length !== 1) {
548
+ throw new Error(`${where}: not() takes exactly one condition`);
549
+ }
550
+ }
551
+ else if (c.conds.length < 2) {
552
+ throw new Error(`${where}: ${c.kind}() takes at least two conditions`);
553
+ }
554
+ for (const sub of c.conds)
555
+ validateCondition(where, sub);
556
+ return;
557
+ }
502
558
  if (!isCall(c)) {
503
559
  if (isFlowSlot(c.field)) {
504
560
  const nullOp = c.op === 'isNull' || c.op === 'isNotNull';
505
- if (!nullOp) {
506
- throw new Error(`${where}: a slot-level check only supports isNull/isNotNull`);
561
+ if (nullOp) {
562
+ if (c.value !== undefined) {
563
+ throw new Error(`${where}: ${c.op} takes no value`);
564
+ }
507
565
  }
508
- if (c.value !== undefined) {
509
- throw new Error(`${where}: ${c.op} takes no value`);
566
+ else {
567
+ if (!isScalarSlot(c.field)) {
568
+ throw new Error(`${where}: a slot-level check only supports isNull/isNotNull`);
569
+ }
570
+ if (c.value === undefined) {
571
+ throw new Error(`${where}: ${c.op} requires a value`);
572
+ }
510
573
  }
511
574
  return;
512
575
  }
@@ -540,10 +603,18 @@ function validateCondition(where, c) {
540
603
  if (c.result !== undefined) {
541
604
  throw new Error(`${where}: a predicate call cannot bind a result slot`);
542
605
  }
606
+ if (m.result === undefined || m.result.jsType !== 'boolean') {
607
+ const declared = m.result === undefined ? 'void' : m.result.jsType;
608
+ throw new Error(`${where}: utils predicate "${m.name}" must declare a boolean result (got ${declared}) — ` +
609
+ `predicates (can/is/has) return boolean; defense guards (assert/validate/ensure) throw internally and are invoked, not used in IF`);
610
+ }
543
611
  }
544
612
  /** Slots a condition reads: a comparison's field slot (or the slot itself for
545
- * slot-level checks) or a predicate call's arg slots. */
613
+ * slot-level checks), a predicate call's arg slots, or every sub-condition's
614
+ * slots for a composite. */
546
615
  function conditionSlots(c) {
616
+ if (isConditionGroup(c))
617
+ return c.conds.flatMap(conditionSlots);
547
618
  if (!isCall(c))
548
619
  return [isFlowSlot(c.field) ? c.field : c.field.slot];
549
620
  return c.args ?? [];
@@ -1,4 +1,4 @@
1
- import { isCall, isFlowSlot, methodOf } from './flow.js';
1
+ import { isCall, isConditionGroup, isFlowSlot, methodOf } from './flow.js';
2
2
  // Mermaid driver: converts a FlowSchema into a Mermaid flowchart (TD).
3
3
  // Node ids are hierarchical (n0, n0_0, n0_0_0, ...) so nested sub-flows stay
4
4
  // globally unique. A node with a sub-flow renders as a subgraph block whose
@@ -214,6 +214,11 @@ function renderDataLine(n, outgoing) {
214
214
  const addCondition = (c) => {
215
215
  if (c === undefined)
216
216
  return;
217
+ if (isConditionGroup(c)) {
218
+ for (const s of c.conds)
219
+ addCondition(s);
220
+ return;
221
+ }
217
222
  if (!isCall(c)) {
218
223
  reads.add(isFlowSlot(c.field) ? c.field.name : c.field.slot.name);
219
224
  return;
@@ -5,8 +5,8 @@ import type { DomainAggregate } from './aggregate.js';
5
5
  * exposes the three fixed operation skeletons (load / save / delete) that the
6
6
  * generator expands from the aggregate structure:
7
7
  *
8
- * save(order) = tx { rootDao.upsert + memberDao cascade by via FK }
9
- * load(id) = rootDao.get + memberDao by via FK
8
+ * save(order) = tx { rootDao.upsert + memberDao cascade by FK / extends }
9
+ * load(id) = rootDao.get + memberDao by FK / extends
10
10
  * delete(id) = tx { memberDao delete + rootDao delete }
11
11
  *
12
12
  * Callers face the domain concept (Order), never the tables. DAO stays
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CollectionSchemaBase, Field, SchemaBase } from './dsl.js';
2
2
  import type { DtoField } from './dto.js';
3
+ import type { ExceptionSchema } from './exception.js';
3
4
  import type { FrontAppSchema, ProjectApiSchema } from './project.js';
4
5
  /** A utility method with a full signature. */
5
6
  export interface UtilsMethodSchema extends SchemaBase {
@@ -12,6 +13,12 @@ export interface UtilsMethodSchema extends SchemaBase {
12
13
  * computed amounts, ...). The method output is a fresh value, never a
13
14
  * shared table column. Omit for void methods (pure actions). */
14
15
  result?: Field;
16
+ /** Exceptions this method may throw — the failure contract of a defense
17
+ * guard (assert/validate/ensure: void, throws internally). Flows route
18
+ * invoked guards' throws into their escape set automatically, so a
19
+ * guard's throws must be declared by the calling service method (or
20
+ * caught in a TRY). Predicates (can/is/has, boolean) do not throw. */
21
+ throws?: ExceptionSchema[];
15
22
  }
16
23
  /** Method input for defineUtils: type/schema/name are set by the builder. */
17
24
  export type UtilsMethodDef = Omit<UtilsMethodSchema, 'type' | 'schema' | 'name'>;
package/dist/utils.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { assertNoInlineContainers } from './dto.js';
1
2
  export function defineUtils(options) {
2
3
  if (options.api !== undefined && options.app !== undefined && !options.api.apps.includes(options.app)) {
3
4
  throw new Error(`utils ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
@@ -12,6 +13,9 @@ export function defineUtils(options) {
12
13
  };
13
14
  for (const key of Object.keys(options.methods)) {
14
15
  const method = options.methods[key];
16
+ // Same rule as DTO fields: args must not nest inline containers — use a
17
+ // DTO field reference (args: { items: SubmitRequest.fields.items }).
18
+ assertNoInlineContainers(options.name, method.args);
15
19
  const methodSchema = {
16
20
  type: 'utilsMethod',
17
21
  name: key,
@@ -19,14 +23,18 @@ export function defineUtils(options) {
19
23
  schema,
20
24
  args: method.args,
21
25
  result: method.result,
26
+ throws: method.throws,
22
27
  };
23
28
  // Args: write back on the DtoField wrapper only (safe: wrappers are
24
- // created per method via dtoField(), never shared). Inline fields get
25
- // their underlying Field written back too; fields wrapping table columns
26
- // (domain rules) keep the shared column instance untouched — its
27
- // name/schema already point to the table.
29
+ // created per method via dtoField(), never shared). Shared instances
30
+ // DTO fields passed by reference (args: { items: OrderSubmitRequest.fields.items })
31
+ // and fields wrapping table columns (domain rules) stay untouched: the
32
+ // DTO owns its field name/schema, the shared column instance keeps its
33
+ // table identity.
28
34
  for (const argKey of Object.keys(methodSchema.args)) {
29
35
  const df = methodSchema.args[argKey];
36
+ if (df.schema !== undefined)
37
+ continue;
30
38
  df.name = argKey;
31
39
  df.schema = schema;
32
40
  if (df.field.schema === undefined) {