lakutata 3.0.0 → 3.1.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 +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +507 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +62 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +504 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/README.md +16 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +445 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +46 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +459 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/README.md +14 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- package/doc/zh/ORMIntegration.md +0 -319
package/doc/zh/MCP.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Lakutata MCP
|
|
2
|
+
|
|
3
|
+
模型上下文协议(MCP)让 AI 助手使用一个应用:调用它的**工具**,读取它的**资源**,填写它的**提示词**。`@lakutata/mcp` 包把控制器方法作为这些内容提供出来,可以通过子进程的标准输入输出,也可以通过 HTTP。它不属于 `lakutata`:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install lakutata @lakutata/mcp
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
| 声明方式 | 是什么 | 谁来使用 |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `@MCPTool(name)` | 模型带着参数调用的动作 | 模型,在它决定调用时 |
|
|
12
|
+
| `@MCPResource(uri)` | 按 URI 读取的数据 | 客户端,把它交给模型 |
|
|
13
|
+
| `@MCPPrompt(name)` | 用参数填写的消息模板 | 用户,在客户端中选择它 |
|
|
14
|
+
|
|
15
|
+
## 1. 基于 stdio 的服务器
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import {Application, DTO, Exception} from 'lakutata'
|
|
19
|
+
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
|
|
20
|
+
import {Prefix} from 'lakutata/decorator/ctrl'
|
|
21
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
22
|
+
import {Logger} from 'lakutata/com/logger'
|
|
23
|
+
import {MCPPrompt, MCPResource, MCPTool, stdioMCPEntrypoint} from '@lakutata/mcp'
|
|
24
|
+
|
|
25
|
+
class DeviceSnDTO extends DTO {
|
|
26
|
+
@Expect(DTO.String().required().description('The serial number of the device'))
|
|
27
|
+
public sn: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class StatusDTO extends DTO {
|
|
31
|
+
@Expect(DTO.String().required())
|
|
32
|
+
public sn: string
|
|
33
|
+
|
|
34
|
+
@Expect(DTO.Boolean().required())
|
|
35
|
+
public online: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class OfflineException extends Exception {
|
|
39
|
+
public errno: string = 'E_OFFLINE'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Prefix({mcp: 'device'})
|
|
43
|
+
class DeviceController extends Controller {
|
|
44
|
+
//工具 device_status
|
|
45
|
+
@MCPTool('status', DeviceSnDTO, {description: 'The online status of a device', readOnly: true, output: StatusDTO})
|
|
46
|
+
public async status(input: DeviceSnDTO): Promise<StatusDTO> {
|
|
47
|
+
return {sn: input.sn, online: input.sn !== 'off'} as StatusDTO
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@MCPTool('reboot', DeviceSnDTO, {description: 'Reboot a device', destructive: true})
|
|
51
|
+
public async reboot(input: DeviceSnDTO): Promise<string> {
|
|
52
|
+
if (input.sn === 'off') throw new OfflineException('The device {sn} is offline', {sn: input.sn})
|
|
53
|
+
return `Rebooting ${input.sn}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//每台设备的资源:device://A1/status
|
|
57
|
+
@MCPResource('device://{sn}/status', DeviceSnDTO, {mimeType: 'application/json', description: 'The status of a device'})
|
|
58
|
+
public async statusResource(input: DeviceSnDTO): Promise<StatusDTO> {
|
|
59
|
+
return {sn: input.sn, online: true} as StatusDTO
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
//提示词 device_diagnose
|
|
63
|
+
@MCPPrompt('diagnose', DeviceSnDTO, {description: 'Diagnose the failures of a device'})
|
|
64
|
+
public async diagnose(input: DeviceSnDTO): Promise<string> {
|
|
65
|
+
return `Diagnose the device ${input.sn} from its status and its logs.`
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Application.run(() => ({
|
|
70
|
+
id: 'devices.app',
|
|
71
|
+
name: 'Devices',
|
|
72
|
+
components: {
|
|
73
|
+
//标准输出属于协议:日志写到标准错误
|
|
74
|
+
log: {class: Logger, destinations: [process.stderr]},
|
|
75
|
+
entrypoint: buildEntrypoints({
|
|
76
|
+
controllers: [DeviceController],
|
|
77
|
+
mcp: stdioMCPEntrypoint({instructions: 'Check the status of a device before rebooting it.'})
|
|
78
|
+
})
|
|
79
|
+
},
|
|
80
|
+
bootstrap: ['entrypoint']
|
|
81
|
+
}))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- 客户端用 `node build/App.js` 这样的命令把应用作为子进程运行,通过它的标准输入输出与它通信。客户端关闭它的输入后,应用就会退出(`exitOnClose: false` 让它继续运行)。
|
|
85
|
+
- 标准输出承载协议的消息:日志要像上面那样写到标准错误,也不能调用 `console.log()`。
|
|
86
|
+
- `stdioMCPEntrypoint()` 的选项:`serverName`(默认为应用的名字)、`serverVersion`(`'1.0.0'`)、给模型的 `instructions`、`stdin` 和 `stdout`、`exitOnClose`、`name` 以及 `middlewares`。
|
|
87
|
+
- 在 stdio 上,服务器使用 2025 年的几个协议版本。
|
|
88
|
+
|
|
89
|
+
## 2. 工具
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
@MCPTool(name, DTO?, options?)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| 选项 | 含义 |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `description` | 工具做什么,写给模型看;没有它时,模型看到的是 `ControllerClass:method` |
|
|
98
|
+
| `title` | 用于显示的名字 |
|
|
99
|
+
| `readOnly`、`destructive`、`idempotent`、`openWorld` | 给客户端的提示:工具不做任何修改、会做出不可撤销的修改、再次调用结果相同、会访问外部系统 |
|
|
100
|
+
| `output` | 结果的 DTO:发送它的 JSON Schema,结果也会同时作为结构化内容给出 |
|
|
101
|
+
| `acl`、`rule`、`middlewares`…… | 所有动作共有的选项(见 [Controllers](Controllers.md) 第 3 节) |
|
|
102
|
+
|
|
103
|
+
- 名字由 1 到 128 个字母、数字、`_`、`-` 或 `.` 组成。`@Prefix({mcp: 'device'})` 给控制器中工具和提示词的名字加上前缀:`status` 变成 `device_status`。
|
|
104
|
+
- 参数由 DTO 的 JSON Schema 描述;`.description()` 向模型说明一个参数。DTO 校验参数,与所有动作一样。
|
|
105
|
+
- 两个控制器声明了同一个工具时,由后注册的那个提供,与其他传输方式相同。
|
|
106
|
+
|
|
107
|
+
工具的返回值成为它的结果:
|
|
108
|
+
|
|
109
|
+
| 返回 | 结果 |
|
|
110
|
+
|---|---|
|
|
111
|
+
| 字符串 | 一段文本 |
|
|
112
|
+
| 对象 | 它的 JSON 文本,同时把对象作为结构化内容 |
|
|
113
|
+
| `undefined` | 没有内容 |
|
|
114
|
+
| `Result.file(buffer, {contentType})` | 对应类型时是图片或音频,否则是嵌入的资源 |
|
|
115
|
+
| `Result.status(status, data)` | 它的数据 |
|
|
116
|
+
|
|
117
|
+
- 类的实例(DTO)只有在工具声明了 `output` 时才作为结构化内容给出。声明了 `output` 却没有返回对象的工具会失败。
|
|
118
|
+
- 服务器不按 `output` 检查结构化内容;由客户端检查,不符合的结果会被客户端拒绝。
|
|
119
|
+
- 工具抛出的错误,包括参数校验和访问控制的错误,会作为标记为错误的结果交给模型,文本是 `errno: message`(`E_OFFLINE: The device off is offline`)。模型可以据此修正它的调用。
|
|
120
|
+
|
|
121
|
+
## 3. 资源
|
|
122
|
+
|
|
123
|
+
```text
|
|
124
|
+
@MCPResource(uri, DTO?, options?)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- 没有变量的 URI(`device://catalog`)是一个独立的资源;带变量的 URI(`device://{sn}/status`)是一个模板,由客户端填写。URI 不会加前缀。
|
|
128
|
+
- 变量就是动作的输入。它们是字符串:把它们声明为字符串,或者像 HTTP 请求的查询参数那样,用 `.strict(false)` 转换。
|
|
129
|
+
- 选项:`resourceName`(列出的名字,默认是动作的名字)、`title`、`mimeType`、`audience`(`['user']`、`['assistant']`)、`priority`(0 到 1),以及所有动作共有的选项。
|
|
130
|
+
|
|
131
|
+
| 返回 | 内容 |
|
|
132
|
+
|---|---|
|
|
133
|
+
| 字符串 | 文本,类型为 `mimeType`(默认 `text/plain`) |
|
|
134
|
+
| 其他值 | 它的 JSON,类型为 `mimeType`(默认 `application/json`) |
|
|
135
|
+
| `Buffer`、`Result.file()` | 按类型给出文本或二进制数据 |
|
|
136
|
+
|
|
137
|
+
读取资源时抛出的错误以协议错误响应:`statusCode` 是客户端错误(400 到 499,包括输入无效)时为参数无效(`-32602`),否则为内部错误(`-32603`)。错误消息是 `errno: message`。
|
|
138
|
+
|
|
139
|
+
## 4. 提示词
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
@MCPPrompt(name, DTO?, options?)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- DTO 的属性就是提示词的参数,会列给客户端。它们是字符串:把它们声明为字符串,或者用 `.strict(false)` 转换。
|
|
146
|
+
- 字符串成为一条用户消息;数组的每一项成为一条消息(字符串,或者 `{role, content}`);对象 `{messages}` 按原样发送。
|
|
147
|
+
- 选项 `title`,以及所有动作共有的选项。错误的处理与资源相同。
|
|
148
|
+
|
|
149
|
+
## 5. 上下文
|
|
150
|
+
|
|
151
|
+
在 MCP 动作中,`this.context` 是一个 `MCPContext`:
|
|
152
|
+
|
|
153
|
+
| 属性 | 是什么 |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `kind` | `'tool'`、`'resource'` 或 `'prompt'` |
|
|
156
|
+
| `name` | 工具或提示词的名字,包括前缀;对于资源,是声明时的 URI |
|
|
157
|
+
| `uri` | 读取的 URI,用于资源 |
|
|
158
|
+
| `data` | 校验之前的参数(资源的 `data` 为空,它的变量就是输入) |
|
|
159
|
+
| `authInfo` | `authenticate` 的返回值(第 6 节) |
|
|
160
|
+
| `progress(progress, total?, message?)` | 向客户端报告耗时调用的进度 |
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
164
|
+
import {MCPTool, type MCPContext} from '@lakutata/mcp'
|
|
165
|
+
|
|
166
|
+
class ImportController extends Controller {
|
|
167
|
+
@MCPTool('import', {description: 'Import the devices'})
|
|
168
|
+
public async import(): Promise<string> {
|
|
169
|
+
const context: MCPContext = this.context as MCPContext
|
|
170
|
+
for (let step: number = 1; step <= 3; step++) {
|
|
171
|
+
//客户端取消了这次调用
|
|
172
|
+
if (this.signal?.aborted) return 'cancelled'
|
|
173
|
+
await context.progress(step, 3, `step ${step}`)
|
|
174
|
+
}
|
|
175
|
+
return 'imported'
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- 客户端取消调用时,`this.signal` 会被中止。
|
|
181
|
+
- 一个方法可以同时服务多种传输方式(`@GET` 和 `@MCPTool`):对于 MCP 的请求,`this.context.type` 是 `'MCP'`(`MCP_CONTEXT_TYPE`)。
|
|
182
|
+
- 入口的访问规则适用于以 `acl: true` 声明的 MCP 动作,前提是规则的 `supportContextTypes()` 包含 `MCP_CONTEXT_TYPE`(见 [Middlewares](Middlewares.md))。
|
|
183
|
+
- 服务器不会向客户端请求任何东西:没有采样(sampling)、征询(elicitation),也不向客户端发送日志。
|
|
184
|
+
|
|
185
|
+
**通知。** `MCPNotifier` 是一个可以注入的组件(`@Inject(MCPNotifier)`),用来告诉客户端有内容变化:`resourceUpdated(uri)`、`resourcesChanged()`、`toolsChanged()`、`promptsChanged()`。在 stdio 上,只有订阅了某个 URI 的客户端才会收到这个资源的更新。在 HTTP 上,只有使用 2026-07-28 协议版本、并且监听变化的客户端才会收到通知。
|
|
186
|
+
|
|
187
|
+
## 6. 基于 HTTP
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
import type {IncomingMessage} from 'node:http'
|
|
191
|
+
import {Application} from 'lakutata'
|
|
192
|
+
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
|
|
193
|
+
import {httpMCPEntrypoint, MCPTool, type MCPAuthInfo} from '@lakutata/mcp'
|
|
194
|
+
|
|
195
|
+
class ClockController extends Controller {
|
|
196
|
+
@MCPTool('now', {description: 'The current time of the server', readOnly: true})
|
|
197
|
+
public async now(): Promise<string> {
|
|
198
|
+
return new Date().toISOString()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
//抛出错误时以 401 响应
|
|
203
|
+
function verifyToken(request: IncomingMessage): MCPAuthInfo {
|
|
204
|
+
const token: string = request.headers.authorization?.replace(/^Bearer /, '') ?? ''
|
|
205
|
+
if (token !== process.env.MCP_TOKEN) throw new Error('Invalid token')
|
|
206
|
+
return {token: token, clientId: 'assistant', scopes: []}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
//客户端连接 http://127.0.0.1:3000/mcp
|
|
210
|
+
Application.run(() => ({
|
|
211
|
+
id: 'clock.app',
|
|
212
|
+
name: 'Clock',
|
|
213
|
+
components: {
|
|
214
|
+
entrypoint: buildEntrypoints({
|
|
215
|
+
controllers: [ClockController],
|
|
216
|
+
mcp: httpMCPEntrypoint({port: 3000, authenticate: verifyToken})
|
|
217
|
+
})
|
|
218
|
+
},
|
|
219
|
+
bootstrap: ['entrypoint']
|
|
220
|
+
}))
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
| 选项 | 默认值 | 含义 |
|
|
224
|
+
|---|---|---|
|
|
225
|
+
| `port`、`host`、`path` | `3000`、`'127.0.0.1'`、`'/mcp'` | 监听的位置 |
|
|
226
|
+
| `authenticate` | | `(request) => MCPAuthInfo \| undefined`:客户端的身份认证;抛出错误时以 401 响应,返回 `undefined` 时不认证,照常处理请求 |
|
|
227
|
+
| `allowedHosts`、`allowedOrigins` | 在本机地址上时为本机的名字 | 接受的 `Host` 和 `Origin` 请求头(否则为 403);`false` 表示不检查 |
|
|
228
|
+
| `legacy` | `'stateless'` | 2025 年协议版本的客户端:以无会话的方式服务,或者用 `'reject'` 拒绝 |
|
|
229
|
+
| `maxRequestBodySize` | 4 MiB | 更大的请求体以 413 响应 |
|
|
230
|
+
| `bus` | 内存中 | 通知的去向,用于在应用的多个实例之间共享 |
|
|
231
|
+
| `listen` | `true` | `false` 表示不打开端口:把 `handler` 挂到其他服务器上 |
|
|
232
|
+
| `serverName`、`serverVersion`、`instructions`、`server`、`listening`、`name`、`middlewares` | | 与 stdio 相同;`server` 是 `node:http` 服务器的选项 |
|
|
233
|
+
|
|
234
|
+
- 它通过 Streamable HTTP 提供协议:2026-07-28 版本,以及无会话方式的 2025 年版本。
|
|
235
|
+
- 在本机地址上,`Host` 和 `Origin` 请求头会与本机的名字比对,防止 DNS 重绑定攻击:在代理之后时,要列出客户端使用的名字。在其他地址上,除非列出名字,否则不做检查。
|
|
236
|
+
- 应用停止时,正在处理的调用会得到响应,然后端口关闭。
|
|
237
|
+
|
|
238
|
+
**与 HTTP 适配器共用端口。** 设置 `listen: false`,在 Express 应用的 `before` 钩子中挂载适配器的 `handler`:
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import {buildEntrypoints} from 'lakutata/com/entrypoint'
|
|
242
|
+
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
|
|
243
|
+
import {httpMCPEntrypoint} from '@lakutata/mcp'
|
|
244
|
+
|
|
245
|
+
//客户端连接时使用的名字(http://localhost:8080/mcp)
|
|
246
|
+
const mcp = httpMCPEntrypoint({listen: false, allowedHosts: ['localhost', '127.0.0.1', 'mcp.example.com']})
|
|
247
|
+
|
|
248
|
+
export const entrypoint = buildEntrypoints({
|
|
249
|
+
controllers: [],
|
|
250
|
+
http: expressHTTPEntrypoint({port: 8080, before: (app): void => void app.all('/mcp', mcp.handler)}),
|
|
251
|
+
mcp: mcp
|
|
252
|
+
})
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
挂载的 `handler` 只按给出的名字检查 `Host` 和 `Origin` 请求头;在适配器启动之前和停止之后,它以 503 响应。
|
|
256
|
+
|
|
257
|
+
## 7. 错误
|
|
258
|
+
|
|
259
|
+
| 情形 | 客户端收到 |
|
|
260
|
+
|---|---|
|
|
261
|
+
| 工具失败:异常、参数无效、访问被拒绝 | 标记为错误的结果:`errno: message` |
|
|
262
|
+
| 资源或提示词因客户端错误(400 到 499)失败 | 协议错误 `-32602`,消息为 `errno: message`,数据为 `{errno}` |
|
|
263
|
+
| 资源或提示词因其他原因失败 | 协议错误 `-32603` |
|
|
264
|
+
| 未知的工具、提示词或资源 | 协议错误 `-32602`(`Tool nope not found`) |
|
|
265
|
+
| 工具或提示词的名字无效,资源 URI 没有协议名 | 声明类时抛出 `TypeError` |
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Lakutata 中间件与访问控制
|
|
2
|
+
|
|
3
|
+
中间件在动作前后运行:它在动作之前看到每个请求,在动作之后看到结果。访问控制决定一个请求能不能到达它的动作。两者对所有传输方式都以同样的方式工作。
|
|
4
|
+
|
|
5
|
+
## 1. 中间件
|
|
6
|
+
|
|
7
|
+
中间件是一个函数 `(invocation, next) => Promise<unknown>`。它调用 `next()` 让请求继续,并返回请求的结果:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import {type ActionInvocation, Controller, type Next} from 'lakutata/com/entrypoint'
|
|
11
|
+
import {GET, UseMiddlewares} from 'lakutata/decorator/ctrl'
|
|
12
|
+
|
|
13
|
+
async function timing(invocation: ActionInvocation, next: Next): Promise<unknown> {
|
|
14
|
+
const started: number = Date.now()
|
|
15
|
+
try {
|
|
16
|
+
return await next()
|
|
17
|
+
} finally {
|
|
18
|
+
console.log(`${invocation.action.name} took ${Date.now() - started} ms`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@UseMiddlewares(timing)
|
|
23
|
+
class HealthController extends Controller {
|
|
24
|
+
@GET('/health')
|
|
25
|
+
public async health(): Promise<string> {
|
|
26
|
+
return 'ok'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
中间件也可以是一个继承 `Middleware` 并实现 `handle(invocation, next)` 的类。这个类在每个请求的作用域里创建,所以可以注入其他对象:
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import {Component} from 'lakutata'
|
|
35
|
+
import {type ActionInvocation, Middleware, type Next} from 'lakutata/com/entrypoint'
|
|
36
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
37
|
+
|
|
38
|
+
class Metrics extends Component {
|
|
39
|
+
public record(name: string, milliseconds: number): void {
|
|
40
|
+
console.log(name, milliseconds)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class Timing extends Middleware {
|
|
45
|
+
//在应用的 components 中声明为 metrics 的组件
|
|
46
|
+
@Inject('metrics')
|
|
47
|
+
protected readonly metrics: Metrics
|
|
48
|
+
|
|
49
|
+
public async handle(invocation: ActionInvocation, next: Next): Promise<unknown> {
|
|
50
|
+
const started: number = Date.now()
|
|
51
|
+
try {
|
|
52
|
+
return await next()
|
|
53
|
+
} finally {
|
|
54
|
+
this.metrics.record(invocation.action.name, Date.now() - started)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`invocation` 描述当前请求:
|
|
61
|
+
|
|
62
|
+
| 属性 | 含义 |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `context` | 请求的上下文(动作中的 `this.context`)。它的 `data`(校验之前的输入)可以修改 |
|
|
65
|
+
| `action` | 动作:`name`、`description`、`groups` 和地址(`route`、`command`、`pattern`) |
|
|
66
|
+
| `container` | 请求作用域的容器,用来获取这个请求的对象 |
|
|
67
|
+
| `signal` | 请求的 `AbortSignal` |
|
|
68
|
+
|
|
69
|
+
- 不调用 `next()` 直接返回的中间件,会自己回复这个请求:动作不会执行。缓存就可以这样实现。
|
|
70
|
+
- 抛出错误的中间件会让请求失败,效果与动作抛出错误相同。
|
|
71
|
+
- `next()` 只能调用一次:第二次调用会抛出 `MiddlewareNextCalledException`。
|
|
72
|
+
- 中间件对所有传输方式都会执行,包括定时任务。可以用 `invocation.context.type` 区分它们。
|
|
73
|
+
|
|
74
|
+
## 2. 在哪里声明
|
|
75
|
+
|
|
76
|
+
| 位置 | 作用于 |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `buildEntrypoints({middlewares})` | 所有动作 |
|
|
79
|
+
| 适配器的 `middlewares` 选项 | 通过这个适配器到达的动作 |
|
|
80
|
+
| 控制器类上的 `@UseMiddlewares(...)` | 这个类及其子类的动作 |
|
|
81
|
+
| 动作选项 `middlewares` | 这个动作 |
|
|
82
|
+
| 方法上的 `@UseMiddlewares(...)` | 这个方法 |
|
|
83
|
+
|
|
84
|
+
它们按照表中的顺序执行,从第一行(最外层)到最后一行:入口、适配器、控制器类、动作选项、方法。在控制器上,父类的中间件先于子类的执行。最后一个中间件之后,依次是输入校验、访问控制和动作本身。
|
|
85
|
+
|
|
86
|
+
## 3. 向动作传递数据
|
|
87
|
+
|
|
88
|
+
中间件常常会得到动作需要的东西,比如发送请求的用户。不要把它放进输入:DTO 在中间件之后才校验输入,还会去掉没有声明的键。应该改用请求的提供者:`@Scoped` 提供者在每个请求中只有一个实例,中间件给它赋值,动作注入的就是同一个实例。
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
import {Exception, Provider} from 'lakutata'
|
|
92
|
+
import {type ActionInvocation, Controller, type HTTPContext, Middleware, type Next} from 'lakutata/com/entrypoint'
|
|
93
|
+
import {GET, UseMiddlewares} from 'lakutata/decorator/ctrl'
|
|
94
|
+
import {Autoload, Inject, Scoped} from 'lakutata/decorator/di'
|
|
95
|
+
|
|
96
|
+
class UnauthorizedException extends Exception {
|
|
97
|
+
public errno: string = 'E_UNAUTHORIZED'
|
|
98
|
+
|
|
99
|
+
public statusCode: number = 401
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
//每个请求一个实例,第一次使用时自动注册
|
|
103
|
+
@Autoload()
|
|
104
|
+
@Scoped()
|
|
105
|
+
export class CurrentUser extends Provider {
|
|
106
|
+
public name: string
|
|
107
|
+
|
|
108
|
+
public role: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
class Authenticate extends Middleware {
|
|
112
|
+
public async handle(invocation: ActionInvocation, next: Next): Promise<unknown> {
|
|
113
|
+
const token: string | undefined = (invocation.context as HTTPContext).request.headers.authorization
|
|
114
|
+
if (!token) throw new UnauthorizedException('Not logged in')
|
|
115
|
+
const user: CurrentUser = await invocation.container.get(CurrentUser)
|
|
116
|
+
//实际应用中要根据令牌查出用户
|
|
117
|
+
user.name = token
|
|
118
|
+
user.role = token === 'ada' ? 'admin' : 'user'
|
|
119
|
+
return await next()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@UseMiddlewares(Authenticate)
|
|
124
|
+
class ProfileController extends Controller {
|
|
125
|
+
@Inject(CurrentUser)
|
|
126
|
+
protected readonly user: CurrentUser
|
|
127
|
+
|
|
128
|
+
@GET('/me')
|
|
129
|
+
public async me(): Promise<{ name: string, role: string }> {
|
|
130
|
+
return {name: this.user.name, role: this.user.role}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```text
|
|
136
|
+
$ curl localhost:8080/me -H 'Authorization: ada'
|
|
137
|
+
{"name":"ada","role":"admin"}
|
|
138
|
+
$ curl localhost:8080/me
|
|
139
|
+
{"errno":"E_UNAUTHORIZED","errMsg":"Not logged in"}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## 4. 访问控制
|
|
143
|
+
|
|
144
|
+
声明了 `acl: true` 的动作,会由入口的访问规则检查。访问规则是继承 `AccessControlRule` 的类,列在 `buildEntrypoints({rules})` 中:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
import {Provider} from 'lakutata'
|
|
148
|
+
import {AccessControlRule, ContextType, Controller, type HTTPContext} from 'lakutata/com/entrypoint'
|
|
149
|
+
import {DELETE, GET} from 'lakutata/decorator/ctrl'
|
|
150
|
+
import {Autoload, Inject, Scoped} from 'lakutata/decorator/di'
|
|
151
|
+
|
|
152
|
+
//第 3 节的提供者,由中间件 Authenticate 赋值
|
|
153
|
+
@Autoload()
|
|
154
|
+
@Scoped()
|
|
155
|
+
class CurrentUser extends Provider {
|
|
156
|
+
public name: string
|
|
157
|
+
|
|
158
|
+
public role: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
//admin 分组的动作只允许管理员访问
|
|
162
|
+
export class AdminRule extends AccessControlRule<HTTPContext> {
|
|
163
|
+
@Inject(CurrentUser)
|
|
164
|
+
protected readonly user: CurrentUser
|
|
165
|
+
|
|
166
|
+
public get supportContextTypes(): ContextType[] {
|
|
167
|
+
return [ContextType.HTTP]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
public async rule(): Promise<boolean> {
|
|
171
|
+
if (!this.targetAction.groups.includes('admin')) return true
|
|
172
|
+
return this.user.role === 'admin'
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export class StatsController extends Controller {
|
|
177
|
+
@GET('/admin/stats', {acl: true, groups: ['admin']})
|
|
178
|
+
public async stats(): Promise<string> {
|
|
179
|
+
return 'stats'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
//只属于这个动作的规则,不需要 acl
|
|
183
|
+
@DELETE('/drafts/:id', {rule: async (context): Promise<boolean> => (context as HTTPContext).request.headers['x-owner'] === 'yes'})
|
|
184
|
+
public async removeDraft(): Promise<void> {
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
//buildEntrypoints({controllers: [StatsController], rules: [AdminRule], httpActionGroups: {admin: 'Administration'}})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- 访问规则在每个请求的作用域里创建,所以可以注入这个请求的对象,比如中间件赋值过的 `CurrentUser`。
|
|
192
|
+
- 在规则里,`this.context` 是请求的上下文,`this.input` 是校验后的输入,`this.targetAction` 是目标动作(它的 `name`、`groups`),`this.actions` 是该传输方式的全部动作。
|
|
193
|
+
- `supportContextTypes` 列出规则要检查的传输方式,其他传输方式会跳过这条规则。
|
|
194
|
+
- 先按 `rules` 选项的顺序执行各条规则,再执行动作的 `rule` 选项。第一个返回 `false` 的规则会以 `AccessDeniedException` 拒绝请求(errno 为 `E_ACCESS_DENY`,HTTP 上是状态码 403);抛出错误的规则会以它自己的错误拒绝请求(比如 401)。
|
|
195
|
+
- 动作的 `rule` 选项可以单独使用:没有 `acl` 时,只检查这一条规则。
|
|
196
|
+
- 既没有 `acl` 也没有 `rule` 的动作不做检查。`acl: true` 的动作只要没有规则拒绝就允许访问。
|
|
197
|
+
|
|
198
|
+
访问控制在中间件和输入校验之后、动作之前执行。要改变整个应用的判断方式,见 [Entrypoint](Entrypoint.md) 第 7 节。
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Lakutata 迁移
|
|
2
|
+
|
|
3
|
+
`synchronize: true` 会在每次连接数据库时,让表结构与实体保持一致:开发时很方便,但在生产环境中并不安全,比如重命名的列会连同数据一起被删掉。生产环境中的表结构通过**迁移**来变更:每个迁移是一个类,只做一处变更,按顺序执行,并记录在数据库中,保证每个迁移只执行一次。
|
|
4
|
+
|
|
5
|
+
## 1. 一个迁移
|
|
6
|
+
|
|
7
|
+
迁移实现 `lakutata/orm` 中的 `MigrationInterface`。`up()` 执行变更,`down()` 撤销变更:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import type {MigrationInterface, QueryRunner} from 'lakutata/orm'
|
|
11
|
+
|
|
12
|
+
export class AddBookAuthor1792161460658 implements MigrationInterface {
|
|
13
|
+
public name: string = 'AddBookAuthor1792161460658'
|
|
14
|
+
|
|
15
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
16
|
+
await queryRunner.query('ALTER TABLE "books" ADD "author" character varying')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
20
|
+
await queryRunner.query('ALTER TABLE "books" DROP COLUMN "author"')
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- 迁移的名字以毫秒时间戳(13 位数字)结尾:迁移按时间戳的顺序执行。两个迁移同名会报错。
|
|
26
|
+
- 数据库的 `migrations` 选项列出迁移:迁移类,或者编译后文件的 glob(相对于工作目录)。
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import {buildDatabaseOptions} from 'lakutata/com/database'
|
|
30
|
+
|
|
31
|
+
export const db = buildDatabaseOptions({
|
|
32
|
+
type: 'postgres',
|
|
33
|
+
url: process.env.DATABASE_URL,
|
|
34
|
+
entities: [],
|
|
35
|
+
migrations: ['build/migrations/*.js']
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- 已执行的迁移记录在名为 `migrations` 的表中(`migrationsTableName` 可以改名)。每个迁移只执行一次,即使它的时间戳早于最后执行的那个迁移。
|
|
40
|
+
|
|
41
|
+
## 2. `lakutata` 命令
|
|
42
|
+
|
|
43
|
+
用 `lakutata` 创建的项目自带迁移相关的脚本:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm run migration:generate # 根据实体的变化生成迁移
|
|
47
|
+
npm run migration:create -- seed # 一个空迁移,src/migrations/<时间戳>-seed.ts
|
|
48
|
+
npm run migration:run # 执行待执行的迁移
|
|
49
|
+
npm run migration:show # 列出已执行和待执行的迁移
|
|
50
|
+
npm run migration:revert # 撤销最后执行的迁移
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
这些脚本调用 `lakutata migration <action>`:
|
|
54
|
+
|
|
55
|
+
| 选项 | 默认值 | 含义 |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `--dir` | `src/migrations` | 写入迁移的目录(`generate`、`create`) |
|
|
58
|
+
| `--script` | `dev` | 构建并启动应用的 npm 脚本 |
|
|
59
|
+
| `--database` | 所有数据库 | 命令针对的数据库;有多个数据库时,撤销必须指定 |
|
|
60
|
+
| `--transaction` | 数据库的 `migrationsTransactionMode` | `all`、`each` 或 `none`(第 6 节) |
|
|
61
|
+
| `--fake` | `false` | 只把迁移记录为已执行(或已撤销),不真正执行 |
|
|
62
|
+
| `--package` | 当前目录 | 在工作区的哪个包中执行命令 |
|
|
63
|
+
|
|
64
|
+
`create` 只写一个空迁移,不启动应用。其他操作会带着第 3 节的环境变量运行应用的 npm 脚本:命令由应用自己完成。
|
|
65
|
+
|
|
66
|
+
## 3. 应用的迁移命令
|
|
67
|
+
|
|
68
|
+
带着下面某个环境变量启动的应用不会正常启动,而是执行命令:它读取自己的选项,连接数据库,执行命令,然后退出。它的 `bootstrap` 和 `onLaunched` 回调都不会执行,也不会启动任何服务。
|
|
69
|
+
|
|
70
|
+
| 变量 | 命令 |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `MIGRATION_GENERATE=<目录>` | 把实体与表之间的差异写成一个迁移(第 4 节) |
|
|
73
|
+
| `MIGRATION_RUN=1` | 执行待执行的迁移 |
|
|
74
|
+
| `MIGRATION_REVERT=1` | 撤销最后执行的迁移 |
|
|
75
|
+
| `MIGRATION_SHOW=1` | 列出选项中的迁移:已执行(`[X]`)和待执行(`[ ]`) |
|
|
76
|
+
| `MIGRATION_DATABASE=<名字>` | 命令针对的数据库:它的组件或提供者的名字(默认所有数据库) |
|
|
77
|
+
| `MIGRATION_TRANSACTION=all\|each\|none` | `MIGRATION_RUN` 和 `MIGRATION_REVERT` 使用的事务方式(第 6 节) |
|
|
78
|
+
| `MIGRATION_FAKE=1` | 只把迁移记录为已执行(或已撤销),不真正执行 |
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
$ MIGRATION_SHOW=1 node build/App.js
|
|
82
|
+
[db] [ ] Migrations1792161460658
|
|
83
|
+
[db] 1 pending migration
|
|
84
|
+
$ MIGRATION_RUN=1 node build/App.js
|
|
85
|
+
[db] Migration Migrations1792161460658 has been run
|
|
86
|
+
$ MIGRATION_REVERT=1 node build/App.js
|
|
87
|
+
[db] Migration Migrations1792161460658 has been reverted
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- 开关变量的值为 `1`、`true` 或 `yes` 时才生效:`MIGRATION_RUN=0` 会让应用照常启动。
|
|
91
|
+
- 命令作用于选项顶层(`components`、`providers`)声明的 `Database` 组件和提供者,每个数据库以它的键作为名字。
|
|
92
|
+
- 命令成功时退出码为 0,失败时为 1。有多个数据库时,`MIGRATION_REVERT` 需要 `MIGRATION_DATABASE`。
|
|
93
|
+
|
|
94
|
+
## 4. 生成迁移
|
|
95
|
+
|
|
96
|
+
`MIGRATION_GENERATE` 比较实体与数据库中的表,把差异的 SQL 写成一个迁移:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
$ MIGRATION_GENERATE=src/migrations node build/App.js
|
|
100
|
+
Migration /home/app/src/migrations/1792161460658-db.ts has been generated successfully.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { MigrationInterface, QueryRunner } from "lakutata/orm";
|
|
105
|
+
|
|
106
|
+
export class Migrations1792161460658 implements MigrationInterface {
|
|
107
|
+
name = 'Migrations1792161460658'
|
|
108
|
+
|
|
109
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
110
|
+
await queryRunner.query(`
|
|
111
|
+
CREATE TABLE "books" (
|
|
112
|
+
"id" SERIAL NOT NULL,
|
|
113
|
+
"title" character varying NOT NULL,
|
|
114
|
+
CONSTRAINT "PK_f3f2f25a099d24e12545b70b022" PRIMARY KEY ("id")
|
|
115
|
+
)
|
|
116
|
+
`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
120
|
+
await queryRunner.query(`
|
|
121
|
+
DROP TABLE "books"
|
|
122
|
+
`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- 数据库必须能连上,并且已经执行了已有的迁移:差异是根据它当前的表计算的。
|
|
129
|
+
- 文件是 `<目录>/<时间戳>-<数据库名>.ts`,类名取自目录(`src/migrations` 得到 `Migrations<时间戳>`),是 TypeScript 文件。随应用一起编译:`migrations` 选项列出的是编译后的文件。
|
|
130
|
+
- 执行迁移之前先读一遍:重命名的属性会变成删除一列再新增一列,旧列的数据会丢失。必要时修改其中的 SQL。
|
|
131
|
+
- 没有任何变化时,命令什么都不写,并以退出码 1 退出(`No changes in database schema were found`)。
|
|
132
|
+
- 有多个数据库时,每个表结构有差异的数据库各自生成一个文件。给每个数据库的 `migrations` 选项只列出它自己的文件(`'build/migrations/*-db.js'`),否则各个数据库会执行彼此的迁移。
|
|
133
|
+
|
|
134
|
+
## 5. 启动时执行迁移
|
|
135
|
+
|
|
136
|
+
除了使用命令,数据库也可以在每次连接时执行待执行的迁移,只要设置 `migrationsRun: true`。此时的顺序是:`dropSchema`(如果设置了),迁移,然后是 `synchronize`(如果设置了)。
|
|
137
|
+
|
|
138
|
+
- `Database` 组件只连接一次:列在 `bootstrap` 里时,它在应用启动过程中执行迁移。
|
|
139
|
+
- `Database` 提供者每次被解析时都会连接,所以每次都会检查迁移。
|
|
140
|
+
|
|
141
|
+
组件和提供者还有 `runMigrations({transaction, fake})`、`undoLastMigration({transaction, fake})` 和 `showMigrations()`(返回是否有待执行的迁移)三个方法。
|
|
142
|
+
|
|
143
|
+
## 6. 事务
|
|
144
|
+
|
|
145
|
+
数据库的选项 `migrationsTransactionMode`,或者单次命令的 `MIGRATION_TRANSACTION`:
|
|
146
|
+
|
|
147
|
+
| 方式 | 待执行的迁移…… |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `all`(默认) | 在同一个事务中执行:失败时全部撤销 |
|
|
150
|
+
| `each` | 各自在自己的事务中执行:失败时保留之前已执行的迁移 |
|
|
151
|
+
| `none` | 不使用事务 |
|
|
152
|
+
|
|
153
|
+
- 在 `each` 和 `none` 方式下,迁移可以设置自己的 `transaction` 属性(`true` 或 `false`);在 `all` 方式下这样做会报错。
|
|
154
|
+
- PostgreSQL 和 SQLite 在事务中执行表结构的变更。MySQL 和 MariaDB 会立即提交每一处表结构的变更:失败的迁移可能已经执行了一部分变更。
|
|
155
|
+
|
|
156
|
+
## 7. MongoDB
|
|
157
|
+
|
|
158
|
+
MongoDB 数据库(`mongodb`、`mongo-embedded`)没有可以比较的表结构:`MIGRATION_GENERATE` 不会为它们生成任何内容,它们的迁移需要手写。不支持 `queryRunner.query()`,要用 MongoDB 的实体管理器来完成工作:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import type {MigrationInterface, MongoEntityManager, QueryRunner} from 'lakutata/orm'
|
|
162
|
+
|
|
163
|
+
export class RenameTitle1792161460700 implements MigrationInterface {
|
|
164
|
+
public name: string = 'RenameTitle1792161460700'
|
|
165
|
+
|
|
166
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
167
|
+
const manager: MongoEntityManager = queryRunner.manager as MongoEntityManager
|
|
168
|
+
await manager.updateMany('notes', {}, {$rename: {title: 'name'}})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
172
|
+
const manager: MongoEntityManager = queryRunner.manager as MongoEntityManager
|
|
173
|
+
await manager.updateMany('notes', {}, {$rename: {name: 'title'}})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
MongoDB 不使用事务执行这些迁移:迁移失败时,之前已执行的迁移,以及它自己在失败前做出的变更,都会保留。
|