@pylonts/dsl 1.1.18 → 1.1.20

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/README.md CHANGED
@@ -22,6 +22,7 @@ project(地图:应用与 API 拓扑)
22
22
  - [table.md](./docs/table.md) — 定义表(详细设计)
23
23
  - [dto.md](./docs/dto.md) — 定义 DTO
24
24
  - [curd.md](./docs/curd.md) — 管理端 CRUD 页面标准(CurdSchema)
25
+ - [utils.md](./docs/utils.md) — 工具模块与领域规则(UtilsSchema:防御 guard / 判断 / 计算)
25
26
  - [aggregate.md](./docs/aggregate.md) — 聚合/仓储 DSL 扩展规划(声明层已落地:DomainAggregate/RepositorySchema + 存储校验,生成器待选型)
26
27
  - [domain-event.md](./docs/domain-event.md) — 领域事件 DSL 扩展规划(声明层已落地:DomainEventSchema + event_schema 存储校验,发布/订阅待实现)
27
28
  - [ddd-principles.md](./docs/ddd-principles.md) — DDD 落地原则(已决策)
package/dist/dto.d.ts CHANGED
@@ -103,6 +103,15 @@ export declare function dtoField(field: Field | DtoArrayFieldDef | DtoObjectFiel
103
103
  export declare function isDtoMessage(v: unknown): v is DtoMessage;
104
104
  /** Structural check — a DtoField wraps a Field in a .field property and has no .type of its own. */
105
105
  export declare function isDtoField(v: unknown): v is DtoField;
106
+ /** Resolve a ref chain to its terminal DtoField (the one without .ref).
107
+ * Cycles are a DSL definition error — fail loudly at render time. */
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). */
111
+ export declare function dtoFieldJsType(df: DtoField): string;
112
+ /** Enum JS names referenced by a DtoField, recursing into inline array/object
113
+ * wrappers; DtoMessage item references stop the walk. First-occurrence order. */
114
+ export declare function dtoCollectEnumRefs(df: DtoField, out?: string[]): string[];
106
115
  export declare function dtoArrayField(def: {
107
116
  items: DtoField | DtoMessage;
108
117
  } & Omit<BaseField, 'name'>): DtoArrayField;
package/dist/dto.js CHANGED
@@ -119,6 +119,48 @@ export function isDtoField(v) {
119
119
  return false;
120
120
  return 'field' in v && !('type' in v);
121
121
  }
