@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/dist/aggregate.d.ts +3 -13
- package/dist/aggregate.js +19 -22
- package/dist/dto.d.ts +18 -2
- package/dist/dto.js +75 -9
- package/dist/flow-script.js +73 -7
- package/dist/flow.d.ts +20 -3
- package/dist/flow.js +82 -11
- package/dist/mermaid-driver.js +6 -1
- package/dist/repository.d.ts +2 -2
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +12 -4
- package/docs/aggregate-implementation.md +174 -0
- package/docs/aggregate.md +49 -12
- package/docs/dto.md +130 -106
- package/docs/table.md +41 -0
- package/docs/utils.md +19 -12
- package/package.json +1 -1
- package/src/aggregate.ts +32 -41
- package/src/dto.ts +87 -8
- package/src/flow-script.ts +68 -6
- package/src/flow.ts +99 -17
- package/src/mermaid-driver.ts +5 -1
- package/src/repository.ts +2 -2
- package/src/utils.ts +18 -4
package/src/aggregate.ts
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
import type { SchemaBase } from './dsl.js';
|
|
2
|
-
import type { TableSchema
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
/**
|
|
10
|
-
export
|
|
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 '${
|
|
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
|
-
|
|
69
|
-
(
|
|
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 '${
|
|
69
|
+
`aggregate '${schema.name}': member '${role}' array must contain exactly one table schema`,
|
|
84
70
|
);
|
|
85
71
|
}
|
|
86
|
-
const
|
|
87
|
-
|
|
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 '${
|
|
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 '${
|
|
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
|
-
|
|
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
|
|
204
|
-
*
|
|
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
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
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:
|
|
229
|
-
//
|
|
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).
|
package/src/flow-script.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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)
|
|
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 (
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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)
|
|
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
|
}
|
package/src/mermaid-driver.ts
CHANGED
|
@@ -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
|
|
10
|
-
* load(id) = rootDao.get + memberDao by
|
|
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).
|
|
75
|
-
//
|
|
76
|
-
// (domain rules)
|
|
77
|
-
// name/schema
|
|
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) {
|