@taicode/common-server 4.0.0-beta.5 → 4.0.0-beta.7

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 (56) hide show
  1. package/output/app/index.d.ts +3 -1
  2. package/output/app/index.d.ts.map +1 -1
  3. package/output/app/index.js +47 -14
  4. package/output/auth/index.d.ts +3 -3
  5. package/output/auth/index.d.ts.map +1 -1
  6. package/output/auth/index.js +3 -2
  7. package/output/core/boot/context.d.ts +54 -34
  8. package/output/core/boot/context.d.ts.map +1 -1
  9. package/output/core/boot/context.js +42 -4
  10. package/output/core/boot/lifecycle.d.ts +39 -7
  11. package/output/core/boot/lifecycle.d.ts.map +1 -1
  12. package/output/core/boot/lifecycle.js +59 -19
  13. package/output/core/boot/shutdown.d.ts +9 -5
  14. package/output/core/boot/shutdown.d.ts.map +1 -1
  15. package/output/core/boot/shutdown.js +9 -4
  16. package/output/core/boot/signal.d.ts +1 -1
  17. package/output/core/boot/signal.js +1 -1
  18. package/output/core/boot/steps.d.ts +60 -29
  19. package/output/core/boot/steps.d.ts.map +1 -1
  20. package/output/core/boot/steps.js +72 -13
  21. package/output/core/entries.d.ts +51 -23
  22. package/output/core/entries.d.ts.map +1 -1
  23. package/output/core/entries.js +70 -31
  24. package/output/core/entries.test.js +251 -10
  25. package/output/core/env.d.ts +21 -0
  26. package/output/core/env.d.ts.map +1 -1
  27. package/output/core/env.js +28 -0
  28. package/output/core/resource.d.ts +56 -0
  29. package/output/core/resource.d.ts.map +1 -0
  30. package/output/core/resource.js +56 -0
  31. package/output/core/test-support/database.d.ts +2 -9
  32. package/output/core/test-support/database.d.ts.map +1 -1
  33. package/output/core/test-support/database.js +3 -16
  34. package/output/core/test-support/database.test.js +2 -1
  35. package/output/core/test-support/environment.d.ts +8 -3
  36. package/output/core/test-support/environment.d.ts.map +1 -1
  37. package/output/core/test-support/environment.js +10 -7
  38. package/output/core/test-support/test-server.d.ts +49 -19
  39. package/output/core/test-support/test-server.d.ts.map +1 -1
  40. package/output/core/test-support/test-server.js +25 -6
  41. package/output/core/test-support/test-server.test.js +7 -5
  42. package/output/core/test-support/testdata.d.ts +171 -0
  43. package/output/core/test-support/testdata.d.ts.map +1 -0
  44. package/output/core/test-support/testdata.js +202 -0
  45. package/output/core/test-support/testdata.test.d.ts +2 -0
  46. package/output/core/test-support/testdata.test.d.ts.map +1 -0
  47. package/output/core/test-support/testdata.test.js +240 -0
  48. package/output/core/types.d.ts +235 -71
  49. package/output/core/types.d.ts.map +1 -1
  50. package/output/cors/index.d.ts +2 -2
  51. package/output/cors/index.d.ts.map +1 -1
  52. package/output/cors/index.js +2 -1
  53. package/output/rate-limit/index.d.ts +2 -2
  54. package/output/rate-limit/index.d.ts.map +1 -1
  55. package/output/rate-limit/index.js +2 -1
  56. package/package.json +3 -3
@@ -1,9 +1,9 @@
1
1
  import { initServiceUnits } from './boot/lifecycle.js';
2
2
  import { attachSignalHandlers } from './boot/signal.js';
3
- import { buildClients, buildConfig, buildResources, buildServices, createBootContext, createDocumentContext, installShutdownHook, registerCoreStep, registerDocumentCoreStep, registerDocumentRoutersStep, registerDocumentSystemRoutesStep, registerMiddlewares, registerRouters, registerSystemRoutesStep, } from './boot/steps.js';
4
- import { loadEnvironment, resolveConfigEnv } from './env.js';
3
+ import { buildClients, buildConfig, buildResources, buildServices, createBootContext, createDocumentContext, installShutdownHook, registerCoreStep, registerDocumentCoreStep, registerDocumentRoutersStep, registerDocumentSystemRoutesStep, registerMiddlewares, registerRouters, registerSystemRoutesStep, resolveAppEnv, setupResources, } from './boot/steps.js';
4
+ import { loadEnvironment, resolveConfigEnv, CLEANABLE_ENVS } from './env.js';
5
5
  import { SERVER_BRAND } from './types.js';