122
+ /** Resolve a ref chain to its terminal DtoField (the one without .ref).
123
+ * Cycles are a DSL definition error — fail loudly at render time. */
124
+ export function resolveDtoRefChain(f) {
125
+ const seen = new Set();
126
+ let cur = f;
127
+ while (cur.ref !== undefined) {
128
+ if (seen.has(cur.ref)) {
129
+ throw new Error(`dto field ${cur.name}: circular ref chain (field references itself)`);
130
+ }
131
+ seen.add(cur.ref);
132
+ cur = cur.ref;
133
+ }
134
+ return cur;
135
+ }
136
+ /** TS type of a DtoField in generated code — unwraps the wrapped field
137
+ * (enum → its JS name, date/datetime → string, containers → jsType). */
138
+ 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')
143
+ return 'string';
144
+ return f.jsType;
145
+ }
146
+ /** Enum JS names referenced by a DtoField, recursing into inline array/object
147
+ * wrappers; DtoMessage item references stop the walk. First-occurrence order. */
148
+ export function dtoCollectEnumRefs(df, out = []) {
149
+ const f = df.field;
150
+ if (f.type === 'enum') {
151
+ out.push(f.enum.jsName);
152
+ }
153
+ else if (f.type === 'array') {
154
+ const items = f.items;
155
+ if (isDtoField(items))
156
+ dtoCollectEnumRefs(items, out);
157
+ }
158
+ else if (f.type === 'object') {
159
+ for (const child of Object.values(f.properties))
160
+ dtoCollectEnumRefs(child, out);
161
+ }
162
+ return out;
163
+ }
122
164
  export function dtoArrayField(def) {
123
165
  // Items stay as-is: an inline DtoField is rendered inline, a DtoMessage is
124
166
  // referenced by name (the driver renders Type.Array(<DtoName>)).
@@ -1,4 +1,4 @@
1
- import { isDtoField, isDtoMessage } from './dto.js';
1
+ import { isDtoField, isDtoMessage, resolveDtoRefChain } from './dto.js';
2
2
  import { collectEnumRefs } from './dsl.js';
3
3
  function renderString(s) {
4
4
  return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
@@ -16,20 +16,6 @@ function fieldDescription(field) {
16
16
  function dtoFieldDescription(f) {
17
17
  return f.description ?? fieldDescription(f.field);
18
18
  }
19
- /** Resolve a ref chain to its terminal field (the one without .ref).
20
- * Cycles are a DSL definition error — fail loudly at render time. */
21
- function resolveRefChain(f) {
22
- const seen = new Set();
23
- let cur = f;
24
- while (cur.ref !== undefined) {
25
- if (seen.has(cur.ref)) {
26
- throw new Error(`dto field ${cur.name}: circular ref chain (field references itself)`);
27
- }
28
- seen.add(cur.ref);
29
- cur = cur.ref;
30
- }
31
- return cur;
32
- }
33
19
  function renderBasic(field, pattern, defaultValue, resolver, indent = 0, description) {
34
20
  if (pattern !== undefined && field.type !== 'string') {
35
21
  throw new Error(`pattern is only supported on string fields, got ${field.type} (${field.name})`);
@@ -176,7 +162,7 @@ function renderField(f, indent, resolver) {
176
162
  * chain, inherit the terminal field's type/constraints, keep the referencing
177
163
  * field's own overrides (pattern / default / description). */
178
164
  function renderRefBase(f, indent, resolver) {
179
- const target = resolveRefChain(f);
165
+ const target = resolveDtoRefChain(f);
180
166
  const targetField = target.field;
181
167
  const pattern = f.pattern ?? target.pattern;
182
168
  const defaultValue = f.default ?? target.default;
@@ -187,7 +173,7 @@ function renderRefBase(f, indent, resolver) {
187
173
  * override first, then the chain's DtoField-level optional, then the bare
188
174
  * column optionality. */
189
175
  function renderRefField(f, indent, resolver) {
190
- const target = resolveRefChain(f);
176
+ const target = resolveDtoRefChain(f);
191
177
  const optional = f.optional ?? target.optional ?? target.field.optional ?? false;
192
178
  const base = renderRefBase(f, indent, resolver);
193
179
  return optional ? `Type.Optional(${base})` : base;
@@ -225,7 +211,7 @@ function renderValue(f, indent, resolver) {
225
211
  }
226
212
  function collectEnumImports(f, resolver, out) {
227
213
  if (f.ref !== undefined) {
228
- collectEnumImports(resolveRefChain(f), resolver, out);
214
+ collectEnumImports(resolveDtoRefChain(f), resolver, out);
229
215
  return;
230
216
  }
231
217
  if (f.field.type === 'array') {
@@ -270,14 +256,25 @@ export function collectDtoImports(schema, resolver, out) {
270
256
  for (const f of Object.values(schema.fields))
271
257
  collectEnumImports(f, resolver, out);
272
258
  }
259
+ /** JSON Schema readOnly annotation on a rendered scalar schema: the token
260
+ * owns the field, the client must not send it (the __inject adapter
261
+ * overwrites any client-supplied value anyway). Injection fields are always
262
+ * scalar columns (tables forbid nested columns), so the only object literal
263
+ * in a rendered scalar base is its options block. */
264
+ function withReadOnly(base) {
265
+ const idx = base.lastIndexOf('{');
266
+ if (idx === -1)
267
+ return base.replace(/\(\s*\)$/, '({ readOnly: true })');
268
+ return `${base.slice(0, idx + 1)} readOnly: true,${base.slice(idx + 1)}`;
269
+ }
273
270
  /** Render the server-injection base: token-injected fields as Optional
274
- * properties of a TypeBox object, plus a non-enumerable __inject adapter
275
- * (same mechanism as hand-written bases, see pylon __inject docs) that fills
276
- * each field from the token at runtime. */
271
+ * readOnly properties of a TypeBox object, plus a non-enumerable __inject
272
+ * adapter (same mechanism as hand-written bases, see pylon __inject docs)
273
+ * that fills each field from the token at runtime. */
277
274
  function renderInjectBase(fields, resolver) {
278
275
  const entries = Object.entries(fields).map(([name, f]) => {
279
276
  const base = f.ref !== undefined ? renderRefBase(f, 1, resolver) : renderValue(f, 1, resolver);
280
- return ` ${name}: Type.Optional(${base})`;
277
+ return ` ${name}: Type.Optional(${withReadOnly(base)})`;
281
278
  });
282
279
  const inner = `Type.Object({\n${entries.join(',\n')}\n})`;
283
280
  const assigns = Object.keys(fields)
package/dist/utils.d.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  import type { CollectionSchemaBase, Field, SchemaBase } from './dsl.js';
2
+ import type { DtoField } from './dto.js';
2
3
  import type { FrontAppSchema, ProjectApiSchema } from './project.js';
3
4
  /** A utility method with a full signature. */
4
5
  export interface UtilsMethodSchema extends SchemaBase {
5
6
  type: 'utilsMethod';
6
7
  /** The utility module this method belongs to. */
7
8
  schema: UtilsSchema;
8
- /** Input fields. */
9
- args: Record<string, Field>;
10
- /** Output field. */
11
- result: Field;
9
+ /** Input fields — DtoField wrappers (dtoField(...)); may wrap table columns. */
10
+ args: Record<string, DtoField>;
11
+ /** Output field — a plain inline Field (boolean for checks, decimal for
12
+ * computed amounts, ...). The method output is a fresh value, never a
13
+ * shared table column. Omit for void methods (pure actions). */
14
+ result?: Field;
12
15
  }
13
16
  /** Method input for defineUtils: type/schema/name are set by the builder. */
14
17
  export type UtilsMethodDef = Omit<UtilsMethodSchema, 'type' | 'schema' | 'name'>;
package/dist/utils.js CHANGED
@@ -20,13 +20,27 @@ export function defineUtils(options) {
20
20
  args: method.args,
21
21
  result: method.result,
22
22
  };
23
+ // 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.
23
28
  for (const argKey of Object.keys(methodSchema.args)) {
24
- const field = methodSchema.args[argKey];
25
- field.name = argKey;
26
- field.schema = methodSchema;
29
+ const df = methodSchema.args[argKey];
30
+ df.name = argKey;
31
+ df.schema = schema;
32
+ if (df.field.schema === undefined) {
33
+ df.field.name = argKey;
34
+ df.field.schema = schema;
35
+ }
36
+ }
37
+ // Result (optional): a plain inline Field — write name/schema back only
38
+ // when it is unowned (schema === undefined); shared instances (table
39
+ // columns, fields already claimed by another container) stay untouched.
40
+ if (methodSchema.result !== undefined && methodSchema.result.schema === undefined) {
41
+ methodSchema.result.name = key;
42
+ methodSchema.result.schema = schema;
27
43
  }
28
- methodSchema.result.name = key;
29
- methodSchema.result.schema = methodSchema;
30
44
  schema.methods[key] = methodSchema;
31
45
  }
32
46
  return schema;
package/docs/dto.md CHANGED
@@ -48,6 +48,39 @@ dtoField(stringField({ maxLength: 32 })).setOperator('like')
48
48
  dtoField(intField()).setDefault(0) // TypeBox default 注解
49
49
  ```
50
50
 
51
+ ## 字段引用规格(Reference Spec)
52
+
53
+ `DtoField` 通过 `setRef(other)` 或共享字段实例引用其他字段,表达"本字段来源于 X"。**引用不是任意的**——只有业务契约 DTO(controller/service 消费的 `dto_schema/{app}` 或 `dto_schema/common` DTO)的字段可以作为引用方,且只能引用三类被引用方:
54
+
55
+ | # | 引用 | 语法 | 语义 |
56
+ |---|---|---|---|
57
+ | 1 | **field 引用** | `dtoField(table.columns.x)`(共享实例)或 `setRef` 指向包装表列的 DtoField | 与数据库字段同义(来源:库) |
58
+ | 2 | **token 引用** | `fromToken(token, [...])` 或 `setRef(token 字段)` | 字段来源于登录身份(服务端注入) |
59
+ | 3 | **third 引用** | `setRef(third 消息字段)` | 字段来源于第三方消息(参数或结果) |
60
+
61
+ **方向约束**:
62
+
63
+ - 引用方:业务契约 DTO 的字段;
64
+ - 被引用方:token 字段 / third 消息字段 / 表列 field——**业务 DTO 之间不能互相引用**(convert 的 sources/target 各自独立,映射由搬运生成推导,不靠 DTO 互 ref);
65
+ - third 字段、token 字段不可反向引用业务 DTO;
66
+ - ref 链无环(渲染期强校验,循环引用报错)。
67
+
68
+ **对 convert 自动搬运的意义**:同源判定 = ref 链终端 / 共享 field 实例,且只有规格内合法引用构成可推导搬运——业务 DTO ← third(防腐翻译,`setRef(thirdField)` = "本字段从 third 参数/结果来")、业务 DTO ← entity(共享表列实例)都是可自动生成搬运的映射;越界引用是声明错误。
69
+
70
+ > entity 列是裸 `Field` 实例:DTO 字段 `dtoField(order.columns.x)` 与 entity 列共享同一实例即构成 field 引用。
71
+
72
+ ### token 引用的注入与 readOnly
73
+
74
+ `fromToken(token, fields)` 是 token 引用的标准入口:每个投影字段 `setRef(token 字段)`(复用类型/约束)+ 标记 `injectFrom`(服务端注入)。typebox 生成物中注入字段渲染为 **`Type.Optional(...)` + `readOnly: true` 注解**(JSON Schema annotation)——服务器字段,客户端不得上送:
75
+
76
+ ```ts
77
+ id: Type.Optional(Type.Integer({ readOnly: true })),
78
+ ```
79
+
80
+ 同时生成非枚举 `__inject` 适配器,fastify RPC 层在进 controller 前执行 `body.k = token.k` 从登录身份填充——**客户端即使上送也会被覆盖**(上送无效)。三层闭环:声明 readOnly(不可上送语义)+ Optional(可不传)+ 运行时覆盖(上送无效)。
81
+
82
+ 注意:**只有 `fromToken()` 自动设置 `injectFrom`**;手写 `setRef(token 字段)` 只表达引用关系,不触发注入渲染(如需注入须显式标记或改走 fromToken)。
83
+
51
84
  ## 默认值
52
85
 
53
86
  - `setDefault(v)` 设 DTO 层默认值,渲染为 TypeBox `default:` 注解(`Type.String({ default: 'PENDING' })`、`Type.Enum(OrderStatus, { default: OrderStatus.PENDING })`)。
package/docs/utils.md ADDED
@@ -0,0 +1,104 @@
1
+ # 工具模块与领域规则(UtilsSchema)
2
+
3
+ UtilsSchema 是**业务无关工具模块**(base utility modules)的声明:带完整签名的纯函数集合,在 service 层被调用(含 flow 的 `invoke`)。方法签名(args/result)由 schema 声明,方法体由用户实现。
4
+
5
+ **当 utils 名称与某张表同名时,它就是该实体的领域规则集**(DDD 领域服务落点)——命名即归属、位置即角色:`utils_schema/.../order.utils.ts` = order 表的领域规则(`OrderUtils`)。
6
+
7
+ ## 定义
8
+
9
+ ```ts
10
+ import { defineUtils, booleanField, decimalField, dtoField, intField, stringField } from '@pylonts/dsl';
11
+ import { api, miniuser } from '../../../../project.config';
12
+ import { order } from '../../../../schema/order.table';
13
+
14
+ const orderUtils = defineUtils({
15
+ name: 'OrderUtils',
16
+ api,
17
+ app: miniuser,
18
+ description: '订单领域规则',
19
+ methods: {
20
+ // 防御 guard:条件抛错,通过无返回值(void),失败即 throw
21
+ assertCancelable: {
22
+ args: { status: dtoField(order.columns.status) },
23
+ },
24
+ // 判断:返回 boolean 判定值,供 flow IF 条件位使用
25
+ canCancel: {
26
+ args: { status: dtoField(order.columns.status) },
27
+ result: booleanField(),
28
+ },
29
+ // 计算:返回派生标量
30
+ calcTotal: {
31
+ args: { unitPrice: dtoField(decimalField({ precision: 10, scale: 2 })), qty: dtoField(intField({ min: 1 })) },
32
+ result: decimalField({ precision: 10, scale: 2 }),
33
+ },
34
+ },
35
+ });
36
+
37
+ // 必须 default-export 方法引用对象(XXUtils 约定),loader 从 refs 反推 schema
38
+ export default {
39
+ assertCancelable: orderUtils.methods.assertCancelable,
40
+ canCancel: orderUtils.methods.canCancel,
41
+ calcTotal: orderUtils.methods.calcTotal,
42
+ };
43
+ ```
44
+
45
+ ### 方法形态
46
+
47
+ - **args**:`Record<string, DtoField>`——用 `dtoField(...)` 包装。可包装**表列**(`dtoField(order.columns.status)`,领域规则引用表字段的标准方式)或内联字段(`dtoField(stringField(...))`)。包装层反写不污染共享表列实例(与 `buildMessage` 同规则:仅内联字段写回底层)。
48
+ - **result**:`Field | undefined`——输出是全新值(boolean 判断、decimal 计算、string 派生文案),**不是共享表列**;省略 = void 方法(防御 guard 通过时无返回值)。
49
+ - **失败语义**:规则失败直接 `throw`(配合 flow 的 `BusinessException`/`CodeException` 通道),**不返回错误码结构**。
50
+
51
+ ## 文件与存储约定
52
+
53
+ - 一文件一 utils;文件必须 **default-export 方法引用对象**(`export default { m: xxUtils.methods.m }`),schema 变量模块私有不导出。
54
+ - 文件名 = 名字去 `Utils`/`Util` 后缀转 kebab:`OrderUtils → order.utils.ts`、`AmtUtils → amt.utils.ts`。
55
+ - 存储位置按绑定(机器校验,`lint utils`):
56
+
57
+ | 绑定 | 目录(utils_schema/) | 生成物 |
58
+ |---|---|---|
59
+ | api + app | `{api}/{app}/utils/` | `{api}/src/modules/{app}/utils/{Name}.ts` |
60
+ | 仅 api | `{api}/common/utils/` | `{api}/src/modules/common/utils/`(后端公共) |
61
+ | 仅 app | `{app}/utils/` | `{app.dir}/src/utils/`(前端) |
62
+ | 无绑定 | `common/utils/` 或 `shared/utils/` | `{root}/shared/utils/`(前后端共享) |
63
+
64
+ ## 领域规则三分类
65
+
66
+ 从纯函数视角,方法的出口只有两种:**返回值**或**抛异常**。因此领域规则方法恰好三类:
67
+
68
+ | 分类 | 命名 | 本质 | result | 失败语义 | 典型示例 |
69
+ |---|---|---|---|---|---|
70
+ | **1 防御 guard** | `assertXX` / `validateXX` / `ensureXX` | 条件抛错(前置校验) | `void` | **失败必抛错**,通过无返回值 | `assertCancelable`——已完成/已取消订单 throw |
71
+ | **2 判断** | `canXX` / `isXX` / `hasXX` | 返回判定值(谓词) | `boolean` / 枚举 | 返回 false/枚举值,不抛 | `canCancel`、`isExpired`——供 flow `IF(...)` 条件位 |
72
+ | **3 计算** | `calcXX` / `formatXX` / 动词 | 派生值计算 | 标量(金额/数量/文案) | 一般不抛(入参合法前提) | `calcTotal`——金额计算 |
73
+
74
+ ### 为什么这就是全集
75
+
76
+ - **判断 = 计算的特例**:boolean/枚举也是值,只是返回值窄化——单独分类是为 flow 谓词位(`IF(canCancel(slots.status))`)的语义清晰,不是本质区别。
77
+ - **防御 guard = 判断 + 抛错**:guard 内部必然先判断再 throw,是"判断"失败分支的显式化;通过时没有调用方需要的值,所以返回 `void`。
78
+ - **void 方法只能是防御 guard**:纯函数无副作用,不返回也不抛 = 什么都没做——void 方法若默认通过、特殊状态才抛,就是 guard 的语义。
79
+
80
+ ### 命名与返回的一致性(可机器校验)
81
+
82
+ - `assert/validate/ensure` 前缀 → **必须 void**(guard:通过无值、失败抛错);
83
+ - `can/is/has` 前缀 → **必须 boolean 或枚举**(判断谓词);
84
+ - 计算类 → 标量 result。
85
+ - 命名与 result 形态不匹配(如 `canXX` 返回 void)即声明错误——lint 可按此机器校验。
86
+
87
+ ## 建议(best practices)
88
+
89
+ 1. **命名即归属**:utils 名与表名一致 = 该实体领域规则集(`OrderUtils` ↔ `order`)。业务无关的通用工具(`AmtUtils`/`DateTimeUtils`)不绑定表,放共享目录。
90
+ 2. **规则二分定性**(既定决策):**不查库 → utils 谓词**(纯函数);**查库 → service 断言方法**(`invoke(dao.findByName) → IF(isNotNull) → THROW`);**硬不变量 → 表约束**(TableSchema unique/FK,DB 物理兜底)。不建 RuleSchema——规则已有 schema:表/谓词/守卫。
91
+ 3. **失败抛错,不返回错误码结构**:校验失败 `throw new Error('ORDER_CANNOT_CANCEL: ...')` 或业务异常类;flow 的 TRY-CATCH 按异常名捕获。
92
+ 4. **复用判据**:规则被 ≥2 处使用才提取为 utils;单处使用留在 flow 守卫片段("三行相似胜过过早抽象")。
93
+ 5. **纯函数、无 I/O**:utils 不做数据库/网络访问;需要 I/O 的规则属于 service。
94
+ 6. **表列引用用 `dtoField(table.columns.x)` 包装**:包装层反写安全,共享列实例永不被动;参数名可以与列名不同(如 `state` 包装 `status` 列)。
95
+ 7. **result 选型与命名一致**:防御 guard → **`void`** + `assert/validate/ensure` 前缀(抛错即语义);判断 → `booleanField()`(或枚举)+ `can/is/has` 前缀(供 flow 谓词位);计算 → `decimalField`/`intField`/`stringField` 标量。
96
+ 8. **flow 集成**:判断类可直接作 `IF(cond)` 条件位谓词(多入参靠 invoke 多槽位);防御 guard 通过 `invoke` 在流程前置位调用。
97
+
98
+ ## 生成与合并
99
+
100
+ `pylonts gen utils`:生成 `export class OrderUtils { static ... }` 骨架(每方法 `Not implemented` throw),签名由 schema 推导(enum → 枚举类型名 + 自动 import;date/datetime → string;无 result → `void`)。已存在文件**方法级 MERGE**(用户填充的方法体保留、私有 helper 不碰、契约删方法移除、新方法追加 stub);`--force` 整文件覆盖。覆盖规范见 [gen-utils-overwrite.md](../../docs/gen-utils-overwrite.md)。
101
+
102
+ ## 校验
103
+
104
+ `pylonts lint utils`:存储位置 + 绑定共享实例 + 文件名机器校验(`lint all` 已包含)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonts/dsl",
3
- "version": "1.1.18",
3
+ "version": "1.1.20",
4
4
  "description": "Schema definition DSL with drivers: MySQL DDL, TS enum, TypeBox schema codegen.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/dto.ts CHANGED
@@ -185,6 +185,45 @@ export function isDtoField(v: unknown): v is DtoField {
185
185
  return 'field' in v && !('type' in v);
186
186
  }
187
187
 
188
+ /** Resolve a ref chain to its terminal DtoField (the one without .ref).
189
+ * Cycles are a DSL definition error — fail loudly at render time. */
190
+ export function resolveDtoRefChain(f: DtoField): DtoField {
191
+ const seen = new Set<DtoField>();
192
+ let cur: DtoField = f;
193
+ while (cur.ref !== undefined) {
194
+ if (seen.has(cur.ref)) {
195
+ throw new Error(`dto field ${cur.name}: circular ref chain (field references itself)`);
196
+ }
197
+ seen.add(cur.ref);
198
+ cur = cur.ref;
199
+ }
200
+ return cur;
201
+ }
202
+
203
+ /** TS type of a DtoField in generated code — unwraps the wrapped field
204
+ * (enum → its JS name, date/datetime → string, containers → jsType). */
205
+ export function dtoFieldJsType(df: DtoField): string {
206
+ const f = df.field;
207
+ if (f.type === 'enum') return f.enum.jsName;
208
+ if (f.type === 'date' || f.type === 'datetime') return 'string';
209
+ return f.jsType;
210
+ }
211
+
212
+ /** Enum JS names referenced by a DtoField, recursing into inline array/object
213
+ * wrappers; DtoMessage item references stop the walk. First-occurrence order. */
214
+ export function dtoCollectEnumRefs(df: DtoField, out: string[] = []): string[] {
215
+ const f = df.field;
216
+ if (f.type === 'enum') {
217
+ out.push(f.enum.jsName);
218
+ } else if (f.type === 'array') {
219
+ const items = f.items;
220
+ if (isDtoField(items)) dtoCollectEnumRefs(items, out);
221
+ } else if (f.type === 'object') {
222
+ for (const child of Object.values(f.properties)) dtoCollectEnumRefs(child, out);
223
+ }
224
+ return out;
225
+ }
226
+
188
227
  export function dtoArrayField(def: { items: DtoField | DtoMessage } & Omit<BaseField, 'name'>): DtoArrayField {
189
228
  // Items stay as-is: an inline DtoField is rendered inline, a DtoMessage is
190
229
  // referenced by name (the driver renders Type.Array(<DtoName>)).
@@ -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,19 +1,27 @@
1
1
  import type { CollectionSchemaBase, Field, SchemaBase } from './dsl.js';
2
+ import type { DtoField } from './dto.js';
2
3
  import type { FrontAppSchema, ProjectApiSchema } from './project.js';
3
4
 
4
5
  // Base utility modules — business-agnostic helpers with full method
5
6
  // signatures (e.g. DateTimeUtils.format). Called at the service layer;
6
7
  // their args/results are own wire types, independent of business schemas.
8
+ // A utils whose name matches a table name is a domain rule module: its
9
+ // methods may wrap table columns via dtoField(table.columns.x) — the DtoField
10
+ // wrapper owns the write-back, so the shared column instance is never
11
+ // mutated (same rule as buildMessage: only inline fields get their
12
+ // underlying Field written back).
7
13
 
8
14
  /** A utility method with a full signature. */
9
15
  export interface UtilsMethodSchema extends SchemaBase {
10
16
  type: 'utilsMethod';
11
17
  /** The utility module this method belongs to. */
12
18
  schema: UtilsSchema;
13
- /** Input fields. */
14
- args: Record<string, Field>;
15
- /** Output field. */
16
- result: Field;
19
+ /** Input fields — DtoField wrappers (dtoField(...)); may wrap table columns. */
20
+ args: Record<string, DtoField>;
21
+ /** Output field — a plain inline Field (boolean for checks, decimal for
22
+ * computed amounts, ...). The method output is a fresh value, never a
23
+ * shared table column. Omit for void methods (pure actions). */
24
+ result?: Field;
17
25
  }
18
26
 
19
27
  /** Method input for defineUtils: type/schema/name are set by the builder. */
@@ -62,13 +70,27 @@ export function defineUtils(options: {
62
70
  args: method.args,
63
71
  result: method.result,
64
72
  };
73
+ // 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.
65
78
  for (const argKey of Object.keys(methodSchema.args)) {
66
- const field = methodSchema.args[argKey] as Field;
67
- field.name = argKey;
68
- field.schema = methodSchema;
79
+ const df = methodSchema.args[argKey] as DtoField;
80
+ df.name = argKey;
81
+ df.schema = schema;
82
+ if (df.field.schema === undefined) {
83
+ df.field.name = argKey;
84
+ df.field.schema = schema;
85
+ }
86
+ }
87
+ // Result (optional): a plain inline Field — write name/schema back only
88
+ // when it is unowned (schema === undefined); shared instances (table
89
+ // columns, fields already claimed by another container) stay untouched.
90
+ if (methodSchema.result !== undefined && methodSchema.result.schema === undefined) {
91
+ methodSchema.result.name = key;
92
+ methodSchema.result.schema = schema;
69
93
  }
70
- methodSchema.result.name = key;
71
- methodSchema.result.schema = methodSchema;
72
94
  schema.methods[key] = methodSchema;
73
95
  }
74
96
  return schema;