@pylonts/dsl 1.1.19 → 1.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/flow.ts CHANGED
@@ -210,9 +210,49 @@ export interface Comparison {
210
210
  }
211
211
 
212
212
  /** The machine-readable condition behind a guard check or a branch edge
213
- * (optional): a utils predicate call (its boolean result decides) or a field
214
- * comparison. */
215
- export type GuardCondition = FlowCall | Comparison;
213
+ * (optional): a utils predicate call (its boolean result decides), a field
214
+ * comparison, or a composite (not/and/or over sub-conditions). */
215
+ export type GuardCondition = FlowCall | Comparison | ConditionGroup;
216
+
217
+ /** A composite condition: not (exactly one sub-condition), and/or (two or
218
+ * more). Renderers parenthesize sub-groups, so nesting stays unambiguous. */
219
+ export interface ConditionGroup {
220
+ kind: 'not' | 'and' | 'or';
221
+ conds: GuardCondition[];
222
+ }
223
+
224
+ export function isConditionGroup(c: unknown): c is ConditionGroup {
225
+ return (
226
+ typeof c === 'object' &&
227
+ c !== null &&
228
+ ((c as { kind?: unknown }).kind === 'not' || (c as { kind?: unknown }).kind === 'and' || (c as { kind?: unknown }).kind === 'or') &&
229
+ Array.isArray((c as { conds?: unknown }).conds)
230
+ );
231
+ }
232
+
233
+ /** Negation — the only way to invert a condition (e.g. !predicate). */
234
+ export function not(...conds: GuardCondition[]): ConditionGroup {
235
+ if (conds.length !== 1) {
236
+ throw new Error('not: takes exactly one condition');
237
+ }
238
+ return { kind: 'not', conds };
239
+ }
240
+
241
+ /** Conjunction — every sub-condition must hold. */
242
+ export function and(...conds: GuardCondition[]): ConditionGroup {
243
+ if (conds.length < 2) {
244
+ throw new Error('and: requires at least two conditions');
245
+ }
246
+ return { kind: 'and', conds };
247
+ }
248
+
249
+ /** Disjunction — at least one sub-condition holds. */
250
+ export function or(...conds: GuardCondition[]): ConditionGroup {
251
+ if (conds.length < 2) {
252
+ throw new Error('or: requires at least two conditions');
253
+ }
254
+ return { kind: 'or', conds };
255
+ }
216
256
 
