@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/docs/token.md ADDED
@@ -0,0 +1,327 @@
1
+ # Token 令牌体系 DSL 扩展提案
2
+
3
+ > 状态:**提案(待决策)**
4
+ > 关联代码:`pylon/src/types.ts`(User interface)、`pylon-fastify/src/common/auth/*`(签名/JWT 鉴权策略)、`pylon-sign-db-driver`/`pylon-sign-redis-driver`(密钥解析)、`dsl/src/dto.ts`(DTO)、`dsl/src/service.ts`(service)
5
+ > 背景:BLE 案例 Customer 无处安放 + `@pylonts/core` 的 User 概念过弱
6
+ > **定位(已决策):推翻现有的"HMAC 签名 + JWT 登录"双层鉴权体系,合并为 token 令牌体系**——签名/加密材料与身份数据统一由同一个两态对象承载
7
+
8
+ ## 1. 背景:Customer 的身份问题
9
+
10
+ BLE 案例(business-ble)中,Java 侧 `Customer.java` 是 **POS 服务器端映射对象**——它由服务器上下文注入(请求进来时从卡/设备上下文解析出的客户),不是数据库表、不是 DTO 消息、不是实体行对象。pylon 迁移时它被表达成三份互相独立的重复:
11
+
12
+ | 位置 | 表达方式 | 问题 |
13
+ |------|---------|------|
14
+ | `utils_schema/ble-api/ble-wx/utils/ble.utils.ts` | `objectField({ properties: {...} })` 内联为 `getBsId/getOpId` 的参数 | 内联,无身份、无复用 |
15
+ | `dto_schema/ble-wx/ble-charge.dto.ts` | `Customer` 是一个 `buildInput` DTO | 它根本不是消息契约,是身份对象 |
16
+ | 同上 | `SubmitCpuRequest.customer` 内联 `dtoObjectField` 重复同一份结构 | 与 Customer DTO 结构重复、无单一事实来源 |
17
+
18
+ 三份结构必须手工保持一致,字段变了要改三处。**根本原因:pylon 没有"服务器端映射对象"这个概念**。
19
+
20
+ ## 2. 安全定级依据:等保 2.0 三级
21
+
22
+ 涉及银联/支付宝支付的 app(商城、收单平台、支付小程序),定级逻辑(GB/T 22240-2020):涉及资金交易 + 大规模个人信息,受破坏后"对社会秩序和公共利益造成严重损害" → **第三级(监督保护级)**。金融行业惯例:支付清算核心系统四级;支付类平台/app 三级。银联/支付宝/微信支付的合作准入(收单外包、服务商尽调)均以等保三级为门槛(案例:地铁支付宝小程序、收钱吧)。三级义务:**每年至少一次等级测评 + 渗透测试**,近 300 项要求、73 类测评分类。
23
+
24
+ 三级对应用/会话层的硬性要求(GB/T 22239-2019)与本设计的映射:
25
+
26
+ | 条款 | 要求 | 对 token 令牌体系的意义 |
27
+ |------|------|-------------------------------|
28
+ | 8.1.4.1 身份鉴别 | 身份标识唯一;**两种以上鉴别技术组合**(即双因子);登录失败锁定(≤5 次/≥30 分钟);**会话空闲超时断开** | token 固定 30 分钟过期(Redis TTL)+ refreshToken 刷新;删 token = 终止会话;双因子是登录链要补的能力 |
29
+ | 8.1.4.3 安全审计 | 审计覆盖每个用户和重要操作(支付/退款/核销),记录防篡改,**保存 ≥6 个月** | Token 的 `identity` 段正是审计"谁在什么时间做了什么"的锚点;服务端 Redis 对象比 JWT payload 更便于审计追踪 |
30
+ | 8.1.4.7/8.1.4.8 数据完整性/保密性 | 传输加密(TLS)、**存储加密**(银行卡号等敏感数据) | 卡号/账户字段在 Token、表、DTO 全链路要有敏感字段标记(加密落库/脱敏展示) |
31
+ | 8.1.4.10 个人信息保护 | 仅采集必要信息、去标识化 | Token 字段最小化(identity + 必要业务字段) |
32
+ | 移动互联扩展(附录 A2) | 移动应用加固、会话管理、防逆向 | 客户端只存 token(纯 hash)而非完整身份信息 |
33
+
34
+ 结论对提案的支撑:
35
+
36
+ 1. **token 令牌体系与等保三级同向**:Redis 存对象 + token 纯 hash = 会话可终止、可审计、空闲超时可控、对象实时更新(改密后旧会话即时失效);JWT 反序列化模式在"会话终止"和"审计"上是短板。
37
+ 2. **双因子认证**进入落地范围(登录链生成时考虑口令 + 短信/OTP 组合)。
38
+ 3. **敏感字段标记**(卡号等)是 Token 设计要考虑的维度——等保要求存储加密,字段级敏感标记驱动加解密与脱敏。
39
+
40
+ ### 2.1 token 体系等保三级合规性审查
41
+
42
+ 逐条对照三级要求审查 token 体系是否成立:
43
+
44
+ | 条款 | 要求 | token 体系是否满足 | 结论 |
45
+ |------|------|-------------------|------|
46
+ | 8.1.4.1 身份鉴别 | 身份标识唯一 | token 随机 hash 唯一(Redis key) | ✅ |
47
+ | 8.1.4.1 身份鉴别 | 两种以上鉴别技术组合(双因子) | 登录 = 口令 + 签名密钥(密码技术因子,token 体系已保证客户端持有)——可论证成立;**但落地必须禁止纯口令登录** | ⚠️ 落地约束 |
48
+ | 8.1.4.1 身份鉴别 | 登录失败锁定(≤5 次/≥30 分钟) | 未设计 | ⚠️ 落地必须补 |
49
+ | 8.1.4.1 身份鉴别 | 会话空闲超时断开 / 终止会话 | Redis TTL + 删 token | ✅ |
50
+ | 8.1.4.3 安全审计 | 每用户、重要操作审计,防篡改,≥6 个月 | identity 段作审计锚点;**Redis 不承担审计——审计日志独立持久化** | ⚠️ 边界注明 |
51
+ | 8.1.4.4 入侵防范 | 防重放攻击 | 签名含 timestamp + nonce(**不退役**);token 不可解析防篡改 | ✅ |
52
+ | 8.1.4.7 数据完整性 | 传输完整性保护 | 每请求签名,覆盖 body + token | ✅ |
53
+ | 8.1.4.8 数据保密性 | 传输加密、存储加密 | TLS + 可选加密密钥;**Redis 中的签名密钥需加密存储 + Redis 自身按等保加固**(密码认证、访问控制、超时断开) | ⚠️ 落地约束 |
54
+ | 8.1.4.10 个人信息保护 | 最小化、去标识化 | identity 段字段最小化(key + 必要业务字段) | ✅ |
55
+ | 附录 A2 移动互联 | 移动应用加固、会话管理 | 客户端只存纯 hash token + 密钥,无身份数据 | ✅ |
56
+
57
+ **审查结论:体系成立**,前提是落地时补四个硬约束:
58
+
59
+ 1. **防重放不退役**:签名机制保留 timestamp + nonce(退役的只是独立密钥解析体系);
60
+ 2. **双因子落地**:登录 = 口令 + 签名密钥,禁止纯口令登录(等保要求两种鉴别技术组合);
61
+ 3. **登录失败锁定**:≤5 次 / 锁 ≥30 分钟;
62
+ 4. **Redis 安全加固**:签名密钥加密存储(不落明文)、Redis 密码认证 + 访问控制 + 超时断开(等保对 Redis 有专项测评)、审计日志独立持久化(Redis 不作审计载体)。
63
+
64
+ ## 3. 现状:签名 + 登录双层体系,两者独立
65
+
66
+ 现有鉴权是**两个互不相关的层**(现状明文:"HMAC 证明'请求来自合法客户端',JWT 证明'用户是谁'——两者独立,互不相关"):
67
+
68
+ | 层 | 机制 | 服务端载体 | 密钥/凭证 | 吊销 |
69
+ |----|------|-----------|----------|------|
70
+ | HMAC 签名层 | 客户端对每请求签名(x-app-key/x-timestamp/x-nonce/x-signature) | `SignatureStrategy` + SignUtils,密钥由 sign-db-driver / sign-redis-driver 按需查表解析 | appKey + secret(sign/issue 签发) | nonce 防重放窗口 |
71
+ | JWT 登录层 | 登录后 getToken() 附 Authorization header | `JwtStrategy`:`jwtVerify()` 后 `request.user as User` 反序列化 payload | jwt secret + payload 对象 | 可选 blacklist 检查 |
72
+
73
+ 双层体系的问题:
74
+
75
+ 1. **身份与通信材料分离**:验签走密钥存储、认证走 token payload,两条路径、两套过期与吊销管理;
76
+ 2. **每请求两轮验证**:先验签、再验 token;
77
+ 3. **User 弱**:`@pylonts/core` 手写 interface `{ id; role?; type? }`,三处各自为政构建(login 手写字面量 / HMAC `{ id: appKey }` / JWT `as User` 强转),无声明、无校验、无生成,controller 全是 `_user: User` 弃用参数;
78
+ 4. **DTO 注入只映射一个 id**:`__inject` 适配器(`InjectFn = (body, user) => void`)已有设计但 User 只有 id,多属性映射无能力;
79
+ 5. **安全状态不达标**:JWT 反序列化在"会话终止/审计"上是短板(§2 等保三级要求),blacklist 是补丁式吊销。
80
+
81
+ ## 4. 提案:TokenSchema —— 用户身份对象
82
+
83
+ **定义**:Token 是服务器端映射的用户身份对象(登录主体、客户身份),由服务器上下文注入,具有**声明式定义、唯一身份、类型生成**三个一等公民属性。**没有多来源**:每个模块(api + app)一份身份,归属确定。
84
+
85
+ **口径约定**:大写 **Token** = 服务端两态对象(本 DSL 声明的对象);小写 **token** = 客户端持有的随机 hash 字符串(纯引用,不可解析)。
86
+
87
+ **与现有概念的边界**:
88
+
89
+ | 概念 | 回答的问题 | 数据来源 |
90
+ |------|-----------|---------|
91
+ | TableSchema | 数据存在哪张表 | 数据库 DDL |
92
+ | DtoMessage | 消息契约长什么样 | 请求/响应体 |
93
+ | EntitySchema | DAO 行对象长什么样 | 表行 |
94
+ | **TokenSchema** | **"我是谁"(用户身份)** | **服务器上下文注入** |
95
+
96
+ ### 4.1 DSL 声明
97
+
98
+ ```ts
99
+ // token_schema/api/admin/token/admin-user.token.ts
100
+ export const adminUserToken = defineToken({
101
+ name: 'AdminUser',
102
+ description: 'admin 后台登录主体',
103
+ api: api, // 归属后端(模块双定:api + app)
104
+ app: admin,
105
+ security: { // 安全材料段:签名、加密数据(未登录即有)
106
+ ...from(apiKeyTable, [apiKeyTable.columns.app_key, apiKeyTable.columns.secret, apiKeyTable.columns.cipher]),
107
+ },
108
+ identity: { // 身份段:登录后才有
109
+ ...from(adminUserTable, [adminUserTable.columns.id, adminUserTable.columns.username]),
110
+ },
111
+ key: 'id', // 日志/审计用的身份锚点字段
112
+ });
113
+ ```
114
+
115
+ **两段结构(已决策)**:Token 与一般身份对象不同,它存储**两类数据**,且两类数据的**存在时机不同**:
116
+
117
+ | 段 | 内容 | 存在时机 | 作用 |
118
+ |----|------|---------|------|
119
+ | `security` 安全材料段 | 签名 key、加密数据(与客户端通信材料) | **未登录即有**(appKey 签名阶段) | HMAC 验签、通道加解密 |
120
+ | `identity` 身份段 | 身份数据(id/username/bsId 等) | **登录后才有** | 业务消费"我是谁" |
121
+
122
+ ```
123
+ 未登录态:Token = { security } (匿名客户端:只有签名、加密数据)
124
+ 登录态: Token = { security } + { identity } (登录后身份附着到同一对象)
125
+ ```
126
+
127
+ **与 JWT 的本质区别(已决策)**:JWT 是"登录后才签发身份凭证"——无登录无 token;Token 是**两态对象**——未登录就有对象(通信材料),登录只是给对象**附着身份**。token 始终只是对象引用,不携带任何数据。
128
+
129
+ **声明规则**:
130
+
131
+ | 规则 | 含义 |
132
+ |------|------|
133
+ | `api` + `app` 双必填 | Token 归属模块,一个后端一个身份,与 service/dao 的归属规则一致 |
134
+ | **所有字段全部来源于表**(已决策) | 两段字段都必须 `from()` 表列,**可以是多张表**;不允许内联字段——身份数据不是凭空声明的,都有表落点(`from` 天然继承类型/校验/语义) |
135
+ | `key` 指定身份锚点字段 | 必须指向 identity 段中存在的必填字段(日志、审计用),替代现在 `user.id` 的约定 |
136
+
137
+ **身份有效数据的确定时机(已决策):login 时**——登录成功时从表读取 identity 段字段,附着到 Redis 已有对象(security 段在获取 token 时已存在);此后身份有效数据以对象为准(不再回查表)。对象失效(删 token)即重新登录。
138
+
139
+ ### 4.2 token 令牌体系(已决策)
140
+
141
+ **token 形态(已决策)**:token 是 **xx 位随机数(hash 值)**——只有令牌信息、无任何其他数据、**不可解析**。它只是一个引用,无 payload、无 exp(过期完全由 Redis 键 TTL 管理),**JWT 完全退役**。
142
+
143
+ **客户端持有(已决策)**:
144
+
145
+ | 材料 | 必须性 | 获取时机 |
146
+ |------|--------|---------|
147
+ | token(随机 hash) | 必须 | 调用"获取 token"接口后 |
148
+ | 签名密钥 | 必须 | 调用"获取 token"接口后 |
149
+ | 加密密钥 | 可选 | 调用"获取 token"接口后 |
150
+ | 业务数据(身份数据) | 不一定需要 | 调用 login 后 |
151
+
152
+ **客户端使用规则(已决策)**:
153
+
154
+ - token **每次请求必须上送**——**唯一例外:获取 token 接口**(首个请求,无 token 可上送);
155
+ - **所有接口都需要签名**(包括获取 token 接口——初始密钥见下方"bootstrap 已决策:方案 2");无 token 的请求(获取 token、未携带 token 的 login)用初始密钥签名;
156
+ - 签名密钥对请求签名(token 在签名范围内),加密密钥用于可选通道加密。
157
+
158
+ **接口清单(已决策)**:
159
+
160
+ | 接口 | 场景 | 是否上送 token | 签名材料 | 返回 |
161
+ |------|------|--------------|---------|------|
162
+ | 获取 token | 仅未登录浏览场景(可选) | **否**(首个请求) | 初始密钥(方案 2) | token + 签名密钥(+ 加密密钥?);**不返回 refreshToken** |
163
+ | login | 所有场景必选 | 有则上送(未登录浏览场景的匿名 token;微信等已登录场景无 token) | 有 token 用其 security 段;无 token 用初始密钥 | 新 token + refreshToken + 业务数据? |
164
+ | refresh | 所有场景必选 | 否(上送 refreshToken) | **refreshToken 派生密钥**(`HMAC(refreshToken, 固定盐)`,两端可算,已决策) | 新 token |
165
+
166
+ **标准配置 = login + refresh 两个接口**(微信等已有登录体系的场景);**支持未登录浏览时加"获取 token"接口,共 3 个**。refreshToken **只在 login 时返回**,获取 token 接口不返回。
167
+
168
+ **服务端存储(已决策)**:**Redis 按 token 存储信息**——签名密钥、加密密钥等通讯信息(security 段)+ 用户身份信息(identity 段,login 之后)。**token TTL 固定 30 分钟**,过期后经 refreshToken 重新生成。
169
+
170
+ **生命周期**:
171
+
172
+ ```
173
+ 获取 token:生成 token(随机 hash)→ 生成签名密钥(+ 可选加密密钥)
174
+ → Redis 存 token → { security 段 }
175
+ → 返回客户端 { token, 签名密钥, 加密密钥? }(**不返回 refreshToken**)
176
+ 登录: 客户端签名请求(上送凭据;未登录浏览场景同时上送匿名 token)
177
+ → 验签(有 token 用其 security 段;无 token 用初始密钥)
178
+ → 凭据校验 → **单点确认**(查用户表旧 token → 删 Redis 旧对象)
179
+ → **若上送匿名 token 则删除其 Redis 对象**(未登录浏览场景)
180
+ → 生成新 token + refreshToken → 存用户表(token + refresh_token + login_at 列)
181
+ → 按 identity 段从表读身份数据 → Redis 存新对象(两态演进)
182
+ → 返回客户端 { 新 token, refreshToken, 业务数据? }
183
+ 业务请求:客户端上送 token + 签名 → 服务端按 token 查 Redis → 一次还原对象
184
+ (security 段验签/加解密,identity 段供业务——原两轮验证合成一步)
185
+ → 查不到 = 401(删 token 即吊销,blacklist 退役;防重放仍靠签名 timestamp + nonce)
186
+ 刷新: 上送 refreshToken → 按 refreshToken 查账户表定位用户(单点校验)
187
+ → 查表取 refreshToken 派生密钥验签(HMAC(refreshToken, 固定盐))
188
+ → 重新生成 token(新 hash)→ 更新 Redis 映射 → 返回新 token
189
+ 退出: Redis 删除 token(服务器端主动失效)
190
+ ```
191
+
192
+ **刷新机制(已决策)**:
193
+
194
+ - **token 有效期 30 分钟**(Redis 键 TTL),过期后可刷新;
195
+ - **refreshToken 只在 login 时返回**(获取 token 接口不返回),用 refreshToken 重新生成 token(延长有效期);
196
+ - **token + refreshToken 都存储于本系统账户表**(账户表 token 列 + refresh_token 列,一用户一行);
197
+ - **login 时单点确认**:查询用户表已有 token → **删除旧 token 对应的数据**(Redis 旧对象删除,旧会话立即失效)→ 生成新 token + refreshToken 写回用户表——后登录踢掉前登录;
198
+ - **login 时直接生成 token + refreshToken 存用户表,并记录登录时间(login_at 列;命名约定:精确时间列统一用 at 后缀)**;
199
+ - **refreshToken 过期时间 7 天(可配置,默认 7 天),从登录时间起算**(行业惯例 7~30 天:微信 30 天 / 支付宝 40 天 / Auth0 30 天;支付自建账户体系普遍取 7 天下限)——**过期即失效,必须重新 login**;
200
+ - **轮换策略(已决策:不轮换)**:refreshToken 只在 login 时生成、固定不变至过期——刷新只重新生成 token(写 token 列),refreshToken 不换;7 天到期强制重新 login;
201
+ - refreshToken **仅刷新接口上送**,业务请求不上送。
202
+
203
+ **refresh 接口签名(已决策:refreshToken 派生密钥)**:refresh 的签名密钥由 refreshToken 派生(`HMAC(refreshToken, 固定盐)`)——**能正确签名 == 持有 refreshToken**,签名与凭据一体、每用户独立;不依赖全局初始密钥(初始密钥泄露不波及 refresh)。服务端验签天然同源:按 refreshToken 查账户表的结果即派生密钥输入,零额外查询。
204
+
205
+ **单点确认与刷新流程**:
206
+
207
+ ```
208
+ login 单点:查询用户表已有 token → 删除 Redis 中旧 token 对象(旧会话立即失效)
209
+ → 生成新 token + refreshToken → 存用户表(token + refresh_token + login_at 列)+ Redis
210
+ 刷新: 上送 refreshToken → 按 refreshToken 查账户表定位用户 + 校验登录时间未超 7 天
211
+ → 派生密钥验签(HMAC(refreshToken, 固定盐))
212
+ → 重新生成 token(新 hash)→ 更新用户表 token 列 + Redis 映射 → 返回新 token
213
+ ```
214
+
215
+ **双层 → 单层对照**:
216
+
217
+ | 维度 | 现状(签名 + 登录双层) | 目标(token 令牌单层) |
218
+ |------|------------------------|----------------------|
219
+ | 层数 | HMAC 签名层 + JWT 登录层,独立互不相关 | 一个 token → 一个两态对象 |
220
+ | token 形态 | JWT:payload 塞 user 对象,客户端可解析 | **纯随机 hash**:不可解析、无数据 |
221
+ | 匿名态 | 只有验签(无对象) | **有对象**:只有 security 段 |
222
+ | 登录 | 签发携带身份的新凭证 | **身份附着到已有对象**(两态演进) |
223
+ | 验证次数 | 每请求两轮(先验签、再验 token) | 一次还原(验签材料与身份同对象) |
224
+ | 密钥管理 | appKey+secret / jwt secret 两套 | security 段统一承载(Redis 随 token 存) |
225
+ | 吊销 | blacklist 补丁 | 删 token 即吊销,blacklist 退役;**防重放保留**(签名含 timestamp + nonce) |
226
+ | 过期 | JWT exp + blacklist 双轨 | **token 固定 30 分钟**(Redis TTL)+ refreshToken 刷新重新生成 |
227
+ | 对象更新 | 旧 token 携带旧数据 | Redis 对象实时 |
228
+ | 信息暴露 | 用户信息在客户端可解 | 客户端只见 hash |
229
+ | 存储 | 密钥表按需查 + 无身份存储 | **Redis:token → { 通讯信息 + 身份信息 }** |
230
+
231
+ **与现有 HMAC 体系的关系**:客户端签名行为保留(所有请求都签名),验签所需的签名密钥由 Redis 中对象的 security 段提供——获取 token 后即存在。**获取 token 接口本身也要签名**(推翻现有"唯一例外")。
232
+
233
+ #### 初始密钥(bootstrap,已决策:方案 2)
234
+
235
+ 获取 token 接口要签名,但客户端此时还没有签名密钥——需要**初始密钥**保障两端一致,两个候选方案:
236
+
237
+ **方案 1:deviceId 即初始密钥**
238
+
239
+ - 客户端固定自己的 deviceId(native app 读设备标识;**H5 只能是随机数,客户端保存**);
240
+ - 获取 token 时同时上送 deviceId,作为初始化签名密钥——服务端按此验证首次签名,随后签发真密钥。
241
+
242
+ **方案 2:约定算法推导(固定密钥 + 时间窗口)**
243
+
244
+ - 双方内置固定密钥(客户端包内 + 服务端配置);
245
+ - 双方约定算法按时间窗口计算一个动态密钥(如 `HMAC(固定密钥, 时间窗口)`)——**两端独立计算,结果一样**,首次请求用它签名。
246
+
247
+ | 维度 | 方案 1:deviceId | 方案 2:约定算法 |
248
+ |------|-----------------|----------------|
249
+ | 两端一致性 | 客户端生成 → 上送服务端(事后一致) | 双方独立计算(事前一致) |
250
+ | 服务端可验证性 | 弱:服务端无法预知 deviceId,首次请求本质是"信任上送" | 强:服务端独立验算,无需信任首次请求 |
251
+ | 密钥保密性 | 弱:deviceId 是公开标识,非秘密;明文上送 | 强:密钥不经过网络 |
252
+ | 防重放 | 无(固定 deviceId 签名可重放) | 时间窗口天然限制,窗口过期失效 |
253
+ | 密钥轮换 | 无 | 动态轮换(窗口粒度) |
254
+ | 泄露风险 | deviceId 可伪造/复制;H5 随机数清缓存即失、可被拷走 | 固定密钥被逆向提取(native 加固 / H5 混淆缓解有限)→ 全局失效,需服务端可更换 + 客户端更新 |
255
+ | 实现复杂度 | 低(无预置密钥管理) | 中(时钟同步、窗口容忍、密钥管理) |
256
+
257
+ **风险与缓解**:两方案都需要 TLS 保护传输。方案 1 的安全强度依赖"首次信任",适合低风险通道;方案 2 强度更高(服务端可预验证、密钥动态),但固定密钥泄露是全局性风险,需内置密钥可轮换机制。**已决策:方案 2(约定算法推导:固定密钥 + 时间窗口)**——服务端可独立验算、密钥不经过网络、时间窗口天然防重放;落地约束:时钟同步(服务端容忍 ±1 窗口)、固定密钥服务端可轮换 + 客户端可更新。
258
+
259
+ ### 4.3 消费方改造
260
+
261
+ | 消费方 | 现状 | 改造后 |
262
+ |--------|------|--------|
263
+ | `SignatureStrategy`(HMAC 验签) | 验签后查密钥表,`{ id: appKey }` | **并入对象还原**:验签材料取自 Redis 对象 security 段 |
264
+ | `JwtStrategy` | `request.user as User` + 可选 blacklist | 退役——token 是 hash 不可解析,按 token 查 Redis 还原对象,查不到 401 |
265
+ | 获取 token 接口 | sign/issue 签发 appKey+secret(唯一免签名 @Public 接口) | 生成 token hash + 签名密钥(+可选加密密钥)存 Redis;**接口本身也要签名**(初始密钥方案 2:固定密钥 + 时间窗口) |
266
+ | `@Public()` 装饰器 | 标记免签名接口(sign/issue 唯一使用) | **退役——所有接口都必须签名,无豁免**(获取 token 接口用初始密钥签名) |
267
+ | `signToken/verifyToken` | `(user: User)` 弱类型 | 退役——无 JWT 签发/验证 |
268
+ | 登录服务 | `signToken({ id: String(row.id), type: 'admin' })` 手写 | 身份附着到 Redis 已有对象(机器校验 identity 段字段齐全),返回业务数据(可选) |
269
+ | controller handler | `(body, _user: User)` | `(body, token: AdminUserToken)`,按模块类型化 |
270
+ | 黑名单 checker | `(user: User)` | 机制退役(删 token 替代) |
271
+ | **DTO 字段注入** | `__inject` 适配器 `(body, user) => void`,只映射一个 id | **`from(token)`:DTO 字段从 Token 映射多个属性**,自动标记服务器注入(客户端不传、运行时填充) |
272
+ | utils/service/flow 方法参数 | `objectField` 内联(Customer 现状) | `args: { customer: customerToken.fields }` 或直接引用 Token |
273
+
274
+ ### 4.4 Customer 落地(本案例的落点,已决策:打散 + 注入)
275
+
276
+ 1. `schema/customer.table.ts` 保留——Customer 对应一张用户表;
277
+ 2. `customerToken = defineToken({ name: 'Customer', api: bleApi, app: bleWx, security: { ...from(密钥表, [...]) }, identity: { ...from(customer, [id, account, device, channel, phone]) }, key: 'id' })`——POS 服务器映射的客户身份,单一事实来源;
278
+ 3. **SubmitCpuRequest.customer 打散**:嵌套对象拆为顶层字段,从 Token 映射——`from(customerToken)` 展开 id/account/device/channel/phone 进 DTO,字段自动标记为服务器注入(客户端不传,运行时由 Token 填充,复用现有 `__inject` 机制、从"一个 id"扩展为"多个属性"):
279
+
280
+ ```ts
281
+ // dto_schema/ble-wx/ble-charge.dto.ts
282
+ export const SubmitCpuRequest = buildInput('SubmitCpuRequest', {
283
+ ...from(customerToken, [id, account, device, channel, phone]), // 注入字段
284
+ fee: dtoField(intField({ optional: false, label: '充值金额(分)' })),
285
+ devId: dtoField(stringField({ maxLength: 32, optional: false, label: '设备号' })),
286
+ // ...其余 POS 参数
287
+ });
288
+ ```
289
+
290
+ 4. `ble.utils.ts` 的 `customerParam` 内联删除,`getBsId/getOpId` 参数改引用 Token 字段;
291
+ 5. `Customer` 这个独立 buildInput DTO 删除(无消费者,字段已并入 SubmitCpuRequest)。
292
+
293
+ ### 4.5 目录与校验
294
+
295
+ - 存放:`token_schema/{api.name}/{app.name}/token/{name}.token.ts`——与 service_schema/dao_schema 同布局。
296
+ - lint(`loadTokens` + `pylonts lint token`):目录规则、一文件一 Token、**字段列绑定规则(两段字段都必须 `from()` 表列,可跨多张表;禁止内联字段)**、`key` 指向 identity 段中存在的必填字段、security/identity 两段区分校验(identity 段字段不能出现在 security 段)、api+app 归属校验(app ∈ api.apps)。
297
+
298
+ ## 5. 落地步骤
299
+
300
+ | 步骤 | 内容 | 依赖 |
301
+ |------|------|------|
302
+ | 1 | `dsl/src/token.ts`:`TokenSchema` + `defineToken`(api+app 归属、security/identity 两段全 from 表列可跨表、`key`)+ 定义期校验 | 无 |
303
+ | 2 | `dsl/src/dto.ts`:`from()` 支持 Token 源(展开字段 + 标记服务器注入);typebox-driver 渲染注入元数据 | 1 |
304
+ | 3 | `pylon/src/validation.ts`:`InjectFn` 从 `(body, user)` 扩展为 `(body, token)`,支持多属性映射;生成物消费 | 1 |
305
+ | 4 | 类型生成:TypeBox schema + TS 类型(前端类型) | 1、2 |
306
+ | 5 | Redis 存储:token → { security 段 + identity 段 } 映射(TTL 30 分钟 + refreshToken 刷新) | 无 |
307
+ | 6 | `pylon-fastify` auth 链**合并改造**:login + refresh 两个接口(未登录浏览场景加获取 token 接口,共 3 个)/ 获取 token 接口生成 token hash + 签名/加密密钥存 Redis(不返回 refreshToken)/ SignatureStrategy 并入对象还原(验签材料取自 security 段,timestamp + nonce 防重放保留;无 token 请求用初始密钥)/ login 按声明读表附着 identity 段 + 单点确认 + 匿名 token 清理 / JwtStrategy + signToken + blacklist + **@Public 退役**(所有接口必须签名,无豁免) | 1、5 |
308
+ | 6a | **等保三硬约束落地**:① 双因子登录(口令 + 签名密钥,禁止纯口令);② 登录失败锁定(≤5 次 / 锁 ≥30 分钟);③ Redis 安全加固(签名密钥加密存储、密码认证、访问控制、超时断开);审计日志独立持久化(Redis 不作审计载体) | 6 |
309
+ | 7 | 登录链生成(cli admin-login / sign)产出 Token 构造代码 | 4、6 |
310
+ | 8 | 存储规则 + lint:`loadTokens` + `pylonts lint token` | 1 |
311
+ | 9 | BLE Customer 落地(标准验收用例):customer 表 + CustomerToken + SubmitCpuRequest 打散注入,删除 utils/DTO 重复 | 1、2、8 |
312
+
313
+ ## 6. 决策状态
314
+
315
+ | # | 决策点 | 状态 |
316
+ |---|--------|------|
317
+ | 1 | **体系定位** | **已决策:推翻"HMAC 签名 + JWT 登录"双层体系,合并为 token 令牌单层**——签名/加密材料与身份统一由两态对象承载 |
318
+ | 2 | Token 字段来源 | **已决策:所有字段全部来源于表,可以是多张表**(禁止内联字段) |
319
+ | 3 | **两段结构** | **已决策:`security` 段(签名/加密数据,未登录即有)+ `identity` 段(身份数据,登录后附着)**——两态对象,token 仅对象引用 |
320
+ | 4 | **token 形态** | **已决策:纯随机 hash**——只有令牌信息、不可解析、无 payload/exp,JWT 完全退役 |
321
+ | 5 | 多来源(jwt/hmac/third) | **已决策:不需要**——Token 就是用户身份,单一概念,归属模块确定 |
322
+ | 6 | 身份有效数据确定时机 | **已决策:login 时**——登录时从表读取 identity 段字段附着到 Redis 已有对象;login 返回业务数据(不一定需要) |
323
+ | 7 | 安全材料字段 | **已决策:security 段携带签名密钥(必须)、加密密钥(可选)**——获取 token 时建立,验签/加解密消费对象 |
324
+ | 8 | **客户端使用规则** | **已决策:token 每次上送(唯一例外:获取 token 接口);所有接口都签名,无豁免——`@Public` 退役**(获取 token 等无 token 请求用**初始密钥方案 2**:固定密钥 + 时间窗口约定算法,两端独立计算) |
325
+ | 9 | `SubmitCpuRequest.customer` 处理 | **已决策:打散融入 DTO + 服务端变量注入**(`from(token)` 多属性映射,复用/扩展现有 `__inject` 机制) |
326
+ | 10 | **存储选型** | **已决策:Redis**(token → 通讯信息 + 身份信息) |
327
+ | 11 | **刷新机制** | **已决策:token 有效期 30 分钟;refreshToken 只在 login 时返回**(获取 token 接口不返回);**标准接口 = login + refresh,未登录浏览场景加获取 token 接口共 3 个**;**token + refreshToken + 登录时间(login_at)都存账户表;login 时单点确认——查用户表旧 token、删除对应 Redis 数据、直接生成新 token + refreshToken 写回(上送匿名 token 则一并删除)**;**refreshToken 有效期 7 天(可配置,默认 7 天),从登录时间起算,过期即失效必须重新 login**;**轮换策略已决策:不轮换**(refreshToken 只在 login 时生成,刷新只换 token) |
@@ -0,0 +1,85 @@
1
+ # @Trans 可重入改造(REQUIRED 传播)
2
+
3
+ > 状态:**已实现(2026-08-15)**
4
+ > 关联代码:`pylon-dao/src/trans.ts`、`pylon-dao/src/init.ts`(txStorage)
5
+ > 背景:ts-libs 会话「dd DDD 扩展讨论」;是 [aggregate.md](./aggregate.md)(聚合仓储)与 [domain-event.md](./domain-event.md)(领域事件)的**共同前置底座**
6
+
7
+ ## 1. 技术结论
8
+
9
+ **`@Trans` 需要可重入:多层 trans 只有外层生效**(REQUIRED 传播语义)。
10
+
11
+ ```
12
+ 规则:
13
+ 外层已有事务(txStorage 有 trx)→ 直接复用,不开新的
14
+ 外层无事务 → 才开新事务
15
+ ```
16
+
17
+ ## 2. 现状(未做):每次 @Trans 都开新事务
18
+
19
+ `pylon-dao/src/trans.ts` 当前实现:
20
+
21
+ ```ts
22
+ export function Trans() {
23
+ return function (_target, _propertyKey, descriptor) {
24
+ const original = descriptor.value;
25
+ descriptor.value = async function (...args) {
26
+ return knex.transaction(async (trx: Knex) => { // ← 每次都开新事务!
27
+ return txStorage.run(trx, () => original.apply(this, args));
28
+ });
29
+ };
30
+ };
31
+ }
32
+ ```
33
+
34
+ 问题:service 方法 `@Trans()` 调 service 方法 `@Trans()` 时,内层**又开一个新事务**——MySQL 嵌套开事务行为不可控。
35
+
36
+ ## 3. 需要的改动(一行判断)
37
+
38
+ ```ts
39
+ import { txStorage, knex } from './init.js';
40
+
41
+ export function Trans() {
42
+ return function (_target, _propertyKey, descriptor) {
43
+ const original = descriptor.value;
44
+ descriptor.value = async function (...args) {
45
+ // 重入检查:当前上下文已在事务中 → 复用,不开新事务
46
+ if (txStorage.getStore()) {
47
+ return original.apply(this, args); // 加入外层事务
48
+ }
49
+ return knex.transaction(async (trx: Knex) => { // 只有外层才开事务
50
+ return txStorage.run(trx, () => original.apply(this, args));
51
+ });
52
+ };
53
+ };
54
+ }
55
+ ```
56
+
57
+ `txStorage` 是 `AsyncLocalStorage`,`getStore()` 有值 = 已在事务中。**这一行 `if` 就是全部改动**。
58
+
59
+ ## 4. 为什么必须做(是 DDD 扩展的前置条件)
60
+
61
+ ```
62
+ CheckoutService.placeOrder(@Trans() 外层) ← 开事务
63
+ ├─ OrderRepo.save(order)(@Trans() 内层) ← 复用外层,不开新的
64
+ ├─ InventoryDao.deduct(@Trans() 内层) ← 复用外层
65
+ └─ OutboxDao.insert(无 @Trans()) ← 直接在事务内执行
66
+
67
+ 失败回滚:内层抛异常 → 整个外层事务回滚(含所有聚合的变更)
68
+ ```
69
+
70
+ 没有可重入的话:内层 `@Trans()` 自己 commit/rollback,外层感知不到,**多表一致性直接崩**。
71
+
72
+ | 依赖方 | 为什么需要可重入 |
73
+ |--------|----------------|
74
+ | **聚合/仓储(aggregate.md)** | `Repository.save()` 标 `@Trans()`(聚合组内多表原子),service 用例方法也标 `@Trans()`(用例整体)——两者必须可重入,否则嵌套事务爆炸 |
75
+ | **领域事件(domain-event.md)** | afterCommit 钩子要挂在"真正的外层事务"提交后触发;不可重入时内层先提交,钩子时机错乱 |
76
+ | **DAO 层** | 无 `@Trans()`,靠透明代理自动落 trx,不受影响 |
77
+
78
+ ## 5. 落地结果(已完成 2026-08-15)
79
+
80
+ 1. ✅ 修改 `pylon-dao/src/trans.ts`:加重入判断(`txStorage.getStore()` 复用,REQUIRED 传播);
81
+ 2. ✅ 补测试 `pylon-dao/test/trans.test.ts`(vitest + mock knex):外层 @Trans 调内层 @Trans 只开一个事务、内层单独调用开一个事务、内层抛异常整体回滚——3 个测试全绿;
82
+ 3. ✅ `pylon-dao` 新增 vitest devDependency + `test` script + `tsconfig.json` include 加 `test`(vitest 需读 experimentalDecorators),`tsconfig.build.json` 不受影响(独立 include src 仅产物);
83
+ 4. ✅ typecheck / build 通过。
84
+
85
+ 后续:聚合级联保存 + 用例事务在可重入下正确协作(aggregate.md 落地时验证)。
package/package.json CHANGED
@@ -1,10 +1,25 @@
1
1
  {
2
2
  "name": "@pylonts/dsl",
3
- "version": "1.1.6",
3
+ "version": "1.1.12",
4
4
  "description": "Schema definition DSL with drivers: MySQL DDL, TS enum, TypeBox schema codegen.",
5
5
  "type": "module",
6
- "main": "src/index.ts",
6
+ "main": "dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./flow-script": {
14
+ "types": "./dist/flow-script.d.ts",
15
+ "default": "./dist/flow-script.js"
16
+ },
17
+ "./business-flow": {
18
+ "types": "./dist/business-flow.d.ts",
19
+ "default": "./dist/business-flow.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
8
23
  "files": [
9
24
  "dist",
10
25
  "src",
@@ -12,9 +27,10 @@
12
27
  ],
13
28
  "scripts": {
14
29
  "build": "tsc -p tsconfig.build.json",
15
- "prepublishOnly": "npm run build",
30
+ "prepublishOnly": "node ../scripts/fix-versions.mjs prepare && node ../scripts/check-publish.mjs && npm run build",
16
31
  "test": "vitest run",
17
- "typecheck": "npx tsc --noEmit"
32
+ "typecheck": "npx tsc --noEmit",
33
+ "postpublish": "node ../scripts/fix-versions.mjs restore"
18
34
  },
19
35
  "keywords": [
20
36
  "dsl",
@@ -25,10 +41,13 @@
25
41
  "author": "",
26
42
  "license": "MIT",
27
43
  "dependencies": {
28
- "@pylonts/core": "file:../pylon"
44
+ "@pylonts/core": "^1.1.2"
29
45
  },
30
46
  "devDependencies": {
31
47
  "typescript": "^7.0.2",
32
48
  "vitest": "^4.1.10"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
33
52
  }
34
- }
53
+ }
package/src/action.ts CHANGED
@@ -1,11 +1,52 @@
1
- import { SchemaBase } from './dsl.js';
2
-
3
- /** An action a user can perform on a page (e.g. submit, approve, reject).
4
- * Subclasses use `type` as the discriminator. */
5
- export interface ActionSchema extends SchemaBase {
6
- type: string;
7
- }
8
-
9
- export function defineAction(name: string, description?: string): ActionSchema {
10
- return { name, description, type: 'gesture' };
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 };
11
52
  }
