@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/src/dao.ts CHANGED
@@ -1,11 +1,18 @@
1
- import { SchemaBase, Field, Operator } from './dsl.js';
2
- import { FrontAppSchema } from './project.js';
1
+ import { SchemaBase, Field } from './dsl.js';
2
+ import type { FrontAppSchema, ProjectApiSchema } from './project.js';
3
3
  import type { DtoMessage } from './dto.js';
4
4
  import { TableSchema } from './db.js';
5
+ import type { EntitySchema } from './entity.js';
6
+ import type { FilterSchema } from './filter.js';
7
+ import type { SetExpr, ValueExpr } from './expr.js';
5
8
 
6
9
  /** A data-access layer bound to exactly one frontend app. */
7
10
  export interface DaoSchema extends SchemaBase {
8
11
  type: 'dao';
12
+ /** The backend api module this DAO belongs to (shared instance from
13
+ * project.config.ts apis). DAOs are always backend-side, so storage is
14
+ * dao_schema/{api.name}/{app.name}/dao/. */
15
+ api: ProjectApiSchema;
9
16
  /** The frontend app this DAO belongs to (shared instance from project.config). */
10
17
  app: FrontAppSchema;
11
18
  /** The table this DAO operates on (single-table atomicity). */
@@ -21,25 +28,319 @@ export type DaoMethodDef =
21
28
  | Omit<InsertSchema, 'schema' | 'name'>
22
29
  | Omit<UpdateSchema, 'schema' | 'name'>
23
30
  | Omit<DeleteSchema, 'schema' | 'name'>
31
+ | Omit<UpsertSchema, 'schema' | 'name'>
24
32
  | Omit<AggregateSchema, 'schema' | 'name'>;
25
33
 
34
+ /** The tenant column of the dao table when the app declares a tenant.
35
+ * Deterministic name derivation: `{tenant.phrase}_{tenant.pk}` (e.g. shop
36
+ * with pk id → `shop_id`) — checked directly against the table columns,
37
+ * no FK traversal. Tables without that column are global tables (valid:
38
+ * system config tables carry no tenant id). Exported for generator/linter. */
39
+ export function tenantFkOf(dao: DaoSchema): Field | undefined {
40
+ const tenant = dao.app.tenant;
41
+ if (!tenant) return undefined;
42
+ const pk = tenant.primaryKey;
43
+ const phrase = tenant.phrase;
44
+ if (!pk || Array.isArray(pk) || !phrase) {
45
+ 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})`);
46
+ }
47
+ return dao.table.columns[`${phrase.name}_${pk.name}`];
48
+ }
49
+
50
+ /** The optimistic-lock version column of the dao table (table-level
51
+ * declaration), machine-enforced: update args must carry it, set must not
52
+ * touch it. */
53
+ function versionOf(dao: DaoSchema): Field | undefined {
54
+ return dao.table.version;
55
+ }
56
+
57
+ /** Validates a ValueExpr: column refs belong to the dao table, param names
58
+ * are unique, literal/bin types are numeric-compatible. */
59
+ function validateValueExpr(dao: DaoSchema, expr: ValueExpr, params: Set<string>, methodName: string): void {
60
+ const table = dao.table;
61
+ const cols = Object.values(table.columns);
62
+ switch (expr.kind) {
63
+ case 'col':
64
+ if (!cols.includes(expr.field)) {
65
+ throw new Error(`dao ${dao.name}.${methodName}: expr references column '${expr.field.name}' which is not a column of table ${table.name}`);
66
+ }
67
+ return;
68
+ case 'lit':
69
+ if (typeof expr.value === 'string') {
70
+ throw new Error(`dao ${dao.name}.${methodName}: string literal in a numeric expression — use a param instead`);
71
+ }
72
+ return;
73
+ case 'param':
74
+ if (params.has(expr.name)) {
75
+ throw new Error(`dao ${dao.name}.${methodName}: duplicate param '${expr.name}' in set expressions`);
76
+ }
77
+ params.add(expr.name);
78
+ return;
79
+ case 'bin':
80
+ validateValueExpr(dao, expr.left, params, methodName);
81
+ validateValueExpr(dao, expr.right, params, methodName);
82
+ return;
83
+ }
84
+ }
85
+
86
+ function validateUpdateSet(dao: DaoSchema, methodName: string, m: UpdateSchema): void {
87
+ const argCols = new Set(m.args.columns.map((c) => c.name));
88
+ const version = versionOf(dao);
89
+ for (const setExpr of m.set ?? []) {
90
+ if (version && setExpr.col === version) {
91
+ throw new Error(`dao ${dao.name}.${methodName}: set must not touch version column '${version.name}' — the optimistic lock manages it`);
92
+ }
93
+ if (!Object.values(dao.table.columns).includes(setExpr.col)) {
94
+ throw new Error(`dao ${dao.name}.${methodName}: set column '${setExpr.col.name}' is not a column of table ${dao.table.name}`);
95
+ }
96
+ if (argCols.has(setExpr.col.name)) {
97
+ 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`);
98
+ }
99
+ validateValueExpr(dao, setExpr.expr, new Set(), methodName);
100
+ }
101
+ }
102
+
103
+ /** Validates read-method result columns: external reference columns must be
104
+ * reachable through a main-table foreign key (FK = join condition). */
105
+ function assertColumnReachable(dao: DaoSchema, methodName: string, c: Field): void {
106
+ const table = dao.table;
107
+ if (!c.schema) {
108
+ throw new Error(`dao ${dao.name}.${methodName}: row column '${c.name}' has no schema`);
109
+ }
110
+ if (c.schema === table) return;
111
+ const srcTable = c.schema as TableSchema;
112
+ const reachable = Object.values(table.foreignKeys ?? {}).some((fk) => {
113
+ const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
114
+ return refs.some((r) => r.schema === srcTable);
115
+ });
116
+ if (!reachable) {
117
+ 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`);
118
+ }
119
+ }
120
+
121
+ /** Validates read-method result columns: external reference columns must be
122
+ * reachable through a main-table foreign key (FK = join condition). */
123
+ function validateRowColumns(dao: DaoSchema, methodName: string, row: EntitySchema): void {
124
+ for (const c of row.columns) assertColumnReachable(dao, methodName, c);
125
+ }
126
+
127
+ /** Validates key args (get/delete): every key field must be a main-table
128
+ * column; the tenant column is injected as a separate parameter and can
129
+ * never be a key (a duplicate parameter would render). */
130
+ function validateKeyArgs(dao: DaoSchema, methodName: string, args: Field | Field[]): void {
131
+ const cols = Object.values(dao.table.columns);
132
+ const keys = Array.isArray(args) ? args : [args];
133
+ for (const k of keys) {
134
+ if (!cols.includes(k)) {
135
+ throw new Error(`dao ${dao.name}.${methodName}: key column '${k.name}' is not a column of table ${dao.table.name}`);
136
+ }
137
+ }
138
+ const tenant = tenantFkOf(dao);
139
+ if (tenant && keys.includes(tenant)) {
140
+ throw new Error(`dao ${dao.name}.${methodName}: key '${tenant.name}' is the tenant column — it is injected as a separate parameter, never a key`);
141
+ }
142
+ }
143
+
144
+ /** Update runs without JOINs, so its where criteria must all reference
145
+ * main-table columns — a cross-table criterion would render a raw column
146
+ * name that no table in the query provides. Also requires at least one
147
+ * locating criterion: without a pk/tenant/version/filter the generated
148
+ * UPDATE would have an empty WHERE and touch every row. */
149
+ function validateUpdateWhere(dao: DaoSchema, methodName: string, m: UpdateSchema): void {
150
+ if (m.where) {
151
+ for (const c of m.where.conditions) {
152
+ if (c.field.schema !== dao.table) {
153
+ const srcName = (c.field.schema as TableSchema | undefined)?.name ?? 'unknown';
154
+ throw new Error(`dao ${dao.name}.${methodName}: where criterion on '${c.field.name}' belongs to table '${srcName}' — update is single-table and cannot join`);
155
+ }
156
+ if (c.optional) {
157
+ 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)`);
158
+ }
159
+ if (!m.args.columns.includes(c.field)) {
160
+ 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`);
161
+ }
162
+ }
163
+ }
164
+ const pk = dao.table.primaryKey;
165
+ const hasPk = pk !== undefined && (Array.isArray(pk) ? pk.length > 0 : true);
166
+ const hasLocator = hasPk || tenantFkOf(dao) !== undefined || versionOf(dao) !== undefined || (m.where !== undefined && m.where.conditions.length > 0);
167
+ if (!hasLocator) {
168
+ 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)`);
169
+ }
170
+ }
171
+
172
+ /** Order-by columns must be part of the result row: the generated ORDER BY
173
+ * uses the row's result keys, which only exist for selected columns. */
174
+ function validateOrderBy(dao: DaoSchema, methodName: string, m: FindSchema): void {
175
+ if (!m.orderBy) return;
176
+ const orders = Array.isArray(m.orderBy) ? m.orderBy : [m.orderBy];
177
+ for (const o of orders) {
178
+ if (!m.results.columns.includes(o.column)) {
179
+ throw new Error(`dao ${dao.name}.${methodName}: orderBy column '${o.column.name}' is not part of results '${m.results.name}'`);
180
+ }
181
+ }
182
+ }
183
+
184
+ /** Aggregate results: the result entity must carry at least one column; every
185
+ * plain column (grouping dimension) and every aggregate field's underlying
186
+ * column must be reachable (same rule as read results). */
187
+ function validateAggregateResults(dao: DaoSchema, methodName: string, m: AggregateSchema): void {
188
+ const columns = m.results.columns;
189
+ if (columns.length === 0) {
190
+ throw new Error(`dao ${dao.name}.${methodName}: aggregate has no results`);
191
+ }
192
+ for (const c of columns) {
193
+ if (c.type === 'aggregate') {
194
+ if (c.expr.field) assertColumnReachable(dao, methodName, c.expr.field);
195
+ } else {
196
+ assertColumnReachable(dao, methodName, c);
197
+ }
198
+ }
199
+ }
200
+
201
+ /** Upsert conflict keys: must equal the pk or a complete unique index column
202
+ * set, and every key must be carried by args. Auto-increment tables are
203
+ * forbidden (MySQL auto_increment burns ids on duplicate-key updates). */
204
+ function validateUpsert(dao: DaoSchema, methodName: string, m: UpsertSchema): void {
205
+ const table = dao.table;
206
+ if (table.autoIncrement) {
207
+ 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)`);
208
+ }
209
+ const keys = Array.isArray(m.keys) ? m.keys : [m.keys];
210
+ if (keys.length === 0) {
211
+ throw new Error(`dao ${dao.name}.${methodName}: upsert keys must be non-empty`);
212
+ }
213
+ for (const k of keys) {
214
+ if (k.schema !== table) {
215
+ throw new Error(`dao ${dao.name}.${methodName}: upsert key '${k.name}' is not a column of table ${table.name}`);
216
+ }
217
+ }
218
+ const keyNames = keys.map((k) => k.name).sort();
219
+ const candidates: Array<string[]> = [];
220
+ const uniqueCols = new Set<string>();
221
+ const pk = table.primaryKey;
222
+ if (pk) {
223
+ const cs = (Array.isArray(pk) ? pk : [pk]).map((c) => c.name);
224
+ candidates.push(cs.sort());
225
+ for (const n of cs) uniqueCols.add(n);
226
+ }
227
+ for (const idx of table.indexes ?? []) {
228
+ if (idx.unique) {
229
+ const cs = (Array.isArray(idx.columns) ? idx.columns : [idx.columns]).map((c) => c.name);
230
+ candidates.push(cs.sort());
231
+ for (const n of cs) uniqueCols.add(n);
232
+ }
233
+ }
234
+ const matches = candidates.some((c) => c.length === keyNames.length && c.every((n, i) => n === keyNames[i]));
235
+ if (!matches) {
236
+ 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}`);
237
+ }
238
+ for (const k of keys) {
239
+ if (!m.args.columns.includes(k)) {
240
+ throw new Error(`dao ${dao.name}.${methodName}: upsert args '${m.args.name}' must carry conflict key '${k.name}'`);
241
+ }
242
+ }
243
+ // ON DUPLICATE KEY UPDATE has no WHERE — tenant isolation is only possible
244
+ // when the tenant column participates in the conflict key.
245
+ const tenant = tenantFkOf(dao);
246
+ if (tenant && !keys.includes(tenant)) {
247
+ 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)`);
248
+ }
249
+ // The merge clause (col = new.col) only writes non-key writable columns —
250
+ // conflict keys are the conflict identity, readOnly columns stay
251
+ // DB-managed, and every pk/unique column is excluded: a merge writing a
252
+ // unique column could collide with another row's value and chain-fire the
253
+ // duplicate-key handler (unique columns are conflict identity, not data).
254
+ const mergeCols = m.args.columns.filter((c) => !keys.includes(c) && !c.readOnly && !uniqueCols.has(c.name));
255
+ if (mergeCols.length === 0) {
256
+ 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)`);
257
+ }
258
+ }
259
+
260
+ /** Validates write-method args: every column must come from the dao table
261
+ * (single-table atomicity). */
262
+ function validateWriteArgs(dao: DaoSchema, methodName: string, args: EntitySchema): void {
263
+ const cols = Object.values(dao.table.columns);
264
+ for (const c of args.columns) {
265
+ if (!cols.includes(c)) {
266
+ throw new Error(`dao ${dao.name}.${methodName}: args column '${c.name}' of '${args.name}' is not a column of table ${dao.table.name}`);
267
+ }
268
+ }
269
+ }
270
+
271
+ /** Enforces the tenant/version column presence on row-carried methods
272
+ * (decisions: insert = declared in args; update = extracted from row).
273
+ * Tenant is required on both writes (the column is always scoped); version
274
+ * is required only on update — inserts let the DB default initialize it. */
275
+ function validateRowCarriedColumns(dao: DaoSchema, methodName: string, method: InsertSchema | UpdateSchema | UpsertSchema): void {
276
+ const tenant = tenantFkOf(dao);
277
+ if (tenant) {
278
+ if (!method.args.columns.includes(tenant)) {
279
+ throw new Error(`dao ${dao.name}.${methodName}: args '${method.args.name}' must include tenant column '${tenant.name}'`);
280
+ }
281
+ }
282
+ if (method.type === 'update') {
283
+ const version = versionOf(dao);
284
+ if (version && !method.args.columns.includes(version)) {
285
+ 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`);
286
+ }
287
+ }
288
+ }
289
+
26
290
  export function defineDao(options: {
27
291
  name: string;
292
+ api: ProjectApiSchema;
28
293
  app: FrontAppSchema;
29
294
  table: TableSchema;
30
295
  methods: Record<string, DaoMethodDef>;
31
296
  description?: string;
32
297
  }): DaoSchema {
298
+ if (!options.api.apps.includes(options.app)) {
299
+ throw new Error(`dao ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
300
+ }
33
301
  const schema: DaoSchema = {
34
302
  type: 'dao',
35
303
  name: options.name,
36
304
  description: options.description,
305
+ api: options.api,
37
306
  app: options.app,
38
307
  table: options.table,
39
308
  methods: {},
40
309
  };
41
310
  for (const key of Object.keys(options.methods)) {
42
311
  const method = options.methods[key] as DaoMethodDef;
312
+ for (const ref of [method.args, 'where' in method ? method.where : undefined]) {
313
+ if (isFilterSchema(ref) && (ref.api !== options.api || ref.app !== options.app)) {
314
+ throw new Error(
315
+ `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}`,
316
+ );
317
+ }
318
+ }
319
+ // Method-kind validation (declaration is complete — every kind is checked).
320
+ if (method.type === 'insert' || method.type === 'update' || method.type === 'upsert') {
321
+ const m = method as InsertSchema | UpdateSchema | UpsertSchema;
322
+ validateWriteArgs(schema, key, m.args as EntitySchema);
323
+ validateRowCarriedColumns(schema, key, m);
324
+ if (method.type === 'update') {
325
+ validateUpdateSet(schema, key, method as UpdateSchema);
326
+ validateUpdateWhere(schema, key, method as UpdateSchema);
327
+ }
328
+ if (method.type === 'upsert') {
329
+ validateUpsert(schema, key, method as UpsertSchema);
330
+ }
331
+ }
332
+ if (method.type === 'get' || method.type === 'delete') {
333
+ validateKeyArgs(schema, key, (method as GetSchema | DeleteSchema).args);
334
+ }
335
+ if (method.type === 'find' || method.type === 'get') {
336
+ validateRowColumns(schema, key, (method as FindSchema | GetSchema).results);
337
+ }
338
+ if (method.type === 'find') {
339
+ validateOrderBy(schema, key, method as FindSchema);
340
+ }
341
+ if (method.type === 'aggregate') {
342
+ validateAggregateResults(schema, key, method as AggregateSchema);
343
+ }
43
344
  // Spread of a union loses discriminant correlation; the cast is safe
44
345
  // (the builder only adds name and the back-reference field).
45
346
  schema.methods[key] = { ...method, name: key, schema } as DaoMethodSchema;
@@ -47,23 +348,16 @@ export function defineDao(options: {
47
348
  return schema;
48
349
  }
49
350
 
50
- // DAO method kinds. Signature only: name + params + result. No logic —
51
- // complex SQL goes into description (text), never into schema.
52
-
53
- /** One AND-combined criterion: a column plus its comparison operator. */
54
- export interface QueryField {
55
- /** Column to compare. */
56
- field: Field;
57
- /** Comparison operator; defaults to 'eq'. */
58
- op?: Operator;
351
+ function isFilterSchema(value: unknown): value is FilterSchema {
352
+ if (typeof value !== 'object' || value === null) return false;
353
+ const v = value as Record<string, unknown>;
354
+ return v.type === 'filter' && typeof v.name === 'string';
59
355
  }
60
356
 
61
- /** Query criteria for find methods. */
62
- export interface QuerySchema extends SchemaBase {
63
- type: 'query';
64
- /** Criteria; all fields are AND-combined. */
65
- fields: QueryField[];
66
- }
357
+ // DAO method kinds. Signature only: name + params + result. No logic —
358
+ // complex SQL goes into description (text), never into schema.
359
+ // Query conditions are FilterSchema references (the single shared filter
360
+ // model) never inline criteria.
67
361
 
68
362
  /** Sort specification for find results. */
69
363
  export interface OrderBySchema {
@@ -77,28 +371,33 @@ export interface OrderBySchema {
77
371
  export interface FindSchema extends SchemaBase {
78
372
  type: 'find';
79
373
  schema: DaoSchema;
80
- /** Query criteria; omit = all rows. */
81
- args?: QuerySchema;
374
+ /** Query filter; omit = all rows. */
375
+ args?: FilterSchema;
82
376
  /** Pagination marker: 'page' (page/pageSize) or 'limit' (position/limit).
83
377
  * Only presence matters — parameter shapes are a generator convention. */
84
378
  mode?: 'page' | 'limit';
85
379
  /** Result sort; omit = no explicit order. */
86
380
  orderBy?: OrderBySchema | OrderBySchema[];
87
- /** Row type of the result list. */
381
+ /** Result row: selected columns (multi-table via external reference columns). */
88
382
  results: EntitySchema;
89
383
  }
90
384
 
91
- /** Fetch a single row by primary key. Composite PK is not supported. */
385
+ /** Fetch a single row by exact key any column or AND combination of
386
+ * columns (single PK, composite PK, getByXX are all legal declarations).
387
+ * May miss the row: the generated signature returns Row | null. */
92
388
  export interface GetSchema extends SchemaBase {
93
389
  type: 'get';
94
390
  schema: DaoSchema;
95
- /** Single PK scalar value. */
96
- args: Field;
97
- /** Row type. Get may miss the row: the generated signature returns Entity | null. */
391
+ /** Key column(s): one field or AND-exact-matched fields. */
392
+ args: Field | Field[];
393
+ /** Additional criteria AND-combined onto the key (e.g. state condition
394
+ * for optimistic reads). */
395
+ where?: FilterSchema;
396
+ /** Result row: selected columns (multi-table via external reference columns). */
98
397
  results: EntitySchema;
99
398
  }
100
399
 
101
- /** Insert one row; returns number (insert id). */
400
+ /** Insert one row; returns the generated key (string). */
102
401
  export interface InsertSchema extends SchemaBase {
103
402
  type: 'insert';
104
403
  schema: DaoSchema;
@@ -107,23 +406,40 @@ export interface InsertSchema extends SchemaBase {
107
406
  }
108
407
 
109
408
  /** Update one row; returns number (affected rows). The where key is derived
110
- * from the PK columns inside args. */
409
+ * from the PK columns inside args; tenant/version columns are extracted
410
+ * from the row into WHERE and never SET (optimistic lock auto-manages
411
+ * `version = version + 1`). */
111
412
  export interface UpdateSchema extends SchemaBase {
112
413
  type: 'update';
113
414
  schema: DaoSchema;
114
415
  /** Row object containing the PK columns plus the columns to set. */
115
416
  args: EntitySchema;
417
+ /** Expression-set columns (`col = expr`) beyond direct assignment. */
418
+ set?: SetExpr[];
116
419
  /** Additional criteria beyond the derived PK (AND-combined). */
117
- where?: QuerySchema;
420
+ where?: FilterSchema;
118
421
  }
119
422
 
120
- /** Delete one row by primary key; returns number (affected rows).
121
- * Composite PK is not supported. */
423
+ /** Delete one row by exact key; returns number (affected rows). */
122
424
  export interface DeleteSchema extends SchemaBase {
123
425
  type: 'delete';
124
426
  schema: DaoSchema;
125
- /** Single PK scalar value. */
126
- args: Field;
427
+ /** Key column(s): one field or AND-exact-matched fields. */
428
+ args: Field | Field[];
429
+ }
430
+
431
+ /** Insert-or-update (MySQL ON DUPLICATE KEY UPDATE): atomic idempotent write.
432
+ * Returns affected rows (1 = inserted, 2 = updated, 0 = unchanged).
433
+ * No id generation — every conflict key must be carried in args. Tables with
434
+ * an auto-increment pk are forbidden (MySQL auto_increment burns ids on
435
+ * duplicate-key updates). */
436
+ export interface UpsertSchema extends SchemaBase {
437
+ type: 'upsert';
438
+ schema: DaoSchema;
439
+ /** Row object: must carry every conflict key plus the columns to merge. */
440
+ args: EntitySchema;
441
+ /** Conflict keys: must equal the pk or a complete unique index column set. */
442
+ keys: Field | Field[];
127
443
  }
128
444
 
129
445
  export type DaoMethodSchema =
@@ -132,41 +448,39 @@ export type DaoMethodSchema =
132
448
  | InsertSchema
133
449
  | UpdateSchema
134
450
  | DeleteSchema
451
+ | UpsertSchema
135
452
  | AggregateSchema;
136
453
 
137
- /** Aggregate expression result for aggregate queries. */
138
- export interface ComputeExpr {
139
- fn: 'sum' | 'avg' | 'count';
140
- /** Column the function applies to; absent for count(*). */
141
- field?: Field;
142
- }
143
-
144
- /** Aggregate expressions: Compute.sum(col) / Compute.avg(col) / Compute.count(). */
145
- export const Compute = {
146
- sum(field: Field): ComputeExpr {
147
- return { fn: 'sum', field };
148
- },
149
- avg(field: Field): ComputeExpr {
150
- return { fn: 'avg', field };
151
- },
152
- count(): ComputeExpr {
153
- return { fn: 'count' };
154
- },
155
- };
156
-
157
- /** Aggregate query (count/sum/avg): returns computed scalar values. */
454
+ /** Aggregate query (find upgraded at the select level): the result entity
455
+ * mixes plain columns (the GROUP BY dimensions) and aggregate fields
456
+ * (aggField — count/sum/avg).
457
+ *
458
+ * Usage — aggregate fields are defined in the entity file, the dao method
459
+ * only references the entity:
460
+ *
461
+ * ```ts
462
+ * // entity_schema/{api}/{app}/entity/order.entity.ts
463
+ * export const orderStatusStats = defineEntity({
464
+ * name: 'OrderStatusStats',
465
+ * api, app,
466
+ * columns: [
467
+ * order.columns.status, // GROUP BY dimension
468
+ * aggField('total', Compute.count()), // count → jsType 'number'
469
+ * aggField('sumAmt', Compute.sum(order.columns.amount)), // sum(decimal) → 'string'
470
+ * ],
471
+ * });
472
+ *
473
+ * // dao_schema/{api}/{app}/dao/order.dao.ts
474
+ * stats: { type: 'aggregate', args: orderFilter, results: orderStatusStats },
475
+ * ``` */
158
476
  export interface AggregateSchema extends SchemaBase {
159
477
  type: 'aggregate';
160
478
  schema: DaoSchema;
161
- /** Criteria, same shape as find. */
162
- args?: QuerySchema;
163
- /** Computed results: key = result field name. */
164
- results: Record<string, ComputeExpr>;
165
- }
166
-
167
- /** A database entity backed by a table. */
168
- export interface EntitySchema extends SchemaBase {
169
- type: 'entity';
170
- /** The table columns of this entity. */
171
- columns: Field[];
479
+ /** Criteria filter, same shape as find. */
480
+ args?: FilterSchema;
481
+ /** Result entity: plain columns group the rows (one row per group);
482
+ * aggregate fields become the computed output columns. The entity — with
483
+ * its aggField definitions — lives in the entity file ({table}.entity.ts);
484
+ * the dao method only references it, never defines aggregate fields inline. */
485
+ results: EntitySchema;
172
486
  }
package/src/db.ts CHANGED
@@ -27,6 +27,9 @@ export interface TableSchemaOptions<
27
27
  autoIncrement?: Field;
28
28
  /** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */
29
29
  label?: Field;
30
+ /** Optimistic lock version column: updates auto-manage `version = version + 1`
31
+ * and `WHERE version = ?` (value taken from the row). Must be an integer column. */
32
+ version?: Field;
30
33
  primaryKey?: Field | Field[];
31
34
  indexes?: Index[];
32
35
  foreignKeys?: Record<string, ForeignKey>;
@@ -59,6 +62,9 @@ export class TableSchema<
59
62
  foreignKeys?: Record<string, ForeignKey>;
60
63
  /** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */
61
64
  label?: Field;
65
+ /** Optimistic lock version column: updates auto-manage `version = version + 1`
66
+ * and `WHERE version = ?` (value taken from the row). Must be an integer column. */
67
+ version?: Field;
62
68
  /** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */
63
69
  phrase?: EntityPhrase;
64
70
  /** 本表引用的所有枚举定义(map,key 为枚举标识),显式声明供 gen-enums 收集 */
@@ -82,6 +88,7 @@ export class TableSchema<
82
88
  this.indexes = options.indexes;
83
89
  this.foreignKeys = options.foreignKeys;
84
90
  this.label = options.label;
91
+ this.version = options.version;
85
92
  this.phrase = options.phrase;
86
93
  this.enums = options.enums;
87
94
  this.columns = options.columns;
@@ -111,6 +118,12 @@ export function defineTable<
111
118
  if (table.label && !Object.values(table.columns).includes(table.label)) {
112
119
  throw new Error(`table '${name}': label field '${table.label.name}' must be one of the table's columns`);
113
120
  }
121
+ if (table.version && !Object.values(table.columns).includes(table.version)) {
122
+ throw new Error(`table '${name}': version field '${table.version.name}' must be one of the table's columns`);
123
+ }
124
+ if (table.version && table.version.jsType !== 'number') {
125
+ throw new Error(`table '${name}': version field '${table.version.name}' must be an integer column`);
126
+ }
114
127
  for (const key of Object.keys(table.columns)) {
115
128
  const field = table.columns[key] as Field;
116
129
  if (field.schema && field.schema !== table) {
@@ -0,0 +1,74 @@
1
+ import { SchemaBase } from './dsl.js';
2
+ import type { DtoField } from './dto.js';
3
+ import type { FlowSchema } from './flow.js';
4
+
5
+ /**
6
+ * Domain event declaration: a fact that has happened ("OrderCancelled"),
7
+ * carrying a data snapshot (not references). Declared as a first-class schema
8
+ * so the flow `publish` action can be compile-time checked against it (event
9
+ * name exists, payload fields match) and the outbox table + handler wiring can
10
+ * be generated.
11
+ *
12
+ * Command vs event: a command says "do something" (future tense, one receiver,
13
+ * caller senses failure); an event says "something happened" (past tense, zero
14
+ * to many subscribers, publisher does not care who handles it).
15
+ */
16
+ export interface DomainEventSchema extends SchemaBase {
17
+ type: 'domain-event';
18
+ /** Payload fields: data snapshot, not references. */
19
+ fields: Record<string, DtoField>;
20
+ }
21
+
22
+ export function defineDomainEvent(options: {
23
+ name: string;
24
+ fields: Record<string, DtoField>;
25
+ description?: string;
26
+ }): DomainEventSchema {
27
+ if (Object.keys(options.fields).length === 0) {
28
+ throw new Error(`domain event '${options.name}': fields must not be empty`);
29
+ }
30
+ return {
31
+ type: 'domain-event',
32
+ name: options.name,
33
+ description: options.description,
34
+ fields: options.fields,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Event subscription: declares that a handler processes a domain event.
40
+ * The processing logic is a flow (the flow model is the execution model — a
41
+ * handler flow receives the event payload as its input slot). A plain async
42
+ * function is accepted as the runtime path until a flow executor exists; the
43
+ * declared flow is then compile-time checked (payload fields match the event)
44
+ * and drives generation.
45
+ */
46
+ export interface EventHandlerSchema extends SchemaBase {
47
+ type: 'event-handler';
48
+ /** Subscribed event name (must match a defineDomainEvent name). */
49
+ event: string;
50
+ /** Processing flow — receives the event payload as its input slot. */
51
+ flow?: FlowSchema;
52
+ /** Runtime handler function (used directly when no flow executor exists). */
53
+ handler?: (payload: Record<string, unknown>) => Promise<void> | void;
54
+ }
55
+
56
+ export function defineEventHandler(options: {
57
+ name: string;
58
+ event: string;
59
+ flow?: FlowSchema;
60
+ handler?: (payload: Record<string, unknown>) => Promise<void> | void;
61
+ description?: string;
62
+ }): EventHandlerSchema {
63
+ if (options.flow === undefined && options.handler === undefined) {
64
+ throw new Error(`event handler '${options.name}': at least one of flow or handler is required`);
65
+ }
66
+ return {
67
+ type: 'event-handler',
68
+ name: options.name,
69
+ description: options.description,
70
+ event: options.event,
71
+ flow: options.flow,
72
+ handler: options.handler,
73
+ };
74
+ }