@pylonts/dsl 1.1.12 → 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 (48) 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/service.d.ts +13 -8
  18. package/dist/service.js +1 -1
  19. package/dist/third-service.d.ts +10 -53
  20. package/dist/third-service.js +3 -78
  21. package/dist/typebox-driver.d.ts +0 -6
  22. package/dist/typebox-driver.js +8 -36
  23. package/dist/utils.d.ts +2 -2
  24. package/docs/curd.md +146 -146
  25. package/docs/dao-generation.md +477 -477
  26. package/docs/project.md +31 -31
  27. package/docs/token.md +326 -326
  28. package/package.json +1 -1
  29. package/src/action.ts +51 -51
  30. package/src/controller.ts +53 -53
  31. package/src/convert.ts +76 -78
  32. package/src/curd.ts +104 -104
  33. package/src/dao.ts +504 -485
  34. package/src/dsl.ts +296 -257
  35. package/src/dto.ts +323 -266
  36. package/src/entity.ts +43 -42
  37. package/src/expr.ts +64 -64
  38. package/src/filter.ts +71 -69
  39. package/src/flow-script.ts +702 -695
  40. package/src/flow.ts +1272 -1226
  41. package/src/index.ts +46 -46
  42. package/src/mermaid-driver.ts +339 -339
  43. package/src/mysql-driver.ts +108 -108
  44. package/src/project.ts +138 -138
  45. package/src/service.ts +112 -107
  46. package/src/third-service.ts +68 -191
  47. package/src/typebox-driver.ts +234 -268
  48. package/src/utils.ts +74 -74
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonts/dsl",
3
- "version": "1.1.12",
3
+ "version": "1.1.13",
4
4
  "description": "Schema definition DSL with drivers: MySQL DDL, TS enum, TypeBox schema codegen.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/action.ts CHANGED
