@pylonts/dsl 1.1.11 → 1.1.13

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 (50) hide show
  1. package/dist/convert.d.ts +6 -8
  2. package/dist/curd.js +1 -1
  3. package/dist/dao.d.ts +10 -7
  4. package/dist/dao.js +20 -7
  5. package/dist/dsl.d.ts +18 -1
  6. package/dist/dsl.js +40 -0
  7. package/dist/dto.d.ts +13 -8
  8. package/dist/dto.js +68 -13
  9. package/dist/entity.d.ts +4 -3
  10. package/dist/entity.js +1 -1
  11. package/dist/filter.d.ts +6 -4
  12. package/dist/filter.js +1 -1
  13. package/dist/flow-script.js +8 -2
  14. package/dist/flow.d.ts +10 -2
  15. package/dist/flow.js +44 -4
  16. package/dist/mermaid-driver.js +2 -2
  17. package/dist/project.d.ts +5 -2
  18. package/dist/project.js +21 -2
  19. package/dist/service.d.ts +13 -8
  20. package/dist/service.js +1 -1
  21. package/dist/third-service.d.ts +10 -53
  22. package/dist/third-service.js +3 -78
  23. package/dist/typebox-driver.d.ts +0 -6
  24. package/dist/typebox-driver.js +8 -36
  25. package/dist/utils.d.ts +2 -2
  26. package/docs/curd.md +55 -20
  27. package/docs/dao-generation.md +477 -477
  28. package/docs/project.md +32 -24
  29. package/docs/token.md +326 -326
  30. package/package.json +1 -1
  31. package/src/action.ts +51 -51
  32. package/src/controller.ts +53 -53
  33. package/src/convert.ts +76 -78
  34. package/src/curd.ts +104 -104
  35. package/src/dao.ts +504 -485
  36. package/src/dsl.ts +296 -257
  37. package/src/dto.ts +323 -266
  38. package/src/entity.ts +43 -42
  39. package/src/expr.ts +64 -64
  40. package/src/filter.ts +71 -69
  41. package/src/flow-script.ts +702 -695
  42. package/src/flow.ts +1272 -1226
  43. package/src/index.ts +46 -46
  44. package/src/mermaid-driver.ts +339 -339
  45. package/src/mysql-driver.ts +108 -108
  46. package/src/project.ts +138 -114
  47. package/src/service.ts +112 -107
  48. package/src/third-service.ts +68 -191
  49. package/src/typebox-driver.ts +234 -268
  50. package/src/utils.ts +74 -74
package/dist/convert.d.ts CHANGED
@@ -1,14 +1,12 @@
1
- import type { SchemaBase } from './dsl.js';
1
+ import type { CollectionSchemaBase, SchemaBase } from './dsl.js';
2
2
  import type { DtoMessage } from './dto.js';
3
3
  import type { TableSchema } from './db.js';
4
4
  import type { EntitySchema } from './entity.js';
5
- import type { ThirdMethodSchema } from './third-service.js';
6
5
  import type { FrontAppSchema, ProjectApiSchema } from './project.js';
7
- /** A source/target collection of a convert — dto, entity, table or
8
- * third-party message. Entity sources may carry aggregate fields
9
- * (aggField), e.g. an aggregate result entity projected into a wire
10
- * message. */
11
- export type ConvertSourceSchema = DtoMessage | TableSchema | ThirdMethodSchema | EntitySchema;
6
+ /** A source/target collection of a convert — dto, entity or table. Entity
7
+ * sources may carry aggregate fields (aggField), e.g. an aggregate result
8
+ * entity projected into a wire message. */
9
+ export type ConvertSourceSchema = DtoMessage | TableSchema | EntitySchema;
12
10
  /** Method input for defineConvert: type/schema/name are set by the builder. */
13
11
  export type ConvertMethodDef = Omit<ConvertMethodSchema, 'type' | 'schema' | 'name'>;
14
12
  /** One conversion: multiple source collections → single target collection. */
@@ -22,7 +20,7 @@ export interface ConvertMethodSchema extends SchemaBase {
22
20
  target: ConvertSourceSchema;
23
21
  }
24
22
  /** Declares multi-source → single-target schema integrations grouped by source identity. */
