@taicode/common-server 3.8.0 → 4.0.0-beta.10

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 (218) hide show
  1. package/output/app/index.d.ts +18 -15
  2. package/output/app/index.d.ts.map +1 -1
  3. package/output/app/index.js +330 -9
  4. package/output/auth/index.d.ts +73 -0
  5. package/output/auth/index.d.ts.map +1 -0
  6. package/output/auth/index.js +196 -0
  7. package/output/core/assemble.d.ts +64 -0
  8. package/output/core/assemble.d.ts.map +1 -0
  9. package/output/core/assemble.js +88 -0
  10. package/output/core/boot/context.d.ts +109 -0
  11. package/output/core/boot/context.d.ts.map +1 -0
  12. package/output/core/boot/context.js +145 -0
  13. package/output/core/boot/lifecycle.d.ts +206 -0
  14. package/output/core/boot/lifecycle.d.ts.map +1 -0
  15. package/output/core/boot/lifecycle.js +255 -0
  16. package/output/core/boot/shutdown.d.ts +34 -0
  17. package/output/core/boot/shutdown.d.ts.map +1 -0
  18. package/output/core/boot/shutdown.js +50 -0
  19. package/output/core/boot/signal.d.ts +13 -0
  20. package/output/core/boot/signal.d.ts.map +1 -0
  21. package/output/{app → core/boot}/signal.js +5 -4
  22. package/output/core/boot/steps.d.ts +138 -0
  23. package/output/core/boot/steps.d.ts.map +1 -0
  24. package/output/core/boot/steps.js +259 -0
  25. package/output/core/entries.d.ts +151 -0
  26. package/output/core/entries.d.ts.map +1 -0
  27. package/output/core/entries.js +242 -0
  28. package/output/core/entries.test.d.ts +2 -0
  29. package/output/core/entries.test.d.ts.map +1 -0
  30. package/output/core/entries.test.js +458 -0
  31. package/output/core/env.d.ts +83 -0
  32. package/output/core/env.d.ts.map +1 -0
  33. package/output/core/env.js +124 -0
  34. package/output/{app → core}/env.test.d.ts.map +1 -1
  35. package/output/core/env.test.js +105 -0
  36. package/output/core/modules/auth-provider/index.d.ts +10 -0
  37. package/output/core/modules/auth-provider/index.d.ts.map +1 -0
  38. package/output/core/modules/auth-provider/index.js +9 -0
  39. package/output/core/modules/auth-provider/types.d.ts +71 -0
  40. package/output/core/modules/auth-provider/types.d.ts.map +1 -0
  41. package/output/core/modules/context/index.d.ts +18 -0
  42. package/output/core/modules/context/index.d.ts.map +1 -0
  43. package/output/core/modules/context/index.js +23 -0
  44. package/output/core/modules/context/index.test.d.ts.map +1 -0
  45. package/output/core/modules/cookie/index.d.ts +21 -0
  46. package/output/core/modules/cookie/index.d.ts.map +1 -0
  47. package/output/core/modules/cookie/index.js +17 -0
  48. package/output/core/modules/identity/index.d.ts +4 -0
  49. package/output/core/modules/identity/index.d.ts.map +1 -0
  50. package/output/core/modules/identity/index.js +14 -0
  51. package/output/core/modules/logging/index.d.ts +5 -0
  52. package/output/core/modules/logging/index.d.ts.map +1 -0
  53. package/output/core/modules/logging/index.js +29 -0
  54. package/output/core/modules/openapi/index.d.ts +7 -0
  55. package/output/core/modules/openapi/index.d.ts.map +1 -0
  56. package/output/core/modules/openapi/index.js +41 -0
  57. package/output/core/modules/permission-operations/index.d.ts +26 -0
  58. package/output/core/modules/permission-operations/index.d.ts.map +1 -0
  59. package/output/core/modules/permission-operations/index.js +36 -0
  60. package/output/core/modules/permission-operations/index.test.d.ts +2 -0
  61. package/output/core/modules/permission-operations/index.test.d.ts.map +1 -0
  62. package/output/core/modules/permission-operations/index.test.js +45 -0
  63. package/output/core/modules/request-id/index.d.ts +33 -0
  64. package/output/core/modules/request-id/index.d.ts.map +1 -0
  65. package/output/core/modules/request-id/index.js +66 -0
  66. package/output/core/modules/response/index.d.ts +50 -0
  67. package/output/core/modules/response/index.d.ts.map +1 -0
  68. package/output/core/modules/response/index.js +102 -0
  69. package/output/{schema/index.d.ts → core/modules/schema/builders.d.ts} +8 -1
  70. package/output/core/modules/schema/builders.d.ts.map +1 -0
  71. package/output/{schema/index.js → core/modules/schema/builders.js} +7 -4
  72. package/output/core/modules/schema/index.d.ts +34 -0
  73. package/output/core/modules/schema/index.d.ts.map +1 -0
  74. package/output/core/modules/schema/index.js +62 -0
  75. package/output/core/modules/schema/index.test.d.ts +2 -0
  76. package/output/core/modules/schema/index.test.d.ts.map +1 -0
  77. package/output/core/modules/schema/index.test.js +55 -0
  78. package/output/core/modules/system-routes/index.d.ts +15 -0
  79. package/output/core/modules/system-routes/index.d.ts.map +1 -0
  80. package/output/core/modules/system-routes/index.js +205 -0
  81. package/output/core/resource.d.ts +56 -0
  82. package/output/core/resource.d.ts.map +1 -0
  83. package/output/core/resource.js +56 -0
  84. package/output/core/test-support/database.d.ts +89 -0
  85. package/output/core/test-support/database.d.ts.map +1 -0
  86. package/output/core/test-support/database.js +131 -0
  87. package/output/core/test-support/database.test.d.ts.map +1 -0
  88. package/output/core/test-support/database.test.js +111 -0
  89. package/output/core/test-support/environment.d.ts +38 -0
  90. package/output/core/test-support/environment.d.ts.map +1 -0
  91. package/output/core/test-support/environment.js +45 -0
  92. package/output/core/test-support/service-context.d.ts +64 -0
  93. package/output/core/test-support/service-context.d.ts.map +1 -0
  94. package/output/core/test-support/service-context.js +63 -0
  95. package/output/core/test-support/service-context.test.d.ts +2 -0
  96. package/output/core/test-support/service-context.test.d.ts.map +1 -0
  97. package/output/core/test-support/service-context.test.js +55 -0
  98. package/output/core/test-support/test-server.d.ts +154 -0
  99. package/output/core/test-support/test-server.d.ts.map +1 -0
  100. package/output/core/test-support/test-server.js +114 -0
  101. package/output/core/test-support/test-server.test.d.ts +2 -0
  102. package/output/core/test-support/test-server.test.d.ts.map +1 -0
  103. package/output/core/test-support/test-server.test.js +296 -0
  104. package/output/core/test-support/testdata.d.ts +255 -0
  105. package/output/core/test-support/testdata.d.ts.map +1 -0
  106. package/output/core/test-support/testdata.js +271 -0
  107. package/output/core/test-support/testdata.test.d.ts +2 -0
  108. package/output/core/test-support/testdata.test.d.ts.map +1 -0
  109. package/output/core/test-support/testdata.test.js +334 -0
  110. package/output/core/type-augment.d.ts +34 -0
  111. package/output/core/type-augment.d.ts.map +1 -0
  112. package/output/core/type-augment.js +7 -0
  113. package/output/core/types.d.ts +582 -0
  114. package/output/core/types.d.ts.map +1 -0
  115. package/output/core/types.js +2 -0
  116. package/output/cors/index.d.ts +3 -0
  117. package/output/cors/index.d.ts.map +1 -0
  118. package/output/cors/index.js +28 -0
  119. package/output/index.d.ts +10 -4
  120. package/output/index.d.ts.map +1 -1
  121. package/output/index.js +25 -5
  122. package/output/rate-limit/index.d.ts +23 -0
  123. package/output/rate-limit/index.d.ts.map +1 -0
  124. package/output/rate-limit/index.js +43 -0
  125. package/output/{rate-limiter/rate-limiter.d.ts → rate-limit/limiter.d.ts} +1 -1
  126. package/output/rate-limit/limiter.d.ts.map +1 -0
  127. package/output/{rate-limiter → rate-limit}/memory-store.d.ts +6 -0
  128. package/output/rate-limit/memory-store.d.ts.map +1 -0
  129. package/output/{rate-limiter → rate-limit}/memory-store.js +8 -0
  130. package/output/{rate-limiter → rate-limit}/redis-store.d.ts +18 -0
  131. package/output/rate-limit/redis-store.d.ts.map +1 -0
  132. package/output/{rate-limiter → rate-limit}/redis-store.js +35 -0
  133. package/output/rate-limit/redis-store.test.d.ts.map +1 -0
  134. package/output/{rate-limiter → rate-limit}/redis-store.test.js +2 -2
  135. package/output/rate-limit/types.d.ts +45 -0
  136. package/output/rate-limit/types.d.ts.map +1 -0
  137. package/output/{redis-cache/redis-cache.d.ts → redis/cache.d.ts} +1 -1
  138. package/output/redis/cache.d.ts.map +1 -0
  139. package/output/redis/cache.test.d.ts +2 -0
  140. package/output/redis/cache.test.d.ts.map +1 -0
  141. package/output/{redis-cache/redis-cache.test.js → redis/cache.test.js} +1 -1
  142. package/output/redis/index.d.ts +50 -0
  143. package/output/redis/index.d.ts.map +1 -0
  144. package/output/redis/index.js +71 -0
  145. package/output/redis/index.test.d.ts +2 -0
  146. package/output/{context → redis}/index.test.d.ts.map +1 -1
  147. package/output/redis/index.test.js +55 -0
  148. package/output/redis-queue/index.js +1 -1
  149. package/output/redis-queue/test-helpers.d.ts +1 -1
  150. package/output/redis-queue/test-helpers.js +1 -1
  151. package/output/redis-queue/types.d.ts +3 -12
  152. package/output/redis-queue/types.d.ts.map +1 -1
  153. package/package.json +11 -7
  154. package/output/app/assemble.d.ts +0 -16
  155. package/output/app/assemble.d.ts.map +0 -1
  156. package/output/app/assemble.js +0 -124
  157. package/output/app/database.d.ts +0 -36
  158. package/output/app/database.d.ts.map +0 -1
  159. package/output/app/database.js +0 -40
  160. package/output/app/database.test.d.ts.map +0 -1
  161. package/output/app/database.test.js +0 -66
  162. package/output/app/default-modules.d.ts +0 -23
  163. package/output/app/default-modules.d.ts.map +0 -1
  164. package/output/app/default-modules.js +0 -40
  165. package/output/app/define-server.d.ts +0 -12
  166. package/output/app/define-server.d.ts.map +0 -1
  167. package/output/app/define-server.js +0 -289
  168. package/output/app/define-server.test.d.ts +0 -2
  169. package/output/app/define-server.test.d.ts.map +0 -1
  170. package/output/app/define-server.test.js +0 -299
  171. package/output/app/env.d.ts +0 -24
  172. package/output/app/env.d.ts.map +0 -1
  173. package/output/app/env.js +0 -38
  174. package/output/app/env.test.js +0 -78
  175. package/output/app/module.d.ts +0 -88
  176. package/output/app/module.d.ts.map +0 -1
  177. package/output/app/module.js +0 -34
  178. package/output/app/shutdown.d.ts +0 -9
  179. package/output/app/shutdown.d.ts.map +0 -1
  180. package/output/app/shutdown.js +0 -15
  181. package/output/app/shutdown.test.d.ts +0 -2
  182. package/output/app/shutdown.test.d.ts.map +0 -1
  183. package/output/app/shutdown.test.js +0 -149
  184. package/output/app/signal.d.ts +0 -12
  185. package/output/app/signal.d.ts.map +0 -1
  186. package/output/app/steps.d.ts +0 -17
  187. package/output/app/steps.d.ts.map +0 -1
  188. package/output/app/steps.js +0 -41
  189. package/output/app/types.d.ts +0 -149
  190. package/output/app/types.d.ts.map +0 -1
  191. package/output/context/index.d.ts +0 -6
  192. package/output/context/index.d.ts.map +0 -1
  193. package/output/context/index.js +0 -14
  194. package/output/rate-limiter/index.d.ts +0 -5
  195. package/output/rate-limiter/index.d.ts.map +0 -1
  196. package/output/rate-limiter/index.js +0 -4
  197. package/output/rate-limiter/memory-store.d.ts.map +0 -1
  198. package/output/rate-limiter/rate-limiter.d.ts.map +0 -1
  199. package/output/rate-limiter/redis-store.d.ts.map +0 -1
  200. package/output/rate-limiter/redis-store.test.d.ts.map +0 -1
  201. package/output/rate-limiter/types.d.ts +0 -27
  202. package/output/rate-limiter/types.d.ts.map +0 -1
  203. package/output/redis-cache/index.d.ts +0 -4
  204. package/output/redis-cache/index.d.ts.map +0 -1
  205. package/output/redis-cache/index.js +0 -1
  206. package/output/redis-cache/redis-cache.d.ts.map +0 -1
  207. package/output/redis-cache/redis-cache.test.d.ts +0 -2
  208. package/output/redis-cache/redis-cache.test.d.ts.map +0 -1
  209. package/output/schema/index.d.ts.map +0 -1
  210. /package/output/{app → core}/env.test.d.ts +0 -0
  211. /package/output/{app → core/modules/auth-provider}/types.js +0 -0
  212. /package/output/{context → core/modules/context}/index.test.d.ts +0 -0
  213. /package/output/{context → core/modules/context}/index.test.js +0 -0
  214. /package/output/{app → core/test-support}/database.test.d.ts +0 -0
  215. /package/output/{rate-limiter/rate-limiter.js → rate-limit/limiter.js} +0 -0
  216. /package/output/{rate-limiter → rate-limit}/redis-store.test.d.ts +0 -0
  217. /package/output/{rate-limiter → rate-limit}/types.js +0 -0
  218. /package/output/{redis-cache/redis-cache.js → redis/cache.js} +0 -0