@@ -1,52 +1,52 @@
1
- import { SchemaBase } from './dsl.js';
2
- import type { DtoField, DtoMessage, DtoArrayField, DtoObjectField } from './dto.js';
3
- import type { RefSchema } from './ref.js';
4
- import type { ControllerMethodSchema } from './controller.js';
5
-
6
- /** An action a user can perform on a page (e.g. submit, approve, reject).
7
- * Subclasses use `type` as the discriminator. */
8
- export interface ActionSchema extends SchemaBase {
9
- type: string;
10
- }
11
-
12
- export function defineAction(name: string, description?: string): ActionSchema {
13
- return { name, description, type: 'gesture' };
14
- }
15
-
16
- /** Parameter data source for a call argument. */
17
- export type DataRef =
18
- | { type: 'route'; key: string }
19
- | { type: 'data'; key: string }
20
- | { type: 'value'; value: unknown };
21
-
22
- /** Create a route-parameter reference. */
23
- export function route(key: string): DataRef {
24
- return { type: 'route', key };
25
- }
26
-
27
- /** Create a page-data reference. */
28
- export function data(key: string): DataRef {
29
- return { type: 'data', key };
30
- }
31
-
32
- export interface CallAction extends ActionSchema {
33
- type: 'call';
34
- func: ControllerMethodSchema;
35
- args?: Record<string, DtoField | DtoMessage | DtoArrayField | DtoObjectField | RefSchema | string>;
36
- }
37
-
38
- export function call(func: ControllerMethodSchema, args?: Record<string, DtoField | DtoMessage | DtoArrayField | DtoObjectField | RefSchema | string>): CallAction {
39
- return { name: func.name, type: 'call', func, args };
40
- }
41
-
42
- /** Assign a call's result to a page data field.
43
- * React: setState({ [field]: await ... }). Mini-program: this.setData({ [field]: ... }). */
44
- export interface SetDataAction extends ActionSchema {
45
- type: 'setData';
46
- call: CallAction;
47
- field: DtoField;
48
- }
49
-
50
- export function setData(call: CallAction, field: DtoField): SetDataAction {
51
- return { name: 'setData', type: 'setData', call, field };
1
+ import { SchemaBase } from './dsl.js';
2
+ import type { DtoField, DtoMessage, DtoArrayField, DtoObjectField } from './dto.js';
3
+ import type { RefSchema } from './ref.js';
4
+ import type { ControllerMethodSchema } from './controller.js';
5
+
6
+ /** An action a user can perform on a page (e.g. submit, approve, reject).
7
+ * Subclasses use `type` as the discriminator. */
8
+ export interface ActionSchema extends SchemaBase {
9
+ type: string;
10
+ }
11
+
12
+ export function defineAction(name: string, description?: string): ActionSchema {
13
+ return { name, description, type: 'gesture' };
14
+ }
15
+
16
+ /** Parameter data source for a call argument. */
17
+ export type DataRef =
18
+ | { type: 'route'; key: string }
19
+ | { type: 'data'; key: string }
20
+ | { type: 'value'; value: unknown };
21
+
22
+ /** Create a route-parameter reference. */
23
+ export function route(key: string): DataRef {
24
+ return { type: 'route', key };
25
+ }
26
+
27
+ /** Create a page-data reference. */
28
+ export function data(key: string): DataRef {
29
+ return { type: 'data', key };
30
+ }
31
+
32
+ export interface CallAction extends ActionSchema {
33
+ type: 'call';
34
+ func: ControllerMethodSchema;
35
+ args?: Record<string, DtoField | DtoMessage | DtoArrayField | DtoObjectField | RefSchema | string>;
36
+ }
37
+
38
+ export function call(func: ControllerMethodSchema, args?: Record<string, DtoField | DtoMessage | DtoArrayField | DtoObjectField | RefSchema | string>): CallAction {
39
+ return { name: func.name, type: 'call', func, args };
40
+ }
41
+
42
+ /** Assign a call's result to a page data field.
43
+ * React: setState({ [field]: await ... }). Mini-program: this.setData({ [field]: ... }). */
44
+ export interface SetDataAction extends ActionSchema {
45
+ type: 'setData';
46
+ call: CallAction;
47
+ field: DtoField;
48
+ }
49
+
50
+ export function setData(call: CallAction, field: DtoField): SetDataAction {
51
+ return { name: 'setData', type: 'setData', call, field };
52
52
  }
package/src/controller.ts CHANGED
@@ -1,54 +1,54 @@
1
- import { SchemaBase } from './dsl.js';
2
- import { FrontAppSchema, ProjectApiSchema } from './project.js';
3
- import type { DtoMessage } from './dto.js';
4
-
5
- /** A backend RPC controller. Strong constraints:
6
- * - a backend module maps 1:1 to a frontend app (they are peers);
7
- * - a controller serves exactly one frontend app — no cross-module calls. */
8
- export interface ControllerSchema extends SchemaBase {
9
- type: 'controller';
10
- /** The backend api module this controller belongs to (shared instance from
11
- * project.config.ts apis). Controllers are always backend-side, so storage
12
- * is controller_schema/{api.name}/{app.name}/controller/. */
13
- api: ProjectApiSchema;
14
- /** The frontend app this controller serves (shared instance from project.config). */
15
- app: FrontAppSchema;
16
- /** RPC methods, keyed by method name (key === method.name, enforced by the builder). */
17
- methods: Record<string, ControllerMethodSchema>;
18
- }
19
-
20
- export function defineController(options: {
21
- name: string;
22
- api: ProjectApiSchema;
23
- app: FrontAppSchema;
24
- /** Method declarations: type/schema/name are injected by this builder. */
25
- methods: Record<string, Omit<ControllerMethodSchema, 'type' | 'schema' | 'name'>>;
26
- description?: string;
27
- }): ControllerSchema {
28
- if (!options.api.apps.includes(options.app)) {
29
- throw new Error(`controller ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
30
- }
31
- const schema: ControllerSchema = {
32
- type: 'controller',
33
- name: options.name,
34
- description: options.description,
35
- api: options.api,
36
- app: options.app,
37
- methods: {},
38
- };
39
- for (const [key, method] of Object.entries(options.methods)) {
40
- schema.methods[key] = { type: 'method', schema, name: key, ...method };
41
- }
42
- return schema;
43
- }
44
-
45
- /** An RPC method exposed by a controller. Carries the shared API call
46
- * signature: one request DTO in, one response shape out. Referenced by
47
- * frontend page actions — both sides use the exact same instance, so
48
- * drift is impossible. */
49
- export interface ControllerMethodSchema extends SchemaBase {
50
- type: 'method';
51
- schema: ControllerSchema;
52
- args: DtoMessage;
53
- results: DtoMessage | number | boolean | string;
1
+ import { SchemaBase } from './dsl.js';
2
+ import { FrontAppSchema, ProjectApiSchema } from './project.js';
3
+ import type { DtoMessage } from './dto.js';
4
+
5
+ /** A backend RPC controller. Strong constraints:
6
+ * - a backend module maps 1:1 to a frontend app (they are peers);
7
+ * - a controller serves exactly one frontend app — no cross-module calls. */
8
+ export interface ControllerSchema extends SchemaBase {
9
+ type: 'controller';
10
+ /** The backend api module this controller belongs to (shared instance from
11
+ * project.config.ts apis). Controllers are always backend-side, so storage
12
+ * is controller_schema/{api.name}/{app.name}/controller/. */
13
+ api: ProjectApiSchema;
14
+ /** The frontend app this controller serves (shared instance from project.config). */
15
+ app: FrontAppSchema;
16
+ /** RPC methods, keyed by method name (key === method.name, enforced by the builder). */
17
+ methods: Record<string, ControllerMethodSchema>;
18
+ }
19
+
20
+ export function defineController(options: {
21
+ name: string;
22
+ api: ProjectApiSchema;
23
+ app: FrontAppSchema;
24
+ /** Method declarations: type/schema/name are injected by this builder. */
25
+ methods: Record<string, Omit<ControllerMethodSchema, 'type' | 'schema' | 'name'>>;
26
+ description?: string;
27
+ }): ControllerSchema {
28
+ if (!options.api.apps.includes(options.app)) {
29
+ throw new Error(`controller ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
30
+ }
31
+ const schema: ControllerSchema = {
32
+ type: 'controller',
33
+ name: options.name,
34
+ description: options.description,
35
+ api: options.api,
36
+ app: options.app,
37
+ methods: {},
38
+ };
39
+ for (const [key, method] of Object.entries(options.methods)) {
40
+ schema.methods[key] = { type: 'method', schema, name: key, ...method };
41
+ }
42
+ return schema;
43
+ }
44
+
45
+ /** An RPC method exposed by a controller. Carries the shared API call
46
+ * signature: one request DTO in, one response shape out. Referenced by
47
+ * frontend page actions — both sides use the exact same instance, so
48
+ * drift is impossible. */
49
+ export interface ControllerMethodSchema extends SchemaBase {
50
+ type: 'method';
51
+ schema: ControllerSchema;
52
+ args: DtoMessage;
53
+ results: DtoMessage | number | boolean | string;
54
54
  }
package/src/convert.ts CHANGED
@@ -1,79 +1,77 @@
1
- import type { SchemaBase } from './dsl.js';
2
- import type { DtoMessage } from './dto.js';
3
- import type { TableSchema } from './db.js';
4
- import type { EntitySchema } from './entity.js';
5
- import type { ThirdMethodSchema } from './third-service.js';
6
- import type { FrontAppSchema, ProjectApiSchema } from './project.js';
7
-
8
- // Schema-collection integration: multiple source collections combine into
9
- // one target collection (e.g. two entity tables into one dto, or entity
10
- // columns plus a dto into one third-party wire message).
11
- //
12
- // A convert file binds to ONE source identity — a table (internal mapping,
13
- // {Table}Convert.ts) or a third-party service (anti-corruption translation,
14
- // {third-service}.convert.ts) and holds N methods keyed by name (same
15
- // shape as defineService / defineDao).
16
-
17
- /** A source/target collection of a convert dto, entity, table or
18
- * third-party message. Entity sources may carry aggregate fields
19
- * (aggField), e.g. an aggregate result entity projected into a wire
20
- * message. */
21
- export type ConvertSourceSchema = DtoMessage | TableSchema | ThirdMethodSchema | EntitySchema;
22
-
23
- /** Method input for defineConvert: type/schema/name are set by the builder. */
24
- export type ConvertMethodDef = Omit<ConvertMethodSchema, 'type' | 'schema' | 'name'>;
25
-
26
- /** One conversion: multiple source collections → single target collection. */
27
- export interface ConvertMethodSchema extends SchemaBase {
28
- type: 'convertMethod';
29
- /** The convert file this method belongs to. */
30
- schema: ConvertSchema;
31
- /** Source schemasone or more, mixed dimensions. */
32
- sources: ConvertSourceSchema[];
33
- /** Target schema — the single integrated collection. */
34
- target: ConvertSourceSchema;
35
- }
36
-
37
- /** Declares multi-source → single-target schema integrations grouped by source identity. */
38
- export interface ConvertSchema extends SchemaBase {
39
- type: 'convert';
40
- /** The backend api module this convert belongs to (shared instance from
41
- * project.config.ts apis). Storage is convert_schema/{api.name}/{app.name}/
42
- *same layout as service_schema. */
43
- api: ProjectApiSchema;
44
- /** The app (module) this convert belongs to its artifact lands in modules/{app}/convert/. */
45
- app: FrontAppSchema;
46
- /** Methods keyed by name — the map key is written back as the method name. */
47
- methods: Record<string, ConvertMethodSchema>;
48
- }
49
-
50
- export function defineConvert(options: {
51
- name: string;
52
- api: ProjectApiSchema;
53
- app: FrontAppSchema;
54
- methods: Record<string, ConvertMethodDef>;
55
- description?: string;
56
- }): ConvertSchema {
57
- if (Object.keys(options.methods).length === 0) {
58
- throw new Error(`convert '${options.name}': methods must not be empty`);
59
- }
60
- if (!options.api.apps.includes(options.app)) {
61
- throw new Error(`convert '${options.name}': api '${options.api.name}' does not serve app '${options.app.name}'`);
62
- }
63
- const schema: ConvertSchema = {
64
- type: 'convert',
65
- name: options.name,
66
- description: options.description,
67
- api: options.api,
68
- app: options.app,
69
- methods: {},
70
- };
71
- for (const key of Object.keys(options.methods)) {
72
- const method = options.methods[key] as ConvertMethodDef;
73
- if (method.sources.length === 0) {
74
- throw new Error(`convert '${options.name}': method '${key}' sources must not be empty`);
75
- }
76
- schema.methods[key] = { type: 'convertMethod', schema, ...method, name: key };
77
- }
78
- return schema;
1
+ import type { CollectionSchemaBase, SchemaBase } from './dsl.js';
2
+ import type { DtoMessage } from './dto.js';
3
+ import type { TableSchema } from './db.js';
4
+ import type { EntitySchema } from './entity.js';
5
+ import type { FrontAppSchema, ProjectApiSchema } from './project.js';
6
+
7
+ // Schema-collection integration: multiple source collections combine into
8
+ // one target collection (e.g. two entity tables into one dto, or entity
9
+ // columns plus a dto into one third-party wire message).
10
+ //
11
+ // A convert file binds to ONE source identity — a table (internal mapping,
12
+ // {Table}Convert.ts) or a third-party service (anti-corruption translation,
13
+ // {third-service}.convert.ts) and holds N methods keyed by name (same
14
+ // shape as defineService / defineDao).
15
+
16
+ /** A source/target collection of a convert — dto, entity or table. Entity
17
+ * sources may carry aggregate fields (aggField), e.g. an aggregate result
18
+ * entity projected into a wire message. */
19
+ export type ConvertSourceSchema = DtoMessage | TableSchema | EntitySchema;
20
+
21
+ /** Method input for defineConvert: type/schema/name are set by the builder. */
22
+ export type ConvertMethodDef = Omit<ConvertMethodSchema, 'type' | 'schema' | 'name'>;
23
+
24
+ /** One conversion: multiple source collections single target collection. */
25
+ export interface ConvertMethodSchema extends SchemaBase {
26
+ type: 'convertMethod';
27
+ /** The convert file this method belongs to. */
28
+ schema: ConvertSchema;
29
+ /** Source schemas one or more, mixed dimensions. */
30
+ sources: ConvertSourceSchema[];
31
+ /** Target schemathe single integrated collection. */
32
+ target: ConvertSourceSchema;
33
+ }
34
+
35
+ /** Declares multi-source → single-target schema integrations grouped by source identity. */
36
+ export interface ConvertSchema extends CollectionSchemaBase {
37
+ type: 'convert';
38
+ /** The backend api module this convert belongs to (shared instance from
39
+ * project.config.ts apis). Storage is convert_schema/{api.name}/{app.name}/
40
+ * same layout as service_schema. */
41
+ api: ProjectApiSchema;
42
+ /** The app (module) this convert belongs to its artifact lands in modules/{app}/convert/. */
43
+ app: FrontAppSchema;
44
+ /** Methods keyed by name the map key is written back as the method name. */
45
+ methods: Record<string, ConvertMethodSchema>;
46
+ }
47
+
48
+ export function defineConvert(options: {
49
+ name: string;
50
+ api: ProjectApiSchema;
51
+ app: FrontAppSchema;
52
+ methods: Record<string, ConvertMethodDef>;
53
+ description?: string;
54
+ }): ConvertSchema {
55
+ if (Object.keys(options.methods).length === 0) {
56
+ throw new Error(`convert '${options.name}': methods must not be empty`);
57
+ }
58
+ if (!options.api.apps.includes(options.app)) {
59
+ throw new Error(`convert '${options.name}': api '${options.api.name}' does not serve app '${options.app.name}'`);
60
+ }
61
+ const schema: ConvertSchema = {
62
+ type: 'convert',
63
+ name: options.name,
64
+ description: options.description,
65
+ api: options.api,
66
+ app: options.app,
67
+ methods: {},
68
+ };
69
+ for (const key of Object.keys(options.methods)) {
70
+ const method = options.methods[key] as ConvertMethodDef;
71
+ if (method.sources.length === 0) {
72
+ throw new Error(`convert '${options.name}': method '${key}' sources must not be empty`);
73
+ }
74
+ schema.methods[key] = { type: 'convertMethod', schema, ...method, name: key };
75
+ }
76
+ return schema;
79
77
  }
package/src/curd.ts CHANGED
@@ -1,105 +1,105 @@
1
- import { SchemaBase, Field } from './dsl.js';
2
- import { TableSchema } from './db.js';
3
- import { FrontAppSchema } from './project.js';
4
- import { ActionSchema } from './action.js';
5
- import type { FilterSchema } from './filter.js';
6
- import { toKebabCase } from '@pylonts/core';
7
-
8
- // Admin-only CRUD page standard: binds one entity table to a frontend admin
9
- // app, describing everything needed to generate the list page plus optional
10
- // add/update/detail pages. Field-level columns are plain Field instances
11
- // (table fields, cross-table refs allowed) — DTOs are derived by the generator,
12
- // the schema itself never references DtoMessage.
13
-
14
- /** Mode of an action page: modal dialog or standalone route. */
15
- export type ActionPageMode = 'modal' | 'route';
16
-
17
- /** One CRUD action page (add / update / detail). */
18
- export interface ActionPage {
19
- mode: ActionPageMode;
20
- /** Fields rendered on this page. Required, non-empty — every field the
21
- * frontend shows must be listed explicitly. */
22
- columns: Field[];
23
- }
24
-
25
- /** List page configuration. */
26
- export interface CurdListConfig {
27
- /** List columns; required, non-empty. Every field the list shows must be
28
- * listed explicitly. May include cross-table fields via foreign refs. */
29
- columns: Field[];
30
- /** Page filter (search form + keyword search). Conditions declared on the
31
- * filter render the search form; the filter's keyword (when present)
32
- * drives the keyword query endpoint. Optional — a page without a filter
33
- * has no search form. */
34
- filter?: FilterSchema;
35
- /** Default sort. Required — column and direction are both mandatory. */
36
- orderBy: { column: Field; direction: 'asc' | 'desc' };
37
- /** Column header text overrides: Field.name → header text. */
38
- columnTitles?: Record<string, string>;
39
- }
40
-
41
- /** Admin-only CRUD page standard: binds one entity table to a frontend
42
- * admin app. Drives generation of the list page plus optional
43
- * add/update/detail pages. */
44
- export interface CurdSchema extends SchemaBase {
45
- /** The admin frontend app this CRUD belongs to (shared instance, type must be 'admin'). */
46
- app: FrontAppSchema;
47
- /** The bound entity table (shared instance). */
48
- table: TableSchema;
49
- /** List page Chinese title. */
50
- title: string;
51
- /** Sidebar menu section (group) this CRUD page belongs to. */
52
- section: string;
53
- /** Extra user actions on this page (beyond the standard CRUD). */
54
- actions?: ActionSchema[];
55
- /** Add/update/detail action pages. */
56
- actionPages?: {
57
- add?: ActionPage;
58
- update?: ActionPage;
59
- detail?: ActionPage;
60
- };
61
- list: CurdListConfig;
62
- }
63
-
64
- function assertColumns(curd: CurdSchema, pageName: string, columns: Field[]): void {
65
- if (columns.length === 0) {
66
- throw new Error(`curd ${curd.name}: ${pageName}.columns must be non-empty`);
67
- }
68
- }
69
-
70
- function assertFieldsOwnTable(curd: CurdSchema, label: string, fields: Field[]): void {
71
- for (const f of fields) {
72
- if (f.schema !== curd.table) {
73
- throw new Error(`curd ${curd.name}: ${label} field ${f.name} does not belong to table ${curd.table.name}`);
74
- }
75
- }
76
- }
77
-
78
- /** Defines an admin CRUD page standard. Runtime-validates admin app binding,
79
- * non-empty columns and table field ownership (same style as defineTable). */
80
- export function defineCurd(name: string, schema: Omit<CurdSchema, 'name'>): CurdSchema {
81
- const curd: CurdSchema = { name, ...schema };
82
- if (curd.app.type !== 'admin') {
83
- throw new Error(`curd ${name}: app ${curd.app.name} must be type 'admin' (got '${curd.app.type}')`);
84
- }
85
- // The name is the admin route path — it must be the kebab-case table name
86
- // so paths cannot drift from the table they serve.
87
- const expectedName = toKebabCase(curd.table.name);
88
- if (name !== expectedName) {
89
- throw new Error(`curd name must be '${expectedName}' (kebab-case of table '${curd.table.name}'); got '${name}'`);
90
- }
91
- if (!curd.section) {
92
- throw new Error(`curd ${name}: section is required (sidebar menu group, e.g. '商户管理')`);
93
- }
94
- assertColumns(curd, 'list', curd.list.columns);
95
- for (const [pageName, page] of Object.entries(curd.actionPages ?? {})) {
96
- if (page) assertColumns(curd, `actionPages.${pageName}`, page.columns);
97
- }
98
- if (curd.list.filter !== undefined && curd.list.filter.app !== curd.app) {
99
- throw new Error(
100
- `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}'`,
101
- );
102
- }
103
- assertFieldsOwnTable(curd, 'orderBy', [curd.list.orderBy.column]);
104
- return curd;
1
+ import { SchemaBase, Field } from './dsl.js';
2
+ import { TableSchema } from './db.js';
3
+ import { FrontAppSchema } from './project.js';
4
+ import { ActionSchema } from './action.js';
5
+ import type { FilterSchema } from './filter.js';
6
+ import { toKebabCase } from '@pylonts/core';
7
+
8
+ // Admin-only CRUD page standard: binds one entity table to a frontend admin
9
+ // app, describing everything needed to generate the list page plus optional
10
+ // add/update/detail pages. Field-level columns are plain Field instances
11
+ // (table fields, cross-table refs allowed) — DTOs are derived by the generator,
12
+ // the schema itself never references DtoMessage.
13
+
14
+ /** Mode of an action page: modal dialog or standalone route. */
15
+ export type ActionPageMode = 'modal' | 'route';
16
+
17
+ /** One CRUD action page (add / update / detail). */
18
+ export interface ActionPage {
19
+ mode: ActionPageMode;
20
+ /** Fields rendered on this page. Required, non-empty — every field the
21
+ * frontend shows must be listed explicitly. */
22
+ columns: Field[];
23
+ }
24
+
25
+ /** List page configuration. */
26
+ export interface CurdListConfig {
27
+ /** List columns; required, non-empty. Every field the list shows must be
28
+ * listed explicitly. May include cross-table fields via foreign refs. */
29
+ columns: Field[];
30
+ /** Page filter (search form + keyword search). Conditions declared on the
31
+ * filter render the search form; the filter's keyword (when present)
32
+ * drives the keyword query endpoint. Optional — a page without a filter
33
+ * has no search form. */
34
+ filter?: FilterSchema;
35
+ /** Default sort. Required — column and direction are both mandatory. */
36
+ orderBy: { column: Field; direction: 'asc' | 'desc' };
37
+ /** Column header text overrides: Field.name → header text. */
38
+ columnTitles?: Record<string, string>;
39
+ }
40
+
41
+ /** Admin-only CRUD page standard: binds one entity table to a frontend
42
+ * admin app. Drives generation of the list page plus optional
43
+ * add/update/detail pages. */
44
+ export interface CurdSchema extends SchemaBase {
45
+ /** The admin frontend app this CRUD belongs to (shared instance, type must be 'admin'). */
46
+ app: FrontAppSchema;
47
+ /** The bound entity table (shared instance). */
48
+ table: TableSchema;
49
+ /** List page Chinese title. */
50
+ title: string;
51
+ /** Sidebar menu section (group) this CRUD page belongs to. */
52
+ section: string;
53
+ /** Extra user actions on this page (beyond the standard CRUD). */
54
+ actions?: ActionSchema[];
55
+ /** Add/update/detail action pages. */
56
+ actionPages?: {
57
+ add?: ActionPage;
58
+ update?: ActionPage;
59
+ detail?: ActionPage;
60
+ };
61
+ list: CurdListConfig;
62
+ }
63
+
64
+ function assertColumns(curd: CurdSchema, pageName: string, columns: Field[]): void {
65
+ if (columns.length === 0) {
66
+ throw new Error(`curd ${curd.name}: ${pageName}.columns must be non-empty`);
67
+ }
68
+ }
69
+
70
+ function assertFieldsOwnTable(curd: CurdSchema, label: string, fields: Field[]): void {
71
+ for (const f of fields) {
72
+ if (f.schema !== curd.table) {
73
+ throw new Error(`curd ${curd.name}: ${label} field ${f.name} does not belong to table ${curd.table.name}`);
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Defines an admin CRUD page standard. Runtime-validates admin app binding,
79
+ * non-empty columns and table field ownership (same style as defineTable). */
80
+ export function defineCurd(name: string, schema: Omit<CurdSchema, 'name'>): CurdSchema {
81
+ const curd: CurdSchema = { name, ...schema };
82
+ if (curd.app.type !== 'admin') {
83
+ throw new Error(`curd ${name}: app ${curd.app.name} must be type 'admin' (got '${curd.app.type}')`);
84
+ }
85
+ // The name is the admin route path — it must be the kebab-case table name
86
+ // so paths cannot drift from the table they serve.
87
+ const expectedName = toKebabCase(curd.table.name);
88
+ if (name !== expectedName) {
89
+ throw new Error(`curd name must be '${expectedName}' (kebab-case of table '${curd.table.name}'); got '${name}'`);
90
+ }
91
+ if (!curd.section) {
92
+ throw new Error(`curd ${name}: section is required (sidebar menu group, e.g. '商户管理')`);
93
+ }
94
+ assertColumns(curd, 'list', curd.list.columns);
95
+ for (const [pageName, page] of Object.entries(curd.actionPages ?? {})) {
96
+ if (page) assertColumns(curd, `actionPages.${pageName}`, page.columns);
97
+ }
98
+ if (curd.list.filter !== undefined && curd.list.filter.app !== curd.app) {
99
+ throw new Error(
100
+ `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}'`,
101
+ );
102
+ }
103
+ assertFieldsOwnTable(curd, 'orderBy', [curd.list.orderBy.column]);
104
+ return curd;
105
105
  }