217
257
  /** True when the condition operand is the slot itself (slot-level null check),
218
258
  * not a field access on it. Slot metadata (name) lives on the proxy target and
@@ -221,13 +261,28 @@ export function isFlowSlot(v: unknown): v is FlowSlot {
221
261
  return typeof v === 'object' && v !== null && typeof (v as FlowSlot).name === 'string';
222
262
  }
223
263
 
264
+ /** True when the slot carries a scalar Field (its declared type has a jsType)
265
+ * rather than a message — scalar slots support full comparisons
266
+ * (gt(slots.total, 100)); message slots only slot-level null checks. */
267
+ export function isScalarSlot(slot: FlowSlot): boolean {
268
+ const t = slot.type as { jsType?: unknown } | undefined;
269
+ return typeof t === 'object' && t !== null && t.jsType !== undefined;
270
+ }
271
+
224
272
  function comparison(op: CompareOp, field: unknown, value?: string | number | EnumValue): Comparison {
225
273
  if (isFlowSlot(field)) {
226
- if (op !== 'isNull' && op !== 'isNotNull') {
227
- throw new Error(`${op}: a slot-level check only supports isNull/isNotNull — field comparisons need slots.args.amt`);
228
- }
229
- if (value !== undefined) {
230
- throw new Error(`${op}: takes no value`);
274
+ const nullOp = op === 'isNull' || op === 'isNotNull';
275
+ if (nullOp) {
276
+ if (value !== undefined) {
277
+ throw new Error(`${op}: takes no value`);
278
+ }
279
+ } else {
280
+ if (!isScalarSlot(field)) {
281
+ throw new Error(`${op}: a slot-level check only supports isNull/isNotNull — field comparisons need slots.args.amt`);
282
+ }
283
+ if (value === undefined) {
284
+ throw new Error(`${op}: requires a value`);
285
+ }
231
286
  }
232
287
  return { kind: 'comparison', op, field, value };
233
288
  }
@@ -869,19 +924,37 @@ function validateGuardChecks(schema: FlowSchema): void {
869
924
  }
870
925
 
871
926
  // A condition must be a utils predicate call (boolean result, no result
872
- // slot) or a field comparison whose op is known and whose value presence and
873
- // type match the operator.
927
+ // slot), a field comparison whose op is known and whose value presence and
928
+ // type match the operator, or a composite (not/and/or) whose sub-conditions
929
+ // are each valid.
874
930
  const COMPARE_OPS: readonly CompareOp[] = ['lt', 'le', 'gt', 'ge', 'eq', 'ne', 'isNull', 'isNotNull'];
875
931
 
876
932
  function validateCondition(where: string, c: GuardCondition): void {
933
+ if (isConditionGroup(c)) {
934
+ if (c.kind === 'not') {
935
+ if (c.conds.length !== 1) {
936
+ throw new Error(`${where}: not() takes exactly one condition`);
937
+ }
938
+ } else if (c.conds.length < 2) {
939
+ throw new Error(`${where}: ${c.kind}() takes at least two conditions`);
940
+ }
941
+ for (const sub of c.conds) validateCondition(where, sub);
942
+ return;
943
+ }
877
944
  if (!isCall(c)) {
878
945
  if (isFlowSlot(c.field)) {
879
946
  const nullOp = c.op === 'isNull' || c.op === 'isNotNull';
880
- if (!nullOp) {
881
- throw new Error(`${where}: a slot-level check only supports isNull/isNotNull`);
882
- }
883
- if (c.value !== undefined) {
884
- throw new Error(`${where}: ${c.op} takes no value`);
947
+ if (nullOp) {
948
+ if (c.value !== undefined) {
949
+ throw new Error(`${where}: ${c.op} takes no value`);
950
+ }
951
+ } else {
952
+ if (!isScalarSlot(c.field)) {
953
+ throw new Error(`${where}: a slot-level check only supports isNull/isNotNull`);
954
+ }
955
+ if (c.value === undefined) {
956
+ throw new Error(`${where}: ${c.op} requires a value`);
957
+ }
885
958
  }
886
959
  return;
887
960
  }
@@ -908,18 +981,27 @@ function validateCondition(where: string, c: GuardCondition): void {
908
981
  }
909
982
  return;
910
983
  }
911
- const m = c.method as { type?: string; name: string };
984
+ const m = c.method as Partial<UtilsMethodSchema> & { type?: string; name: string };
912
985
  if (m.type !== 'utilsMethod') {
913
986
  throw new Error(`${where}: check call must be a utils predicate, got ${m.type ?? m.name}`);
914
987
  }
915
988
  if (c.result !== undefined) {
916
989
  throw new Error(`${where}: a predicate call cannot bind a result slot`);
917
990
  }
991
+ if (m.result === undefined || m.result.jsType !== 'boolean') {
992
+ const declared = m.result === undefined ? 'void' : m.result.jsType;
993
+ throw new Error(
994
+ `${where}: utils predicate "${m.name}" must declare a boolean result (got ${declared}) — ` +
995
+ `predicates (can/is/has) return boolean; defense guards (assert/validate/ensure) throw internally and are invoked, not used in IF`,
996
+ );
997
+ }
918
998
  }
919
999
 
920
1000
  /** Slots a condition reads: a comparison's field slot (or the slot itself for
921
- * slot-level checks) or a predicate call's arg slots. */
1001
+ * slot-level checks), a predicate call's arg slots, or every sub-condition's
1002
+ * slots for a composite. */
922
1003
  function conditionSlots(c: GuardCondition): FlowSlot[] {
1004
+ if (isConditionGroup(c)) return c.conds.flatMap(conditionSlots);
923
1005
  if (!isCall(c)) return [isFlowSlot(c.field) ? c.field : c.field.slot];
924
1006
  return c.args ?? [];
925
1007
  }
@@ -1,5 +1,5 @@
1
1
  import { FlowEdge, FlowEnd, FlowNode, FlowSchema, FlowStep, FlowNodeOrEnd, GuardNode, TryNode, IfNode } from './flow.js';
2
- import { isCall, isFlowSlot, methodOf } from './flow.js';
2
+ import { isCall, isConditionGroup, isFlowSlot, methodOf } from './flow.js';
3
3
  import type { FlowMethodRef, FlowNodeMethodRef, GuardCondition } from './flow.js';
4
4
  import { Page } from './page.js';
5
5
  import { PageEdge, PageFlow } from './page-flow.js';
@@ -219,6 +219,10 @@ function renderDataLine(n: FlowNode | GuardNode | IfNode, outgoing: FlowEdge[]):
219
219
  const writes = new Set<string>();
220
220
  const addCondition = (c: GuardCondition | undefined): void => {
221
221
  if (c === undefined) return;
222
+ if (isConditionGroup(c)) {
223
+ for (const s of c.conds) addCondition(s);
224
+ return;
225
+ }
222
226
  if (!isCall(c)) {
223
227
  reads.add(isFlowSlot(c.field) ? c.field.name : c.field.slot.name);
224
228
  return;
package/src/repository.ts CHANGED
@@ -6,8 +6,8 @@ import type { DomainAggregate } from './aggregate.js';
6
6
  * exposes the three fixed operation skeletons (load / save / delete) that the
7
7
  * generator expands from the aggregate structure:
8
8
  *
9
- * save(order) = tx { rootDao.upsert + memberDao cascade by via FK }
10
- * load(id) = rootDao.get + memberDao by via FK
9
+ * save(order) = tx { rootDao.upsert + memberDao cascade by FK / extends }
10
+ * load(id) = rootDao.get + memberDao by FK / extends
11
11
  * delete(id) = tx { memberDao delete + rootDao delete }
12
12
  *
13
13
  * Callers face the domain concept (Order), never the tables. DAO stays
@@ -1,4 +1,4 @@
1
- import { DtoArrayField, DtoField, DtoMessage, DtoObjectField, ImportBase, ImportRef, isDtoField, isDtoMessage } from './dto.js';
1
+ import { DtoArrayField, DtoField, DtoMessage, DtoObjectField, ImportBase, ImportRef, isDtoField, isDtoMessage, resolveDtoRefChain } from './dto.js';
2
2
  import { collectEnumRefs, EnumField, Field } from './dsl.js';
3
3
  import type { TableSchema } from './db.js';
4
4
  import type { TokenSchema } from './token.js';
@@ -34,21 +34,6 @@ function dtoFieldDescription(f: DtoField): string | undefined {
34
34
  return f.description ?? fieldDescription(f.field);
35
35
  }
36
36
 
37
- /** Resolve a ref chain to its terminal field (the one without .ref).
38
- * Cycles are a DSL definition error — fail loudly at render time. */
39
- function resolveRefChain(f: DtoField): DtoField {
40
- const seen = new Set<DtoField>();
41
- let cur: DtoField = f;
42
- while (cur.ref !== undefined) {
43
- if (seen.has(cur.ref)) {
44
- throw new Error(`dto field ${cur.name}: circular ref chain (field references itself)`);
45
- }
46
- seen.add(cur.ref);
47
- cur = cur.ref;
48
- }
49
- return cur;
50
- }
51
-
52
37
  function renderBasic(
53
38
  field: Field,
54
39
  pattern: string | undefined,
@@ -198,7 +183,7 @@ function renderField(f: DtoField, indent: number, resolver: EnumResolver | undef
198
183
  * chain, inherit the terminal field's type/constraints, keep the referencing
199
184
  * field's own overrides (pattern / default / description). */
200
185
  function renderRefBase(f: DtoField, indent: number, resolver: EnumResolver | undefined): string {
201
- const target = resolveRefChain(f);
186
+ const target = resolveDtoRefChain(f);
202
187
  const targetField = target.field as Field;
203
188
  const pattern = f.pattern ?? target.pattern;
204
189
  const defaultValue = f.default ?? target.default;
@@ -210,7 +195,7 @@ function renderRefBase(f: DtoField, indent: number, resolver: EnumResolver | und
210
195
  * override first, then the chain's DtoField-level optional, then the bare
211
196
  * column optionality. */
212
197
  function renderRefField(f: DtoField, indent: number, resolver: EnumResolver | undefined): string {
213
- const target = resolveRefChain(f);
198
+ const target = resolveDtoRefChain(f);
214
199
  const optional = f.optional ?? target.optional ?? (target.field as Field).optional ?? false;
215
200
  const base = renderRefBase(f, indent, resolver);
216
201
  return optional ? `Type.Optional(${base})` : base;
@@ -253,7 +238,7 @@ function collectEnumImports(
253
238
  out: Map<string, ImportBase>,
254
239
  ): void {
255
240
  if (f.ref !== undefined) {
256
- collectEnumImports(resolveRefChain(f), resolver, out);
241
+ collectEnumImports(resolveDtoRefChain(f), resolver, out);
257
242
  return;
258
243
  }
259
244
  if (f.field.type === 'array') {
@@ -303,14 +288,25 @@ export function collectDtoImports(
303
288
  for (const f of Object.values(schema.fields)) collectEnumImports(f, resolver, out);
304
289
  }
305
290
 
291
+ /** JSON Schema readOnly annotation on a rendered scalar schema: the token
292
+ * owns the field, the client must not send it (the __inject adapter
293
+ * overwrites any client-supplied value anyway). Injection fields are always
294
+ * scalar columns (tables forbid nested columns), so the only object literal
295
+ * in a rendered scalar base is its options block. */
296
+ function withReadOnly(base: string): string {
297
+ const idx = base.lastIndexOf('{');
298
+ if (idx === -1) return base.replace(/\(\s*\)$/, '({ readOnly: true })');
299
+ return `${base.slice(0, idx + 1)} readOnly: true,${base.slice(idx + 1)}`;
300
+ }
301
+
306
302
  /** Render the server-injection base: token-injected fields as Optional
307
- * properties of a TypeBox object, plus a non-enumerable __inject adapter
308
- * (same mechanism as hand-written bases, see pylon __inject docs) that fills
309
- * each field from the token at runtime. */
303
+ * readOnly properties of a TypeBox object, plus a non-enumerable __inject
304
+ * adapter (same mechanism as hand-written bases, see pylon __inject docs)
305
+ * that fills each field from the token at runtime. */
310
306
  function renderInjectBase(fields: Record<string, DtoField>, resolver: EnumResolver | undefined): string {
311
307
  const entries = Object.entries(fields).map(([name, f]) => {
312
308
  const base = f.ref !== undefined ? renderRefBase(f, 1, resolver) : renderValue(f, 1, resolver);
313
- return ` ${name}: Type.Optional(${base})`;
309
+ return ` ${name}: Type.Optional(${withReadOnly(base)})`;
314
310
  });
315
311
  const inner = `Type.Object({\n${entries.join(',\n')}\n})`;
316
312
  const assigns = Object.keys(fields)
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
@@ -20,8 +22,14 @@ export interface UtilsMethodSchema extends SchemaBase {
20
22
  args: Record<string, DtoField>;
21
23
  /** Output field — a plain inline Field (boolean for checks, decimal for
22
24
  * computed amounts, ...). The method output is a fresh value, never a
23
- * shared table column. */
24
- result: Field;
25
+ * shared table column. Omit for void methods (pure actions). */
26
+ result?: Field;
27
+ /** Exceptions this method may throw — the failure contract of a defense
28
+ * guard (assert/validate/ensure: void, throws internally). Flows route
29
+ * invoked guards' throws into their escape set automatically, so a
30
+ * guard's throws must be declared by the calling service method (or
31
+ * caught in a TRY). Predicates (can/is/has, boolean) do not throw. */
32
+ throws?: ExceptionSchema[];
25
33
  }
26
34
 
27
35
  /** Method input for defineUtils: type/schema/name are set by the builder. */
@@ -62,6 +70,9 @@ export function defineUtils(options: {
62
70
  };
63
71
  for (const key of Object.keys(options.methods)) {
64
72
  const method = options.methods[key] as UtilsMethodDef;
73
+ // Same rule as DTO fields: args must not nest inline containers — use a
74
+ // DTO field reference (args: { items: SubmitRequest.fields.items }).
75
+ assertNoInlineContainers(options.name, method.args);
65
76
  const methodSchema: UtilsMethodSchema = {
66
77
  type: 'utilsMethod',
67
78
  name: key,
@@ -69,14 +80,17 @@ export function defineUtils(options: {
69
80
  schema,
70
81
  args: method.args,
71
82
  result: method.result,
83
+ throws: method.throws,
72
84
  };
73
85
  // Args: write back on the DtoField wrapper only (safe: wrappers are
74
- // created per method via dtoField(), never shared). Inline fields get
75
- // their underlying Field written back too; fields wrapping table columns
76
- // (domain rules) keep the shared column instance untouched — its
77
- // name/schema already point to the table.
86
+ // created per method via dtoField(), never shared). Shared instances
87
+ // DTO fields passed by reference (args: { items: OrderSubmitRequest.fields.items })
88
+ // and fields wrapping table columns (domain rules) stay untouched: the
89
+ // DTO owns its field name/schema, the shared column instance keeps its
90
+ // table identity.
78
91
  for (const argKey of Object.keys(methodSchema.args)) {
79
92
  const df = methodSchema.args[argKey] as DtoField;
93
+ if (df.schema !== undefined) continue;
80
94
  df.name = argKey;
81
95
  df.schema = schema;
82
96
  if (df.field.schema === undefined) {
@@ -84,9 +98,13 @@ export function defineUtils(options: {
84
98
  df.field.schema = schema;
85
99
  }
86
100
  }
87
- // Result: a plain inline Field — write name/schema back directly.
88
- methodSchema.result.name = key;
89
- methodSchema.result.schema = schema;
101
+ // Result (optional): a plain inline Field — write name/schema back only
102
+ // when it is unowned (schema === undefined); shared instances (table
103
+ // columns, fields already claimed by another container) stay untouched.
104
+ if (methodSchema.result !== undefined && methodSchema.result.schema === undefined) {
105
+ methodSchema.result.name = key;
106
+ methodSchema.result.schema = schema;
107
+ }
90
108
  schema.methods[key] = methodSchema;
91
109
  }
92
110
  return schema;