@pylonts/dsl 1.1.21 → 1.1.22

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.
@@ -0,0 +1,109 @@
1
+ # 概念层 (Concepts)
2
+
3
+ 概念层是**名字层**:每个业务名词先以"名字 + 名词解释"存在,先于一切结构(表、页面、接口、旅程)。它是蓝图体系的根——所有跨层引用(journey / table / PageFlow / DTO)的身份都从概念出发。
4
+
5
+ ## 认知顺序:名词在先,结构在后
6
+
7
+ ```
8
+ 名词(概念):商户 —— 业务先说这个词,先解释它是什么
9
+ ↓ 派生
10
+ 词根(短语):mer —— 为了字段名发明缩写
11
+ ↓ 长出
12
+ 结构:merchant 表 / 商户列表页 / 入驻旅程 —— 各维度引用"商户"
13
+ ```
14
+
15
+ - **概念是本源**:业务人员嘴里只有"商户",`mer` 是工程师后来为字段名发明的代号。
16
+ - **短语(词根)是派生**:概念引用短语,不是短语解释概念。
17
+ - **结构是生长**:表、页面、journey 都是概念"长出"的结构,各自引用概念名。
18
+
19
+ ## 与词典(_dictionary.ts)的关系
20
+
21
+ | | 词典(dictionary) | 概念(concepts) |
22
+ |---|---|---|
23
+ | 单位 | 词根(mer / rate) | 完整名词(商户 / 银联报文) |
24
+ | 主键 | 短语本身 | 名词标识 |
25
+ | 内容 | 短语 + label + 描述 | 名词 + 经典段落解释 |
26
+ | 服务对象 | 字段命名校验(lint field) | 所有维度的引用身份 |
27
+ | 认知顺序 | 后于概念存在(缩写是派生的) | **先于一切存在** |
28
+
29
+ **两者并存,互不反转**:`_dictionary.ts` 维持现状(词根字典,服务字段命名);`_concepts.ts` 是新的概念清单(服务跨层身份)。概念通过 `phrase` 字段引用词根,把"名词 → 缩写"的派生关系显式化。
30
+
31
+ ## 规范位置:schema/_concepts.ts
32
+
33
+ **所有概念统一定义在 `schema/_concepts.ts`**,一个文件一处定义(与 `_dictionary.ts` 同规则)。`table.ts` / journey / PageFlow 引用概念,禁止内联。
34
+
35
+ ## 定义形态
36
+
37
+ ```ts
38
+ // schema/_concepts.ts
39
+ import { defineConcept } from '@pylonts/dsl';
40
+
41
+ export const merchant = defineConcept('merchant', {
42
+ title: '商户',
43
+ description: '入驻平台并签约收单的商家。由 BD 录入,平台审核,提交银联开通后获得登录资格,可登录商户端进行订单核销。',
44
+ phrase: mer, // 引用词根(_dictionary.ts 的实体短语)
45
+ });
46
+
47
+ export const unionpayReport = defineConcept('unionpay-report', {
48
+ title: '银联报文',
49
+ description: '提交给银联的商户资料报文,银联审核后返回审核结果。',
50
+ });
51
+ ```
52
+
53
+ - `name`:概念标识(kebab-case,跨层身份契约——journey / 表 / 页面都叫这个名字)。
54
+ - `title`:中文名。
55
+ - `description`:**经典段落**——一段话讲清楚"这是什么、干什么用的",像文档术语表里的一条。
56
+ - `phrase?`:引用 `_dictionary.ts` 的词根条目(实体短语),表达"这个概念在字段命名里缩写为什么"。
57
+ - 一个概念可以还没有任何结构(没有表、没有页面)——**概念本身就是一个完整的存在**。
58
+
59
+ ## 蓝图态 → 锚定态:概念是跳板
60
+
61
+ 概念层解决"journey 不能等一切都好了才串起来"的矛盾:
62
+
63
+ ```
64
+ journey 步骤:"商户入驻"(名字)
65
+ │ ① 引用概念(只需名字存在)
66
+
67
+ 概念:merchant(名词 + 解释) ← 跳板,先于一切存在
68
+ │ ② 概念长出结构
69
+
70
+ 表 / 页面 / 接口(引用概念名)
71
+ │ ③ 结构生成实现
72
+
73
+ DDL / 路由 / 契约产物
74
+ ```
75
+
76
+ - **蓝图态**:journey 引用概念名即可串线——概念不需要表、不需要页面,只需要名字存在。
77
+ - **锚定态**:概念长出结构后,同一引用自然升级(引用依然有效,只是"对象"变厚了)。
78
+ - **名字未长出结构的比例 = 细化度**:概念清单可统计"系统共 N 个概念,M 个已落表"——这是蓝图完成度的天然度量。
79
+
80
+ ## 身份契约
81
+
82
+ 概念名是**跨层身份**的唯一来源:
83
+
84
+ - journey 说"商户入驻" → 引用 `merchant` 概念
85
+ - 表说 `merchant.table.ts` → 引用 `merchant` 概念
86
+ - 页面说"商户列表页" → 引用 `merchant` 概念
87
+ - lint 检查字段 `mer_id` → 查概念的 `phrase`(词根 `mer`)
88
+
89
+ 所有维度说同一个词,指同一个概念;校验"引用的概念是否存在"是各维度的第一道闸门。
90
+
91
+ ## 校验草案
92
+
93
+ | 规则 | 检查 |
94
+ |------|------|
95
+ | C1 | `name` 唯一、kebab-case;一文件一概念清单(`_concepts.ts` 专用名) |
96
+ | C2 | `description` 必填(经典段落,非空) |
97
+ | C3 | `phrase` 引用必须指向 `_dictionary.ts` 中已定义的短语条目 |
98
+ | C4 | 下游引用(journey / table / PageFlow / DTO)引用的概念必须存在于 `_concepts.ts` |
99
+ | C5 | 概念引用词根时,词根必须与该概念的中文语义一致(人工评审裁决) |
100
+
101
+ ## 消费者(未来)
102
+
103
+ | 消费者 | 用途 |
104
+ |--------|------|
105
+ | journey(蓝图/旅程) | 步骤引用概念名——跨 app 业务线的身份锚点 |
106
+ | table.ts | 表归属概念(现有 `phrase` 链接的上一级) |
107
+ | PageFlow | 页面归属概念 |
108
+ | lint | 跨层身份校验(C4) |
109
+ | 文档/评审 | 概念清单渲染为术语表(markdown) |
package/docs/table.md CHANGED
@@ -131,47 +131,47 @@ export const audit = defineTable('audit', {
131
131
 
132
132
  > **外键是逻辑作用**:`foreignKeys` 用于定义期命名强校验与关系表达,**DDL 默认不渲染物理 FOREIGN KEY 约束**(`pylonts gen sql init` 不传 `generateForeignKeys`)。数据完整性由 Service/DAO 层保证;如需物理约束,调用 `buildCreateTableSql(schema, { generateForeignKeys: true })`。
133
133
 
134
- ## 扩展表(extends)
135
-
136
- 扩展表表示“本表是某张根表的延伸”:主键与根表主键同义、类型一致,生命周期跟随根表(创建 / 保存 / 删除一起做)。除这两条外,扩展表与普通表完全一样,可以有索引、外键、被其他表引用等。
137
-
138
- ### 定义
139
-
140
- ```ts
141
- // order 根表
142
- export const order = defineTable('order', {
143
- ...
144
- primaryKey: id,
145
- });
146
-
147
- // order_address 是 order 的扩展表
148
- const addressId = stringField({ maxLength: 32 }); // 与 order.id 同类型
149
-
150
- export const orderAddress = defineTable('order_address', {
151
- extends: order, // 声明本表是 order 的扩展
152
- primaryKey: addressId, // 主键与根主键同义
153
- columns: {
154
- id: addressId,
155
- receiver_name: stringField({ label: '收货人', maxLength: 32, optional: false }),
156
- ...
157
- },
158
- });
159
- ```
160
-
161
- ### 规则
162
-
163
- - 扩展表的主键与根表主键同义,类型必须一致;
164
- - 根表不能是扩展表;
165
- - 扩展表不能再 `extends`(禁止链式延伸);
166
- - 一个根表可以有多个扩展表;
167
- - 除主键和生命周期外,扩展表与普通表完全一样:可以有普通外键、索引、枚举等,也可被其他表引用;
168
- - 扩展表不需要像普通外键那样命名 `{phrase}_{field}`,也不需要显式声明 `foreignKeys` 来表达与根的关系,`extends` 本身就是关系。
169
-
170
- ### 与聚合的关系
171
-
172
- - `members: { items: [orderItem] }`:数组 → 普通表 → 一对多;
173
- - `members: { address: orderAddress }`:非数组 → 扩展表 → 一对一。
174
-
134
+ ## 扩展表(extends)
135
+
136
+ 扩展表表示“本表是某张根表的延伸”:主键与根表主键同义、类型一致,生命周期跟随根表(创建 / 保存 / 删除一起做)。除这两条外,扩展表与普通表完全一样,可以有索引、外键、被其他表引用等。
137
+
138
+ ### 定义
139
+
140
+ ```ts
141
+ // order 根表
142
+ export const order = defineTable('order', {
143
+ ...
144
+ primaryKey: id,
145
+ });
146
+
147
+ // order_address 是 order 的扩展表
148
+ const addressId = stringField({ maxLength: 32 }); // 与 order.id 同类型
149
+
150
+ export const orderAddress = defineTable('order_address', {
151
+ extends: order, // 声明本表是 order 的扩展
152
+ primaryKey: addressId, // 主键与根主键同义
153
+ columns: {
154
+ id: addressId,
155
+ receiver_name: stringField({ label: '收货人', maxLength: 32, optional: false }),
156
+ ...
157
+ },
158
+ });
159
+ ```
160
+
161
+ ### 规则
162
+
163
+ - 扩展表的主键与根表主键同义,类型必须一致;
164
+ - 根表不能是扩展表;
165
+ - 扩展表不能再 `extends`(禁止链式延伸);
166
+ - 一个根表可以有多个扩展表;
167
+ - 除主键和生命周期外,扩展表与普通表完全一样:可以有普通外键、索引、枚举等,也可被其他表引用;
168
+ - 扩展表不需要像普通外键那样命名 `{phrase}_{field}`,也不需要显式声明 `foreignKeys` 来表达与根的关系,`extends` 本身就是关系。
169
+
170
+ ### 与聚合的关系
171
+
172
+ - `members: { items: [orderItem] }`:数组 → 普通表 → 一对多;
173
+ - `members: { address: orderAddress }`:非数组 → 扩展表 → 一对一。
174
+
175
175
  ## 生成 SQL
176
176
 
177
177
  见 [driver.md](./driver.md)。
package/docs/task.md ADDED
@@ -0,0 +1,81 @@
1
+ # 任务 (Task)
2
+
3
+ Task 是**定时任务(定时器)**的定义——**action 闭包的第二成员**(action = controller | task | third callback,无第四种)。
4
+
5
+ ```
6
+ action = trigger =
7
+ ① controller —— 前端/外部调用的 RPC 入口
8
+ ② task —— 定时器(cron 驱动):到期退款、结算、提现、报表、关单
9
+ ③ third callback —— 外部系统主动回调
10
+ ```
11
+
12
+ - **Task 只表示定时器**(cron 驱动)。异步/事件驱动的操作归 event 体系(EventObserver / EventNotifier,待设计),不属于 task。
13
+ - Task 是**契约**(名字 + cron + 做什么),不声明状态变化——状态变化由 journey 步骤表达。
14
+ - Task 实现(扫描逻辑、幂等)在实现层(如 pylon-flow step)。
15
+
16
+ ## 定义
17
+
18
+ ```ts
19
+ // task_schema/auto-refund.task.ts
20
+ export const AutoRefundTask = defineTask({
21
+ name: 'AutoRefundTask',
22
+ label: '到期自动退款',
23
+ cron: '0 3 * * *',
24
+ description: '扫描已锁定券码 + auto_refund + valid_to<now → 按订单发起全额退款',
25
+ });
26
+ ```
27
+
28
+ ## 字段
29
+
30
+ | 字段 | 类型 | 必填 | 说明 |
31
+ |---|---|---|---|
32
+ | `name` | string | ✅ | `XxTask`(PascalCase + `Task` 后缀) |
33
+ | `label` | string | ✅ | 中文名 |
34
+ | `cron` | string | ✅ | 定时表达式(系统怎么触发的契约,一处看全) |
35
+ | `description` | string | 可选 | 做什么(叙述) |
36
+
37
+ ## 命名与存储规则
38
+
39
+ | 约定 | 规则 | 例子 |
40
+ |---|---|---|
41
+ | `name` | PascalCase + `Task` 后缀 | `AutoRefundTask` |
42
+ | 导出符号 | = name | `export const AutoRefundTask` |
43
+ | 文件位置 | `task_schema/`(项目根,与 schema/ 并列) | `task_schema/auto-refund.task.ts` |
44
+ | 文件名 | name 去 `Task` 后缀转 kebab + `.task.ts` | `auto-refund.task.ts` |
45
+ | 一文件一 task | loader 机器校验(仿 loadDaos/loadEntities) | 多导出/零导出报错 |
46
+
47
+ ## 校验
48
+
49
+ - 运行时(`defineTask`):
50
+ - `name` 必须以 `Task` 结尾,否则抛错
51
+ - `cron` 必填(task 是定时器),否则抛错
52
+ - 存储(`loadTasks`):
53
+ - 唯一合法目录是 `task_schema/` 根(一级)
54
+ - **导出符号 == schema name**,否则抛错
55
+ - 文件名 = name 去 `Task` 后缀转 kebab + `.task.ts`,否则抛错
56
+ - 一文件一 task(多导出/零导出报错)
57
+
58
+ ## 作为 action 引用
59
+
60
+ Task 与 controller、third callback 并列,是 action 闭包成员,被状态迁移与 journey 步骤引用:
61
+
62
+ ```ts
63
+ // journey 步骤
64
+ { action: AutoRefundTask, host: api, text: '到期自动退款' }
65
+ ```
66
+
67
+ lint 校验:`action` 引用必须是三类之一(controller | task | third callback)且引用存在。
68
+
69
+ ## 与 pylon-flow 的关系
70
+
71
+ pylon-flow 的 step 函数是 task 的实现形态之一:
72
+
73
+ ```ts
74
+ // flow/flows/settlement.flow.ts(api driver)
75
+ export async function autoRefund(flow, deps) { ... } // 实现 AutoRefundTask
76
+ export default { autoRefund };
77
+ ```
78
+
79
+ - **task schema = 契约**(名字/cron/做什么)
80
+ - **pylon-flow step = 实现**(怎么跑)
81
+ - 对账:task schema 的 name ↔ pylon-flow 的 step names——"定时任务已声明但没实现"可 lint
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonts/dsl",
3
- "version": "1.1.21",
3
+ "version": "1.1.22",
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,87 @@
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
- }
1
+ import { SchemaBase } from './dsl.js';
2
+ import type { ControllerMethodSchema } from './controller.js';
3
+ import type { ThirdServiceMethodSchema, ThirdCallbackSchema } from './third-service.js';
4
+ import type { TaskSchema } from './task.js';
5
+ import type { TableSchema } from './db.js';
6
+ import type { PageSchema } from './page.js';
7
+
8
+ // Cross-domain actions: what happens along a business line (journey).
9
+ // Distinguished from PageActionSchema (what a user can do ON a page):
10
+ // an Action is one beat of a journey — a page visit, an API call, a
11
+ // third-party invocation, a database write, or a task trigger.
12
+ //
13
+ // Every action is `type + properties + data`: the type picks the property
14
+ // set (page for page visits, method for controller/third, table for db,
15
+ // task for task triggers), and `data` is the input of that beat. No further
16
+ // classification page/controller/third/db/task are all actions, only their
17
+ // properties differ.
18
+
19
+ /** A beat of a journey: type + properties + input data. */
20
+ export interface ActionSchema extends SchemaBase {
21
+ type: 'page' | 'controller' | 'third' | 'db' | 'task';
22
+ /** Input data of this beat (blueprint: names first, refined to refs later). */
23
+ data?: Record<string, unknown>;
24
+ }
25
+
26
+ /** Visit a page — `data` present means fill/submit a form, absent means pure view. */
27
+ export interface PageAction extends ActionSchema {
28
+ type: 'page';
29
+ /** Target page (PageFlow node). */
30
+ page: PageSchema;
31
+ /** Page url/path (e.g. '/bd/apply'). */
32
+ url: string;
33
+ }
34
+
35
+ /** Call a backend controller method. */
36
+ export interface ControllerAction extends ActionSchema {
37
+ type: 'controller';
38
+ /** The controller method invoked (shared instance). */
39
+ method: ControllerMethodSchema;
40
+ }
41
+
42
+ /** Invoke a third-party service method or receive its callback. */
43
+ export interface ThirdAction extends ActionSchema {
44
+ type: 'third';
45
+ /** The third-party method invoked (outbound). */
46
+ method?: ThirdServiceMethodSchema;
47
+ /** The third-party callback received (inbound) — mutually exclusive with method. */
48
+ callback?: ThirdCallbackSchema;
49
+ /** Wait for the async callback (inbound) before the journey continues. */
50
+ async?: boolean;
51
+ }
52
+
53
+ /** Write to a database table. */
54
+ export interface DbAction extends ActionSchema {
55
+ type: 'db';
56
+ /** The table written (shared instance). */
57
+ table: TableSchema;
58
+ /** Write operation: insert | update | delete. */
59
+ op: 'insert' | 'update' | 'delete';
60
+ }
61
+
62
+ /** Trigger a system task (scheduled or async). */
63
+ export interface TaskAction extends ActionSchema {
64
+ type: 'task';
65
+ /** The task triggered (shared instance). */
66
+ task: TaskSchema;
67
+ }
68
+
69
+ /** Builder for a journey action. */
70
+ export const action = {
71
+ page(options: { page: PageSchema; url: string; data?: Record<string, unknown>; description?: string }): PageAction {
72
+ return { name: options.page.name, type: 'page', page: options.page, url: options.url, data: options.data, description: options.description };
73
+ },
74
+ controller(options: { method: ControllerMethodSchema; data?: Record<string, unknown>; description?: string }): ControllerAction {
75
+ return { name: options.method.name, type: 'controller', method: options.method, data: options.data, description: options.description };
76
+ },
77
+ third(options: { method?: ThirdServiceMethodSchema; callback?: ThirdCallbackSchema; data?: Record<string, unknown>; async?: boolean; description?: string }): ThirdAction {
78
+ const name = options.method?.name ?? options.callback?.name ?? 'third';
79
+ return { name, type: 'third', method: options.method, callback: options.callback, data: options.data, async: options.async, description: options.description };
80
+ },
81
+ db(options: { table: TableSchema; op: 'insert' | 'update' | 'delete'; data?: Record<string, unknown>; description?: string }): DbAction {
82
+ return { name: options.table.name, type: 'db', table: options.table, op: options.op, data: options.data, description: options.description };
83
+ },
84
+ task(options: { task: TaskSchema; data?: Record<string, unknown>; description?: string }): TaskAction {
85
+ return { name: options.task.name, type: 'task', task: options.task, data: options.data, description: options.description };
86
+ },
87
+ };
package/src/aggregate.ts CHANGED
@@ -1,95 +1,95 @@
1
- import type { SchemaBase } from './dsl.js';
2
- import type { TableSchema } from './db.js';
3
-
4
- // Aggregate declaration: groups multiple tables into one domain concept with
5
- // a root table, member tables, cross-member invariants and inter-aggregate
6
- // reference rules. This turns "multi-table consistency" from a convention
7
- // (hand-written in flows) into a constraint (lintable, codegen-able).
8
- //
9
- // Members are intentionally minimal:
10
- // members: { items: [orderItem] } -> array = 1:N (normal table)
11
- // members: { address: orderAddress } -> single = 1:1 (extension table;
12
- // designed but not implemented yet)
13
-
14
- /** Member table(s) keyed by role name. Array = 1:N; non-array = 1:1 extension. */
15
- export type AggregateMember = TableSchema | TableSchema[];
16
-
17
- /** A cross-member invariant, checked by generated repository code. */
18
- export interface AggregateInvariant {
19
- name: string;
20
- /** Expression in the aggregate's field vocabulary (e.g. 'total == sum(items.price * items.qty)'). */
21
- check: string;
22
- }
23
-
24
- export interface DomainAggregate extends SchemaBase {
25
- type: 'aggregate';
26
- /** The aggregate root table. */
27
- root: TableSchema;
28
- /** Member tables keyed by role name (e.g. 'items', 'address'). */
29
- members: Record<string, AggregateMember>;
30
- /** Cross-member invariants; optional. */
31
- invariants?: AggregateInvariant[];
32
- /** Inter-aggregate references: only by root ID, keyed by referenced role. */
33
- references?: Record<string, string>;
34
- }
35
-
36
- export function defineAggregate(options: {
37
- root: TableSchema;
38
- members?: Record<string, AggregateMember>;
39
- invariants?: AggregateInvariant[];
40
- references?: Record<string, string>;
41
- description?: string;
42
- }): DomainAggregate {
43
- const schema: DomainAggregate = {
44
- type: 'aggregate',
45
- name: options.root.name,
46
- description: options.description,
47
- root: options.root,
48
- members: options.members ?? {},
49
- invariants: options.invariants,
50
- references: options.references,
51
- };
52
-
53
- // Root must have a primary key (aggregate identity).
54
- if (options.root.primaryKey === undefined) {
55
- throw new Error(`aggregate '${schema.name}': root table '${options.root.name}' must have a primary key`);
56
- }
57
-
58
- const rootPkRefs = Array.isArray(options.root.primaryKey)
59
- ? options.root.primaryKey
60
- : [options.root.primaryKey];
61
-
62
- // Each member must attach to the root. Array members are normal 1:N tables
63
- // and must have exactly one FK referencing the root. Non-array (1:1 extension)
64
- // members are designed but not implemented yet.
65
- for (const [role, member] of Object.entries(schema.members)) {
66
- if (Array.isArray(member)) {
67
- if (member.length !== 1) {
68
- throw new Error(
69
- `aggregate '${schema.name}': member '${role}' array must contain exactly one table schema`,
70
- );
71
- }
72
- const table = member[0];
73
- const fks = Object.values(table.foreignKeys ?? {}).filter((fk) => {
74
- const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
75
- return refs.length === rootPkRefs.length && refs.every((r) => rootPkRefs.includes(r));
76
- });
77
- if (fks.length === 0) {
78
- throw new Error(
79
- `aggregate '${schema.name}': member '${role}' table '${table.name}' has no foreign key referencing root '${options.root.name}' — declare one in the table's foreignKeys`,
80
- );
81
- }
82
- if (fks.length > 1) {
83
- throw new Error(
84
- `aggregate '${schema.name}': member '${role}' table '${table.name}' has ${fks.length} foreign keys referencing root '${options.root.name}' — reduce to one FK for minimal aggregate declarations`,
85
- );
86
- }
87
- } else {
88
- throw new Error(
89
- `aggregate '${schema.name}': member '${role}' is a non-array table — 1:1 extension tables are designed but not implemented yet; use an array for 1:N members`,
90
- );
91
- }
92
- }
93
-
94
- return schema;
1
+ import type { SchemaBase } from './dsl.js';
2
+ import type { TableSchema } from './db.js';
3
+
4
+ // Aggregate declaration: groups multiple tables into one domain concept with
5
+ // a root table, member tables, cross-member invariants and inter-aggregate
6
+ // reference rules. This turns "multi-table consistency" from a convention
7
+ // (hand-written in flows) into a constraint (lintable, codegen-able).
8
+ //
9
+ // Members are intentionally minimal:
10
+ // members: { items: [orderItem] } -> array = 1:N (normal table)
11
+ // members: { address: orderAddress } -> single = 1:1 (extension table;
12
+ // designed but not implemented yet)
13
+
14
+ /** Member table(s) keyed by role name. Array = 1:N; non-array = 1:1 extension. */
15
+ export type AggregateMember = TableSchema | TableSchema[];
16
+
17
+ /** A cross-member invariant, checked by generated repository code. */
18
+ export interface AggregateInvariant {
19
+ name: string;
20
+ /** Expression in the aggregate's field vocabulary (e.g. 'total == sum(items.price * items.qty)'). */
21
+ check: string;
22
+ }
23
+
24
+ export interface DomainAggregate extends SchemaBase {
25
+ type: 'aggregate';
26
+ /** The aggregate root table. */
27
+ root: TableSchema;
28
+ /** Member tables keyed by role name (e.g. 'items', 'address'). */
29
+ members: Record<string, AggregateMember>;
30
+ /** Cross-member invariants; optional. */
31
+ invariants?: AggregateInvariant[];
32
+ /** Inter-aggregate references: only by root ID, keyed by referenced role. */
33
+ references?: Record<string, string>;
34
+ }
35
+
36
+ export function defineAggregate(options: {
37
+ root: TableSchema;
38
+ members?: Record<string, AggregateMember>;
39
+ invariants?: AggregateInvariant[];
40
+ references?: Record<string, string>;
41
+ description?: string;
42
+ }): DomainAggregate {
43
+ const schema: DomainAggregate = {
44
+ type: 'aggregate',
45
+ name: options.root.name,
46
+ description: options.description,
47
+ root: options.root,
48
+ members: options.members ?? {},
49
+ invariants: options.invariants,
50
+ references: options.references,
51
+ };
52
+
53
+ // Root must have a primary key (aggregate identity).
54
+ if (options.root.primaryKey === undefined) {
55
+ throw new Error(`aggregate '${schema.name}': root table '${options.root.name}' must have a primary key`);
56
+ }
57
+
58
+ const rootPkRefs = Array.isArray(options.root.primaryKey)
59
+ ? options.root.primaryKey
60
+ : [options.root.primaryKey];
61
+
62
+ // Each member must attach to the root. Array members are normal 1:N tables
63
+ // and must have exactly one FK referencing the root. Non-array (1:1 extension)
64
+ // members are designed but not implemented yet.
65
+ for (const [role, member] of Object.entries(schema.members)) {
66
+ if (Array.isArray(member)) {
67
+ if (member.length !== 1) {
68
+ throw new Error(
69
+ `aggregate '${schema.name}': member '${role}' array must contain exactly one table schema`,
70
+ );
71
+ }
72
+ const table = member[0];
73
+ const fks = Object.values(table.foreignKeys ?? {}).filter((fk) => {
74
+ const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
75
+ return refs.length === rootPkRefs.length && refs.every((r) => rootPkRefs.includes(r));
76
+ });
77
+ if (fks.length === 0) {
78
+ throw new Error(
79
+ `aggregate '${schema.name}': member '${role}' table '${table.name}' has no foreign key referencing root '${options.root.name}' — declare one in the table's foreignKeys`,
80
+ );
81
+ }
82
+ if (fks.length > 1) {
83
+ throw new Error(
84
+ `aggregate '${schema.name}': member '${role}' table '${table.name}' has ${fks.length} foreign keys referencing root '${options.root.name}' — reduce to one FK for minimal aggregate declarations`,
85
+ );
86
+ }
87
+ } else {
88
+ throw new Error(
89
+ `aggregate '${schema.name}': member '${role}' is a non-array table — 1:1 extension tables are designed but not implemented yet; use an array for 1:N members`,
90
+ );
91
+ }
92
+ }
93
+
94
+ return schema;
95
95
  }
package/src/component.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import type { SchemaBase } from './dsl.js';
2
2
  import type { RefSchema } from './ref.js';
3
- import type { ActionSchema } from './action.js';
3
+ import type { PageActionSchema } from './page-action.js';
4
4
  import type { EventDataSchema } from './event.js';
5
5
 
6
6
  /** A component event trigger declaration. */
7
7
  export interface TriggerSchema extends SchemaBase {
8
8
  /** Data the event carries (e.g. e.detail). */
9
9
  eventData?: EventDataSchema;
10
- /** Actions that fire when the event occurs. */
11
- actions?: ActionSchema[];
10
+ /** Page actions that fire when the event occurs. */
11
+ actions?: PageActionSchema[];
12
12
  }
13
13
 
14
14
  /** A UI component declaration — a virtual schema that describes props and