@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/dist/aggregate.d.ts
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import type { SchemaBase } from './dsl.js';
|
|
2
|
-
import type { TableSchema
|
|
3
|
-
/**
|
|
4
|
-
export
|
|
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 '${
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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 '${
|
|
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
|
-
|
|
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
|
|
110
|
-
*
|
|
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
|
|
137
|
-
*
|
|
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
|
|
140
|
-
if (
|
|
141
|
-
return
|
|
142
|
-
|
|
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
|
-
|
|
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:
|
|
166
|
-
//
|
|
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).
|
package/dist/flow-script.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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)
|
|
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 (
|
|
506
|
-
|
|
561
|
+
if (nullOp) {
|
|
562
|
+
if (c.value !== undefined) {
|
|
563
|
+
throw new Error(`${where}: ${c.op} takes no value`);
|
|
564
|
+
}
|
|
507
565
|
}
|
|
508
|
-
|
|
509
|
-
|
|
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)
|
|
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 ?? [];
|
package/dist/mermaid-driver.js
CHANGED
|
@@ -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;
|
package/dist/repository.d.ts
CHANGED
|
@@ -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
|
|
9
|
-
* load(id) = rootDao.get + memberDao by
|
|
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).
|
|
25
|
-
//
|
|
26
|
-
// (domain rules)
|
|
27
|
-
// name/schema
|
|
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) {
|