@pylonts/dsl 1.1.6 → 1.1.12

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.
Files changed (88) hide show
  1. package/README.md +4 -0
  2. package/dist/action.d.ts +32 -0
  3. package/dist/action.js +14 -0
  4. package/dist/aggregate.d.ts +38 -0
  5. package/dist/aggregate.js +46 -0
  6. package/dist/business-flow.d.ts +9 -0
  7. package/dist/business-flow.js +72 -0
  8. package/dist/controller.d.ts +17 -9
  9. package/dist/controller.js +8 -2
  10. package/dist/convert.d.ts +28 -10
  11. package/dist/convert.js +16 -5
  12. package/dist/curd.d.ts +7 -10
  13. package/dist/curd.js +3 -1
  14. package/dist/dao.d.ts +81 -53
  15. package/dist/dao.js +291 -12
  16. package/dist/db.d.ts +6 -0
  17. package/dist/db.js +10 -0
  18. package/dist/domain-event.d.ts +48 -0
  19. package/dist/domain-event.js +24 -0
  20. package/dist/dsl.d.ts +17 -2
  21. package/dist/dsl.js +7 -0
  22. package/dist/dto.d.ts +6 -4
  23. package/dist/dto.js +5 -4
  24. package/dist/entity.d.ts +29 -0
  25. package/dist/entity.js +13 -0
  26. package/dist/exception.d.ts +9 -3
  27. package/dist/exception.js +25 -1
  28. package/dist/expr.d.ts +45 -0
  29. package/dist/expr.js +32 -0
  30. package/dist/filter.d.ts +45 -0
  31. package/dist/filter.js +21 -0
  32. package/dist/flow-script.d.ts +108 -0
  33. package/dist/flow-script.js +505 -0
  34. package/dist/flow.d.ts +294 -17
  35. package/dist/flow.js +803 -18
  36. package/dist/index.d.ts +6 -2
  37. package/dist/index.js +6 -2
  38. package/dist/mermaid-driver.js +264 -24
  39. package/dist/mysql-driver.js +3 -0
  40. package/dist/project.d.ts +10 -6
  41. package/dist/project.js +35 -4
  42. package/dist/repository.d.ts +26 -0
  43. package/dist/repository.js +8 -0
  44. package/dist/service.d.ts +14 -2
  45. package/dist/service.js +49 -0
  46. package/dist/third-service.d.ts +5 -0
  47. package/dist/third-service.js +1 -0
  48. package/dist/typebox-driver.js +4 -0
  49. package/dist/utils.d.ts +9 -2
  50. package/dist/utils.js +4 -0
  51. package/docs/aggregate.md +110 -0
  52. package/docs/curd.md +146 -111
  53. package/docs/dao-generation.md +478 -0
  54. package/docs/ddd-principles.md +75 -0
  55. package/docs/domain-event.md +137 -0
  56. package/docs/keyword-matcher.md +182 -0
  57. package/docs/project.md +17 -9
  58. package/docs/token.md +327 -0
  59. package/docs/trans-reentrant.md +85 -0
  60. package/package.json +25 -6
  61. package/src/action.ts +51 -10
  62. package/src/aggregate.ts +104 -0
  63. package/src/business-flow.ts +80 -0
  64. package/src/controller.ts +25 -11
  65. package/src/convert.ts +51 -15
  66. package/src/curd.ts +12 -6
  67. package/src/dao.ts +377 -63
  68. package/src/db.ts +13 -0
  69. package/src/domain-event.ts +74 -0
  70. package/src/dsl.ts +23 -2
  71. package/src/dto.ts +9 -6
  72. package/src/entity.ts +43 -0
  73. package/src/exception.ts +30 -5
  74. package/src/expr.ts +65 -0
  75. package/src/filter.ts +70 -0
  76. package/src/flow-script.ts +696 -0
  77. package/src/flow.ts +1129 -46
  78. package/src/index.ts +6 -2
  79. package/src/mermaid-driver.ts +256 -29
  80. package/src/mysql-driver.ts +3 -0
  81. package/src/project.ts +138 -97
  82. package/src/repository.ts +35 -0
  83. package/src/service.ts +68 -3
  84. package/src/third-service.ts +6 -0
  85. package/src/typebox-driver.ts +4 -0
  86. package/src/utils.ts +13 -2
  87. package/src/endpoint.ts +0 -18
  88. package/src/provider.ts +0 -68