6
- export { SERVER_BRAND } from './types.js';
6
+ import { discoverTestDataModules, loadTestData } from './test-support/testdata.js';
7
7
  /** 默认监听地址 */
8
8
  const DEFAULT_HOST = '0.0.0.0';
9
9
  function resolvePort(env) {
@@ -18,36 +18,57 @@ function resolvePort(env) {
18
18
  * `defineServer()`(N1 §1)——**纯定义**:产出一个 `Server` 对象,不建连、不起服务。
19
19
  *
20
20
  * 启动是另一个函数(`startServer`)的事——同一份定义可同时喂给「启动」与「出 spec」
21
- * (`generateOpenApi`)。调用方**不写泛型**:`R` / `C` / `CL` / `S` 全部从工厂返回类型推导。
21
+ * (`generateOpenApi`)。
22
22
  *
23
23
  * ## 怎么用
24
24
  *
25
- * 一个服务**只在 `source/server.ts` 里写一次**,然后在两个地方消费它:
25
+ * 一个服务**只在 `source/server.ts` 里写一次**,然后在三个地方消费它:
26
26
  *
27
27
  * ```ts
28
28
  * // source/server.ts
29
29
  * export const server = defineServer({ app, resources, config, clients, services, middlewares, routers })
30
30
  * ```
31
31
  * ```ts
32
- * // source/index.ts —— 启动
33
- * await startServer(server)
34
- * ```
35
- * ```ts
36
- * // build/openapi-gen.ts —— 出 spec
37
- * await generateOpenApi(server)
38
- * ```
39
- * ```ts
40
- * // *.test.ts —— 测试
41
- * const testServer = await createTestServer(server)
32
+ * await startServer(server) // source/index.ts —— 启动
33
+ * await generateOpenApi(server) // build/openapi-gen.ts —— 出 spec
34
+ * await createTestServer(server) // *.test.ts —— 测试
42
35
  * ```
43
36
  *
44
37
  * ## 两条容易踩的规矩
45
38
  *
46
39
  * - **本函数不读 env、不建连、不读库**。放在模块顶层是安全的:`import { server }` 不会有副作用。
47
40
  * (需要 env 的工具进程自己调 `loadRuntimeEnvironment()`。)
48
- * - **槽位是六个、恒定不缺**。没有业务路由也要写 `routers: () => {}`;没有外部客户端也要写
49
- * `clients: () => ({})`——槽位不留「可省略」分支,省掉一个就会让「这份定义到底声明了什么」
50
- * 需要靠猜。
41
+ * - **`clients` 可省略**,其余槽位恒定:没有业务路由也要写 `routers: () => {}`。省略 `clients`
42
+ * → 空客户端包。其余槽位不留「可省略」分支,省掉一个就会让「这份定义到底声明了什么」需要靠猜。
43
+ *
44
+ * ## 扩展 env(`Env`):`Env` 是**第一个**泛型,类型声明,不影响加载
45
+ *
46
+ * `Env` 排在泛型首位,就是为了能**单独手写**:`defineServer<{ FOO?: string }>({ ... })` 把扩展字段
47
+ * 的类型并进 `ctx.env`(`resources` / `config` 的 `ctx.env` 上可读、有类型)。不写即 `Env = object`
48
+ * (无扩展,`ctx.env` 就是 `ConfigEnv`)。
49
+ *
50
+ * ⚠️ **TypeScript 限制(务必知道)**:只要**显式写了 `Env`**,其余四个泛型
51
+ * (`Resources` / `Config` / `Clients` / `Services`)就**不再从工厂推导、一律退化为 `object`**
52
+ * —— 这是 TS「类型参数全有或全无」的规则,不是本函数能绕过的。后果:`defineServer<MyEnv>({...})`
53
+ * 产出的 `server` 上,`services` 等类型会塌成 `object`,`testServer.services.xxx` 之类会丢类型。
54
+ *
55
+ * 因此两种写法各有适用:
56
+ * - **要完整推导**(典型业务服务,依赖 `ctx.services` / `testServer.services` 的类型):**不写泛型**,
57
+ * 把扩展 env 的类型写在**工厂参数标注**上——`resources: (ctx: ResourcesContext<{ FOO?: string }>) => ...`
58
+ * 或 point-free 传入参数已标注的命名工厂。这样 `Env` 与其余泛型**一起被推导**,不丢类型。
59
+ * - **只在意 `Env`、不依赖其余被推导类型**(简单服务 / 测试):直接 `defineServer<MyEnv>({...})`。
60
+ *
61
+ * ⚠️ **只声明类型,不参与加载**:env 的加载完全走框架内置逻辑(dotenv 分层 + 进程注入),框架
62
+ * **不提供自定义加载**;运行期扩展字段的值就是内置加载后的 `process.env[key]`(字符串直通)。
63
+ *
64
+ * ```ts
65
+ * // 写法一(推荐业务服务用):不写泛型,Env 从工厂参数标注推导,其余泛型一起推导
66
+ * function createResources(ctx: ResourcesContext<{ REDIS_KEY_PREFIX?: string }>) { ... }
67
+ * export const server = defineServer({ app, resources: createResources, config, services, middlewares, routers })
68
+ *
69
+ * // 写法二:显式手写 Env(首位)——简单;但其余泛型退化为 object
70
+ * export const server = defineServer<{ REDIS_KEY_PREFIX?: string }>({ app, resources, config, services, middlewares, routers })
71
+ * ```
51
72
  */
52
73
  export function defineServer(config) {
53
74
  const server = {
@@ -56,19 +77,13 @@ export function defineServer(config) {
56
77
  };
57
78
  return server;
58
79
  }
59
- /** 供内部/调试使用:判断是否为 `defineServer()` 产物 */
60
- export function isServer(value) {
61
- return (typeof value === 'object' &&
62
- value !== null &&
63
- value.__brand === SERVER_BRAND);
64
- }
65
80
  /**
66
81
  * `startServer()`——真正跑起来:构造资源、加载配置、建客户端、建服务、装中间件、注册路由、listen、装信号。
67
82
  *
68
83
  * 步序**就写在本函数里**,不抽到共享的「按模式分支」的装配器:
69
84
  * ```
70
85
  * env → resources → core → config → clients → services → services.init() → middlewares
71
- * → systemRoutes → routers → onClose → ready → listen/signal
86
+ * → systemRoutes → routers → onClose → ready → testdata → listen/signal
72
87
  * ```
73
88
  *
74
89
  * - 最早阶段**固定加载 env**(`.env.dev < .env < .env.local`;`ENV=test` 追加 `.env.test.local`)。
@@ -78,6 +93,16 @@ export function isServer(value) {
78
93
  * 而不是同一个入口按环境变量 / 选项改行为。
79
94
  * - 硬性要求 `Server`(品牌)产物:传裸 `ServerConfig` 是编译错误。
80
95
  *
96
+ * ## `development` / `test` 下会自动装载 `testdata/`
97
+ *
98
+ * 真启动(本地 `pnpm dev`)在 `ready()` 之后、**listen 之前**装载 `<cwd>/testdata` 下的脚本并整体
99
+ * `load()`(见 `test-support/testdata.ts`):端口一旦对外,数据必须已经在位。脚本拿到的是装配好的
100
+ * ctx(`env` + `resources` + `log`),所以它们写数据用的就是被测服务读数据的那份连接。
101
+ * `ENV` 不在 `CLEANABLE_ENVS` 里(即生产)时**整段跳过**——这是 `startServer` 的唯一一处环境
102
+ * 分支,且是安静的:生产不需要测试数据,也没有什么东西需要失败。
103
+ *
104
+ * `cleanup()` 在 `startServer` 路径上不会被调用:真启动要的就是「数据在」,不是「数据不在」。
105
+ *
81
106
  * ## 怎么用
82
107
  *
83
108
  * ```ts
@@ -94,13 +119,14 @@ export function isServer(value) {
94
119
  * 你**需要**知道的两件事:
95
120
  * - 端口只读 env 的 `API_PORT`,**不回退 `PORT`**(`PORT` 属 nginx)。`API_PORT=0` 让内核
96
121
  * 分配空闲端口,多实例本地调试靠它。
97
- * - 失败时已构造的资源会被按固定顺序释放(关闭流程七步),不会漏掉连接池;错误原样抛给
122
+ * - 失败时已构造的资源会被按固定顺序释放(关闭流程八步),不会漏掉连接池;错误原样抛给
98
123
  * 调用方——所以端口占用会让 `startServer` 直接 reject,需要在入口处接住。
99
124
  */
100
125
  export async function startServer(server) {
101
- const env = loadEnvironment();
126
+ const env = resolveAppEnv(loadEnvironment());
102
127
  const ctx = createBootContext(server, env);
103
- const { resources, classifyResourceError } = await buildResources(ctx);
128
+ const { resources, units: resourceUnits, classifyResourceError } = await buildResources(ctx);
129
+ await setupResources(ctx, resourceUnits);
104
130
  await registerCoreStep(ctx, classifyResourceError);
105
131
  await buildConfig(ctx, resources);
106
132
  const clients = await buildClients(ctx, resources);
@@ -109,7 +135,7 @@ export async function startServer(server) {
109
135
  await registerMiddlewares(ctx, services, clients);
110
136
  registerSystemRoutesStep(ctx, services);
111
137
  await registerRouters(ctx, services, clients);
112
- installShutdownHook(ctx, services);
138
+ installShutdownHook(ctx, services, resourceUnits);
113
139
  // 信号处理器「随 listen 装、随 close 卸」。但 Fastify 一旦 listening 就拒绝 `addHook`,
114
140
  // 所以 onClose 里的 detach 必须先挂上,处理器本身等 listen 成功后再装。
115
141
  let detachSignalHandlers = () => { };
@@ -117,11 +143,24 @@ export async function startServer(server) {
117
143
  detachSignalHandlers();
118
144
  });
119
145
  await ctx.app.ready();
146
+ // 服务已经完全就绪(路由 / 中间件 / 关闭钩子都装好),现在把开发用样本数据写进去,然后才开门。
147
+ // 顺序是**装配 → 数据 → listen**:端口一旦对外,第一条请求就必须能看到完整数据;反过来
148
+ // (先 listen 再 load)会留下一段「端口在响应、数据还没就位」的窗口——本地前端 / 集成脚本
149
+ // 恰好在这时请求,就是偶发的 404 / 空列表。
150
+ //
151
+ // 失败直接冒泡给调用方:`testdata/` 的脚本是服务能正常提供数据的前提,它失败时**不该**把端口
152
+ // 占着等人发现。非可清理环境(生产)整段跳过——这是本入口唯一的环境分支。
153
+ if (CLEANABLE_ENVS.includes(env.ENV)) {
154
+ const units = await discoverTestDataModules();
155
+ if (units.length > 0) {
156
+ await loadTestData({ ctx: { env, resources, log: ctx.log }, units, service: ctx.service });
157
+ }
158
+ }
120
159
  try {
121
160
  await ctx.app.listen({ port: resolvePort(env), host: DEFAULT_HOST });
122
161
  }
123
162
  catch (error) {
124
- // 端口占用 / 地址不可用:把已构造的资源按固定七步释放掉,再把错误抛给调用方
163
+ // 端口占用 / 地址不可用:把已构造的资源按固定八步释放掉,再把错误抛给调用方
125
164
  await ctx.app.close();
126
165
  throw error;
127
166
  }
@@ -173,7 +212,7 @@ export async function startServer(server) {
173
212
  * `CONFIG_UNAVAILABLE`——那不是 bug,是在提醒「路由注册期不该依赖运行期产物」。
174
213
  */
175
214
  export async function generateOpenApi(server) {
176
- // 不加载 .env 文件、不读配置
215
+ // 不加载 .env 文件、不读配置、不跑 `resources` / `config` 槽位(出 spec 不要求 env 有效,也不构造 resources / config)
177
216
  const env = resolveConfigEnv();
178
217
  const ctx = createDocumentContext(server, env);
179
218
  try {
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest';
2
2
  import { createAuthMiddleware } from '../auth/index.js';
3
3
  import { defineServer, generateOpenApi, startServer } from './entries.js';
4
4
  import { routerSchema } from './modules/schema/builders.js';
5
+ import { defineResource } from './resource.js';
5
6
  const IDENTITY = { service: 'probe', title: 'Probe', version: '0.0.0' };
6
7
  const ROUTE_SCHEMA = routerSchema({
7
8
  operationId: 'probeGet',
@@ -26,11 +27,11 @@ function buildServer(registerPermissionOperations, onMiddlewares = vi.fn()) {
26
27
  services: () => ({}),
27
28
  middlewares: (ctx) => {
28
29
  onMiddlewares();
29
- return {
30
- auth: createAuthMiddleware(ctx, {
30
+ return [
31
+ createAuthMiddleware(ctx, {
31
32
  client: { verify: async () => ({ user: {}, token: 't' }), registerPermissionOperations },
32
33
  }),
33
- };
34
+ ];
34
35
  },
35
36
  routers: ({ app }) => {
36
37
  app.get('/probe', { schema: ROUTE_SCHEMA }, async () => ({ ok: true }));
@@ -45,11 +46,11 @@ function buildStartableServer(registerPermissionOperations) {
45
46
  config: () => ({ server: { cookieNames: ['token'] } }),
46
47
  clients: () => ({}),
47
48
  services: () => ({}),
48
- middlewares: (ctx) => ({
49
- auth: createAuthMiddleware(ctx, {
49
+ middlewares: (ctx) => [
50
+ createAuthMiddleware(ctx, {
50
51
  client: { verify: async () => ({ user: {}, token: 't' }), registerPermissionOperations },
51
52
  }),
52
- }),
53
+ ],
53
54
  routers: ({ app }) => {
54
55
  app.get('/probe', { schema: ROUTE_SCHEMA }, async () => ({ ok: true }));
55
56
  },
@@ -95,7 +96,7 @@ describe('generateOpenApi', () => {
95
96
  onServices();
96
97
  return {};
97
98
  },
98
- middlewares: () => ({}),
99
+ middlewares: () => [],
99
100
  routers: ({ app }) => {
100
101
  app.get('/probe', { schema: ROUTE_SCHEMA }, async () => ({ ok: true }));
101
102
  },
@@ -122,7 +123,7 @@ describe('generateOpenApi', () => {
122
123
  config: () => ({}),
123
124
  clients: () => ({}),
124
125
  services: () => ({}),
125
- middlewares: () => ({}),
126
+ middlewares: () => [],
126
127
  routers: ({ app }) => {
127
128
  // 故意不声明 schema,验证运行期守卫
128
129
  app.get('/bad', async () => ({ ok: true }));
@@ -173,7 +174,7 @@ describe('startServer', () => {
173
174
  },
174
175
  middlewares: (ctx) => {
175
176
  seen.middleware = ctx.clients.account;
176
- return {};
177
+ return [];
177
178
  },
178
179
  routers: (ctx) => {
179
180
  order.push('routers');
@@ -207,11 +208,251 @@ describe('startServer', () => {
207
208
  });
208
209
  return {};
209
210
  },
210
- middlewares: () => ({}),
211
+ middlewares: () => [],
211
212
  routers: () => { },
212
213
  });
213
214
  const app = await startServer(server);
214
215
  await app.close();
215
216
  expect(order).toEqual(['services', 'clients']);
216
217
  });
218
+ it('middlewares 按**数组顺序**注册(顺序即书写顺序,不受键名影响)', async () => {
219
+ const order = [];
220
+ const unit = (name) => ({ name, register: () => { order.push(name); } });
221
+ const server = defineServer({
222
+ app: IDENTITY,
223
+ resources: () => ({}),
224
+ config: () => ({}),
225
+ services: () => ({}),
226
+ // 故意用「纯数字会被对象重排」的反例命名,证明数组顺序不被引擎重排
227
+ middlewares: () => [unit('2-first'), unit('1-second'), unit('cors')],
228
+ routers: ({ app }) => {
229
+ app.get('/probe', { schema: ROUTE_SCHEMA }, async () => ({ ok: true }));
230
+ },
231
+ });
232
+ const app = await startServer(server);
233
+ expect(order).toEqual(['2-first', '1-second', 'cors']);
234
+ await app.close();
235
+ });
236
+ it('裸注册函数也能进数组(框架补占位名)', async () => {
237
+ let called = false;
238
+ const server = defineServer({
239
+ app: IDENTITY,
240
+ resources: () => ({}),
241
+ config: () => ({}),
242
+ services: () => ({}),
243
+ middlewares: () => [() => { called = true; }],
244
+ routers: ({ app }) => {
245
+ app.get('/probe', { schema: ROUTE_SCHEMA }, async () => ({ ok: true }));
246
+ },
247
+ });
248
+ const app = await startServer(server);
249
+ expect(called).toBe(true);
250
+ await app.close();
251
+ });
252
+ it('clients 槽位可省略(CL 退化为空,services/middlewares/routers 照常)', async () => {
253
+ const server = defineServer({
254
+ app: IDENTITY,
255
+ resources: () => ({}),
256
+ config: () => ({}),
257
+ // 不写 clients
258
+ services: () => ({}),
259
+ middlewares: () => [],
260
+ routers: ({ app }) => {
261
+ app.get('/probe', { schema: ROUTE_SCHEMA }, async () => ({ ok: true }));
262
+ },
263
+ });
264
+ const app = await startServer(server);
265
+ const res = await app.inject({ method: 'GET', url: '/probe' });
266
+ expect(res.statusCode).toBe(200);
267
+ await app.close();
268
+ });
269
+ it('env 扩展:手写首位泛型 Env 即可(defineServer<Env>({...})),值取内置加载后的 process.env(框架不提供自定义加载)', async () => {
270
+ const seen = {};
271
+ // 内置加载逻辑把它读进来;应用只在泛型上声明类型,不参与加载
272
+ process.env.SERVICE_TOKEN = 'from-process-env';
273
+ // Env 排泛型首位 → 可单独手写;ctx.env 随之带上 SERVICE_TOKEN 的类型
274
+ const server = defineServer({
275
+ app: IDENTITY,
276
+ resources: (ctx) => {
277
+ seen.fromResources = ctx.env.SERVICE_TOKEN;
278
+ return {};
279
+ },
280
+ config: (ctx) => {
281
+ // config 的 ctx.env 同样带上了扩展类型
282
+ seen.fromConfig = ctx.env.SERVICE_TOKEN;
283
+ return {};
284
+ },
285
+ services: () => ({}),
286
+ middlewares: () => [],
287
+ routers: ({ app }) => {
288
+ app.get('/probe', { schema: ROUTE_SCHEMA }, async () => ({ ok: true }));
289
+ },
290
+ });
291
+ const app = await startServer(server);
292
+ expect(seen.fromResources).toBe('from-process-env');
293
+ expect(seen.fromConfig).toBe(seen.fromResources);
294
+ delete process.env.SERVICE_TOKEN;
295
+ await app.close();
296
+ });
297
+ });
298
+ describe('resources 单元生命周期(setup / dispose)', () => {
299
+ /** 造一个「记录调用」的资源单元 */
300
+ function probeResource(order, name, lifecycle = {}) {
301
+ const { setup = true, dispose = true } = lifecycle;
302
+ const client = { name };
303
+ return defineResource(client, {
304
+ ...(setup ? { setup: () => { order.push(`setup:${name}`); } } : {}),
305
+ ...(dispose ? { dispose: () => { order.push(`dispose:${name}`); } } : {}),
306
+ });
307
+ }
308
+ it('setup 在 resources 之后、config 之前,逐项按声明序执行', async () => {
309
+ const order = [];
310
+ const server = defineServer({
311
+ app: IDENTITY,
312
+ resources: () => {
313
+ order.push('resources');
314
+ return { first: probeResource(order, 'first'), second: probeResource(order, 'second') };
315
+ },
316
+ config: () => {
317
+ order.push('config');
318
+ return {};
319
+ },
320
+ services: () => ({}),
321
+ middlewares: () => [],
322
+ routers: () => { },
323
+ });
324
+ const app = await startServer(server);
325
+ expect(order).toEqual(['resources', 'setup:first', 'setup:second', 'config']);
326
+ await app.close();
327
+ });
328
+ it('消费方拿到的是解包后的值,不是单元外壳', async () => {
329
+ const order = [];
330
+ let seen;
331
+ const server = defineServer({
332
+ app: IDENTITY,
333
+ resources: () => ({ database: probeResource(order, 'database') }),
334
+ config: (ctx) => {
335
+ seen = ctx.resources.database;
336
+ return {};
337
+ },
338
+ services: () => ({}),
339
+ middlewares: () => [],
340
+ routers: () => { },
341
+ });
342
+ const app = await startServer(server);
343
+ // 看到的是裸 client(`{ name: 'database' }`),没有 `value` / `setup` 外壳
344
+ expect(seen).toEqual({ name: 'database' });
345
+ await app.close();
346
+ });
347
+ it('裸对象(无单元外壳)照旧可用,不产生 setup / dispose 调用', async () => {
348
+ const order = [];
349
+ const server = defineServer({
350
+ app: IDENTITY,
351
+ resources: () => ({ bare: { name: 'bare' } }),
352
+ config: () => ({ picked: true }),
353
+ services: () => ({}),
354
+ middlewares: () => [],
355
+ routers: () => { },
356
+ });
357
+ const app = await startServer(server);
358
+ await app.close();
359
+ expect(order).toEqual([]);
360
+ });
361
+ it('setup 在每次装配时都跑(不做一次性记忆,迁移本身靠状态判定)', async () => {
362
+ const order = [];
363
+ const server = defineServer({
364
+ app: IDENTITY,
365
+ resources: () => ({ database: probeResource(order, 'database', { dispose: false }) }),
366
+ config: () => ({}),
367
+ services: () => ({}),
368
+ middlewares: () => [],
369
+ routers: () => { },
370
+ });
371
+ const first = await startServer(server);
372
+ await first.close();
373
+ const second = await startServer(server);
374
+ await second.close();
375
+ expect(order).toEqual(['setup:database', 'setup:database']);
376
+ });
377
+ it('setup 抛错时装配中断(config 不跑),错误冒泡给入口', async () => {
378
+ const order = [];
379
+ const server = defineServer({
380
+ app: IDENTITY,
381
+ resources: () => ({
382
+ database: defineResource({ name: 'database' }, {
383
+ setup: () => {
384
+ throw new Error('migrate failed');
385
+ },
386
+ }),
387
+ }),
388
+ config: () => {
389
+ order.push('config');
390
+ return {};
391
+ },
392
+ services: () => ({}),
393
+ middlewares: () => [],
394
+ routers: () => { },
395
+ });
396
+ await expect(startServer(server)).rejects.toThrow(/migrate failed/);
397
+ expect(order).toEqual([]);
398
+ });
399
+ it('dispose 按声明序**逆序**执行,排在 flushResources(disposer)之前', async () => {
400
+ const order = [];
401
+ const server = defineServer({
402
+ app: IDENTITY,
403
+ resources: (ctx) => {
404
+ ctx.disposer.add(() => {
405
+ order.push('disposer:resources');
406
+ });
407
+ return { first: probeResource(order, 'first'), second: probeResource(order, 'second') };
408
+ },
409
+ config: () => ({}),
410
+ services: () => ({}),
411
+ middlewares: () => [],
412
+ routers: () => { },
413
+ });
414
+ const app = await startServer(server);
415
+ order.length = 0;
416
+ await app.close();
417
+ expect(order).toEqual(['dispose:second', 'dispose:first', 'disposer:resources']);
418
+ });
419
+ it('单个资源 dispose 失败不阻断其他资源(同阶段语义)', async () => {
420
+ const order = [];
421
+ const server = defineServer({
422
+ app: IDENTITY,
423
+ resources: () => ({
424
+ broken: defineResource({ name: 'broken' }, {
425
+ dispose: () => {
426
+ throw new Error('disconnect failed');
427
+ },
428
+ }),
429
+ healthy: probeResource(order, 'healthy', { setup: false }),
430
+ }),
431
+ config: () => ({}),
432
+ services: () => ({}),
433
+ middlewares: () => [],
434
+ routers: () => { },
435
+ });
436
+ const app = await startServer(server);
437
+ await expect(app.close()).resolves.toBeUndefined();
438
+ // 逆序:healthy 先于 broken;broken 抛错被吞,healthy 已经跑过
439
+ expect(order).toEqual(['dispose:healthy']);
440
+ });
441
+ it('出 spec 路径既不构造 resources,也不跑 setup', async () => {
442
+ const onSetup = vi.fn();
443
+ const server = defineServer({
444
+ app: IDENTITY,
445
+ resources: () => ({ database: defineResource({ name: 'database' }, { setup: onSetup }) }),
446
+ config: () => {
447
+ throw new Error('config 不应在出 spec 路径被调用');
448
+ },
449
+ services: () => ({}),
450
+ middlewares: () => [],
451
+ routers: ({ app }) => {
452
+ app.get('/probe', { schema: ROUTE_SCHEMA }, async () => ({ ok: true }));
453
+ },
454
+ });
455
+ await generateOpenApi(server);
456
+ expect(onSetup).not.toHaveBeenCalled();
457
+ });
217
458
  });
@@ -1,5 +1,26 @@
1
1
  /** `LOG_LEVEL` 未设置时的默认级别(设计 §3.1 ① / §5.1 `logging`) */
2
2
  export declare const DEFAULT_LOG_LEVEL = "info";
3
+ /**
4
+ * 允许执行**破坏性 / 不可逆**操作的环境(`ENV` 取值)。
5
+ *
6
+ * 覆盖两类动作,两者共用同一份判定:
7
+ *
8
+ * - 测试数据库清理(`assertTestDatabaseSchema` 那道闸门的同伴);
9
+ * - `testdata/` 脚本的 `load()` / `cleanup()`(`test-support/testdata.ts`)。
10
+ *
11
+ * `development` 是本地开发,`test` 是 vitest 入口显式声明的环境开关。除此之外一律拒绝——
12
+ * 只看 `NODE_ENV`、CI 标志或变量名都不构成证明:`NODE_ENV=test` 完全可能连着生产库。
13
+ */
14
+ export declare const CLEANABLE_ENVS: readonly string[];
15
+ /**
16
+ * 环境闸门:非可清理环境立即失败。
17
+ *
18
+ * **不在分支里「跳过清理」**——把破坏性动作降级成静默 no-op,会让同一份代码在生产和测试里
19
+ * 走两条不同的路径,测试结果随即失去意义。要禁就整体失败。
20
+ *
21
+ * 报错必须带上当前值:漏配 `ENV` 时的症状否则会变成「莫名其妙不许跑」。
22
+ */
23
+ export declare function assertCleanableEnvironment(env: string): void;
3
24
  /**
4
25
  * 框架在配置前固定加载的 env(N2 §2.2)。
5
26
  *
@@ -1 +1 @@
1
- {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../source/core/env.ts"],"names":[],"mappings":"AAgCA,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,SAAS,CAAA;AA2BvC;;;;;;;;GAQG;AACH,MAAM,WAAW,SAAS;IACxB,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAA;IACX,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAA;IAChB,qBAAqB;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,sCAAsC;IACtC,YAAY,EAAE,MAAM,CAAA;IACpB,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,sDAAsD;AACtD,wBAAgB,gBAAgB,IAAI,SAAS,CAQ5C;AAED;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,MAAM,CAAA;AAEhD,8BAA8B;AAC9B,MAAM,WAAW,sBAAsB;IACrC,IAAI,CAAC,EAAE,eAAe,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,SAAS,CAY/E"}
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../source/core/env.ts"],"names":[],"mappings":"AAgCA,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,SAAS,CAAA;AAEvC;;;;;;;;;;GAUG;AACH,eAAO,MAAM,cAAc,EAAE,SAAS,MAAM,EAA4B,CAAA;AAExE;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAU5D;AA2BD;;;;;;;;GAQG;AACH,MAAM,WAAW,SAAS;IACxB,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAA;IACX,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAA;IAChB,qBAAqB;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,sCAAsC;IACtC,YAAY,EAAE,MAAM,CAAA;IACpB,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,sDAAsD;AACtD,wBAAgB,gBAAgB,IAAI,SAAS,CAQ5C;AAED;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,MAAM,CAAA;AAEhD,8BAA8B;AAC9B,MAAM,WAAW,sBAAsB;IACrC,IAAI,CAAC,EAAE,eAAe,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,SAAS,CAY/E"}
@@ -29,6 +29,34 @@ const ENV_FILE_LAYERS = ['.env.dev', '.env', '.env.local'];
29
29
  const TEST_ENV_FILE_LAYERS = ['.env.test', '.env.test.local'];
30
30
  /** `LOG_LEVEL` 未设置时的默认级别(设计 §3.1 ① / §5.1 `logging`) */
31
31
  export const DEFAULT_LOG_LEVEL = 'info';
32
+ /**
33
+ * 允许执行**破坏性 / 不可逆**操作的环境(`ENV` 取值)。
34
+ *
35
+ * 覆盖两类动作,两者共用同一份判定:
36
+ *
37
+ * - 测试数据库清理(`assertTestDatabaseSchema` 那道闸门的同伴);
38
+ * - `testdata/` 脚本的 `load()` / `cleanup()`(`test-support/testdata.ts`)。
39
+ *
40
+ * `development` 是本地开发,`test` 是 vitest 入口显式声明的环境开关。除此之外一律拒绝——
41
+ * 只看 `NODE_ENV`、CI 标志或变量名都不构成证明:`NODE_ENV=test` 完全可能连着生产库。
42
+ */
43
+ export const CLEANABLE_ENVS = ['development', 'test'];
44
+ /**
45
+ * 环境闸门:非可清理环境立即失败。
46
+ *
47
+ * **不在分支里「跳过清理」**——把破坏性动作降级成静默 no-op,会让同一份代码在生产和测试里
48
+ * 走两条不同的路径,测试结果随即失去意义。要禁就整体失败。
49
+ *
50
+ * 报错必须带上当前值:漏配 `ENV` 时的症状否则会变成「莫名其妙不许跑」。
51
+ */
52
+ export function assertCleanableEnvironment(env) {
53
+ if (CLEANABLE_ENVS.includes(env)) {
54
+ return;
55
+ }
56
+ throw new Error(`[env-gate] 拒绝在 ENV="${env || '<未设置>'}" 下执行会清理数据的操作:` +
57
+ `只接受 ${CLEANABLE_ENVS.join(' / ')}。` +
58
+ '会写入 / 清理 / truncate / reset 数据库的操作绝不能指向非开发环境。');
59
+ }
32
60
  /**
33
61
  * 首次 import 时的真实环境变量快照(此时还没有任何 .env 文件被加载)。
34
62
  * 加载结束后原样恢复,实现「外部注入 > .env.local > .env > .env.dev」。
@@ -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"}