@@ -0,0 +1,205 @@
1
+ import { catchIt, SystemError } from '@taicode/common-base';
2
+ import { z } from 'zod';
3
+ import { createSuccessResponse } from '../response/index.js';
4
+ import { routerSchema } from '../schema/builders.js';
5
+ /**
6
+ * 框架内置系统路由(`app/core/system-routes.ts`)——**框架自动注册、应用侧零注册**。
7
+ *
8
+ * 背景:各应用此前各自维护一份 `modules/system/router.ts`(account / release / notice /
9
+ * expense / open-trade / observai 各写一份),契约容易漂移。现收进框架 core,由 `registerSystemRoutesStep`
10
+ * 在 `routers` 阶段**之前**统一注册,从 `AppIdentity` + `ctx.services` + `ctx.readConfig` 派生。
11
+ *
12
+ * 与既有应用 router 对齐的契约(路径 / 方法 / 响应信封 / NoLogin 语义):
13
+ *
14
+ * | 方法 | 路径 | 说明 | 信封 `data` |
15
+ * |------|------|------|-------------|
16
+ * | GET | `/` | 服务可用性 | `string`(`identity.service`) |
17
+ * | GET | `/health` | k8s probe(NoLoginRequired) | `{ healthy: true }` |
18
+ * | GET | `/system/openapi`| OpenAPI 文档(NoLoginRequired,**原样返回、不套信封**) | — |
19
+ * | POST | `/system/health` | API 版健康检查 | `{ healthy: true }` |
20
+ * | POST | `/system/info` | 版本 + 运行时信息(NoLoginRequired,**不得含管理员等内部配置**) | `{ version, runtime }` |
21
+ *
22
+ * 健康检查:遍历 `services` 的**可选** `health()`,任一失败 / 非 `true` → 整体不健康,
23
+ * 抛 `SystemError('UNKNOWN_ERROR', { operation: 'healthCheck' })`(错误码与信封稳定,不得改)。
24
+ *
25
+ * `readConfig` 为**请求期惰性读取**(出 spec 路径不跑 `config` 槽位,故任何读取都必须发生在
26
+ * 请求处理器内,而非注册期)。
27
+ */
28
+ /**
29
+ * 响应信封助手:**直接用 `core/response` 的实现**。
30
+ *
31
+ * 这里曾经保留一份更窄的本地副本,理由写的是「框架的 `createSuccessResponse`
32
+ * 返回 `status: string` 过宽」——那其实是框架自己的类型缺陷(见 `core/modules/response`
33
+ * 里 `SuccessResponse` 的说明),修在源头之后副本就没有存在理由了。
34
+ *
35
+ * 同一个信封在一个仓里有多份实现,迟早会出现「某一份改了、其他没改」的局部不一致,
36
+ * 而它们的目标形状本应完全一样。
37
+ */
38
+ /** 响应信封(成功 `status: 'SUCCESS'`;错误 `data: null`,`status` 放稳定错误码字符串) */
39
+ function successEnvelope(data) {
40
+ return z.object({
41
+ status: z.enum(['SUCCESS']),
42
+ message: z.string(),
43
+ data,
44
+ });
45
+ }
46
+ /** 错误分支信封:`status` 放开为字符串(错误码集合不由框架枚举),`data` 恒为 `null` */
47
+ function errorEnvelope() {
48
+ return z.object({
49
+ status: z.string(),
50
+ message: z.string(),
51
+ data: z.null(),
52
+ });
53
+ }
54
+ /** 200 / 400 / 500 三分支响应(与各应用 `responseSchema` 同形) */
55
+ function responseEnvelopeSchema(data) {
56
+ return {
57
+ 200: successEnvelope(data),
58
+ 400: errorEnvelope(),
59
+ 500: errorEnvelope(),
60
+ };
61
+ }
62
+ const RootSchema = routerSchema({
63
+ operationId: 'getRoot',
64
+ summary: '根路由',
65
+ tags: ['系统'],
66
+ description: '服务可用性检查(无需认证)',
67
+ response: responseEnvelopeSchema(z.string()),
68
+ });
69
+ const HealthCheckSchema = routerSchema({
70
+ operationId: 'healthCheck',
71
+ summary: '健康检查',
72
+ tags: ['系统'],
73
+ description: '检查服务及其依赖是否正常运行(无需认证,用于 k8s readiness/liveness probe)',
74
+ response: responseEnvelopeSchema(z.object({ healthy: z.boolean().describe('服务是否健康') })),
75
+ });
76
+ const SystemHealthCheckSchema = routerSchema({
77
+ operationId: 'systemHealthCheck',
78
+ summary: '健康检查(API)',
79
+ tags: ['系统'],
80
+ description: '检查服务及其依赖是否正常运行(API 调用版)',
81
+ response: responseEnvelopeSchema(z.object({ healthy: z.boolean().describe('服务是否健康') })),
82
+ });
83
+ const SystemInfoSchema = routerSchema({
84
+ operationId: 'getSystemInfo',
85
+ summary: '获取系统信息',
86
+ tags: ['系统'],
87
+ description: '获取系统版本、运行时配置等信息(无需认证)',
88
+ response: responseEnvelopeSchema(z.object({
89
+ version: z
90
+ .object({
91
+ app: z.string().describe('应用版本'),
92
+ name: z.string().describe('应用名称(`identity.service`)'),
93
+ title: z.string().describe('应用标题(`identity.title`)'),
94
+ node: z.string().describe('Node.js 版本'),
95
+ platform: z.string().describe('运行平台'),
96
+ buildTime: z.string().optional().describe('构建时间'),
97
+ gitCommit: z.string().optional().describe('Git 提交哈希'),
98
+ })
99
+ .describe('版本信息'),
100
+ runtime: z
101
+ .object({
102
+ serverUrl: z.string().nullable().describe('服务公网地址'),
103
+ cookieNames: z
104
+ .array(z.string())
105
+ .describe('Cookie 名称列表(取自已加载配置的 `server.cookieNames`)'),
106
+ })
107
+ .describe('运行时配置'),
108
+ })),
109
+ });
110
+ function readStringArray(value) {
111
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : undefined;
112
+ }
113
+ /** 遍历 services 的可选 `health()`:任一失败 / 非 `true` → false */
114
+ async function checkHealth(services) {
115
+ for (const service of Object.values(services)) {
116
+ const health = service?.health;
117
+ if (typeof health !== 'function') {
118
+ continue;
119
+ }
120
+ const result = await catchIt(health());
121
+ if (result.isError()) {
122
+ return false;
123
+ }
124
+ if (result.value !== true) {
125
+ return false;
126
+ }
127
+ }
128
+ return true;
129
+ }
130
+ /** `/health` 与 `/system/health` 的共用处理器:不健康则抛 `UNKNOWN_ERROR`(k8s probe 依赖 5xx) */
131
+ async function assertHealthy(services) {
132
+ const result = await catchIt(checkHealth(services));
133
+ const ok = result.isError() ? false : result.value;
134
+ if (!ok) {
135
+ throw new SystemError('UNKNOWN_ERROR', {
136
+ operation: 'healthCheck',
137
+ cause: result.isError() ? result.error : 'Service health check failed',
138
+ });
139
+ }
140
+ }
141
+ /** `GET /system/openapi` 的路由元信息 */
142
+ const OpenApiSpecSchema = routerSchema({
143
+ operationId: 'getOpenApiDocument',
144
+ summary: '获取 OpenAPI 文档',
145
+ tags: ['系统'],
146
+ description: '返回本服务的 OpenAPI 文档(无需认证,原样返回、不套响应信封)',
147
+ });
148
+ /**
149
+ * 注册框架内置系统路由(5 个端点)。由 `registerSystemRoutesStep` 在 `server.routers(...)` **之前**调用,
150
+ * 保证框架路由先就位。
151
+ */
152
+ export function registerSystemRoutes(app, options) {
153
+ const { identity, services, readConfig } = options;
154
+ // GET / —— 服务可用性(服务名取自身份块)
155
+ app.get('/', { config: { accessControls: ['NoLoginRequired'] }, schema: RootSchema }, async () => {
156
+ return createSuccessResponse(identity.service);
157
+ });
158
+ // GET /health —— k8s probe(保持 GET,NoLoginRequired)
159
+ app.get('/health', { config: { accessControls: ['NoLoginRequired'] }, schema: HealthCheckSchema }, async () => {
160
+ await assertHealthy(services);
161
+ return createSuccessResponse({ healthy: true });
162
+ });
163
+ // POST /system/health —— API 版健康检查
164
+ app.post('/system/health', { schema: SystemHealthCheckSchema }, async () => {
165
+ await assertHealthy(services);
166
+ return createSuccessResponse({ healthy: true });
167
+ });
168
+ // GET /system/openapi —— OpenAPI 文档出口(NoLoginRequired)
169
+ //
170
+ // **原样返回、不套信封**:文档是机器可读契约(各仓 `api-gen` / `openapi-ts` 直接消费),
171
+ // 包一层 `{ status, message, data }` 就不再是合法 OpenAPI 文档。
172
+ // 实现上返回**已序列化的 JSON 字符串**:Fastify 对 `string` payload 不触发
173
+ // `preSerialization`,`core/response` 的统一信封因而不会介入;`content-type` 显式设为
174
+ // `application/json` 保证客户端按 JSON 解析。
175
+ app.get('/system/openapi', { config: { accessControls: ['NoLoginRequired'] }, schema: OpenApiSpecSchema }, async (_request, reply) => {
176
+ const document = app.swagger();
177
+ return reply.type('application/json').send(JSON.stringify(document));
178
+ });
179
+ // POST /system/info —— 版本 + 运行时(请求期现读配置)
180
+ //
181
+ // ⚠️ **这是公开接口**(不含 `admin` 等内部配置):任何从这里出去的字段都等于公开。
182
+ // 管理员用户名 / 内部账号列表这类信息**永远不返回**——它是撞库与提权的直接输入,
183
+ // 不是「运行时信息」。需要排查管理员配置走运维通道(日志 / 数据库),不走 HTTP。
184
+ //
185
+ // `NoLoginRequired` 与「它是公开接口」是同一件事的两半,必须成对声明:少了这一行,
186
+ // auth 中间件会把探活 / 版本查询挡成 400,客户端与探针都拿不到运行时信息。
187
+ app.post('/system/info', { config: { accessControls: ['NoLoginRequired'] }, schema: SystemInfoSchema }, async () => {
188
+ const server = readConfig('server');
189
+ return createSuccessResponse({
190
+ version: {
191
+ app: identity.version,
192
+ name: identity.service,
193
+ title: identity.title,
194
+ node: process.version,
195
+ platform: process.platform,
196
+ buildTime: process.env.BUILD_TIME,
197
+ gitCommit: process.env.GIT_COMMIT,
198
+ },
199
+ runtime: {
200
+ serverUrl: server?.url ?? server?.serverUrl ?? null,
201
+ cookieNames: readStringArray(server?.cookieNames) ?? [],
202
+ },
203
+ });
204
+ });
205
+ }
@@ -0,0 +1,56 @@
1
+ import type { ResourceLifecycle, ResourceUnit } from './types.js';
2
+ /**
3
+ * 构造资源单元(唯一的正当方式)。
4
+ *
5
+ * ## 为什么需要外壳
6
+ *
7
+ * 资源多是**第三方对象**:`PrismaClient`、Redis client、HTTP 连接池⋯⋯它们的形状改不了。
8
+ * service 可以直接在对象上挂 `init()`(因为 service 是本仓自己写的),资源不行——
9
+ * `prisma.setup = …` 是在改别人的类型,运行期也未必允许。
10
+ *
11
+ * 所以 `setup()` 挂在**框架私有外壳**上,`value` 原样保留:
12
+ *
13
+ * ```ts
14
+ * resources: (ctx) => ({
15
+ * database: defineResource(createPrisma(ctx.env.DATABASE_URL), {
16
+ * async setup() { await pushSchema(ctx.env.DATABASE_URL) },
17
+ * }),
18
+ * })
19
+ * ```
20
+ *
21
+ * 消费方读 `ctx.resources.database` 拿到的是 `PrismaClient` 本身
22
+ * (`ResourcesView` 会解包),不是 `ResourceUnit<PrismaClient>`。
23
+ *
24
+ * ## 单元是可选的
25
+ *
26
+ * 裸对象照旧可用(无 `setup`、无解包开销):`resources: () => ({ database: prisma })`。
27
+ * 只有需要「装配期准备」的资源才包一层。
28
+ *
29
+ * ## `setup` 何时跑、谁跑
30
+ *
31
+ * - **何时**:`resources` 槽位构造完成后、`config` 槽位之前;按**对象声明序**逐项 `await`。
32
+ * 排在 `config` 前是硬要求——`config` 常从数据库读(如 `SystemConfig` 表),库没准备好
33
+ * 它就连不上。
34
+ * - **谁**:框架。`startServer` / `createTestServer` 都调;`generateOpenApi` 不调
35
+ * (出 spec 不构造资源)。应用**永不手调**。
36
+ * - **失败**:立即中断装配并把错误抛给入口(已构造的资源照常释放)。资源准备失败后
37
+ * `config` / `services` 都没有意义,所以不吞。
38
+ *
39
+ * ## 不要在里面写 `if (ENV === 'test')`
40
+ *
41
+ * 准备动作本身应当是**状态驱动**的:`prisma db push` / `migrate deploy` 都会自己比对现状、
42
+ * 只补差量,生产与测试跑同一份代码。需要区分环境的细节应当从 `ctx.env` 取,而不是让
43
+ * 「跑不跑这个函数」变成环境分支。
44
+ *
45
+ * ## 用例间重置不在本文件
46
+ *
47
+ * 「清表 / 清缓存 / 重新插入样本数据」不适合挂在资源上:资源只知道有哪些表、哪些 key,
48
+ * 不知道**谁往里写了什么**。写在资源上就变成了「新增一个 model 就得回去改一次清库清单」,
49
+ * 漏改只会安静地变脏。
50
+ *
51
+ * 所以它单独成机制:**`packages/server/testdata/*.ts|js`,每个脚本自带 `load()` / `cleanup()`**,
52
+ * 由框架在 `development` / `test` 下自动装载并执行(见 `core/test-support/testdata.ts`)。
53
+ * 数据脚本自己知道要插入什么,也就自己知道怎么删干净。
54
+ */
55
+ export declare function defineResource<Value>(value: Value, lifecycle?: ResourceLifecycle): ResourceUnit<Value>;
56
+ //# sourceMappingURL=resource.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../source/core/resource.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAEjE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoDG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,CAAC,EAAE,iBAAiB,GAAG,YAAY,CAAC,KAAK,CAAC,CAEtG"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 构造资源单元(唯一的正当方式)。
3
+ *
4
+ * ## 为什么需要外壳
5
+ *
6
+ * 资源多是**第三方对象**:`PrismaClient`、Redis client、HTTP 连接池⋯⋯它们的形状改不了。
7
+ * service 可以直接在对象上挂 `init()`(因为 service 是本仓自己写的),资源不行——
8
+ * `prisma.setup = …` 是在改别人的类型,运行期也未必允许。
9
+ *
10
+ * 所以 `setup()` 挂在**框架私有外壳**上,`value` 原样保留:
11
+ *
12
+ * ```ts
13
+ * resources: (ctx) => ({
14
+ * database: defineResource(createPrisma(ctx.env.DATABASE_URL), {
15
+ * async setup() { await pushSchema(ctx.env.DATABASE_URL) },
16
+ * }),
17
+ * })
18
+ * ```
19
+ *
20
+ * 消费方读 `ctx.resources.database` 拿到的是 `PrismaClient` 本身
21
+ * (`ResourcesView` 会解包),不是 `ResourceUnit<PrismaClient>`。
22
+ *
23
+ * ## 单元是可选的
24
+ *
25
+ * 裸对象照旧可用(无 `setup`、无解包开销):`resources: () => ({ database: prisma })`。
26
+ * 只有需要「装配期准备」的资源才包一层。
27
+ *
28
+ * ## `setup` 何时跑、谁跑
29
+ *
30
+ * - **何时**:`resources` 槽位构造完成后、`config` 槽位之前;按**对象声明序**逐项 `await`。
31
+ * 排在 `config` 前是硬要求——`config` 常从数据库读(如 `SystemConfig` 表),库没准备好
32
+ * 它就连不上。
33
+ * - **谁**:框架。`startServer` / `createTestServer` 都调;`generateOpenApi` 不调
34
+ * (出 spec 不构造资源)。应用**永不手调**。
35
+ * - **失败**:立即中断装配并把错误抛给入口(已构造的资源照常释放)。资源准备失败后
36
+ * `config` / `services` 都没有意义,所以不吞。
37
+ *
38
+ * ## 不要在里面写 `if (ENV === 'test')`
39
+ *
40
+ * 准备动作本身应当是**状态驱动**的:`prisma db push` / `migrate deploy` 都会自己比对现状、
41
+ * 只补差量,生产与测试跑同一份代码。需要区分环境的细节应当从 `ctx.env` 取,而不是让
42
+ * 「跑不跑这个函数」变成环境分支。
43
+ *
44
+ * ## 用例间重置不在本文件
45
+ *
46
+ * 「清表 / 清缓存 / 重新插入样本数据」不适合挂在资源上:资源只知道有哪些表、哪些 key,
47
+ * 不知道**谁往里写了什么**。写在资源上就变成了「新增一个 model 就得回去改一次清库清单」,
48
+ * 漏改只会安静地变脏。
49
+ *
50
+ * 所以它单独成机制:**`packages/server/testdata/*.ts|js`,每个脚本自带 `load()` / `cleanup()`**,
51
+ * 由框架在 `development` / `test` 下自动装载并执行(见 `core/test-support/testdata.ts`)。
52
+ * 数据脚本自己知道要插入什么,也就自己知道怎么删干净。
53
+ */
54
+ export function defineResource(value, lifecycle) {
55
+ return lifecycle ? { value, ...lifecycle } : { value };
56
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 测试数据库安全检查(强制规则见 `@taicode/skills` 的 `test-database-isolation`)。
3
+ *
4
+ * 两道闸门,缺一不可:
5
+ *
6
+ * | 闸门 | 拒绝条件 | 缺失后的后果 |
7
+ * | --- | --- | --- |
8
+ * | 环境闸门 | `ENV` 不是可清理环境 | 生产 runner 上误跑破坏性测试 |
9
+ * | schema 闸门 | 目标 schema 不以 `_test` 结尾 | 绕过命名约定后仍能同步 / 清理应用 schema |
10
+ *
11
+ * 不需要「应用目标 vs 测试目标」比对:测试目标只由 `DATABASE_URL` **派生**得到(schema
12
+ * 追加 `_test`,幂等),再由 schema 闸门只放行 `*_test`。两条合起来,测试目标结构上不可能
13
+ * 是应用目标;再拿两个连接串互相比较,比的也只是同一份派生结果。
14
+ *
15
+ * ## 为什么在框架里
16
+ *
17
+ * 这套逻辑此前在 `account` / `expense` / `release` / `observai` 各写一遍,互相抄、互相漂移。
18
+ * 它保护的是**数据不丢**,属于「项目可以决定怎么实现,但不能决定要不要实现」的那类规则——
19
+ * 所以实现只能有一份,由框架提供;各仓的 `vitest.config.ts` / `global-setup.ts` 只调用,不复制。
20
+ *
21
+ * ## 为什么用 `throw` 而不是 `process.exit`
22
+ *
23
+ * 本模块被两类进程共用:
24
+ *
25
+ * - `createTestServer`(vitest worker 内):退出码由 vitest 决定,抛错即该文件失败;
26
+ * - `vitest.config.ts` / `global-setup.ts`(配置与全局初始化):抛错同样以非零状态结束进程。
27
+ *
28
+ * 抛错在两处都能得到「立刻失败」,而 `process.exit(1)` 在 worker 内会杀掉整个进程池、
29
+ * 丢掉其余文件的报告。错误信息本身必须足够定位,不能靠退出码表达。
30
+ */
31
+ /** 测试 schema 的强制后缀(`account` → `account_test`) */
32
+ export declare const TEST_SCHEMA_SUFFIX = "_test";
33
+ /**
34
+ * 一个连接串的**全部**连接维度。
35
+ *
36
+ * 比较必须覆盖四个维度:只比字符串会误判(`localhost:5432` 与 `localhost:5432/` 不同、
37
+ * `?schema=` 与默认端口都能让「不同字符串」指向同一 schema)。
38
+ */
39
+ export interface DatabaseTarget {
40
+ hostname: string;
41
+ /** 已按默认端口补齐 */
42
+ port: string;
43
+ /** 路径部分(不含开头的 `/`) */
44
+ database: string;
45
+ /** `?schema=`,缺省 `public` */
46
+ schema: string;
47
+ }
48
+ /** 解析连接串的连接维度(解析失败即抛错:配置错误不该被当成「不同的目标」放过去) */
49
+ export declare function resolveDatabaseTarget(connectionString: string): DatabaseTarget;
50
+ /**
51
+ * 打印用的连接串(**密码一律替换为 `***`**)。
52
+ *
53
+ * 报错要能定位「连的是哪儿」,但不能把密码写进 CI 日志。
54
+ */
55
+ export declare function maskDatabaseUrl(connectionString: string): string;
56
+ /**
57
+ * 由应用连接串派生测试连接串:只改 schema,`host` / `port` / `database` 保持与应用一致。
58
+ *
59
+ * 派生是**幂等**的(`account` → `account_test`,`account_test` → `account_test`),
60
+ * 这样重复调用不会产生 `account_test_test`。
61
+ *
62
+ * 应用 schema 本身就以 `_test` 结尾时**直接拒绝**:此时派生结果与应用目标完全相同,测试会
63
+ * 清空应用数据。这是唯一需要「应用目标」才能发现的情况,所以把它固化成派生的一步——不需要
64
+ * 调用方额外传应用连接串、也不需要另开一道闸门。
65
+ */
66
+ export declare function deriveTestDatabaseUrl(appDatabaseUrl: string): string;
67
+ /** 环境闸门(实现在 `core/env.ts`,这里**只转出**:判定只有一份) */
68
+ export { CLEANABLE_ENVS, assertCleanableEnvironment } from '../env.js';
69
+ /** schema 闸门:目标 schema 必须以 `_test` 结尾(在**任何**同步 / 迁移 / 清理之前执行) */
70
+ export declare function assertTestDatabaseSchema(testDatabaseUrl: string): void;
71
+ /** `resolveTestDatabase()` 入参 */
72
+ export interface TestDatabaseResolutionOptions {
73
+ /** 环境标记(`ENV`);非可清理环境直接拒绝 */
74
+ env: string;
75
+ /** 显式测试连接串(`TEST_DATABASE_URL`);优先级最高 */
76
+ testDatabaseUrl?: string;
77
+ }
78
+ /**
79
+ * 结算测试连接串,并**顺序**跑两道闸门:环境 → schema 命名。
80
+ *
81
+ * 顺序是刻意的:环境闸门决定「这台机器能不能做破坏性操作」,schema 闸门决定「目标名字是否
82
+ * 表示测试库」。
83
+ *
84
+ * 派生规则:显式 `testDatabaseUrl` 优先;否则由 `process.env.DATABASE_URL` 派生(schema
85
+ * 追加 `_test`,**幂等**);两者都没有即拒绝——「猜一个连接串」在会清理数据的场景里不可接受。
86
+ */
87
+ export declare function resolveTestDatabase(options: TestDatabaseResolutionOptions): string;
88
+ export declare function parseConnectionString(connectionString: string): URL;
89
+ //# sourceMappingURL=database.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../../source/core/test-support/database.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAIH,kDAAkD;AAClD,eAAO,MAAM,kBAAkB,UAAU,CAAA;AAQzC;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAA;IAChB,eAAe;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,sBAAsB;IACtB,QAAQ,EAAE,MAAM,CAAA;IAChB,6BAA6B;IAC7B,MAAM,EAAE,MAAM,CAAA;CACf;AAED,8CAA8C;AAC9C,wBAAgB,qBAAqB,CAAC,gBAAgB,EAAE,MAAM,GAAG,cAAc,CAS9E;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAShE;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAcpE;AAED,+CAA+C;AAC/C,OAAO,EAAE,cAAc,EAAE,0BAA0B,EAAE,MAAM,WAAW,CAAA;AACtE,kEAAkE;AAClE,wBAAgB,wBAAwB,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI,CAUtE;AAED,iCAAiC;AACjC,MAAM,WAAW,6BAA6B;IAC5C,6BAA6B;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,yCAAyC;IACzC,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,6BAA6B,GAAG,MAAM,CAiBlF;AAED,wBAAgB,qBAAqB,CAAC,gBAAgB,EAAE,MAAM,GAAG,GAAG,CASnE"}
@@ -0,0 +1,131 @@
1
+ /**
2
+ * 测试数据库安全检查(强制规则见 `@taicode/skills` 的 `test-database-isolation`)。
3
+ *
4
+ * 两道闸门,缺一不可:
5
+ *
6
+ * | 闸门 | 拒绝条件 | 缺失后的后果 |
7
+ * | --- | --- | --- |
8
+ * | 环境闸门 | `ENV` 不是可清理环境 | 生产 runner 上误跑破坏性测试 |
9
+ * | schema 闸门 | 目标 schema 不以 `_test` 结尾 | 绕过命名约定后仍能同步 / 清理应用 schema |
10
+ *
11
+ * 不需要「应用目标 vs 测试目标」比对:测试目标只由 `DATABASE_URL` **派生**得到(schema
12
+ * 追加 `_test`,幂等),再由 schema 闸门只放行 `*_test`。两条合起来,测试目标结构上不可能
13
+ * 是应用目标;再拿两个连接串互相比较,比的也只是同一份派生结果。
14
+ *
15
+ * ## 为什么在框架里
16
+ *
17
+ * 这套逻辑此前在 `account` / `expense` / `release` / `observai` 各写一遍,互相抄、互相漂移。
18
+ * 它保护的是**数据不丢**,属于「项目可以决定怎么实现,但不能决定要不要实现」的那类规则——
19
+ * 所以实现只能有一份,由框架提供;各仓的 `vitest.config.ts` / `global-setup.ts` 只调用,不复制。
20
+ *
21
+ * ## 为什么用 `throw` 而不是 `process.exit`
22
+ *
23
+ * 本模块被两类进程共用:
24
+ *
25
+ * - `createTestServer`(vitest worker 内):退出码由 vitest 决定,抛错即该文件失败;
26
+ * - `vitest.config.ts` / `global-setup.ts`(配置与全局初始化):抛错同样以非零状态结束进程。
27
+ *
28
+ * 抛错在两处都能得到「立刻失败」,而 `process.exit(1)` 在 worker 内会杀掉整个进程池、
29
+ * 丢掉其余文件的报告。错误信息本身必须足够定位,不能靠退出码表达。
30
+ */
31
+ import { assertCleanableEnvironment } from '../env.js';
32
+ /** 测试 schema 的强制后缀(`account` → `account_test`) */
33
+ export const TEST_SCHEMA_SUFFIX = '_test';
34
+ /** 连接串未写端口时按协议默认端口补齐后再比较(PostgreSQL 默认 5432) */
35
+ const DEFAULT_POSTGRES_PORT = '5432';
36
+ /** 连接串未写 `?schema=` 时的实际 schema(PostgreSQL 默认 `public`) */
37
+ const DEFAULT_DATABASE_SCHEMA = 'public';
38
+ /** 解析连接串的连接维度(解析失败即抛错:配置错误不该被当成「不同的目标」放过去) */
39
+ export function resolveDatabaseTarget(connectionString) {
40
+ const parsed = parseConnectionString(connectionString);
41
+ return {
42
+ hostname: parsed.hostname,
43
+ port: parsed.port || DEFAULT_POSTGRES_PORT,
44
+ database: parsed.pathname.replace(/^\//, ''),
45
+ schema: parsed.searchParams.get('schema') ?? DEFAULT_DATABASE_SCHEMA,
46
+ };
47
+ }
48
+ /**
49
+ * 打印用的连接串(**密码一律替换为 `***`**)。
50
+ *
51
+ * 报错要能定位「连的是哪儿」,但不能把密码写进 CI 日志。
52
+ */
53
+ export function maskDatabaseUrl(connectionString) {
54
+ const parsed = tryParseConnectionString(connectionString);
55
+ if (!parsed) {
56
+ return '<无法解析的连接串>';
57
+ }
58
+ if (parsed.password) {
59
+ parsed.password = '***';
60
+ }
61
+ return parsed.toString();
62
+ }
63
+ /**
64
+ * 由应用连接串派生测试连接串:只改 schema,`host` / `port` / `database` 保持与应用一致。
65
+ *
66
+ * 派生是**幂等**的(`account` → `account_test`,`account_test` → `account_test`),
67
+ * 这样重复调用不会产生 `account_test_test`。
68
+ *
69
+ * 应用 schema 本身就以 `_test` 结尾时**直接拒绝**:此时派生结果与应用目标完全相同,测试会
70
+ * 清空应用数据。这是唯一需要「应用目标」才能发现的情况,所以把它固化成派生的一步——不需要
71
+ * 调用方额外传应用连接串、也不需要另开一道闸门。
72
+ */
73
+ export function deriveTestDatabaseUrl(appDatabaseUrl) {
74
+ const parsed = parseConnectionString(appDatabaseUrl);
75
+ const schema = parsed.searchParams.get('schema') ?? DEFAULT_DATABASE_SCHEMA;
76
+ if (schema.endsWith(TEST_SCHEMA_SUFFIX)) {
77
+ throw new Error(`[test-database] 应用连接串的 schema 已是 "${schema}"(以 ${TEST_SCHEMA_SUFFIX} 结尾),` +
78
+ `派生出的测试目标会与应用目标完全相同(${maskDatabaseUrl(appDatabaseUrl)})。` +
79
+ '应用运行时 schema 绝不能以 _test 结尾:请修正 DATABASE_URL,而不是放宽守卫。');
80
+ }
81
+ parsed.searchParams.set('schema', `${schema}${TEST_SCHEMA_SUFFIX}`);
82
+ return parsed.toString();
83
+ }
84
+ /** 环境闸门(实现在 `core/env.ts`,这里**只转出**:判定只有一份) */
85
+ export { CLEANABLE_ENVS, assertCleanableEnvironment } from '../env.js';
86
+ /** schema 闸门:目标 schema 必须以 `_test` 结尾(在**任何**同步 / 迁移 / 清理之前执行) */
87
+ export function assertTestDatabaseSchema(testDatabaseUrl) {
88
+ const { schema } = resolveDatabaseTarget(testDatabaseUrl);
89
+ if (schema.endsWith(TEST_SCHEMA_SUFFIX)) {
90
+ return;
91
+ }
92
+ throw new Error(`[test-database] 拒绝使用 schema "${schema}":测试 schema 必须以 ${TEST_SCHEMA_SUFFIX} 结尾` +
93
+ `(当前目标 ${maskDatabaseUrl(testDatabaseUrl)})。`);
94
+ }
95
+ /**
96
+ * 结算测试连接串,并**顺序**跑两道闸门:环境 → schema 命名。
97
+ *
98
+ * 顺序是刻意的:环境闸门决定「这台机器能不能做破坏性操作」,schema 闸门决定「目标名字是否
99
+ * 表示测试库」。
100
+ *
101
+ * 派生规则:显式 `testDatabaseUrl` 优先;否则由 `process.env.DATABASE_URL` 派生(schema
102
+ * 追加 `_test`,**幂等**);两者都没有即拒绝——「猜一个连接串」在会清理数据的场景里不可接受。
103
+ */
104
+ export function resolveTestDatabase(options) {
105
+ assertCleanableEnvironment(options.env);
106
+ const explicit = options.testDatabaseUrl || process.env.TEST_DATABASE_URL;
107
+ const base = process.env.DATABASE_URL;
108
+ const testDatabaseUrl = explicit || (base ? deriveTestDatabaseUrl(base) : '');
109
+ if (!testDatabaseUrl) {
110
+ throw new Error('[test-database] 无法确定测试连接串:TEST_DATABASE_URL 与 DATABASE_URL 都为空。' +
111
+ '会清理数据库的测试必须显式指向一个独立的 *_test schema。');
112
+ }
113
+ assertTestDatabaseSchema(testDatabaseUrl);
114
+ return testDatabaseUrl;
115
+ }
116
+ export function parseConnectionString(connectionString) {
117
+ const parsed = tryParseConnectionString(connectionString);
118
+ if (!parsed) {
119
+ throw new Error(`[test-database] 无法解析连接串 "${connectionString}":测试连接目标必须可读,` +
120
+ '否则无法安全地派生 / 校验。检查 DATABASE_URL / TEST_DATABASE_URL 是否为合法 URL。');
121
+ }
122
+ return parsed;
123
+ }
124
+ function tryParseConnectionString(connectionString) {
125
+ try {
126
+ return new URL(connectionString);
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.test.d.ts","sourceRoot":"","sources":["../../../source/core/test-support/database.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,111 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { CLEANABLE_ENVS, assertCleanableEnvironment } from '../env.js';
3
+ import { assertTestDatabaseSchema, deriveTestDatabaseUrl, maskDatabaseUrl, resolveDatabaseTarget, resolveTestDatabase, } from './database.js';
4
+ /** 稳定的测试夹具(含密码,用于验证脱敏) */
5
+ const APP_URL = 'postgresql://postgres:secret@db.internal:5432/taicode-labs?schema=account';
6
+ /** 每个用例自行还原 env(`resolveTestDatabase` 会读 `process.env` 作为兜底) */
7
+ const ORIGINAL_ENV = { ...process.env };
8
+ afterEach(() => {
9
+ process.env = { ...ORIGINAL_ENV };
10
+ });
11
+ describe('resolveDatabaseTarget', () => {
12
+ it('解析出四个连接维度(端口与 schema 按协议默认值补齐)', () => {
13
+ expect(resolveDatabaseTarget(APP_URL)).toEqual({
14
+ hostname: 'db.internal',
15
+ port: '5432',
16
+ database: 'taicode-labs',
17
+ schema: 'account',
18
+ });
19
+ });
20
+ it('未写端口时按 5432 补齐(否则 localhost 与 localhost:5432 会被误判为不同目标)', () => {
21
+ expect(resolveDatabaseTarget('postgresql://postgres@localhost/db').port).toBe('5432');
22
+ });
23
+ it('未写 schema 时按 public 补齐', () => {
24
+ expect(resolveDatabaseTarget('postgresql://postgres@localhost/db').schema).toBe('public');
25
+ });
26
+ it('连接串无法解析即抛错(不能把解析失败当成「不同的目标」放过去)', () => {
27
+ expect(() => resolveDatabaseTarget('not-a-url')).toThrow(/无法解析连接串/);
28
+ });
29
+ });
30
+ describe('deriveTestDatabaseUrl', () => {
31
+ it('只改 schema,host / port / database 保持与应用完全一致', () => {
32
+ const derived = resolveDatabaseTarget(deriveTestDatabaseUrl(APP_URL));
33
+ expect(derived).toEqual({
34
+ hostname: 'db.internal',
35
+ port: '5432',
36
+ database: 'taicode-labs',
37
+ schema: 'account_test',
38
+ });
39
+ });
40
+ it('应用 schema 已以 _test 结尾时拒绝:派生结果会与应用目标完全相同', () => {
41
+ // 这是唯一需要「应用目标」才能发现的情况,因此固化成派生的一步。
42
+ expect(() => deriveTestDatabaseUrl('postgresql://postgres:secret@db.internal:5432/taicode-labs?schema=account_test')).toThrow(/派生出的测试目标会与应用目标完全相同/);
43
+ });
44
+ it('保留密码与查询参数里的其它键', () => {
45
+ const derived = new URL(deriveTestDatabaseUrl(`${APP_URL}&sslmode=require`));
46
+ expect(derived.password).toBe('secret');
47
+ expect(derived.searchParams.get('sslmode')).toBe('require');
48
+ });
49
+ });
50
+ describe('maskDatabaseUrl', () => {
51
+ it('隐藏密码(报错要能定位目标,但不能把密码写进 CI 日志)', () => {
52
+ const masked = maskDatabaseUrl(APP_URL);
53
+ expect(masked).not.toContain('secret');
54
+ expect(masked).toContain('***');
55
+ expect(masked).toContain('db.internal');
56
+ });
57
+ it('无法解析时给出占位而不是抛错(它只用于报错信息)', () => {
58
+ expect(maskDatabaseUrl('not-a-url')).toBe('<无法解析的连接串>');
59
+ });
60
+ });
61
+ describe('assertCleanableEnvironment(环境闸门)', () => {
62
+ it.each(CLEANABLE_ENVS)('放行可清理环境 %s', (env) => {
63
+ expect(() => assertCleanableEnvironment(env)).not.toThrow();
64
+ });
65
+ it('拒绝生产环境,并在错误里写明当前值与可接受值', () => {
66
+ expect(() => assertCleanableEnvironment('production')).toThrow(/ENV="production"/);
67
+ });
68
+ it('拒绝未设置的空值(默认放行等于把守卫关掉)', () => {
69
+ expect(() => assertCleanableEnvironment('')).toThrow(/<未设置>/);
70
+ });
71
+ });
72
+ describe('assertTestDatabaseSchema(schema 闸门)', () => {
73
+ it('放行 *_test', () => {
74
+ expect(() => assertTestDatabaseSchema('postgresql://postgres@localhost/db?schema=account_test')).not.toThrow();
75
+ });
76
+ it('拒绝非 *_test(在同步 / 迁移 / 清理之前就失败)', () => {
77
+ expect(() => assertTestDatabaseSchema(APP_URL)).toThrow(/account/);
78
+ });
79
+ });
80
+ describe('resolveTestDatabase(两道闸门串联 + 派生)', () => {
81
+ it('无任何变量时拒绝(猜测连接串在会清理数据的场景里不可接受)', () => {
82
+ delete process.env.DATABASE_URL;
83
+ delete process.env.TEST_DATABASE_URL;
84
+ expect(() => resolveTestDatabase({ env: 'development' })).toThrow(/无法确定测试连接串/);
85
+ });
86
+ it('显式 TEST_DATABASE_URL 优先于派生', () => {
87
+ process.env.DATABASE_URL = APP_URL;
88
+ process.env.TEST_DATABASE_URL = 'postgresql://postgres:secret@db.internal:5432/taicode-labs?schema=explicit_test';
89
+ expect(resolveTestDatabase({ env: 'development' })).toContain('explicit_test');
90
+ });
91
+ it('没有显式变量时由 DATABASE_URL 派生', () => {
92
+ process.env.DATABASE_URL = APP_URL;
93
+ delete process.env.TEST_DATABASE_URL;
94
+ expect(resolveDatabaseTarget(resolveTestDatabase({ env: 'development' })).schema).toBe('account_test');
95
+ });
96
+ it('顺序:环境闸门先于 schema 闸门(生产环境下不先抱怨 schema 名)', () => {
97
+ process.env.TEST_DATABASE_URL = APP_URL;
98
+ expect(() => resolveTestDatabase({ env: 'production' })).toThrow(/拒绝在 ENV="production"/);
99
+ });
100
+ it('通过闸门后返回可用的测试连接串', () => {
101
+ process.env.DATABASE_URL = APP_URL;
102
+ delete process.env.TEST_DATABASE_URL;
103
+ const resolved = resolveTestDatabase({ env: 'development' });
104
+ expect(resolveDatabaseTarget(resolved)).toEqual({
105
+ hostname: 'db.internal',
106
+ port: '5432',
107
+ database: 'taicode-labs',
108
+ schema: 'account_test',
109
+ });
110
+ });
111
+ });