package/dist/dao.js CHANGED
@@ -1,29 +1,308 @@
1
+ /** The tenant column of the dao table when the app declares a tenant.
2
+ * Deterministic name derivation: `{tenant.phrase}_{tenant.pk}` (e.g. shop
3
+ * with pk id → `shop_id`) — checked directly against the table columns,
4
+ * no FK traversal. Tables without that column are global tables (valid:
5
+ * system config tables carry no tenant id). Exported for generator/linter. */
6
+ export function tenantFkOf(dao) {
7
+ const tenant = dao.app.tenant;
8
+ if (!tenant)
9
+ return undefined;
10
+ const pk = tenant.primaryKey;
11
+ const phrase = tenant.phrase;
12
+ if (!pk || Array.isArray(pk) || !phrase) {
13
+ throw new Error(`app '${dao.app.name}' tenant table '${tenant.name}' must declare a single-column primaryKey and a phrase (tenant column name = {phrase}_{pk})`);
14
+ }
15
+ return dao.table.columns[`${phrase.name}_${pk.name}`];
16
+ }
17
+ /** The optimistic-lock version column of the dao table (table-level
18
+ * declaration), machine-enforced: update args must carry it, set must not
19
+ * touch it. */
20
+ function versionOf(dao) {
21
+ return dao.table.version;
22
+ }
23
+ /** Validates a ValueExpr: column refs belong to the dao table, param names
24
+ * are unique, literal/bin types are numeric-compatible. */
25
+ function validateValueExpr(dao, expr, params, methodName) {
26
+ const table = dao.table;
27
+ const cols = Object.values(table.columns);
28
+ switch (expr.kind) {
29
+ case 'col':
30
+ if (!cols.includes(expr.field)) {
31
+ throw new Error(`dao ${dao.name}.${methodName}: expr references column '${expr.field.name}' which is not a column of table ${table.name}`);
32
+ }
33
+ return;
34
+ case 'lit':
35
+ if (typeof expr.value === 'string') {
36
+ throw new Error(`dao ${dao.name}.${methodName}: string literal in a numeric expression — use a param instead`);
37
+ }
38
+ return;
39
+ case 'param':
40
+ if (params.has(expr.name)) {
41
+ throw new Error(`dao ${dao.name}.${methodName}: duplicate param '${expr.name}' in set expressions`);
42
+ }
43
+ params.add(expr.name);
44
+ return;
45
+ case 'bin':
46
+ validateValueExpr(dao, expr.left, params, methodName);
47
+ validateValueExpr(dao, expr.right, params, methodName);
48
+ return;
49
+ }
50
+ }
51
+ function validateUpdateSet(dao, methodName, m) {
52
+ const argCols = new Set(m.args.columns.map((c) => c.name));
53
+ const version = versionOf(dao);
54
+ for (const setExpr of m.set ?? []) {
55
+ if (version && setExpr.col === version) {
56
+ throw new Error(`dao ${dao.name}.${methodName}: set must not touch version column '${version.name}' — the optimistic lock manages it`);
57
+ }
58
+ if (!Object.values(dao.table.columns).includes(setExpr.col)) {
59
+ throw new Error(`dao ${dao.name}.${methodName}: set column '${setExpr.col.name}' is not a column of table ${dao.table.name}`);
60
+ }
61
+ if (argCols.has(setExpr.col.name)) {
62
+ throw new Error(`dao ${dao.name}.${methodName}: set column '${setExpr.col.name}' also appears in args '${m.args.name}' — a column is either directly assigned or expression-set, never both`);
63
+ }
64
+ validateValueExpr(dao, setExpr.expr, new Set(), methodName);
65
+ }
66
+ }
67
+ /** Validates read-method result columns: external reference columns must be
68
+ * reachable through a main-table foreign key (FK = join condition). */
69
+ function assertColumnReachable(dao, methodName, c) {
70
+ const table = dao.table;
71
+ if (!c.schema) {
72
+ throw new Error(`dao ${dao.name}.${methodName}: row column '${c.name}' has no schema`);
73
+ }
74
+ if (c.schema === table)
75
+ return;
76
+ const srcTable = c.schema;
77
+ const reachable = Object.values(table.foreignKeys ?? {}).some((fk) => {
78
+ const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
79
+ return refs.some((r) => r.schema === srcTable);
80
+ });
81
+ if (!reachable) {
82
+ throw new Error(`dao ${dao.name}.${methodName}: row column '${c.name}' belongs to table ${srcTable.name} but ${table.name} has no foreign key pointing to it`);
83
+ }
84
+ }
85
+ /** Validates read-method result columns: external reference columns must be
86
+ * reachable through a main-table foreign key (FK = join condition). */
87
+ function validateRowColumns(dao, methodName, row) {
88
+ for (const c of row.columns)
89
+ assertColumnReachable(dao, methodName, c);
90
+ }
91
+ /** Validates key args (get/delete): every key field must be a main-table
92
+ * column; the tenant column is injected as a separate parameter and can
93
+ * never be a key (a duplicate parameter would render). */
94
+ function validateKeyArgs(dao, methodName, args) {
95
+ const cols = Object.values(dao.table.columns);
96
+ const keys = Array.isArray(args) ? args : [args];
97
+ for (const k of keys) {
98
+ if (!cols.includes(k)) {
99
+ throw new Error(`dao ${dao.name}.${methodName}: key column '${k.name}' is not a column of table ${dao.table.name}`);
100
+ }
101
+ }
102
+ const tenant = tenantFkOf(dao);
103
+ if (tenant && keys.includes(tenant)) {
104
+ throw new Error(`dao ${dao.name}.${methodName}: key '${tenant.name}' is the tenant column — it is injected as a separate parameter, never a key`);
105
+ }
106
+ }
107
+ /** Update runs without JOINs, so its where criteria must all reference
108
+ * main-table columns — a cross-table criterion would render a raw column
109
+ * name that no table in the query provides. Also requires at least one
110
+ * locating criterion: without a pk/tenant/version/filter the generated
111
+ * UPDATE would have an empty WHERE and touch every row. */
112
+ function validateUpdateWhere(dao, methodName, m) {
113
+ if (m.where) {
114
+ for (const c of m.where.conditions) {
115
+ if (c.field.schema !== dao.table) {
116
+ const srcName = c.field.schema?.name ?? 'unknown';
117
+ throw new Error(`dao ${dao.name}.${methodName}: where criterion on '${c.field.name}' belongs to table '${srcName}' — update is single-table and cannot join`);
118
+ }
119
+ if (c.optional) {
120
+ throw new Error(`dao ${dao.name}.${methodName}: where criterion on '${c.field.name}' is optional — update criteria must be required (a missing value would silently drop the criterion)`);
121
+ }
122
+ if (!m.args.columns.includes(c.field)) {
123
+ throw new Error(`dao ${dao.name}.${methodName}: where criterion on '${c.field.name}' is not carried by args '${m.args.name}' — the generated UPDATE reads it from the row`);
124
+ }
125
+ }
126
+ }
127
+ const pk = dao.table.primaryKey;
128
+ const hasPk = pk !== undefined && (Array.isArray(pk) ? pk.length > 0 : true);
129
+ const hasLocator = hasPk || tenantFkOf(dao) !== undefined || versionOf(dao) !== undefined || (m.where !== undefined && m.where.conditions.length > 0);
130
+ if (!hasLocator) {
131
+ throw new Error(`dao ${dao.name}.${methodName}: update on table '${dao.table.name}' has no locating criteria — declare a primaryKey, tenant, version column, or a where filter (an empty WHERE updates every row)`);
132
+ }
133
+ }
134
+ /** Order-by columns must be part of the result row: the generated ORDER BY
135
+ * uses the row's result keys, which only exist for selected columns. */
136
+ function validateOrderBy(dao, methodName, m) {
137
+ if (!m.orderBy)
138
+ return;
139
+ const orders = Array.isArray(m.orderBy) ? m.orderBy : [m.orderBy];
140
+ for (const o of orders) {
141
+ if (!m.results.columns.includes(o.column)) {
142
+ throw new Error(`dao ${dao.name}.${methodName}: orderBy column '${o.column.name}' is not part of results '${m.results.name}'`);
143
+ }
144
+ }
145
+ }
146
+ /** Aggregate results: the result entity must carry at least one column; every
147
+ * plain column (grouping dimension) and every aggregate field's underlying
148
+ * column must be reachable (same rule as read results). */
149
+ function validateAggregateResults(dao, methodName, m) {
150
+ const columns = m.results.columns;
151
+ if (columns.length === 0) {
152
+ throw new Error(`dao ${dao.name}.${methodName}: aggregate has no results`);
153
+ }
154
+ for (const c of columns) {
155
+ if (c.type === 'aggregate') {
156
+ if (c.expr.field)
157
+ assertColumnReachable(dao, methodName, c.expr.field);
158
+ }
159
+ else {
160
+ assertColumnReachable(dao, methodName, c);
161
+ }
162
+ }
163
+ }
164
+ /** Upsert conflict keys: must equal the pk or a complete unique index column
165
+ * set, and every key must be carried by args. Auto-increment tables are
166
+ * forbidden (MySQL auto_increment burns ids on duplicate-key updates). */
167
+ function validateUpsert(dao, methodName, m) {
168
+ const table = dao.table;
169
+ if (table.autoIncrement) {
170
+ throw new Error(`dao ${dao.name}.${methodName}: upsert on table '${table.name}' with auto-increment pk is forbidden (MySQL auto_increment burns ids on duplicate-key updates)`);
171
+ }
172
+ const keys = Array.isArray(m.keys) ? m.keys : [m.keys];
173
+ if (keys.length === 0) {
174
+ throw new Error(`dao ${dao.name}.${methodName}: upsert keys must be non-empty`);
175
+ }
176
+ for (const k of keys) {
177
+ if (k.schema !== table) {
178
+ throw new Error(`dao ${dao.name}.${methodName}: upsert key '${k.name}' is not a column of table ${table.name}`);
179
+ }
180
+ }
181
+ const keyNames = keys.map((k) => k.name).sort();
182
+ const candidates = [];
183
+ const uniqueCols = new Set();
184
+ const pk = table.primaryKey;
185
+ if (pk) {
186
+ const cs = (Array.isArray(pk) ? pk : [pk]).map((c) => c.name);
187
+ candidates.push(cs.sort());
188
+ for (const n of cs)
189
+ uniqueCols.add(n);
190
+ }
191
+ for (const idx of table.indexes ?? []) {
192
+ if (idx.unique) {
193
+ const cs = (Array.isArray(idx.columns) ? idx.columns : [idx.columns]).map((c) => c.name);
194
+ candidates.push(cs.sort());
195
+ for (const n of cs)
196
+ uniqueCols.add(n);
197
+ }
198
+ }
199
+ const matches = candidates.some((c) => c.length === keyNames.length && c.every((n, i) => n === keyNames[i]));
200
+ if (!matches) {
201
+ throw new Error(`dao ${dao.name}.${methodName}: upsert keys [${keys.map((k) => k.name).join(', ')}] must equal the pk or a complete unique index column set of table ${table.name}`);
202
+ }
203
+ for (const k of keys) {
204
+ if (!m.args.columns.includes(k)) {
205
+ throw new Error(`dao ${dao.name}.${methodName}: upsert args '${m.args.name}' must carry conflict key '${k.name}'`);
206
+ }
207
+ }
208
+ // ON DUPLICATE KEY UPDATE has no WHERE — tenant isolation is only possible
209
+ // when the tenant column participates in the conflict key.
210
+ const tenant = tenantFkOf(dao);
211
+ if (tenant && !keys.includes(tenant)) {
212
+ throw new Error(`dao ${dao.name}.${methodName}: upsert on tenant-scoped table '${table.name}' must include tenant column '${tenant.name}' in keys (ON DUPLICATE KEY UPDATE has no WHERE — tenant isolation requires the tenant column to be part of the conflict key)`);
213
+ }
214
+ // The merge clause (col = new.col) only writes non-key writable columns —
215
+ // conflict keys are the conflict identity, readOnly columns stay
216
+ // DB-managed, and every pk/unique column is excluded: a merge writing a
217
+ // unique column could collide with another row's value and chain-fire the
218
+ // duplicate-key handler (unique columns are conflict identity, not data).
219
+ const mergeCols = m.args.columns.filter((c) => !keys.includes(c) && !c.readOnly && !uniqueCols.has(c.name));
220
+ if (mergeCols.length === 0) {
221
+ throw new Error(`dao ${dao.name}.${methodName}: upsert has no merge columns — args '${m.args.name}' carries only conflict keys, pk/unique and readOnly columns (ON DUPLICATE KEY UPDATE needs at least one writable non-unique column)`);
222
+ }
223
+ }
224
+ /** Validates write-method args: every column must come from the dao table
225
+ * (single-table atomicity). */
226
+ function validateWriteArgs(dao, methodName, args) {
227
+ const cols = Object.values(dao.table.columns);
228
+ for (const c of args.columns) {
229
+ if (!cols.includes(c)) {
230
+ throw new Error(`dao ${dao.name}.${methodName}: args column '${c.name}' of '${args.name}' is not a column of table ${dao.table.name}`);
231
+ }
232
+ }
233
+ }
234
+ /** Enforces the tenant/version column presence on row-carried methods
235
+ * (decisions: insert = declared in args; update = extracted from row).
236
+ * Tenant is required on both writes (the column is always scoped); version
237
+ * is required only on update — inserts let the DB default initialize it. */
238
+ function validateRowCarriedColumns(dao, methodName, method) {
239
+ const tenant = tenantFkOf(dao);
240
+ if (tenant) {
241
+ if (!method.args.columns.includes(tenant)) {
242
+ throw new Error(`dao ${dao.name}.${methodName}: args '${method.args.name}' must include tenant column '${tenant.name}'`);
243
+ }
244
+ }
245
+ if (method.type === 'update') {
246
+ const version = versionOf(dao);
247
+ if (version && !method.args.columns.includes(version)) {
248
+ throw new Error(`dao ${dao.name}.${methodName}: args '${method.args.name}' must include version column '${version.name}' — the optimistic lock reads it from the row`);
249
+ }
250
+ }
251
+ }
1
252
  export function defineDao(options) {
253
+ if (!options.api.apps.includes(options.app)) {
254
+ throw new Error(`dao ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
255
+ }
2
256
  const schema = {
3
257
  type: 'dao',
4
258
  name: options.name,
5
259
  description: options.description,
260
+ api: options.api,
6
261
  app: options.app,
7
262
  table: options.table,
8
263
  methods: {},
9
264
  };
10
265
  for (const key of Object.keys(options.methods)) {
11
266
  const method = options.methods[key];
267
+ for (const ref of [method.args, 'where' in method ? method.where : undefined]) {
268
+ if (isFilterSchema(ref) && (ref.api !== options.api || ref.app !== options.app)) {
269
+ throw new Error(`dao ${options.name}: method '${key}' references filter '${ref.name}' bound to ${ref.api.name}/${ref.app.name} but the dao is bound to ${options.api.name}/${options.app.name}`);
270
+ }
271
+ }
272
+ // Method-kind validation (declaration is complete — every kind is checked).
273
+ if (method.type === 'insert' || method.type === 'update' || method.type === 'upsert') {
274
+ const m = method;
275
+ validateWriteArgs(schema, key, m.args);
276
+ validateRowCarriedColumns(schema, key, m);
277
+ if (method.type === 'update') {
278
+ validateUpdateSet(schema, key, method);
279
+ validateUpdateWhere(schema, key, method);
280
+ }
281
+ if (method.type === 'upsert') {
282
+ validateUpsert(schema, key, method);
283
+ }
284
+ }
285
+ if (method.type === 'get' || method.type === 'delete') {
286
+ validateKeyArgs(schema, key, method.args);
287
+ }
288
+ if (method.type === 'find' || method.type === 'get') {
289
+ validateRowColumns(schema, key, method.results);
290
+ }
291
+ if (method.type === 'find') {
292
+ validateOrderBy(schema, key, method);
293
+ }
294
+ if (method.type === 'aggregate') {
295
+ validateAggregateResults(schema, key, method);
296
+ }
12
297
  // Spread of a union loses discriminant correlation; the cast is safe
13
298
  // (the builder only adds name and the back-reference field).
14
299
  schema.methods[key] = { ...method, name: key, schema };
15
300
  }
16
301
  return schema;
17
302
  }
18
- /** Aggregate expressions: Compute.sum(col) / Compute.avg(col) / Compute.count(). */
19
- export const Compute = {
20
- sum(field) {
21
- return { fn: 'sum', field };
22
- },
23
- avg(field) {
24
- return { fn: 'avg', field };
25
- },
26
- count() {
27
- return { fn: 'count' };
28
- },
29
- };
303
+ function isFilterSchema(value) {
304
+ if (typeof value !== 'object' || value === null)
305
+ return false;
306
+ const v = value;
307
+ return v.type === 'filter' && typeof v.name === 'string';
308
+ }
package/dist/db.d.ts CHANGED
@@ -17,6 +17,9 @@ export interface TableSchemaOptions<N extends string = string, C extends Record<
17
17
  autoIncrement?: Field;
18
18
  /** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */
19
19
  label?: Field;
20
+ /** Optimistic lock version column: updates auto-manage `version = version + 1`
21
+ * and `WHERE version = ?` (value taken from the row). Must be an integer column. */
22
+ version?: Field;
20
23
  primaryKey?: Field | Field[];
21
24
  indexes?: Index[];
22
25
  foreignKeys?: Record<string, ForeignKey>;
@@ -44,6 +47,9 @@ export declare class TableSchema<N extends string = string, C extends Record<str
44
47
  foreignKeys?: Record<string, ForeignKey>;
45
48
  /** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */
46
49
  label?: Field;
50
+ /** Optimistic lock version column: updates auto-manage `version = version + 1`
51
+ * and `WHERE version = ?` (value taken from the row). Must be an integer column. */
52
+ version?: Field;
47
53
  /** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */
48
54
  phrase?: EntityPhrase;
49
55
  /** 本表引用的所有枚举定义(map,key 为枚举标识),显式声明供 gen-enums 收集 */
package/dist/db.js CHANGED
@@ -18,6 +18,9 @@ export class TableSchema {
18
18
  foreignKeys;
19
19
  /** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */
20
20
  label;
21
+ /** Optimistic lock version column: updates auto-manage `version = version + 1`
22
+ * and `WHERE version = ?` (value taken from the row). Must be an integer column. */
23
+ version;
21
24
  /** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */
22
25
  phrase;
23
26
  /** 本表引用的所有枚举定义(map,key 为枚举标识),显式声明供 gen-enums 收集 */
@@ -40,6 +43,7 @@ export class TableSchema {
40
43
  this.indexes = options.indexes;
41
44
  this.foreignKeys = options.foreignKeys;
42
45
  this.label = options.label;
46
+ this.version = options.version;
43
47
  this.phrase = options.phrase;
44
48
  this.enums = options.enums;
45
49
  this.columns = options.columns;
@@ -61,6 +65,12 @@ export function defineTable(name, schema) {
61
65
  if (table.label && !Object.values(table.columns).includes(table.label)) {
62
66
  throw new Error(`table '${name}': label field '${table.label.name}' must be one of the table's columns`);
63
67
  }
68
+ if (table.version && !Object.values(table.columns).includes(table.version)) {
69
+ throw new Error(`table '${name}': version field '${table.version.name}' must be one of the table's columns`);
70
+ }
71
+ if (table.version && table.version.jsType !== 'number') {
72
+ throw new Error(`table '${name}': version field '${table.version.name}' must be an integer column`);
73
+ }
64
74
  for (const key of Object.keys(table.columns)) {
65
75
  const field = table.columns[key];
66
76
  if (field.schema && field.schema !== table) {
@@ -0,0 +1,48 @@
1
+ import { SchemaBase } from './dsl.js';
2
+ import type { DtoField } from './dto.js';
3
+ import type { FlowSchema } from './flow.js';
4
+ /**
5
+ * Domain event declaration: a fact that has happened ("OrderCancelled"),
6
+ * carrying a data snapshot (not references). Declared as a first-class schema
7
+ * so the flow `publish` action can be compile-time checked against it (event
8
+ * name exists, payload fields match) and the outbox table + handler wiring can
9
+ * be generated.
10
+ *
11
+ * Command vs event: a command says "do something" (future tense, one receiver,
12
+ * caller senses failure); an event says "something happened" (past tense, zero
13
+ * to many subscribers, publisher does not care who handles it).
14
+ */
15
+ export interface DomainEventSchema extends SchemaBase {
16
+ type: 'domain-event';
17
+ /** Payload fields: data snapshot, not references. */
18
+ fields: Record<string, DtoField>;
19
+ }
20
+ export declare function defineDomainEvent(options: {
21
+ name: string;
22
+ fields: Record<string, DtoField>;
23
+ description?: string;
24
+ }): DomainEventSchema;
25
+ /**
26
+ * Event subscription: declares that a handler processes a domain event.
27
+ * The processing logic is a flow (the flow model is the execution model — a
28
+ * handler flow receives the event payload as its input slot). A plain async
29
+ * function is accepted as the runtime path until a flow executor exists; the
30
+ * declared flow is then compile-time checked (payload fields match the event)
31
+ * and drives generation.
32
+ */
33
+ export interface EventHandlerSchema extends SchemaBase {
34
+ type: 'event-handler';
35
+ /** Subscribed event name (must match a defineDomainEvent name). */
36
+ event: string;
37
+ /** Processing flow — receives the event payload as its input slot. */
38
+ flow?: FlowSchema;
39
+ /** Runtime handler function (used directly when no flow executor exists). */
40
+ handler?: (payload: Record<string, unknown>) => Promise<void> | void;
41
+ }
42
+ export declare function defineEventHandler(options: {
43
+ name: string;
44
+ event: string;
45
+ flow?: FlowSchema;
46
+ handler?: (payload: Record<string, unknown>) => Promise<void> | void;
47
+ description?: string;
48
+ }): EventHandlerSchema;
@@ -0,0 +1,24 @@
1
+ export function defineDomainEvent(options) {
2
+ if (Object.keys(options.fields).length === 0) {
3
+ throw new Error(`domain event '${options.name}': fields must not be empty`);
4
+ }
5
+ return {
6
+ type: 'domain-event',
7
+ name: options.name,
8
+ description: options.description,
9
+ fields: options.fields,
10
+ };
11
+ }
12
+ export function defineEventHandler(options) {
13
+ if (options.flow === undefined && options.handler === undefined) {
14
+ throw new Error(`event handler '${options.name}': at least one of flow or handler is required`);
15
+ }
16
+ return {
17
+ type: 'event-handler',
18
+ name: options.name,
19
+ description: options.description,
20
+ event: options.event,
21
+ flow: options.flow,
22
+ handler: options.handler,
23
+ };
24
+ }
package/dist/dsl.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ImportBase } from './import-base.js';
2
2
  import type { MockDescriptor } from './mock.js';
3
+ import type { ComputeExpr } from './expr.js';
3
4
  /** Field query comparison operators (search field semantics). */
4
- export type Operator = 'eq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ne';
5
+ export type Operator = 'eq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ne' | 'in' | 'null' | 'notNull';
5
6
  export interface SchemaBase {
6
7
  name: string;
7
8
  description?: string;
@@ -113,7 +114,17 @@ interface ObjectField extends BaseField {
113
114
  jsType: 'object';
114
115
  properties: Record<string, Field>;
115
116
  }
116
- export type Field = StringField | TextField | IntField | BigintField | DecimalField | RateField | BooleanField | DateField | TimeField | DateTimeField | EnumField | JsonField | ArrayField | ObjectField;
117
+ /** Aggregate result column (count/sum/avg) of an aggregate query a Field
118
+ * so that aggregate result entities can carry it like any other column.
119
+ * jsType follows the aggregate precision rule: count → number;
120
+ * sum/avg over an integer column → number, anything else → string. */
121
+ interface AggregateField extends BaseField {
122
+ type: 'aggregate';
123
+ jsType: 'number' | 'string';
124
+ /** The aggregate expression producing this column. */
125
+ expr: ComputeExpr;
126
+ }
127
+ export type Field = StringField | TextField | IntField | BigintField | DecimalField | RateField | BooleanField | DateField | TimeField | DateTimeField | EnumField | JsonField | ArrayField | ObjectField | AggregateField;
117
128
  type FieldExtras<T extends Field> = Omit<T, 'name' | 'type' | 'jsType'>;
118
129
  export declare function stringField(extra?: FieldExtras<StringField>): StringField;
119
130
  export declare function textField(extra?: FieldExtras<TextField>): TextField;
@@ -129,4 +140,8 @@ export declare function jsonField(extra?: FieldExtras<JsonField>): JsonField;
129
140
  export declare function arrayField(extra: FieldExtras<ArrayField>): ArrayField;
130
141
  export declare function objectField(extra: FieldExtras<ObjectField>): ObjectField;
131
142
  export declare function enumField(extra: Omit<EnumField, 'name' | 'type' | 'jsType'>): EnumField;
143
+ /** Aggregate result column field: count → number; sum/avg over an integer
144
+ * column → number, anything else (decimal/bigint/rate…) → string.
145
+ * Used in aggregate-query result entities (see AggregateSchema). */
146
+ export declare function aggField(name: string, expr: ComputeExpr): AggregateField;
132
147
  export {};
package/dist/dsl.js CHANGED
@@ -53,3 +53,10 @@ export function enumField(extra) {
53
53
  const jsType = extra.enum.valueType === 'integer' ? 'number' : 'string';
54
54
  return { name: '', type: 'enum', jsType, ...extra };
55
55
  }
56
+ /** Aggregate result column field: count → number; sum/avg over an integer
57
+ * column → number, anything else (decimal/bigint/rate…) → string.
58
+ * Used in aggregate-query result entities (see AggregateSchema). */
59
+ export function aggField(name, expr) {
60
+ const jsType = expr.field === undefined || expr.field.type === 'integer' ? 'number' : 'string';
61
+ return { name, type: 'aggregate', jsType, expr };
62
+ }
package/dist/dto.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { BaseField, CollectionSchemaBase, Field, Operator, SchemaBase } from './dsl.js';
2
2
  import { TableSchema } from './db.js';
3
+ import type { EntitySchema } from './entity.js';
3
4
  import type { ImportBase } from './import-base.js';
4
5
  import type { ThirdMethodSchema } from './third-service.js';
5
6
  /** Re-export — Operator lives on the DSL level (see dsl.ts). */
@@ -100,8 +101,9 @@ export declare function buildInput(name: string, fields: Record<string, DtoField
100
101
  export declare function buildOutput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage;
101
102
  export declare function buildQuery(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage;
102
103
  export declare function buildPk(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage;
103
- /** Field-collection source a DTO can project from: a DB table or a third-party method message. */
104
- export type DtoFieldSource = TableSchema | ThirdMethodSchema;
105
- /** Project fields from a field-collection source (table or third-party method
106
- * message) and wrap them as DTO fields (aligned with dto.from). */
104
+ /** Field-collection source a DTO can project from: a DB table, a third-party
105
+ * method message, or an entity (which may carry aggregate fields). */
106
+ export type DtoFieldSource = TableSchema | ThirdMethodSchema | EntitySchema;
107
+ /** Project fields from a field-collection source (table, third-party method
108
+ * message or entity) and wrap them as DTO fields (aligned with dto.from). */
107
109
  export declare function from(source: DtoFieldSource, fields: Field[]): Record<string, DtoField>;
package/dist/dto.js CHANGED
@@ -174,16 +174,17 @@ function ownsField(source, field) {
174
174
  return Object.values(source.fields).includes(field);
175
175
  return Object.values(source.columns).includes(field);
176
176
  }
177
- /** Project fields from a field-collection source (table or third-party method
178
- * message) and wrap them as DTO fields (aligned with dto.from). */
177
+ /** Project fields from a field-collection source (table, third-party method
178
+ * message or entity) and wrap them as DTO fields (aligned with dto.from). */
179
179
  export function from(source, fields) {
180
180
  const out = {};
181
181
  for (const field of fields) {
182
182
  if (!ownsField(source, field)) {
183
183
  throw new Error(`dto.from(${source.name}): field ${field.name} does not belong to this ${source.type}`);
184
184
  }
185
- // DB columns map to camelCase interface names (mer_id → merId); wire-format
186
- // names are protocol names themselves and stay untouched.
185
+ // DB columns map to camelCase interface names (mer_id → merId); aggregate
186
+ // field names are already camel and pass through; wire-format names are
187
+ // protocol names themselves and stay untouched.
187
188
  out[isThirdMethod(source) ? field.name : toCamelCase(field.name)] = dtoField(field);
188
189
  }
189
190
  return out;
@@ -0,0 +1,29 @@
1
+ import type { Field, SchemaBase } from './dsl.js';
2
+ import type { FrontAppSchema, ProjectApiSchema } from './project.js';
3
+ /** A row object: a set of database columns. Columns may come from one table
4
+ * (write args of insert/update/upsert) or span tables through the main
5
+ * table's foreign keys (read results of find/get) — where the columns come
6
+ * from is the responsibility of the consuming position, not of this schema.
7
+ * Storage is entity_schema/{api.name}/{app.name}/entity/{table}.entity.ts:
8
+ * one file per table (the main table), any number of entities per file.
9
+ * "Row" is only a naming convention for read-shaped entities
10
+ * (OrderListRow, OrderDetailRow) — they are all defineEntity declarations. */
11
+ export interface EntitySchema extends SchemaBase {
12
+ type: 'entity';
13
+ /** The backend api module this entity belongs to (shared instance from
14
+ * project.config.ts apis). Entities are always backend-side. */
15
+ api: ProjectApiSchema;
16
+ /** The frontend app this entity belongs to (shared instance from project.config). */
17
+ app: FrontAppSchema;
18
+ /** The columns of this row object: main-table columns, external reference
19
+ * columns, and — for aggregate result entities — aggField columns
20
+ * (count/sum/avg outputs). */
21
+ columns: Field[];
22
+ }
23
+ export declare function defineEntity(options: {
24
+ name: string;
25
+ api: ProjectApiSchema;
26
+ app: FrontAppSchema;
27
+ columns: Field[];
28
+ description?: string;
29
+ }): EntitySchema;
package/dist/entity.js ADDED
@@ -0,0 +1,13 @@
1
+ export function defineEntity(options) {
2
+ if (!options.api.apps.includes(options.app)) {
3
+ throw new Error(`entity ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
4
+ }
5
+ return {
6
+ type: 'entity',
7
+ name: options.name,
8
+ description: options.description,
9
+ api: options.api,
10
+ app: options.app,
11
+ columns: options.columns,
12
+ };
13
+ }
@@ -3,12 +3,18 @@ import type { ImportBase } from './import-base.js';
3
3
  /** Describes an exception a method can throw. */
4
4
  export interface ExceptionSchema extends SchemaBase, Omit<ImportBase, 'type'> {
5
5
  type: 'exception';
6
- /** Whether the caller may safely retry after catching this exception. */
7
- retryable: boolean;
8
6
  }
9
7
  export declare function defineException(options: {
10
8
  name: string;
11
9
  from: string;
12
- retryable: boolean;
13
10
  description?: string;
14
11
  }): ExceptionSchema;
12
+ /** Timeout or unrecoverable I/O failure */
13
+ export declare const IOException: ExceptionSchema;
14
+ /** Business error code carried by the exception*/
15
+ export declare const CodeException: ExceptionSchema;
16
+ /** Business rule violation (maps to 422) */
17
+ export declare const BusinessException: ExceptionSchema;
18
+ /** Anything else — unexpected, carries no business semantics. Rendered as a
19
+ * plain Error / system exception, no dedicated runtime class. */
20
+ export declare const UnexpectedException: ExceptionSchema;
package/dist/exception.js CHANGED
@@ -3,7 +3,31 @@ export function defineException(options) {
3
3
  type: 'exception',
4
4
  name: options.name,
5
5
  from: options.from,
6
- retryable: options.retryable,
7
6
  description: options.description,
8
7
  };
9
8
  }
9
+ /** Timeout or unrecoverable I/O failure */
10
+ export const IOException = defineException({
11
+ name: 'IOException',
12
+ from: '@pylonts/core',
13
+ description: 'Network timeout or unrecoverable error',
14
+ });
15
+ /** Business error code carried by the exception*/
16
+ export const CodeException = defineException({
17
+ name: 'CodeException',
18
+ from: '@pylonts/core',
19
+ description: 'Business error code',
20
+ });
21
+ /** Business rule violation (maps to 422) */
22
+ export const BusinessException = defineException({
23
+ name: 'BusinessException',
24
+ from: '@pylonts/core',
25
+ description: 'Business rule violation',
26
+ });
27
+ /** Anything else — unexpected, carries no business semantics. Rendered as a
28
+ * plain Error / system exception, no dedicated runtime class. */
29
+ export const UnexpectedException = defineException({
30
+ name: 'UnexpectedException',
31
+ from: '',
32
+ description: 'Unexpected error',
33
+ });