25
- export interface ConvertSchema extends SchemaBase {
23
+ export interface ConvertSchema extends CollectionSchemaBase {
26
24
  type: 'convert';
27
25
  /** The backend api module this convert belongs to (shared instance from
28
26
  * project.config.ts apis). Storage is convert_schema/{api.name}/{app.name}/
package/dist/curd.js CHANGED
@@ -33,7 +33,7 @@ export function defineCurd(name, schema) {
33
33
  assertColumns(curd, `actionPages.${pageName}`, page.columns);
34
34
  }
35
35
  if (curd.list.filter !== undefined && curd.list.filter.app !== curd.app) {
36
- throw new Error(`curd ${name}: list.filter '${curd.list.filter.name}' is bound to app '${curd.list.filter.app.name}' but the curd belongs to app '${curd.app.name}'`);
36
+ throw new Error(`curd ${name}: list.filter '${curd.list.filter.name}' is bound to ${curd.list.filter.app?.name ?? 'common'} but the curd belongs to app '${curd.app.name}'`);
37
37
  }
38
38
  assertFieldsOwnTable(curd, 'orderBy', [curd.list.orderBy.column]);
39
39
  return curd;
package/dist/dao.d.ts CHANGED
@@ -1,18 +1,21 @@
1
- import { SchemaBase, Field } from './dsl.js';
1
+ import { CollectionSchemaBase, SchemaBase, Field } from './dsl.js';
2
2
  import type { FrontAppSchema, ProjectApiSchema } from './project.js';
3
3
  import { TableSchema } from './db.js';
4
4
  import type { EntitySchema } from './entity.js';
5
5
  import type { FilterSchema } from './filter.js';
6
6
  import type { SetExpr } from './expr.js';
7
- /** A data-access layer bound to exactly one frontend app. */
8
- export interface DaoSchema extends SchemaBase {
7
+ /** A data-access layer bound to one frontend app, or to the api-level common
8
+ * domain layer (app unset — shared across modules). */
9
+ export interface DaoSchema extends CollectionSchemaBase {
9
10
  type: 'dao';
10
11
  /** The backend api module this DAO belongs to (shared instance from
11
12
  * project.config.ts apis). DAOs are always backend-side, so storage is
12
- * dao_schema/{api.name}/{app.name}/dao/. */
13
+ * dao_schema/{api.name}/{app.name}/dao/ — app unset = the api-level common
14
+ * domain layer, stored at dao_schema/{api.name}/common/dao/. */
13
15
  api: ProjectApiSchema;
14
- /** The frontend app this DAO belongs to (shared instance from project.config). */
15
- app: FrontAppSchema;
16
+ /** The frontend app this DAO belongs to (shared instance from project.config).
17
+ * Unset = api-level common domain DAO shared by all modules of the api. */
18
+ app?: FrontAppSchema;
16
19
  /** The table this DAO operates on (single-table atomicity). */
17
20
  table: TableSchema;
18
21
  /** Methods keyed by name — the map key is written back as the method name. */
@@ -29,7 +32,7 @@ export declare function tenantFkOf(dao: DaoSchema): Field | undefined;
29
32
  export declare function defineDao(options: {
30
33
  name: string;
31
34
  api: ProjectApiSchema;
32
- app: FrontAppSchema;
35
+ app?: FrontAppSchema;
33
36
  table: TableSchema;
34
37
  methods: Record<string, DaoMethodDef>;
35
38
  description?: string;
package/dist/dao.js CHANGED
@@ -1,16 +1,18 @@
1
+ import { isAggregate } from './dsl.js';
1
2
  /** The tenant column of the dao table when the app declares a tenant.
2
3
  * Deterministic name derivation: `{tenant.phrase}_{tenant.pk}` (e.g. shop
3
4
  * with pk id → `shop_id`) — checked directly against the table columns,
4
5
  * no FK traversal. Tables without that column are global tables (valid:
5
6
  * system config tables carry no tenant id). Exported for generator/linter. */
6
7
  export function tenantFkOf(dao) {
7
- const tenant = dao.app.tenant;
8
- if (!tenant)
8
+ const app = dao.app;
9
+ const tenant = app?.tenant;
10
+ if (!tenant || !app)
9
11
  return undefined;
10
12
  const pk = tenant.primaryKey;
11
13
  const phrase = tenant.phrase;
12
14
  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})`);
15
+ throw new Error(`app '${app.name}' tenant table '${tenant.name}' must declare a single-column primaryKey and a phrase (tenant column name = {phrase}_{pk})`);
14
16
  }
15
17
  return dao.table.columns[`${phrase.name}_${pk.name}`];
16
18
  }
@@ -51,6 +53,10 @@ function validateValueExpr(dao, expr, params, methodName) {
51
53
  function validateUpdateSet(dao, methodName, m) {
52
54
  const argCols = new Set(m.args.columns.map((c) => c.name));
53
55
  const version = versionOf(dao);
56
+ // Where criteria columns are locators carried by args, not direct assignments —
57
+ // expression-setting the same column is the conditional-update idiom
58
+ // (WHERE state = row.state, SET state = ?).
59
+ const whereCols = new Set((m.where?.conditions ?? []).map((c) => c.field.name));
54
60
  for (const setExpr of m.set ?? []) {
55
61
  if (version && setExpr.col === version) {
56
62
  throw new Error(`dao ${dao.name}.${methodName}: set must not touch version column '${version.name}' — the optimistic lock manages it`);
@@ -58,7 +64,7 @@ function validateUpdateSet(dao, methodName, m) {
58
64
  if (!Object.values(dao.table.columns).includes(setExpr.col)) {
59
65
  throw new Error(`dao ${dao.name}.${methodName}: set column '${setExpr.col.name}' is not a column of table ${dao.table.name}`);
60
66
  }
61
- if (argCols.has(setExpr.col.name)) {
67
+ if (argCols.has(setExpr.col.name) && !whereCols.has(setExpr.col.name)) {
62
68
  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
69
  }
64
70
  validateValueExpr(dao, setExpr.expr, new Set(), methodName);
@@ -152,7 +158,7 @@ function validateAggregateResults(dao, methodName, m) {
152
158
  throw new Error(`dao ${dao.name}.${methodName}: aggregate has no results`);
153
159
  }
154
160
  for (const c of columns) {
155
- if (c.type === 'aggregate') {
161
+ if (isAggregate(c)) {
156
162
  if (c.expr.field)
157
163
  assertColumnReachable(dao, methodName, c.expr.field);
158
164
  }
@@ -250,7 +256,7 @@ function validateRowCarriedColumns(dao, methodName, method) {
250
256
  }
251
257
  }
252
258
  export function defineDao(options) {
253
- if (!options.api.apps.includes(options.app)) {
259
+ if (options.app && !options.api.apps.includes(options.app)) {
254
260
  throw new Error(`dao ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
255
261
  }
256
262
  const schema = {
@@ -265,7 +271,14 @@ export function defineDao(options) {
265
271
  for (const key of Object.keys(options.methods)) {
266
272
  const method = options.methods[key];
267
273
  for (const ref of [method.args, 'where' in method ? method.where : undefined]) {
268
- if (isFilterSchema(ref) && (ref.api !== options.api || ref.app !== options.app)) {
274
+ if (isFilterSchema(ref) && ref.api !== options.api) {
275
+ throw new Error(`dao ${options.name}: method '${key}' references filter '${ref.name}' bound to ${ref.api.name}/${ref.app?.name ?? 'common'} but the dao is bound to ${options.api.name}/${options.app?.name ?? 'common'}`);
276
+ }
277
+ // app-bound dao may use common or same-app filters; common dao may only use common filters.
278
+ if (isFilterSchema(ref) && ref.api === options.api && !options.app && ref.app) {
279
+ throw new Error(`dao ${options.name}: method '${key}' references app-bound filter '${ref.name}' (${ref.app.name}) but the dao is api-level common`);
280
+ }
281
+ if (isFilterSchema(ref) && ref.api === options.api && options.app && ref.app && ref.app !== options.app) {
269
282
  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
283
  }
271
284
  }
package/dist/dsl.d.ts CHANGED
@@ -118,13 +118,30 @@ interface ObjectField extends BaseField {
118
118
  * so that aggregate result entities can carry it like any other column.
119
119
  * jsType follows the aggregate precision rule: count → number;
120
120
  * sum/avg over an integer column → number, anything else → string. */
121
- interface AggregateField extends BaseField {
121
+ export interface AggregateField extends BaseField {
122
122
  type: 'aggregate';
123
123
  jsType: 'number' | 'string';
124
124
  /** The aggregate expression producing this column. */
125
125
  expr: ComputeExpr;
126
126
  }
127
+ /** Whether the field is an aggregate result column — narrows to AggregateField. */
128
+ export declare function isAggregate(field: Field): field is AggregateField;
127
129
  export type Field = StringField | TextField | IntField | BigintField | DecimalField | RateField | BooleanField | DateField | TimeField | DateTimeField | EnumField | JsonField | ArrayField | ObjectField | AggregateField;
130
+ /** TS type of a field in generated code (row interfaces, dao params, filter
131
+ * args): enum → its JS name, date/datetime → string (transported as ISO
132
+ * strings), everything else → jsType. Aggregate fields carry their precision
133
+ * rule in jsType already. The single source for this mapping — renderers
134
+ * must not branch on field.type for a TS type string. */
135
+ export declare function fieldJsType(field: Field): string;
136
+ /** Enum JS names referenced by a field, recursing into array/object
137
+ * containers (wire-format nesting). First-occurrence order — callers that
138
+ * need uniqueness collect into a Set. */
139
+ export declare function collectEnumRefs(field: Field, out?: string[]): string[];
140
+ /** Depth-first walk over a field's container structure: visit is called for
141
+ * every field including containers (top-level, array items, object
142
+ * properties). `key` is the object property name the field sits under
143
+ * (undefined for the top-level field and for array items). */
144
+ export declare function walkContainer(field: Field, visit: (f: Field, key?: string) => void, key?: string): void;
128
145
  type FieldExtras<T extends Field> = Omit<T, 'name' | 'type' | 'jsType'>;
129
146
  export declare function stringField(extra?: FieldExtras<StringField>): StringField;
130
147
  export declare function textField(extra?: FieldExtras<TextField>): TextField;
package/dist/dsl.js CHANGED
@@ -10,6 +10,46 @@ export function rateScale(unit) {
10
10
  export function defineEnum(jsName, valueType, values) {
11
11
  return { jsName, valueType, values };
12
12
  }
13
+ /** Whether the field is an aggregate result column — narrows to AggregateField. */
14
+ export function isAggregate(field) {
15
+ return field.type === 'aggregate';
16
+ }
17
+ /** TS type of a field in generated code (row interfaces, dao params, filter
18
+ * args): enum → its JS name, date/datetime → string (transported as ISO
19
+ * strings), everything else → jsType. Aggregate fields carry their precision
20
+ * rule in jsType already. The single source for this mapping — renderers
21
+ * must not branch on field.type for a TS type string. */
22
+ export function fieldJsType(field) {
23
+ if (field.type === 'enum')
24
+ return field.enum.jsName;
25
+ if (field.type === 'date' || field.type === 'datetime')
26
+ return 'string';
27
+ return field.jsType;
28
+ }
29
+ /** Enum JS names referenced by a field, recursing into array/object
30
+ * containers (wire-format nesting). First-occurrence order — callers that
31
+ * need uniqueness collect into a Set. */
32
+ export function collectEnumRefs(field, out = []) {
33
+ walkContainer(field, (leaf) => {
34
+ if (leaf.type === 'enum')
35
+ out.push(leaf.enum.jsName);
36
+ });
37
+ return out;
38
+ }
39
+ /** Depth-first walk over a field's container structure: visit is called for
40
+ * every field including containers (top-level, array items, object
41
+ * properties). `key` is the object property name the field sits under
42
+ * (undefined for the top-level field and for array items). */
43
+ export function walkContainer(field, visit, key) {
44
+ visit(field, key);
45
+ if (field.type === 'array') {
46
+ walkContainer(field.items, visit);
47
+ }
48
+ else if (field.type === 'object') {
49
+ for (const [k, child] of Object.entries(field.properties))
50
+ walkContainer(child, visit, k);
51
+ }
52
+ }
13
53
  export function stringField(extra = {}) {
14
54
  return { name: '', type: 'string', jsType: 'string', ...extra };
15
55
  }
package/dist/dto.d.ts CHANGED
@@ -2,7 +2,6 @@ import { BaseField, CollectionSchemaBase, Field, Operator, SchemaBase } from './
2
2
  import { TableSchema } from './db.js';
3
3
  import type { EntitySchema } from './entity.js';
4
4
  import type { ImportBase } from './import-base.js';
5
- import type { ThirdMethodSchema } from './third-service.js';
6
5
  /** Re-export — Operator lives on the DSL level (see dsl.ts). */
7
6
  export type { Operator } from './dsl.js';
8
7
  /** Re-export — ImportBase lives on its own module (see import-base.ts). */
@@ -90,7 +89,11 @@ export declare class DtoMessage implements CollectionSchemaBase {
90
89
  /** 引用已存在的 TypeBox base schema,例如 include({ from: '@pylonts/core', name: 'PageRequest' }) */
91
90
  include(...refs: ImportRef[]): this;
92
91
  }
93
- export declare function dtoField(field: Field): DtoField;
92
+ export declare function dtoField(field: Field | DtoArrayFieldDef | DtoObjectFieldDef): DtoField;
93
+ /** Structural check — DtoMessage instances may come from a different module copy, so instanceof is unreliable. */
94
+ export declare function isDtoMessage(v: unknown): v is DtoMessage;
95
+ /** Structural check — a DtoField wraps a Field in a .field property and has no .type of its own. */
96
+ export declare function isDtoField(v: unknown): v is DtoField;
94
97
  export declare function dtoArrayField(def: {
95
98
  items: DtoField | DtoMessage;
96
99
  } & Omit<BaseField, 'name'>): DtoArrayField;
@@ -101,9 +104,11 @@ export declare function buildInput(name: string, fields: Record<string, DtoField
101
104
  export declare function buildOutput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage;
102
105
  export declare function buildQuery(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage;
103
106
  export declare function buildPk(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage;
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). */
109
- export declare function from(source: DtoFieldSource, fields: Field[]): Record<string, DtoField>;
107
+ /** Field-collection source a DTO can project from: a DB table, another DTO
108
+ * message (protocol fields keep their names), or an entity (which may carry
109
+ * aggregate fields). */
110
+ export type DtoFieldSource = TableSchema | DtoMessage | EntitySchema;
111
+ /** Project fields from a field-collection source (table, DTO message or
112
+ * entity) and wrap them as DTO fields (aligned with dto.from). Shared Field
113
+ * instances keep their original identity — the projection references them. */
114
+ export declare function from(source: DtoFieldSource, fields: (Field | DtoArrayFieldDef | DtoObjectFieldDef)[]): Record<string, DtoField>;
package/dist/dto.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { walkContainer } from './dsl.js';
1
2
  import { toCamelCase } from '@pylonts/core';
2
3
  export class DtoField {
3
4
  /** DTO 语义字段名(接口字段名),与 field.name(数据库列名)含义不同。
@@ -95,6 +96,18 @@ export class DtoMessage {
95
96
  export function dtoField(field) {
96
97
  return new DtoField(field);
97
98
  }
99
+ /** Structural check — DtoMessage instances may come from a different module copy, so instanceof is unreliable. */
100
+ export function isDtoMessage(v) {
101
+ if (typeof v !== 'object' || v === null)
102
+ return false;
103
+ return v.type === 'dto';
104
+ }
105
+ /** Structural check — a DtoField wraps a Field in a .field property and has no .type of its own. */
106
+ export function isDtoField(v) {
107
+ if (typeof v !== 'object' || v === null)
108
+ return false;
109
+ return 'field' in v && !('type' in v);
110
+ }
98
111
  export function dtoArrayField(def) {
99
112
  // Items stay as-is: an inline DtoField is rendered inline, a DtoMessage is
100
113
  // referenced by name (the driver renders Type.Array(<DtoName>)).
@@ -123,9 +136,54 @@ function buildMessage(name, direction, fields, description) {
123
136
  df.field.name = key;
124
137
  df.field.schema = message;
125
138
  }
139
+ writeBackNested(df, message);
126
140
  }
127
141
  return message;
128
142
  }
143
+ /** Write back name/schema on nested DTO fields (array items, object
144
+ * properties) — both plain-Field containers (objectField/arrayField) and
145
+ * DtoField containers (dtoObjectField/dtoArrayField). DtoMessage item
146
+ * references are skipped — they carry their own identity. */
147
+ function writeBackNested(df, message) {
148
+ const f = df.field;
149
+ if (f.type === 'array') {
150
+ const items = f.items;
151
+ if (isDtoMessage(items))
152
+ return;
153
+ if (isDtoField(items)) {
154
+ writeBackNested(items, message);
155
+ return;
156
+ }
157
+ walkContainer(items, writeBackLeaf(message));
158
+ return;
159
+ }
160
+ if (f.type === 'object') {
161
+ for (const [key, child] of Object.entries(f.properties)) {
162
+ if (isDtoField(child)) {
163
+ child.name = key;
164
+ child.schema = message;
165
+ if (child.field.schema === undefined) {
166
+ child.field.name = key;
167
+ child.field.schema = message;
168
+ }
169
+ writeBackNested(child, message);
170
+ }
171
+ else {
172
+ writeBackLeaf(message)(child, key);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ /** Name/schema write-back for a plain Field (own fields only — shared
178
+ * instances keep their original identity). */
179
+ function writeBackLeaf(message) {
180
+ return (f, key) => {
181
+ if (key !== undefined && f.schema === undefined) {
182
+ f.name = key;
183
+ f.schema = message;
184
+ }
185
+ };
186
+ }
129
187
  export function buildInput(name, fields, description) {
130
188
  const message = buildMessage(name, DtoDirection.Input, fields, description);
131
189
  // Rule A — set optionality from the DB column rule (skips fields the author
@@ -164,28 +222,25 @@ export function buildPk(name, fields, description) {
164
222
  }
165
223
  return message;
166
224
  }
167
- // Type guard instead of a plain discriminant check: TableSchema.type is declared
168
- // as a class property, so TS widens it to string and cannot narrow the union.
169
- function isThirdMethod(source) {
170
- return source.type === 'thirdMethod';
171
- }
172
225
  function ownsField(source, field) {
173
- if (isThirdMethod(source))
174
- return Object.values(source.fields).includes(field);
175
- return Object.values(source.columns).includes(field);
226
+ if (isDtoMessage(source)) {
227
+ return Object.values(source.fields).some((df) => df.field === field);
228
+ }
229
+ return Object.values(source.columns).some((c) => c === field);
176
230
  }
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). */
231
+ /** Project fields from a field-collection source (table, DTO message or
232
+ * entity) and wrap them as DTO fields (aligned with dto.from). Shared Field
233
+ * instances keep their original identity — the projection references them. */
179
234
  export function from(source, fields) {
180
235
  const out = {};
181
236
  for (const field of fields) {
182
237
  if (!ownsField(source, field)) {
183
- throw new Error(`dto.from(${source.name}): field ${field.name} does not belong to this ${source.type}`);
238
+ throw new Error(`dto.from(${source.name}): field ${field.name} does not belong to this ${isDtoMessage(source) ? 'dto' : source.type}`);
184
239
  }
185
240
  // DB columns map to camelCase interface names (mer_id → merId); aggregate
186
- // field names are already camel and pass through; wire-format names are
241
+ // field names are already camel and pass through; DTO message fields are
187
242
  // protocol names themselves and stay untouched.
188
- out[isThirdMethod(source) ? field.name : toCamelCase(field.name)] = dtoField(field);
243
+ out[isDtoMessage(source) ? field.name : toCamelCase(field.name)] = dtoField(field);
189
244
  }
190
245
  return out;
191
246
  }
package/dist/entity.d.ts CHANGED
@@ -13,8 +13,9 @@ export interface EntitySchema extends SchemaBase {
13
13
  /** The backend api module this entity belongs to (shared instance from
14
14
  * project.config.ts apis). Entities are always backend-side. */
15
15
  api: ProjectApiSchema;
16
- /** The frontend app this entity belongs to (shared instance from project.config). */
17
- app: FrontAppSchema;
16
+ /** The frontend app this entity belongs to (shared instance from project.config).
17
+ * Unset = api-level common domain entity shared by all modules of the api. */
18
+ app?: FrontAppSchema;
18
19
  /** The columns of this row object: main-table columns, external reference
19
20
  * columns, and — for aggregate result entities — aggField columns
20
21
  * (count/sum/avg outputs). */
@@ -23,7 +24,7 @@ export interface EntitySchema extends SchemaBase {
23
24
  export declare function defineEntity(options: {
24
25
  name: string;
25
26
  api: ProjectApiSchema;
26
- app: FrontAppSchema;
27
+ app?: FrontAppSchema;
27
28
  columns: Field[];
28
29
  description?: string;
29
30
  }): EntitySchema;
package/dist/entity.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export function defineEntity(options) {
2
- if (!options.api.apps.includes(options.app)) {
2
+ if (options.app && !options.api.apps.includes(options.app)) {
3
3
  throw new Error(`entity ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
4
4
  }
5
5
  return {
package/dist/filter.d.ts CHANGED
@@ -21,10 +21,12 @@ export interface FilterSchema extends SchemaBase {
21
21
  type: 'filter';
22
22
  /** The backend api module this filter belongs to (shared instance from
23
23
  * project.config.ts apis). Filters are backend-side, so storage is
24
- * filter_schema/{api.name}/{app.name}/filter/. */
24
+ * filter_schema/{api.name}/{app.name}/filter/ — app unset = the api-level
25
+ * common domain layer, stored at filter_schema/{api.name}/common/filter/. */
25
26
  api: ProjectApiSchema;
26
- /** The frontend app this filter belongs to (shared instance from project.config). */
27
- app: FrontAppSchema;
27
+ /** The frontend app this filter belongs to (shared instance from project.config).
28
+ * Unset = api-level common domain filter shared by all modules of the api. */
29
+ app?: FrontAppSchema;
28
30
  /** AND-combined conditions (may be empty when keyword is present). */
29
31
  conditions: FilterCondition[];
30
32
  /** Fuzzy keyword search: one input value matched against multiple columns
@@ -36,7 +38,7 @@ export interface FilterSchema extends SchemaBase {
36
38
  export declare function defineFilter(options: {
37
39
  name: string;
38
40
  api: ProjectApiSchema;
39
- app: FrontAppSchema;
41
+ app?: FrontAppSchema;
40
42
  conditions?: FilterCondition[];
41
43
  keyword?: {
42
44
  columns: Field[];
package/dist/filter.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export function defineFilter(options) {
2
- if (!options.api.apps.includes(options.app)) {
2
+ if (options.app && !options.api.apps.includes(options.app)) {
3
3
  throw new Error(`filter ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
4
4
  }
5
5
  const conditions = options.conditions ?? [];
@@ -9,7 +9,7 @@
9
9
  // The IR stays the single executable model (mermaid, must-analysis, throws
10
10
  // coverage, service contracts all consume it); this layer is a lowering, not
11
11
  // a parallel model.
12
- import { defineFlow, defineSlots, edge, guard, ifNode, isCall, isEnd, isFlowNode, isGuard, isIfNode, methodOf, node, tryNode, } from './flow.js';
12
+ import { defineFlow, defineSlots, edge, guard, ifNode, isCall, isEnd, isFlowNode, isFlowSlot, isGuard, isIfNode, methodOf, node, tryNode, } from './flow.js';
13
13
  /** Call a method as a statement or (in IF position) as a utils predicate. */
14
14
  export function invoke(method, args, result) {
15
15
  return { kind: 'invoke', method, args, result };
@@ -133,7 +133,7 @@ function addUsed(ctx, slot) {
133
133
  }
134
134
  function addConditionUsed(ctx, c) {
135
135
  if (!isCall(c)) {
136
- addUsed(ctx, c.field.slot);
136
+ addUsed(ctx, isFlowSlot(c.field) ? c.field : c.field.slot);
137
137
  return;
138
138
  }
139
139
  for (const t of c.args ?? [])
@@ -175,6 +175,9 @@ function methodThrows(m) {
175
175
  * label when THROW carries no message). */
176
176
  function renderCondition(c) {
177
177
  if (!isCall(c)) {
178
+ if (isFlowSlot(c.field)) {
179
+ return c.op === 'isNull' ? `${c.field.name} is null` : `${c.field.name} is not null`;
180
+ }
178
181
  const field = c.field.field;
179
182
  const ref = `${c.field.slot.name}.${field.name}`;
180
183
  switch (c.op) {
@@ -473,6 +476,9 @@ function rewriteSlots(nodes, edges, slots, entrySlots) {
473
476
  };
474
477
  const cond = (c) => {
475
478
  if (!isCall(c)) {
479
+ if (isFlowSlot(c.field)) {
480
+ return { kind: 'comparison', op: c.op, field: map(c.field), value: c.value };
481
+ }
476
482
  return { kind: 'comparison', op: c.op, field: { slot: map(c.field.slot), field: c.field.field }, value: c.value };
477
483
  }
478
484
  return { method: c.method, args: c.args?.map(map), result: c.result ? map(c.result) : undefined };
package/dist/flow.d.ts CHANGED
@@ -9,7 +9,9 @@ import type { ServiceMethodSchema } from './service.js';
9
9
  import type { ThirdServiceMethodSchema } from './third-service.js';
10
10
  import type { UtilsMethodSchema } from './utils.js';
11
11
  /** A method a flow step can invoke: contract references, or a pure descriptor
12
- * (MethodSchema) for the period before the contract file exists. */
12
+ * (MethodSchema) for the period before the contract file exists.
13
+ * Third-party methods are their own contract (ThirdServiceMethodSchema) —
14
+ * they can never bind a flow. */
13
15
  export type FlowMethodRef = MethodSchema | ConvertMethodSchema | DaoMethodSchema | ServiceMethodSchema | ThirdServiceMethodSchema | UtilsMethodSchema;
14
16
  /** One named data slot of a flow — a register holding a whole object (like a
15
17
  * compiler's register file). The built-in args slot carries the flow input
@@ -85,7 +87,9 @@ export type CompareOp = 'lt' | 'le' | 'gt' | 'ge' | 'eq' | 'ne' | 'isNull' | 'is
85
87
  export interface Comparison {
86
88
  kind: 'comparison';
87
89
  op: CompareOp;
88
- field: SlotFieldRef;
90
+ /** Field-level comparisons bind a slot field; slot-level null checks
91
+ * (isNull/isNotNull on the slot itself) bind the slot directly. */
92
+ field: SlotFieldRef | FlowSlot;
89
93
  /** Compared-against value; null checks take none. */
90
94
  value?: string | number | EnumValue;
91
95
  }
@@ -93,6 +97,10 @@ export interface Comparison {
93
97
  * (optional): a utils predicate call (its boolean result decides) or a field
94
98
  * comparison. */
95
99
  export type GuardCondition = FlowCall | Comparison;
100
+ /** True when the condition operand is the slot itself (slot-level null check),
101
+ * not a field access on it. Slot metadata (name) lives on the proxy target and
102
+ * reads without field interception; a field access resolves to { slot, field }. */
103
+ export declare function isFlowSlot(v: unknown): v is FlowSlot;
96
104
  export declare function lt(field: unknown, value: string | number): Comparison;
97
105
  export declare function le(field: unknown, value: string | number): Comparison;
98
106
  export declare function gt(field: unknown, value: string | number): Comparison;
package/dist/flow.js CHANGED
@@ -20,7 +20,8 @@ export function defineSlots(slots) {
20
20
  prop in target) {
21
21
  return Reflect.get(target, prop, receiver);
22
22
  }
23
- const fields = target.type?.fields;
23
+ const typeObj = target.type;
24
+ const fields = slotFields(typeObj);
24
25
  if (fields !== undefined && Object.prototype.hasOwnProperty.call(fields, prop)) {
25
26
  return { slot: proxy, field: fields[prop] };
26
27
  }
@@ -48,7 +49,7 @@ export function defineSlots(slots) {
48
49
  // unreachable via dot access (slots.args.type reads the message, not the
49
50
  // field) — warn instead of failing: the field may never be needed.
50
51
  function warnShadowedFields(slot) {
51
- const fields = slot.type?.fields;
52
+ const fields = slotFields(slot.type);
52
53
  if (fields === undefined)
53
54
  return;
54
55
  const shadowed = ['type', 'name', 'description'].filter((k) => k in fields);
@@ -56,6 +57,19 @@ function warnShadowedFields(slot) {
56
57
  console.warn(`slot "${slot.name}": message fields ${shadowed.join(', ')} are shadowed by slot metadata and unreachable via field access`);
57
58
  }
58
59
  }
60
+ /** Field map of a slot's declared type: dto/third messages carry `fields`,
61
+ * entity rows carry `columns` (named Fields). */
62
+ function slotFields(type) {
63
+ if (typeof type !== 'object' || type === null)
64
+ return undefined;
65
+ const t = type;
66
+ if (t.fields !== undefined)
67
+ return t.fields;
68
+ if (t.columns !== undefined) {
69
+ return Object.fromEntries(t.columns.map((c) => [c.name, c]));
70
+ }
71
+ return undefined;
72
+ }
59
73
  export function invoke(method, options = {}) {
60
74
  return { method, args: options.args, result: options.result };
61
75
  }
@@ -66,7 +80,22 @@ export function isCall(m) {
66
80
  export function methodOf(m) {
67
81
  return isCall(m) ? m.method : m;
68
82
  }
83
+ /** True when the condition operand is the slot itself (slot-level null check),
84
+ * not a field access on it. Slot metadata (name) lives on the proxy target and
85
+ * reads without field interception; a field access resolves to { slot, field }. */
86
+ export function isFlowSlot(v) {
87
+ return typeof v === 'object' && v !== null && typeof v.name === 'string';
88
+ }
69
89
  function comparison(op, field, value) {
90
+ if (isFlowSlot(field)) {
91
+ if (op !== 'isNull' && op !== 'isNotNull') {
92
+ throw new Error(`${op}: a slot-level check only supports isNull/isNotNull — field comparisons need slots.args.amt`);
93
+ }
94
+ if (value !== undefined) {
95
+ throw new Error(`${op}: takes no value`);
96
+ }
97
+ return { kind: 'comparison', op, field, value };
98
+ }
70
99
  const ref = field;
71
100
  if (typeof ref !== 'object' || ref === null || typeof ref.slot !== 'object' || typeof ref.field !== 'object') {
72
101
  throw new Error(`${op}: field must be a slot field access like slots.args.amt`);
@@ -471,6 +500,16 @@ function validateGuardChecks(schema) {
471
500
  const COMPARE_OPS = ['lt', 'le', 'gt', 'ge', 'eq', 'ne', 'isNull', 'isNotNull'];
472
501
  function validateCondition(where, c) {
473
502
  if (!isCall(c)) {
503
+ if (isFlowSlot(c.field)) {
504
+ const nullOp = c.op === 'isNull' || c.op === 'isNotNull';
505
+ if (!nullOp) {
506
+ throw new Error(`${where}: a slot-level check only supports isNull/isNotNull`);
507
+ }
508
+ if (c.value !== undefined) {
509
+ throw new Error(`${where}: ${c.op} takes no value`);
510
+ }
511
+ return;
512
+ }
474
513
  const f = c.field;
475
514
  if (typeof f !== 'object' || f === null || typeof f.slot !== 'object' || typeof f.field !== 'object') {
476
515
  throw new Error(`${where}: ${c.op} field must be a slot field access like slots.args.amt`);
@@ -502,10 +541,11 @@ function validateCondition(where, c) {
502
541
  throw new Error(`${where}: a predicate call cannot bind a result slot`);
503
542
  }
504
543
  }
505
- /** Slots a condition reads: a comparison's field slot or a predicate call's arg slots. */
544
+ /** Slots a condition reads: a comparison's field slot (or the slot itself for
545
+ * slot-level checks) or a predicate call's arg slots. */
506
546
  function conditionSlots(c) {
507
547
  if (!isCall(c))
508
- return [c.field.slot];
548
+ return [isFlowSlot(c.field) ? c.field : c.field.slot];
509
549
  return c.args ?? [];
510
550
  }
511
551
  // ifNode rules: at least one case, every condition well-formed, and targets
@@ -1,4 +1,4 @@
1
- import { isCall, methodOf } from './flow.js';
1
+ import { isCall, isFlowSlot, methodOf } from './flow.js';
2
2
  // Mermaid driver: converts a FlowSchema into a Mermaid flowchart (TD).
3
3
  // Node ids are hierarchical (n0, n0_0, n0_0_0, ...) so nested sub-flows stay
4
4
  // globally unique. A node with a sub-flow renders as a subgraph block whose
@@ -215,7 +215,7 @@ function renderDataLine(n, outgoing) {
215
215
  if (c === undefined)
216
216
  return;
217
217
  if (!isCall(c)) {
218
- reads.add(c.field.slot.name);
218
+ reads.add(isFlowSlot(c.field) ? c.field.name : c.field.slot.name);
219
219
  return;
220
220
  }
221
221
  for (const t of c.args ?? [])