wok-server 0.7.1 → 0.8.0
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.
- package/README.md +101 -14
- package/dist/http-client/index.js +5 -4
- package/dist/lock/index.js +5 -2
- package/dist/mvc/config.js +4 -2
- package/dist/mvc/exchange.js +32 -7
- package/dist/mvc/index.js +1 -1
- package/dist/mvc/server.js +12 -4
- package/dist/mysql/manager/base.js +5 -8
- package/dist/mysql/manager/ops/delete.js +2 -1
- package/dist/mysql/manager/ops/find.js +8 -4
- package/dist/mysql/manager/ops/insert.js +15 -40
- package/dist/mysql/manager/ops/update.js +2 -1
- package/dist/mysql/manager/ops/upsert.js +11 -31
- package/dist/mysql/migration.js +10 -3
- package/documentation/en/mysql.md +26 -33
- package/documentation/zh-cn/mysql.md +27 -34
- package/documentation/zh-cn/philosophy.md +433 -0
- package/package.json +1 -1
- package/skills/wok-server-api-rules/SKILL.md +113 -0
- package/skills/wok-server-code-navigation/SKILL.md +169 -95
- package/skills/wok-server-mysql/SKILL.md +16 -10
- package/skills/wok-server-mysql/references/version-control.md +7 -5
- package/src/http-client/index.ts +8 -7
- package/src/lock/index.ts +5 -2
- package/src/mvc/config.ts +9 -2
- package/src/mvc/exchange.ts +31 -6
- package/src/mvc/index.ts +1 -1
- package/src/mvc/server.ts +11 -5
- package/src/mysql/manager/base.ts +17 -17
- package/src/mysql/manager/ops/delete.ts +2 -1
- package/src/mysql/manager/ops/find.ts +8 -4
- package/src/mysql/manager/ops/insert.ts +23 -61
- package/src/mysql/manager/ops/update.ts +2 -1
- package/src/mysql/manager/ops/upsert.ts +31 -51
- package/src/mysql/migration.ts +14 -3
- package/types/http-client/index.d.ts +4 -4
- package/types/lock/index.d.ts +3 -0
- package/types/mvc/config.d.ts +5 -0
- package/types/mvc/exchange.d.ts +13 -3
- package/types/mysql/manager/base.d.ts +12 -12
- package/types/mysql/manager/ops/insert.d.ts +2 -16
- package/types/mysql/manager/ops/upsert.d.ts +3 -4
|
@@ -154,13 +154,29 @@ The type mapping logic cannot be modified. Here is the mapping table:
|
|
|
154
154
|
|
|
155
155
|
| JavaScript Type | MySQL Field Type |
|
|
156
156
|
| :---------------- | :------------------------------------------------------------------------ |
|
|
157
|
-
| Boolean | TINYINT |
|
|
158
157
|
| Number | TINYINT, SMALLINT, INT, MEDIUMINT, YEAR, FLOAT, DOUBLE, BIGINT |
|
|
159
158
|
| Date | TIMESTAMP, DATE, DATETIME |
|
|
160
159
|
| Buffer | TINYBLOB, MEDIUMBLOB, LONGBLOB, BLOB, BINARY, VARBINARY, BIT |
|
|
161
160
|
| String | CHAR, VARCHAR, TINYTEXT, MEDIUMTEXT, LONGTEXT, TEXT, ENUM, SET, DECIMAL, TIME |
|
|
162
161
|
| Object or Array | JSON |
|
|
163
162
|
|
|
163
|
+
> **⚠️ Safety note about `boolean` type**
|
|
164
|
+
>
|
|
165
|
+
> MySQL's `BOOLEAN` is actually `TINYINT(1)`. The driver returns `0` or `1` (number type), **not** `true`/`false`. If you declare the field as `boolean` in your entity, comparing query results with `true`/`false` using `===` will produce bugs.
|
|
166
|
+
>
|
|
167
|
+
> The framework does **not** support automatic type mapping and never will. Even if it did, hand-written `query()` custom SQL would still return `0`/`1`, creating a persistent type mismatch between the application layer and the database — a risk that no framework can fully eliminate.
|
|
168
|
+
>
|
|
169
|
+
> **The safest approach** is to declare such fields as `0 | 1`, using the database's native return type directly:
|
|
170
|
+
>
|
|
171
|
+
> ```ts
|
|
172
|
+
> export interface User {
|
|
173
|
+
> // Recommended: use 0 | 1, matching the database return value
|
|
174
|
+
> is_active: 0 | 1
|
|
175
|
+
> }
|
|
176
|
+
> ```
|
|
177
|
+
>
|
|
178
|
+
> `0 | 1` behaves almost identically to `boolean` in conditionals — `if (user.is_active)` and `user.is_active ? '是' : '否'` both work as expected. The only thing to avoid is `=== true` comparison. This way, the returned values are consistent across both CRUD methods and custom SQL — a one-time fix.
|
|
179
|
+
|
|
164
180
|
For nullable fields, define them as nullable in TypeScript:
|
|
165
181
|
|
|
166
182
|
```ts
|
|
@@ -227,13 +243,6 @@ await manager.insert(tableUser, {
|
|
|
227
243
|
nickname: '小明',
|
|
228
244
|
balance: 1
|
|
229
245
|
})
|
|
230
|
-
// Insert with expressions
|
|
231
|
-
await manager.insert(tableUser, {
|
|
232
|
-
id: 'in002',
|
|
233
|
-
nickname: '小红',
|
|
234
|
-
balance: ['expr', '?? * ?', ['score', 2]],
|
|
235
|
-
createAt: ['now']
|
|
236
|
-
})
|
|
237
246
|
// Batch insert
|
|
238
247
|
await manager.insertMany(tableUser, [
|
|
239
248
|
{ id: 'im001', nickname: '张飞', balance: 0 },
|
|
@@ -342,26 +351,6 @@ await manager.modify(`update user set nickname='无名' where nickname='佚名'`
|
|
|
342
351
|
| query | Custom SQL query, returns record list, supports prepared statements |
|
|
343
352
|
| modify | Execute custom SQL, returns affected rows, supports prepared statements |
|
|
344
353
|
|
|
345
|
-
### Insert Expressions
|
|
346
|
-
|
|
347
|
-
Starting from version 0.7.0, `insert`, `insertMany`, `upsert` and other insert methods accept `InsertValue` type,
|
|
348
|
-
allowing expressions in the VALUES clause:
|
|
349
|
-
|
|
350
|
-
```ts
|
|
351
|
-
await manager.insert(tableUser, {
|
|
352
|
-
id: 'in001',
|
|
353
|
-
nickname: '小明',
|
|
354
|
-
// Set to NOW()
|
|
355
|
-
createAt: ['now'],
|
|
356
|
-
// Resolve conflict: set field to the array ['setNull'] (not the setNull operation)
|
|
357
|
-
extra: ['set', ['setNull']],
|
|
358
|
-
// Custom expression: balance = score * 2
|
|
359
|
-
balance: ['expr', '?? * ?', ['score', 2]],
|
|
360
|
-
// Expression without parameters
|
|
361
|
-
balance2: ['expr', 'RAND() * 100']
|
|
362
|
-
})
|
|
363
|
-
```
|
|
364
|
-
|
|
365
354
|
### Order By Expressions
|
|
366
355
|
|
|
367
356
|
Starting from version 0.7.0, the `orderBy` parameter is upgraded to `OrderBy<T>` type, supporting custom
|
|
@@ -630,17 +619,21 @@ In actual development, table and column names don't necessarily need to be param
|
|
|
630
619
|
|
|
631
620
|
As introduced in environment variables, MYSQL_VERSION_CONTROL_ENABLED enables version management, and MYSQL_VERSION_CONTROL_DIR sets the version directory. Currently, absolute paths or relative paths from the process working directory are supported.
|
|
632
621
|
|
|
633
|
-
In the version directory,
|
|
622
|
+
In the version directory, file names must start with a digit, optionally followed by descriptive text.
|
|
634
623
|
|
|
635
624
|
Example version directory file listing:
|
|
636
625
|
|
|
637
626
|
```
|
|
638
|
-
1.sql
|
|
639
|
-
|
|
640
|
-
|
|
627
|
+
1.sql # maps to version 1
|
|
628
|
+
2_init.sql # maps to version 2
|
|
629
|
+
3_add_user_index.sql # maps to version 3
|
|
641
630
|
```
|
|
642
631
|
|
|
643
|
-
Version numbers start from 1 and increment sequentially.
|
|
632
|
+
Version numbers start from 1 and increment sequentially. Names like `v1.sql` or `init_v1.sql` (not starting with a digit) are not supported.
|
|
633
|
+
|
|
634
|
+
> **⚠️ Duplicate version check**: If two files resolve to the same version number (e.g., `1_init.sql` and `1_create_table.sql` both map to version 1), the startup will throw an exception clearly indicating the conflicting file paths.
|
|
635
|
+
|
|
636
|
+
**For program iterations, new versions must add new files, not modify existing ones.** The component does not have file validation to prevent modification of old files. These need to be handled through project code version control.
|
|
644
637
|
|
|
645
638
|
**Note: Do not execute time-consuming SQL in version management. Each version's SQL should be as short as possible.** This is mainly because version management executes within a transaction. Too long execution times may cause transaction timeout and failure, and program startup time will be very long. For time-consuming operations like creating indexes on large tables, manual database operations are required and cannot be completed through version management.
|
|
646
639
|
|
|
@@ -154,13 +154,29 @@ export const tableUser: Table<User> = {
|
|
|
154
154
|
|
|
155
155
|
| js 原生类型 | mysql 字段类型 |
|
|
156
156
|
| :-------------- | :------------------------------------------------------------------- |
|
|
157
|
-
| Boolean | TINYINT |
|
|
158
157
|
| Number | TINYINT,SMALLINT,INT,MEDIUMINT,YEAR.FLOAT,DOUBLE,BIGINT |
|
|
159
158
|
| Date | TIMESTAMP,DATE,DATETIME |
|
|
160
159
|
| Buffer | TINYBLOB,MEDIUMBLOB,LONGBLOB,BLOB,BINARY,VARBINARY,BIT |
|
|
161
160
|
| String | CHAR,VARCHAR,TINYTEXT,MEDIUMTEXT,LONGTEXT,TEXT,ENUM,SET,DECIMAL,TIME |
|
|
162
161
|
| Object 或 Array | JSON |
|
|
163
162
|
|
|
163
|
+
> **⚠️ 关于 `boolean` 类型的安全提示**
|
|
164
|
+
>
|
|
165
|
+
> MySQL 中的 `BOOLEAN` 实际是 `TINYINT(1)`,驱动返回的是 `0` 或 `1`(number 类型),**不是 `true`/`false`**。如果你在实体类中将字段声明为 `boolean`,查询结果的值和 `true`/`false` 做 `===` 全等比较时就会产生 bug。
|
|
166
|
+
>
|
|
167
|
+
> 框架**不支持**类型自动映射,也不会提供。因为即使框架帮你做了映射,手写 `query()` 自定义 SQL 查出来的结果仍然是 `0`/`1`,始终存在应用层与数据库层之间的类型断裂,这是框架层面无法消除的安全隐患。
|
|
168
|
+
>
|
|
169
|
+
> **最安全的做法**是将这类字段的类型声明为 `0 | 1`,直接使用数据库返回的真实类型:
|
|
170
|
+
>
|
|
171
|
+
> ```ts
|
|
172
|
+
> export interface User {
|
|
173
|
+
> // 推荐:使用 0 | 1,与数据库返回的真实值一致
|
|
174
|
+
> is_active: 0 | 1
|
|
175
|
+
> }
|
|
176
|
+
> ```
|
|
177
|
+
>
|
|
178
|
+
> `0 | 1` 在条件判断中的表现和 `boolean` 几乎一样——`if (user.is_active)`、`user.is_active ? '是' : '否'` 都能正常工作。唯一需要注意的就是避免 `=== true` 这种全等比较。这样无论是单表操作方法还是自定义 SQL,查出来的值始终一致,一劳永逸。
|
|
179
|
+
|
|
164
180
|
对于可空字段,可以在 ts 里也定义为可空:
|
|
165
181
|
|
|
166
182
|
```ts
|
|
@@ -228,13 +244,6 @@ await manager.insert(tableUser, {
|
|
|
228
244
|
nickname: '小明',
|
|
229
245
|
balance: 1
|
|
230
246
|
})
|
|
231
|
-
// 插入时使用表达式
|
|
232
|
-
await manager.insert(tableUser, {
|
|
233
|
-
id: 'in002',
|
|
234
|
-
nickname: '小红',
|
|
235
|
-
balance: ['expr', '?? * ?', ['score', 2]],
|
|
236
|
-
createAt: ['now']
|
|
237
|
-
})
|
|
238
247
|
// 批量插入
|
|
239
248
|
await manager.insertMany(tableUser, [
|
|
240
249
|
{ id: 'im001', nickname: '张飞', balance: 0 },
|
|
@@ -343,26 +352,6 @@ await manager.modify(`update user set nickname='无名' where nickname='佚名'`
|
|
|
343
352
|
| query | 自定义 sql 查询,返回记录列表,支持预编译 sql |
|
|
344
353
|
| modify | 执行自定义 sql,返回操作记录数 ,支持预编译 sql |
|
|
345
354
|
|
|
346
|
-
### 插入表达式
|
|
347
|
-
|
|
348
|
-
从 0.7.0 版本开始,`insert`、`insertMany`、`upsert` 等插入方法的 data 参数支持 `InsertValue` 类型,
|
|
349
|
-
可以在 VALUES 子句中使用表达式:
|
|
350
|
-
|
|
351
|
-
```ts
|
|
352
|
-
await manager.insert(tableUser, {
|
|
353
|
-
id: 'in001',
|
|
354
|
-
nickname: '小明',
|
|
355
|
-
// 设置为 NOW()
|
|
356
|
-
createAt: ['now'],
|
|
357
|
-
// 解决冲突:将字段设置为 ['setNull'] 这个数组值(而非执行 setNull 操作)
|
|
358
|
-
extra: ['set', ['setNull']],
|
|
359
|
-
// 自定义表达式:balance = score * 2
|
|
360
|
-
balance: ['expr', '?? * ?', ['score', 2]],
|
|
361
|
-
// 无参数表达式
|
|
362
|
-
balance2: ['expr', 'RAND() * 100']
|
|
363
|
-
})
|
|
364
|
-
```
|
|
365
|
-
|
|
366
355
|
### 排序表达式
|
|
367
356
|
|
|
368
357
|
从 0.7.0 版本开始,`orderBy` 参数升级为 `OrderBy<T>` 类型,除了普通列排序外,还支持自定义表达式排序。
|
|
@@ -639,18 +628,22 @@ update `user` set `name` = 'tom' where `id` = '001'
|
|
|
639
628
|
前面环境变量有介绍,变量 MYSQL_VERSION_CONTROL_ENABLED 启用管理,变量 MYSQL_VERSION_CONTROL_DIR 设置版本目录,
|
|
640
629
|
目前支持绝对路径或者相对于进程工作目录的相对路径。
|
|
641
630
|
|
|
642
|
-
|
|
631
|
+
在版本目录中,文件名必须以数字开头,数字后可加描述文字。
|
|
643
632
|
|
|
644
633
|
版本管理目录文件列表示例:
|
|
645
634
|
|
|
646
635
|
```
|
|
647
|
-
1.sql
|
|
648
|
-
|
|
649
|
-
|
|
636
|
+
1.sql # 等价于版本号 1
|
|
637
|
+
2_init.sql # 等价于版本号 2
|
|
638
|
+
3_add_user_index.sql # 等价于版本号 3
|
|
650
639
|
```
|
|
651
640
|
|
|
652
|
-
版本号是从 1
|
|
653
|
-
|
|
641
|
+
版本号是从 1 开始的,逐个递增。不支持 `v1.sql` 或 `init_v1.sql` 这类不以数字开头的命名。
|
|
642
|
+
|
|
643
|
+
> **⚠️ 重复版本号校验**:如果有两个文件解析出版本号相同(如 `1_init.sql` 和 `1_create_table.sql` 都对应版本 1),启动时会抛异常,并明确指出冲突的文件路径。
|
|
644
|
+
|
|
645
|
+
**程序迭代,新版本必须添加新的文件,而不能改动已有的版本文件。**
|
|
646
|
+
组件并没有文件校验功能,来防止改动旧文件,这些需要在项目中做作好代码的版本控制。
|
|
654
647
|
|
|
655
648
|
**注意不要在版本管理中执行耗时较久的 sql,每个版本的 sql 都尽可能要短。**这主要是因为版本管理在执行在事务中的,
|
|
656
649
|
时间太长会因事务会超导致失败,程序的启动时间也会很长。对于大表创建索引等非常耗时的场景,只能手动操作数据库,无法在版本管理中完成。
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
# wok-server 设计哲学
|
|
2
|
+
|
|
3
|
+
wok-server 不是又一个模仿现有框架的轮子。它的设计没有参考 TypeORM、Prisma 或 Sequelize,而是基于 Java 和 Node.js 的工程经验,选择最直接、最透明、学习成本最低的方案。
|
|
4
|
+
|
|
5
|
+
核心原则只有一个:**实用主义**。
|
|
6
|
+
|
|
7
|
+
下面以 MySQL 组件为例,说明这些设计决策背后的思考。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 为什么不做装饰器
|
|
12
|
+
|
|
13
|
+
主流 ORM(TypeORM、NestJS 的 MikroORM)大量使用装饰器:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
@Entity()
|
|
17
|
+
class User {
|
|
18
|
+
@PrimaryGeneratedColumn()
|
|
19
|
+
id: number
|
|
20
|
+
|
|
21
|
+
@Column({ type: 'varchar', length: 100 })
|
|
22
|
+
name: string
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
装饰器的问题:
|
|
27
|
+
|
|
28
|
+
- **不是 JavaScript 原生特性**,需要 `experimentalDecorators` + `reflect-metadata` + `emitDecoratorMetadata` 三个开关,运行时还要 polyfill
|
|
29
|
+
- **元数据不透明**,实体和数据库的映射关系分散在多个装饰器调用中,调试时看不到全貌,出问题只能猜
|
|
30
|
+
- **学习成本高**,要理解装饰器执行顺序、反射 API、参数化装饰器工厂
|
|
31
|
+
- **强制 OOP 风格**,实体必须是 `class`,不能用 `interface` 或纯对象
|
|
32
|
+
|
|
33
|
+
wok-server 用纯对象配置:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
export interface User {
|
|
37
|
+
id: string
|
|
38
|
+
name: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const tableUser: Table<User> = {
|
|
42
|
+
tableName: 'user',
|
|
43
|
+
id: 'id',
|
|
44
|
+
columns: ['name']
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
所有映射信息在一个对象里,出问题直接看这一个文件。不需要学框架特有的语法,没有运行时依赖。
|
|
49
|
+
|
|
50
|
+
Prisma 虽然也不用装饰器,但发明了 Prisma Schema Language(PSL),学习成本转移到了另一套 DSL 上。wok-server 没有框架特有的语法,配置就是普通 TypeScript 对象。
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 为什么不做跨数据库支持
|
|
55
|
+
|
|
56
|
+
TypeORM 宣传"支持 10 个数据库",Sequelize 和 Prisma 也走跨数据库路线。但真实情况是:
|
|
57
|
+
|
|
58
|
+
- **项目几乎从不换数据库**。迁移涉及数据、存储过程、触发器、权限、性能调优,ORM 抽象层解决不了。
|
|
59
|
+
- **真要换就是重构**,框架很可能也换了。
|
|
60
|
+
- **跨数据库必然复杂**。需要统一 `VARCHAR`、`TEXT`、`STRING` 的语义差异,只能支持各数据库的"交集"特性,高级特性(如 MySQL 的 `ON DUPLICATE KEY UPDATE`、PostgreSQL 的 `JSONB` 操作符)无法暴露。
|
|
61
|
+
|
|
62
|
+
wok-server-mysql 是 MySQL 专用工具,可以:
|
|
63
|
+
|
|
64
|
+
- 充分利用 MySQL 特性(`JSON_EXTRACT`、`ON DUPLICATE KEY UPDATE`、`LIMIT` 语法)
|
|
65
|
+
- 类型映射直接对应 MySQL 的 JS 类型(`TINYINT` → `boolean`)
|
|
66
|
+
- 迁移 SQL 直接写 MySQL 方言,不需要抽象
|
|
67
|
+
- 维护成本只测 MySQL 即可
|
|
68
|
+
|
|
69
|
+
"支持 10 个数据库"是营销卖点,不是技术必然。wok-server 选择深度做好一个数据库。
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 为什么用纯 SQL 迁移
|
|
74
|
+
|
|
75
|
+
TypeORM 的迁移是 TypeScript 文件:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
export class AddUserIndex1640000000000 implements MigrationInterface {
|
|
79
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
80
|
+
await queryRunner.query(`CREATE INDEX ...`)
|
|
81
|
+
}
|
|
82
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
83
|
+
await queryRunner.query(`DROP INDEX ...`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Sequelize 用 `queryInterface` API:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
await queryInterface.addColumn('Users', 'age', {
|
|
92
|
+
type: DataTypes.INTEGER,
|
|
93
|
+
allowNull: true
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Prisma 用声明式 schema 生成:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx prisma migrate dev --name add_age
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
这些方案的代价:
|
|
104
|
+
|
|
105
|
+
- **API 数量爆炸**。`addColumn`、`removeColumn`、`changeColumn`、`renameColumn`、`addIndex`... 几十个方法要学。
|
|
106
|
+
- **表达能力不足**。复杂 DDL(如 `ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE`)无法表达,必须 drop 到 raw SQL。
|
|
107
|
+
- **不透明**。`queryInterface.addColumn` 在 MySQL 和 PostgreSQL 生成的 SQL 不同,出问题更难调试。
|
|
108
|
+
- **DBA 无法审查**。给 DBA 看 TypeScript 代码或 schema 文件?不如直接给 SQL。
|
|
109
|
+
|
|
110
|
+
wok-server 的迁移:
|
|
111
|
+
|
|
112
|
+
```sql
|
|
113
|
+
-- 001_init.sql
|
|
114
|
+
CREATE TABLE `user` (
|
|
115
|
+
`id` varchar(32) NOT NULL PRIMARY KEY,
|
|
116
|
+
`nickname` varchar(100) DEFAULT NULL
|
|
117
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
118
|
+
|
|
119
|
+
-- 002_add_age.sql
|
|
120
|
+
ALTER TABLE `user` ADD COLUMN `age` int DEFAULT NULL AFTER `nickname`;
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
零学习成本(会 SQL 就会写迁移),完全透明(执行的就是你写的 SQL),DBA 可直接审查,表达能力无限(任何数据库特性都能用)。
|
|
124
|
+
|
|
125
|
+
迁移是一次性脚本,框架只负责"按顺序执行 + 版本记录",不应该试图替代 SQL。
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 为什么多数据源是显式的
|
|
130
|
+
|
|
131
|
+
TypeORM 支持自动读写分离:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
new DataSource({
|
|
135
|
+
replication: {
|
|
136
|
+
master: { host: 'server1', ... },
|
|
137
|
+
slaves: [{ host: 'server2', ... }, { host: 'server3', ... }]
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
默认行为:读操作随机路由到 slave,写操作走 master。
|
|
143
|
+
|
|
144
|
+
问题:**写后读可能读到旧数据**。主从同步有延迟,刚写入的数据在 slave 上可能还没同步。自动路由的"魔法"让开发者不知道 SQL 发到了哪里,出了问题难定位。
|
|
145
|
+
|
|
146
|
+
wok-server 的读写分离:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
await enableMysql('master')
|
|
150
|
+
await enableMysql('slave')
|
|
151
|
+
|
|
152
|
+
const masterMgr = getMysqlManager('master')
|
|
153
|
+
const slaveMgr = getMysqlManager('slave')
|
|
154
|
+
|
|
155
|
+
// 写操作显式用 master
|
|
156
|
+
await masterMgr.insert(tableUser, { id: '001', nickname: 'jack' })
|
|
157
|
+
|
|
158
|
+
// 读操作显式用 slave
|
|
159
|
+
const user = await slaveMgr.findById(tableUser, '001')
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
代码多一点,但**完全可控**。业务代码决定走哪个库,不会出现"写后读"的意外。调试时清晰知道 SQL 发到了哪里。
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 为什么复杂查询直接写 SQL
|
|
167
|
+
|
|
168
|
+
wok-server 封装了单表 CRUD,但不做关联自动加载。任何 join 场景直接写 SQL:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const list = await manager.query<{ author: string; book: string }>(
|
|
172
|
+
'select u.nickname as author, b.name as book from ?? u left join ?? b on u.id=b.author_id',
|
|
173
|
+
['user', 'book']
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
TypeORM 的关联查询:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
await repo.find({
|
|
181
|
+
relations: { profile: true, posts: { comments: true } },
|
|
182
|
+
where: { posts: { comments: { author: { name: 'Timber' } } } }
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
嵌套对象在深层关联时极易写错,生成的 SQL 不透明,N+1 问题隐蔽。wok-server 不做这个抽象,复杂查询直接写 SQL,反而更可控。
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 元组表达式:解决歧义的简洁方案
|
|
191
|
+
|
|
192
|
+
更新操作有个经典问题:如何区分"不更新这个字段"和"把这个字段设为 null"?
|
|
193
|
+
|
|
194
|
+
wok-server 用元组:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
await manager.partialUpdate(tableUser, {
|
|
198
|
+
id: 'pu000',
|
|
199
|
+
balance: ['inc', 22], // 自增 22
|
|
200
|
+
nickname: ['setNull'], // 设为 NULL
|
|
201
|
+
avatar: undefined // 不更新(忽略)
|
|
202
|
+
})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
不需要像 TypeORM 那样 drop 到 `QueryBuilder` 或 raw SQL:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
await repo.update('pu000', { balance: () => 'balance + 22' })
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
元组表达式(`['inc']`、`['setNull']`、`['expr', '...']`)是跨数据库可复用的设计,简洁且没有歧义。
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 符合直觉的 API 设计
|
|
216
|
+
|
|
217
|
+
wok-server 的 API 设计遵循一个原则:**减少记忆负担,增加类型约束,但不做类型体操**。
|
|
218
|
+
|
|
219
|
+
### 链式条件比操作符类更直觉
|
|
220
|
+
|
|
221
|
+
TypeORM 的条件查询:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { Like, MoreThan, Between } from 'typeorm'
|
|
225
|
+
|
|
226
|
+
await repo.find({
|
|
227
|
+
where: {
|
|
228
|
+
name: Like('Bob%'),
|
|
229
|
+
age: MoreThan(18),
|
|
230
|
+
score: Between(60, 100)
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
需要记忆 `Like`、`MoreThan`、`Between` 等操作符类,还要正确 import。
|
|
236
|
+
|
|
237
|
+
wok-server 的链式条件:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
await mgr.findFirst(tableUser, c =>
|
|
241
|
+
c.like('name', 'Bob%').gt('age', 18).between('score', 60, 100)
|
|
242
|
+
)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
方法名就是自然语言(`like`、`gt`、`between`),不需要 import 任何东西,也不需要记忆操作符类名。
|
|
246
|
+
|
|
247
|
+
### 统一模式减少 API 记忆
|
|
248
|
+
|
|
249
|
+
wok-server 的查询 API 遵循统一模式:
|
|
250
|
+
|
|
251
|
+
| 操作 | 单条 | 列表 | 分页 | 部分字段 |
|
|
252
|
+
|------|------|------|------|---------|
|
|
253
|
+
| 查询 | `findFirst` | `find` | `paginate` | `findSelect` / `paginateSelect` |
|
|
254
|
+
| 条件 | `criteria` / 链式回调 | `criteria` / 链式回调 | `criteria` / 链式回调 | `criteria` / 链式回调 |
|
|
255
|
+
| 排序 | `orderBy` 参数 | `orderBy` 参数 | `orderBy` 参数 | `orderBy` 参数 |
|
|
256
|
+
|
|
257
|
+
学会一个,其他都是同一模式。不需要记忆 `findOne`、`findOneById`、`findOneOrFail`、`findAndCount` 等几十个变体。
|
|
258
|
+
|
|
259
|
+
### 类型约束但无类型体操
|
|
260
|
+
|
|
261
|
+
wok-server 的类型系统简单直接:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
// Table<T> 定义实体和表的映射
|
|
265
|
+
const tableUser: Table<User> = { ... }
|
|
266
|
+
|
|
267
|
+
// findById 返回 User | undefined
|
|
268
|
+
const user = await mgr.findById(tableUser, '001')
|
|
269
|
+
// user 类型是 User | undefined
|
|
270
|
+
|
|
271
|
+
// insert 参数是 User 类型,必填字段必须存在
|
|
272
|
+
await mgr.insert(tableUser, { id: '001', name: 'Bob' })
|
|
273
|
+
|
|
274
|
+
// findSelect 返回 Pick<User, K>
|
|
275
|
+
const list = await mgr.findSelect(tableUser, ['id', 'name'], { ... })
|
|
276
|
+
// list 类型是 Array<Pick<User, 'id' | 'name'>>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
没有复杂的条件类型、没有映射类型体操、没有 `DeepPartial` 或 `EntityTarget` 等抽象概念。类型是**约束工具**,不是**智力游戏**。
|
|
280
|
+
|
|
281
|
+
Prisma 的类型推断最强,但依赖 `prisma generate` 生成大量类型定义文件。TypeORM 的 `SelectQueryBuilder` 类型链式调用时容易类型丢失。wok-server 用简单的泛型参数实现类型安全,AI 和人类都容易理解。
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## 与主流框架的对比
|
|
286
|
+
|
|
287
|
+
| 维度 | wok-server | TypeORM | Prisma | Sequelize |
|
|
288
|
+
|------|-----------|---------|--------|-----------|
|
|
289
|
+
| 实体配置 | 纯对象 | 装饰器 | PSL 文件 | 模型定义 |
|
|
290
|
+
| 学习成本 | **低** | 高 | 高 | 中 |
|
|
291
|
+
| 迁移方式 | 纯 SQL | TS 套壳 | 声明式生成 | API 抽象 |
|
|
292
|
+
| 迁移透明度 | **高** | 低 | 低 | 低 |
|
|
293
|
+
| 跨数据库 | 不支持(MySQL 专用) | 支持 | 支持 | 支持 |
|
|
294
|
+
| 关联查询 | 手写 SQL | 自动加载 | 自动加载 | 自动加载 |
|
|
295
|
+
| 读写分离 | 显式 manager | 自动路由 | 无原生支持 | 需自行实现 |
|
|
296
|
+
| 类型安全 | 中 | 高 | **高** | 低 |
|
|
297
|
+
|
|
298
|
+
wok-server 的定位是"内部团队的单表 CRUD 工具 + SQL 兜底",在这个定位上,设计是优秀的:没有过度设计,不做关联 ORM、不做自动路由、不做 schema 生成,透明度和可控性优先于便利性。
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## 对 AI 编程友好
|
|
303
|
+
|
|
304
|
+
wok-server 的简单透明设计,在 AI 辅助编程时代有天然优势。
|
|
305
|
+
|
|
306
|
+
### 纯对象配置 = AI 容易理解上下文
|
|
307
|
+
|
|
308
|
+
装饰器的问题:
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
@Entity()
|
|
312
|
+
class User {
|
|
313
|
+
@PrimaryGeneratedColumn()
|
|
314
|
+
id: number
|
|
315
|
+
|
|
316
|
+
@Column({ type: 'varchar', length: 100 })
|
|
317
|
+
name: string
|
|
318
|
+
|
|
319
|
+
@OneToMany(() => Post, post => post.author)
|
|
320
|
+
posts: Post[]
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
AI 需要理解:
|
|
325
|
+
- `@Entity()` 是什么意思
|
|
326
|
+
- `@PrimaryGeneratedColumn()` 的参数有哪些
|
|
327
|
+
- `@OneToMany` 的回调函数 `post => post.author` 是什么关系
|
|
328
|
+
- `Post` 实体在哪里定义
|
|
329
|
+
- 这些装饰器在运行时生成什么 SQL
|
|
330
|
+
|
|
331
|
+
wok-server 的纯对象:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
export const tableUser: Table<User> = {
|
|
335
|
+
tableName: 'user',
|
|
336
|
+
id: 'id',
|
|
337
|
+
columns: ['name', 'email']
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
AI 看到的就是**一个普通对象**,没有魔法。字段名、表名、列名一目了然,不需要理解框架特有的元数据系统。
|
|
342
|
+
|
|
343
|
+
### 显式调用 = AI 不会"猜错"
|
|
344
|
+
|
|
345
|
+
TypeORM 的自动路由:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
// AI 怎么知道这条查询走 master 还是 slave?
|
|
349
|
+
const user = await repo.findOne({ where: { id: 1 } })
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
AI 无法从代码中推断出连接路由逻辑,因为路由规则在 `DataSource` 配置里,和查询代码分离。
|
|
353
|
+
|
|
354
|
+
wok-server 的显式选择:
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
// AI 明确知道:这是读操作,用 slaveMgr
|
|
358
|
+
const user = await slaveMgr.findById(tableUser, '001')
|
|
359
|
+
|
|
360
|
+
// AI 明确知道:这是写操作,用 masterMgr
|
|
361
|
+
await masterMgr.insert(tableUser, { id: '002', name: 'Bob' })
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
AI 从代码本身就能理解意图,不需要追踪隐式的配置。
|
|
365
|
+
|
|
366
|
+
### 纯 SQL 迁移 = AI 直接生成可执行代码
|
|
367
|
+
|
|
368
|
+
Prisma 的迁移流程:
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
# AI 需要理解:改 schema → 生成迁移 → 应用迁移
|
|
372
|
+
npx prisma migrate dev --name add_age
|
|
373
|
+
# 然后还要处理 shadow database、冲突提示...
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
AI 生成的 schema 变更可能触发 Prisma 的交互式提示("是否重置数据库?"),AI 无法处理这种交互。
|
|
377
|
+
|
|
378
|
+
wok-server 的迁移:
|
|
379
|
+
|
|
380
|
+
```sql
|
|
381
|
+
-- 003_add_age.sql
|
|
382
|
+
ALTER TABLE `user` ADD COLUMN `age` int DEFAULT NULL;
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
AI 直接生成 SQL 文件,放到 `db_migration/` 目录,按数字命名。没有生成步骤、没有交互、没有黑盒转换。**AI 生成什么,就是什么**。
|
|
386
|
+
|
|
387
|
+
### 无关联 ORM = AI 不会制造 N+1 陷阱
|
|
388
|
+
|
|
389
|
+
TypeORM 的关联查询:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
// AI 生成这段代码看起来合理
|
|
393
|
+
const users = await repo.find({ relations: { posts: true } })
|
|
394
|
+
|
|
395
|
+
// 但可能触发 N+1:先查 users,再对每个 user 查 posts
|
|
396
|
+
// AI 很难从代码层面发现这个性能问题
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
wok-server 不做关联自动加载,AI 写 join 时必须显式写 SQL:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
const list = await mgr.query(
|
|
403
|
+
'select u.*, p.title from user u left join post p on u.id=p.user_id'
|
|
404
|
+
)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
SQL 的性能问题(如缺少索引、大表全扫描)是**数据库层面的知识**,AI 更容易从 SQL 本身识别,而不是从 ORM 的隐式行为中推断。
|
|
408
|
+
|
|
409
|
+
### 统一的元组表达式 = AI 有明确模式
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
// AI 学习一次模式,到处复用
|
|
413
|
+
{ balance: ['inc', 22] } // 自增
|
|
414
|
+
{ status: ['setNull'] } // 置空
|
|
415
|
+
{ score: ['expr', '?? * ?', ['base', 2]] } // 自定义表达式
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
模式统一、可组合,AI 不容易混淆。相比之下,TypeORM 的更新:
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
// 不同场景用不同 API
|
|
422
|
+
repo.update(id, { balance: () => 'balance + 22' }) // raw SQL
|
|
423
|
+
repo.increment(id, 'balance', 22) // 专用方法
|
|
424
|
+
repo.save(entity) // 完整实体
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
AI 需要记忆多个 API 和它们的适用场景。
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## 一句话总结
|
|
432
|
+
|
|
433
|
+
wok-server 的每一个设计决策,都是在"简单透明"和"功能强大"之间选择了前者。不是因为做不到,而是因为**选择不做**。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wok-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"packageManager": "pnpm@8.9.0",
|
|
5
5
|
"description": "一个基于 NodeJs 和 TypeScript 的后端框架,轻量级、克制、简洁。A lightweight, restrained, and concise backend framework based on Node.js and TypeScript.",
|
|
6
6
|
"scripts": {
|