@@ -0,0 +1,104 @@
1
+ import type { SchemaBase } from './dsl.js';
2
+ import type { TableSchema, ForeignKey } from './db.js';
3
+
4
+ // Aggregate declaration: groups multiple tables into one domain concept with
5
+ // a root table, member attachment rules, cross-member invariants and
6
+ // inter-aggregate reference rules. This turns "multi-table consistency" from a
7
+ // convention (hand-written in flows) into a constraint (lintable, codegen-able).
8
+
9
+ /** How a member table attaches to the aggregate root. */
10
+ export interface AggregateMember {
11
+ /** The member table (shared instance from schema/*.table.ts). */
12
+ table: TableSchema;
13
+ /** The foreign key on the member table pointing to the root table.
14
+ * Must exist in table.foreignKeys and its references must be the root's PK
15
+ * columns. Defaults to the (unique) FK referencing the root table. */
16
+ via?: ForeignKey;
17
+ /** 1:1 member (unique constraint on via.columns) vs 1:N (default). */
18
+ one?: boolean;
19
+ }
20
+
21
+ /** A cross-member invariant, checked by generated repository code. */
22
+ export interface AggregateInvariant {
23
+ name: string;
24
+ /** Expression in the aggregate's field vocabulary (e.g. 'total == sum(items.price * items.qty)'). */
25
+ check: string;
26
+ }
27
+
28
+ export interface DomainAggregate extends SchemaBase {
29
+ type: 'aggregate';
30
+ /** The aggregate root table. */
31
+ root: TableSchema;
32
+ /** Member tables keyed by role name (e.g. 'items', 'address'). */
33
+ members: Record<string, AggregateMember>;
34
+ /** Cross-member invariants; optional. */
35
+ invariants?: AggregateInvariant[];
36
+ /** Inter-aggregate references: only by root ID, keyed by referenced role. */
37
+ references?: Record<string, string>;
38
+ }
39
+
40
+ export function defineAggregate(options: {
41
+ name: string;
42
+ root: TableSchema;
43
+ members?: Record<string, AggregateMember>;
44
+ invariants?: AggregateInvariant[];
45
+ references?: Record<string, string>;
46
+ description?: string;
47
+ }): DomainAggregate {
48
+ const schema: DomainAggregate = {
49
+ type: 'aggregate',
50
+ name: options.name,
51
+ description: options.description,
52
+ root: options.root,
53
+ members: options.members ?? {},
54
+ invariants: options.invariants,
55
+ references: options.references,
56
+ };
57
+
58
+ // Root must have a primary key (aggregate identity).
59
+ if (options.root.primaryKey === undefined) {
60
+ throw new Error(`aggregate '${options.name}': root table '${options.root.name}' must have a primary key`);
61
+ }
62
+
63
+ // Each member must attach to the root via an existing FK referencing the root.
64
+ const rootPkRefs = Array.isArray(options.root.primaryKey)
65
+ ? options.root.primaryKey
66
+ : [options.root.primaryKey];
67
+ for (const [role, member] of Object.entries(schema.members)) {
68
+ const fks = Object.values(member.table.foreignKeys ?? {}).filter(
69
+ (fk) => {
70
+ const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
71
+ return refs.every((r) => rootPkRefs.includes(r)) && refs.length === rootPkRefs.length;
72
+ },
73
+ );
74
+ if (fks.length === 0) {
75
+ throw new Error(
76
+ `aggregate '${options.name}': member '${role}' table '${member.table.name}' has no foreign key referencing root '${options.root.name}' — declare one in the table's foreignKeys`,
77
+ );
78
+ }
79
+ if (member.via !== undefined) {
80
+ const viaKeys = Object.values(member.table.foreignKeys ?? {});
81
+ if (!viaKeys.includes(member.via)) {
82
+ throw new Error(
83
+ `aggregate '${options.name}': member '${role}' via must be one of table '${member.table.name}' foreignKeys — got a non-FK object`,
84
+ );
85
+ }
86
+ const refs = Array.isArray(member.via.references) ? member.via.references : [member.via.references];
87
+ if (!(refs.length === rootPkRefs.length && refs.every((r) => rootPkRefs.includes(r)))) {
88
+ throw new Error(
89
+ `aggregate '${options.name}': member '${role}' via must reference root '${options.root.name}' primary key columns`,
90
+ );
91
+ }
92
+ } else {
93
+ // Default: the (single) FK referencing the root. More than one → must declare via.
94
+ if (fks.length > 1) {
95
+ throw new Error(
96
+ `aggregate '${options.name}': member '${role}' table '${member.table.name}' has ${fks.length} foreign keys referencing root '${options.root.name}' — declare via explicitly`,
97
+ );
98
+ }
99
+ (member as { via: ForeignKey }).via = fks[0];
100
+ }
101
+ }
102
+
103
+ return schema;
104
+ }