@hile/model 2.0.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 ADDED
@@ -0,0 +1,194 @@
1
+ # @hile/model
2
+
3
+ 定义和调用业务模型(model),支持 services 依赖注入和中间件 pipeline。
4
+
5
+ ## 它解决什么问题?
6
+
7
+ 应用中的业务逻辑如果散落在 API 控制器、页面组件和服务中,会变得难以测试和复用。`@hile/model` 提供一个统一的层来封装领域逻辑:
8
+
9
+ - **业务逻辑集中**:每个模型文件包含一个 `defineModel`,业务逻辑不再散落
10
+ - **可选依赖注入**:支持声明 services 依赖,自动 `loadService` 后透传给 main 函数
11
+ - **中间件 pipeline**:支持 Koa 风格中间件链,可在业务逻辑前后执行横切关注点(日志、鉴权、事务等)
12
+ - **与服务容器解耦**:不同于 `@hile/core` 的容器单例,每次 `loadModel` 都重新执行 `main`,适合请求级别的上下文
13
+
14
+ ## 安装
15
+
16
+ ```bash
17
+ pnpm add @hile/model
18
+ ```
19
+
20
+ 依赖 `@hile/core`(workspace peer dependency)。
21
+
22
+ ## 快速开始
23
+
24
+ ```typescript
25
+ import { defineModel, loadModel } from '@hile/model'
26
+
27
+ const greetModel = defineModel(async (input: { name: string }) => {
28
+ return `Hello, ${input.name}!`
29
+ })
30
+
31
+ const result = await loadModel(greetModel, { name: 'World' })
32
+ console.log(result) // Hello, World!
33
+ ```
34
+
35
+ ## 使用指南
36
+
37
+ ### 基础模型(无 services)
38
+
39
+ 一个输入、一个输出:
40
+
41
+ ```typescript
42
+ // src/models/greet.model.ts
43
+ import { defineModel } from '@hile/model'
44
+ export default defineModel(async (input: { name: string }) => {
45
+ return { greeting: `Hello, ${input.name}!` }
46
+ })
47
+ ```
48
+
49
+ ```typescript
50
+ // 消费
51
+ import { loadModel } from '@hile/model'
52
+ import greetModel from '@/models/greet.model'
53
+
54
+ const result = await loadModel(greetModel, { name: 'World' })
55
+ ```
56
+
57
+ ### 注入 services
58
+
59
+ 通过 `services` 声明依赖,`main` 的第一个参数是加载后的 services 实例元组:
60
+
61
+ ```typescript
62
+ import { defineModel } from '@hile/model'
63
+ import { defineService } from '@hile/core'
64
+
65
+ const userService = defineService('user', async () => ({
66
+ findById: async (id: number) => ({ id, name: 'Alice' }),
67
+ }))
68
+
69
+ const getUserModel = defineModel({
70
+ services: [userService],
71
+ async main([user], input: { id: number }) {
72
+ return user.findById(input.id)
73
+ },
74
+ })
75
+ ```
76
+
77
+ services 数组与 main 首参元组顺序一致。
78
+
79
+ ### 中间件 pipeline
80
+
81
+ Koa 风格中间件链,在 `main` 前后执行横切逻辑:
82
+
83
+ ```typescript
84
+ import { defineModel, type PipelineMiddleware } from '@hile/model'
85
+
86
+ const logger: PipelineMiddleware = async (ctx, next) => {
87
+ console.log('before:', ctx.args)
88
+ await next()
89
+ console.log('after')
90
+ }
91
+
92
+ const model = defineModel({
93
+ pipelines: [logger],
94
+ async main(input: { id: number }) {
95
+ return fetchUser(input.id)
96
+ },
97
+ })
98
+ ```
99
+
100
+ 中间件特性:
101
+
102
+ - 通过 `ctx.args` 改写入参
103
+ - 通过 `ctx.state` 在中间件间传递数据
104
+ - 不调 `next()` 可短路,`main` 不会执行
105
+ - 最后一个中间件不应调 `next()`
106
+
107
+ ### 函数简写
108
+
109
+ 无 services 和 pipelines 时可直接传入 `main`:
110
+
111
+ ```typescript
112
+ const model = defineModel(async (input: { id: number }) => input.id)
113
+ // 等价于
114
+ const model = defineModel({ main: async (input: { id: number }) => input.id })
115
+ ```
116
+
117
+ ## API 参考
118
+
119
+ ### 顶层导出
120
+
121
+ | 导出 | 说明 |
122
+ |------|------|
123
+ | `defineModel` | 定义业务模型 |
124
+ | `loadModel` | 执行模型,返回 `Promise<R>` |
125
+ | `isModel` | 校验对象是否合法 `ModelDefinition` |
126
+ | `Pipeline` | pipeline 类 |
127
+ | `PipelineContext` | pipeline 上下文 |
128
+ | `ModelDefinition` | 模型类型 |
129
+ | `ModelProps` | `defineModel` 入参类型 |
130
+ | `ModelFlag` | 模型内部标记类型 |
131
+ | `PipelineMiddleware` | 中间件类型 |
132
+ | `ModelPipeline` | pipeline 中间件列表类型 |
133
+ | `InferServiceResult` | 从 `ServiceRegisterProps` 推断服务实例类型 |
134
+ | `InferredServices` | services 元组 → 实例元组类型 |
135
+
136
+ ### defineModel
137
+
138
+ ```typescript
139
+ // 简写形式
140
+ defineModel<TInput extends object, R>(
141
+ main: (input: TInput) => R | Promise<R>,
142
+ ): ModelDefinition<TInput, R>
143
+
144
+ // 完整形式
145
+ defineModel<S, TInput, R>(
146
+ options: ModelProps<S, TInput, R>,
147
+ ): ModelDefinition<TInput, R>
148
+ ```
149
+
150
+ ### ModelProps
151
+
152
+ | 属性 | 类型 | 必填 | 说明 |
153
+ |------|------|------|------|
154
+ | `main` | `function` | 是 | 业务函数。有 services 时首参为实例元组,次参为 input;否则仅 input |
155
+ | `services` | `ServiceRegisterProps[]` | 否 | 依赖的服务列表,自动 `loadService` |
156
+ | `pipelines` | `PipelineMiddleware[]` | 否 | Koa 风格中间件列表 |
157
+
158
+ ### ModelDefinition
159
+
160
+ ```typescript
161
+ interface ModelDefinition<TInput extends object, R> {
162
+ readonly flag: symbol
163
+ readonly handler: (input: TInput) => Promise<R>
164
+ }
165
+ ```
166
+
167
+ ### loadModel
168
+
169
+ ```typescript
170
+ loadModel<TInput extends object, R>(
171
+ model: ModelDefinition<TInput, R>,
172
+ input: TInput,
173
+ ): Promise<R>
174
+ ```
175
+
176
+ 非容器单例,每次调用都重新执行 `main`。首参非 `defineModel` 返回值时抛 `TypeError`。
177
+
178
+ ### Pipeline
179
+
180
+ | 方法 | 说明 |
181
+ |------|------|
182
+ | `use(fn)` | 注册中间件 |
183
+ | `dispatch(ctx)` | 启动中间件链,返回 `Promise<void>` |
184
+
185
+ ### PipelineContext
186
+
187
+ | 属性 | 类型 | 说明 |
188
+ |------|------|------|
189
+ | `args` | `TInput` | 原始入参,中间件可改写 |
190
+ | `state` | `Record<string, unknown>` | 中间件间共享状态 |
191
+
192
+ ## License
193
+
194
+ MIT
package/SKILL.md ADDED
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: model
3
+ description: "@hile/model: defineModel/loadModel 定义和消费模型;services 依赖注入;pipeline 中间件链;每次 loadModel 重新执行 main"
4
+ ---
5
+
6
+ # @hile/model
7
+
8
+ 与仓库根 **`SKILL.md`** 一并遵守。
9
+
10
+ ## 核心概念
11
+
12
+ - **模型(Model)**:封装一段业务逻辑,通过 `defineModel` 定义,`loadModel` 执行
13
+ - **Services 注入**:通过 `services` 声明依赖,`main` 首参为加载后的实例元组
14
+ - **Pipeline**:Koa 风格中间件链,在 `main` 前后执行横切逻辑
15
+ - **非单例**:每次 `loadModel` 都重新执行 `main`,不同于 `@hile/core` 的容器单例
16
+
17
+ ## 导出
18
+
19
+ | 名称 | 说明 |
20
+ |------|------|
21
+ | `defineModel` | 定义模型,接收 `(main)` 简写或 `ModelProps` 对象 |
22
+ | `loadModel` | `loadModel(model, input)` 执行模型 |
23
+ | `isModel` | 判断值是否为 `defineModel` 返回值 |
24
+ | `ModelDefinition` | 模型类型 |
25
+ | `ModelProps` | `defineModel` 入参类型 |
26
+ | `ModelPipeline` | `readonly PipelineMiddleware[]` |
27
+ | `Pipeline` | 中间件链类 |
28
+ | `PipelineContext` | 中间件上下文 |
29
+ | `PipelineMiddleware` | 中间件类型 `(ctx, next) => Promise<void>` |
30
+ | `InferServiceResult` | 工具类型 |
31
+ | `InferredServices` | 工具类型 |
32
+
33
+ ## 用法规则
34
+
35
+ ### defineModel
36
+
37
+ ```typescript
38
+ // 简写:无 services、无 pipelines
39
+ defineModel(async (input: TInput) => R)
40
+
41
+ // 完整形式
42
+ defineModel({
43
+ services?: [ServiceA, ServiceB], // 可选
44
+ pipelines?: [MiddlewareA, MiddlewareB], // 可选,顺序执行
45
+ async main(
46
+ services?: [InstanceA, InstanceB], // 有 services 时首参
47
+ input: TInput, // 入参
48
+ ): R | Promise<R>,
49
+ })
50
+ ```
51
+
52
+ ### 强制规则
53
+
54
+ 1. **模型文件**:一个文件一个 `defineModel`,`export default`
55
+ 2. **`loadModel` 首参**:必须是 `defineModel` 返回值,否则抛 `TypeError`
56
+ 3. **Pipeline 原则**:
57
+ - 最后一个中间件不应调用 `next()`,否则抛错
58
+ - 中间件短路(不调 `next()`)会使 `main` 不执行
59
+ - 中间件可通过 `ctx.args` 改写入参
60
+ - 中间件间通过 `ctx.state` 传递数据
61
+ 4. **Services**:同一 key 的 service 由 `@hile/core` 容器保证单例;model 本身不缓存结果
62
+
63
+ ## 反模式
64
+
65
+ - 请求内调用 `defineModel`(应在模块顶层定义一次)
66
+ - 手动构造 `ModelDefinition` 对象(必须通过 `defineModel`)
67
+ - 在 `main` 中做副作用不返回结果(model 应是有输入有输出的纯业务函数)
68
+
69
+ ## 测试
70
+
71
+ **`src/model.test.ts`**:`defineModel` 各种形式(完整、简写、services、pipelines)。**`src/pipeline.test.ts`**:中间件顺序、短路、next 多次调用、并发、无中间件等。
@@ -0,0 +1,2 @@
1
+ export { Pipeline, PipelineContext, type PipelineMiddleware, } from "./pipeline.js";
2
+ export { defineModel, loadModel, isModel, type InferServiceResult, type InferredServices, type ModelDefinition, type ModelFlag, type ModelPipeline, type ModelProps, } from "./model.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { Pipeline, PipelineContext, } from "./pipeline.js";
2
+ export { defineModel, loadModel, isModel, } from "./model.js";
@@ -0,0 +1,40 @@
1
+ import { type ServiceRegisterProps } from '@hile/core';
2
+ import { type PipelineMiddleware } from './pipeline.js';
3
+ declare const modelFlag: unique symbol;
4
+ /** `defineModel` 返回值上的标记类型 */
5
+ export type ModelFlag = typeof modelFlag;
6
+ /** model 可用的 pipeline 中间件列表(默认可跨 model 复用) */
7
+ export type ModelPipeline = readonly PipelineMiddleware[];
8
+ /** 从 `ServiceRegisterProps` 推断 `loadService` 后的实例类型 */
9
+ export type InferServiceResult<S> = S extends ServiceRegisterProps<infer R> ? R : never;
10
+ /** `services` 元组 → `main` 首参元组(顺序与 `services` 一致) */
11
+ export type InferredServices<S extends readonly ServiceRegisterProps<any>[]> = {
12
+ readonly [K in keyof S]: InferServiceResult<S[K]>;
13
+ };
14
+ /** `defineModel` 入参;`services` 可选;业务入参为对象 `input` */
15
+ export type ModelProps<S extends readonly ServiceRegisterProps<any>[] | undefined = undefined, TInput extends object = Record<string, unknown>, R = unknown> = {
16
+ services?: S;
17
+ pipelines?: ModelPipeline;
18
+ main: S extends readonly ServiceRegisterProps<any>[] ? (services: InferredServices<S>, input: TInput) => R | Promise<R> : (input: TInput) => R | Promise<R>;
19
+ };
20
+ /**
21
+ * `defineModel` 的统一返回值;仅应由 {@link defineModel} 构造。
22
+ *
23
+ * 对外统一通过 `handler(input)` 调用。
24
+ */
25
+ export type ModelDefinition<TInput extends object = Record<string, unknown>, R = unknown> = {
26
+ readonly flag: ModelFlag;
27
+ readonly handler: (input: TInput) => Promise<R>;
28
+ };
29
+ /**
30
+ * 使用给定参数执行 `model.handler(input)`,并以 Promise 返回其结果
31
+ *(`handler` 内同步抛错也会变为 reject)。
32
+ */
33
+ export declare function loadModel<TInput extends object, R>(model: ModelDefinition<TInput, R>, input: TInput): Promise<R>;
34
+ /** 判断值是否为 {@link defineModel} 的返回值 */
35
+ export declare function isModel(value: unknown): value is ModelDefinition;
36
+ /** 无 services / pipelines 时可直接传入 main:`defineModel(async (input) => ...)` */
37
+ export declare function defineModel<TInput extends object, R>(main: (input: TInput) => R | Promise<R>): ModelDefinition<TInput, R>;
38
+ /** `defineModel({ services?: [A, B], pipelines?: [PA, PB], async main(services?, input) { ... } })` */
39
+ export declare function defineModel<const S extends readonly ServiceRegisterProps<any>[] | undefined = undefined, TInput extends object = Record<string, unknown>, R = unknown>(options: ModelProps<S, TInput, R>): ModelDefinition<TInput, R>;
40
+ export {};
package/dist/model.js ADDED
@@ -0,0 +1,50 @@
1
+ import { loadService } from '@hile/core';
2
+ import { Pipeline, PipelineContext, } from './pipeline.js';
3
+ const modelFlag = Symbol.for('@hile/model');
4
+ /**
5
+ * 使用给定参数执行 `model.handler(input)`,并以 Promise 返回其结果
6
+ *(`handler` 内同步抛错也会变为 reject)。
7
+ */
8
+ export function loadModel(model, input) {
9
+ if (!isModel(model)) {
10
+ return Promise.reject(new TypeError('loadModel: first argument must be a return value of defineModel'));
11
+ }
12
+ return model.handler(input);
13
+ }
14
+ /** 判断值是否为 {@link defineModel} 的返回值 */
15
+ export function isModel(value) {
16
+ return (typeof value === 'object' &&
17
+ value !== null &&
18
+ value.flag === modelFlag &&
19
+ typeof value.handler === 'function');
20
+ }
21
+ export function defineModel(optionsOrMain) {
22
+ if (typeof optionsOrMain === 'function') {
23
+ return defineModel({ main: optionsOrMain });
24
+ }
25
+ const { services, pipelines, main } = optionsOrMain;
26
+ const invokeMain = async (input) => {
27
+ if (services !== undefined) {
28
+ const loaded = await Promise.all(services.map((service) => loadService(service)));
29
+ return Promise.resolve(main(loaded, input));
30
+ }
31
+ return Promise.resolve(main(input));
32
+ };
33
+ const handler = async (input) => {
34
+ if (pipelines !== undefined && pipelines.length > 0) {
35
+ const ctx = new PipelineContext(input);
36
+ const chain = new Pipeline();
37
+ let result;
38
+ for (const middleware of pipelines) {
39
+ chain.use(middleware);
40
+ }
41
+ chain.use(async (ctx) => {
42
+ result = await invokeMain(ctx.args);
43
+ });
44
+ await chain.dispatch(ctx);
45
+ return result;
46
+ }
47
+ return invokeMain(input);
48
+ };
49
+ return { flag: modelFlag, handler };
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { defineService } from '@hile/core';
3
+ import { defineModel, isModel, loadModel } from './model.js';
4
+ const A = defineService('test.a', async () => ({ a: 1 }));
5
+ const B = defineService('test.b', async () => ({ b: 2 }));
6
+ const PA = async (_ctx, next) => next();
7
+ const PB = async (_ctx, next) => next();
8
+ describe('defineModel', () => {
9
+ it('handler 可调用(services + pipelines + main)', async () => {
10
+ const m = defineModel({
11
+ services: [A, B],
12
+ pipelines: [PA, PB],
13
+ async main([a, b], input) {
14
+ return { a, b, id: input.id };
15
+ },
16
+ });
17
+ expect(typeof m.handler).toBe('function');
18
+ expect(m.flag).toBe(Symbol.for('@hile/model'));
19
+ expect(isModel(m)).toBe(true);
20
+ const result = await m.handler({ id: 42 });
21
+ expect(result).toEqual({ a: { a: 1 }, b: { b: 2 }, id: 42 });
22
+ });
23
+ it('无 services:pipelines + main', async () => {
24
+ const m = defineModel({
25
+ pipelines: [PA, PB],
26
+ async main(input) {
27
+ return input.id;
28
+ },
29
+ });
30
+ await expect(m.handler({ id: 7 })).resolves.toBe(7);
31
+ });
32
+ it('无 services、无 pipelines:仅 main', async () => {
33
+ const m = defineModel({
34
+ async main(input) {
35
+ return input.id;
36
+ },
37
+ });
38
+ await expect(m.handler({ id: 3 })).resolves.toBe(3);
39
+ });
40
+ it('无 services、无 pipelines:函数简写', async () => {
41
+ const m = defineModel(async (input) => input.id);
42
+ expect(isModel(m)).toBe(true);
43
+ await expect(m.handler({ id: 3 })).resolves.toBe(3);
44
+ await expect(loadModel(m, { id: 9 })).resolves.toBe(9);
45
+ });
46
+ it('services + main(无 pipelines)', async () => {
47
+ const m = defineModel({
48
+ services: [A],
49
+ async main([a], input) {
50
+ return { a, id: input.id };
51
+ },
52
+ });
53
+ await expect(m.handler({ id: 1 })).resolves.toEqual({ a: { a: 1 }, id: 1 });
54
+ });
55
+ it('返回值符合 ModelDefinition', () => {
56
+ const m = defineModel({
57
+ services: [A],
58
+ async main([a], input) {
59
+ return { a, id: input.id };
60
+ },
61
+ });
62
+ expect(isModel(m)).toBe(true);
63
+ });
64
+ it('isModel 拒绝非 defineModel 对象', () => {
65
+ expect(isModel(null)).toBe(false);
66
+ expect(isModel({ handler: async () => ({}) })).toBe(false);
67
+ });
68
+ it('pipeline 可改写 ctx.args', async () => {
69
+ const m = defineModel({
70
+ pipelines: [
71
+ async (ctx, next) => {
72
+ Object.assign(ctx.args, { id: 99 });
73
+ await next();
74
+ },
75
+ ],
76
+ async main(input) {
77
+ return input.id;
78
+ },
79
+ });
80
+ await expect(m.handler({ id: 1 })).resolves.toBe(99);
81
+ });
82
+ it('loadModel 调用 handler', async () => {
83
+ const m = defineModel({
84
+ services: [A],
85
+ async main([a], input) {
86
+ return { a, id: input.id, name: input.name };
87
+ },
88
+ });
89
+ await expect(loadModel(m, { id: 1, name: 'test' })).resolves.toEqual({
90
+ a: { a: 1 },
91
+ id: 1,
92
+ name: 'test',
93
+ });
94
+ });
95
+ it('loadModel 非 model 应 reject', async () => {
96
+ await expect(loadModel({ handler: async () => 1 }, { id: 1 })).rejects.toThrow('loadModel: first argument must be a return value of defineModel');
97
+ });
98
+ it('services: [] 时 main 收到空元组', async () => {
99
+ const m = defineModel({
100
+ services: [],
101
+ async main(services, input) {
102
+ expect(services).toEqual([]);
103
+ return input.id;
104
+ },
105
+ });
106
+ await expect(m.handler({ id: 5 })).resolves.toBe(5);
107
+ });
108
+ });
@@ -0,0 +1,12 @@
1
+ export type PipelineMiddleware<TInput extends object = Record<string, unknown>> = (ctx: PipelineContext<TInput>, next: () => Promise<void>) => Promise<void>;
2
+ export declare class PipelineContext<TInput extends object = Record<string, unknown>> {
3
+ state: Record<string, unknown>;
4
+ readonly args: TInput;
5
+ constructor(args: TInput);
6
+ }
7
+ export declare class Pipeline<TInput extends object = Record<string, unknown>> {
8
+ private fns;
9
+ use(fn: PipelineMiddleware<TInput>): void;
10
+ /** 与 Koa compose 一致:只驱动中间件链,不返回值;结果由中间件写入 `ctx.state` */
11
+ dispatch(ctx: PipelineContext<TInput>): Promise<void>;
12
+ }
@@ -0,0 +1,31 @@
1
+ export class PipelineContext {
2
+ state = {};
3
+ args;
4
+ constructor(args) {
5
+ this.args = args;
6
+ }
7
+ }
8
+ export class Pipeline {
9
+ fns = [];
10
+ use(fn) {
11
+ this.fns.push(fn);
12
+ }
13
+ /** 与 Koa compose 一致:只驱动中间件链,不返回值;结果由中间件写入 `ctx.state` */
14
+ dispatch(ctx) {
15
+ const fns = this.fns;
16
+ if (fns.length === 0) {
17
+ return Promise.reject(new Error("pipeline: no middleware registered"));
18
+ }
19
+ let index = -1;
20
+ const run = async (i) => {
21
+ if (i <= index)
22
+ throw new Error("next() called multiple times");
23
+ index = i;
24
+ const fn = fns[i];
25
+ if (!fn)
26
+ throw new Error("pipeline: last middleware called next(), it should be terminal");
27
+ await fn(ctx, () => run(i + 1));
28
+ };
29
+ return run(0);
30
+ }
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Pipeline, PipelineContext } from "./pipeline.js";
3
+ describe("Pipeline", () => {
4
+ it("executes middleware in order", async () => {
5
+ const order = [];
6
+ const p = new Pipeline();
7
+ p.use(async (ctx, next) => {
8
+ order.push(1);
9
+ await next();
10
+ order.push(4);
11
+ });
12
+ p.use(async (ctx, next) => {
13
+ order.push(2);
14
+ await next();
15
+ order.push(3);
16
+ });
17
+ p.use(async (ctx) => {
18
+ order.push(5);
19
+ ctx.state.result = ctx.args.value;
20
+ });
21
+ const ctx = new PipelineContext({ value: 42 });
22
+ await p.dispatch(ctx);
23
+ expect(ctx.state.result).toBe(42);
24
+ expect(order).toEqual([1, 2, 5, 3, 4]);
25
+ });
26
+ it("passes context through the chain", async () => {
27
+ const p = new Pipeline();
28
+ p.use(async (ctx, next) => {
29
+ ctx.args.msg += " > middleware1";
30
+ await next();
31
+ });
32
+ p.use(async (ctx, next) => {
33
+ ctx.args.msg += " > middleware2";
34
+ await next();
35
+ });
36
+ p.use(async (ctx) => {
37
+ ctx.state.result = ctx.args.msg;
38
+ });
39
+ const ctx = new PipelineContext({ msg: "start" });
40
+ await p.dispatch(ctx);
41
+ expect(ctx.state.result).toBe("start > middleware1 > middleware2");
42
+ });
43
+ it("allows middleware to short-circuit", async () => {
44
+ const p = new Pipeline();
45
+ let executed = false;
46
+ p.use(async (ctx, _next) => {
47
+ ctx.state.result = "short-circuited";
48
+ });
49
+ p.use(async (ctx, _next) => {
50
+ executed = true;
51
+ ctx.state.result = "should not reach";
52
+ });
53
+ const ctx = new PipelineContext({ value: "input" });
54
+ await p.dispatch(ctx);
55
+ expect(ctx.state.result).toBe("short-circuited");
56
+ expect(executed).toBe(false);
57
+ });
58
+ it("rejects if next() is called multiple times", async () => {
59
+ const p = new Pipeline();
60
+ p.use(async (ctx, next) => {
61
+ await next();
62
+ await expect(next()).rejects.toThrow("next() called multiple times");
63
+ ctx.state.result = "done";
64
+ });
65
+ p.use(async (ctx) => {
66
+ ctx.state.result = ctx.args.value;
67
+ });
68
+ const ctx = new PipelineContext({ value: "ok" });
69
+ await p.dispatch(ctx);
70
+ expect(ctx.state.result).toBe("done");
71
+ });
72
+ it("rejects if the last middleware calls next()", async () => {
73
+ const p = new Pipeline();
74
+ p.use(async (_ctx, next) => next());
75
+ const ctx = new PipelineContext({ value: "x" });
76
+ await expect(p.dispatch(ctx)).rejects.toThrow("last middleware called next");
77
+ });
78
+ it("rejects if no middleware registered", async () => {
79
+ const p = new Pipeline();
80
+ const ctx = new PipelineContext({ value: "x" });
81
+ await expect(p.dispatch(ctx)).rejects.toThrow("no middleware registered");
82
+ });
83
+ it("rejects on sync throw in middleware", async () => {
84
+ const p = new Pipeline();
85
+ p.use(() => {
86
+ throw new Error("sync error");
87
+ });
88
+ const ctx = new PipelineContext({ value: "x" });
89
+ await expect(p.dispatch(ctx)).rejects.toThrow("sync error");
90
+ });
91
+ it("rejects on async throw in middleware", async () => {
92
+ const p = new Pipeline();
93
+ p.use(async () => {
94
+ throw new Error("async error");
95
+ });
96
+ const ctx = new PipelineContext({ value: "x" });
97
+ await expect(p.dispatch(ctx)).rejects.toThrow("async error");
98
+ });
99
+ it("supports concurrent dispatch calls", async () => {
100
+ const p = new Pipeline();
101
+ p.use(async (_ctx, next) => next());
102
+ p.use(async (ctx) => {
103
+ ctx.state.result = ctx.args.id;
104
+ });
105
+ const ctx1 = new PipelineContext({ id: 1 });
106
+ const ctx2 = new PipelineContext({ id: 2 });
107
+ const ctx3 = new PipelineContext({ id: 3 });
108
+ await Promise.all([p.dispatch(ctx1), p.dispatch(ctx2), p.dispatch(ctx3)]);
109
+ expect(ctx1.state.result).toBe(1);
110
+ expect(ctx2.state.result).toBe(2);
111
+ expect(ctx3.state.result).toBe(3);
112
+ });
113
+ it("allows middleware to modify result from downstream", async () => {
114
+ const p = new Pipeline();
115
+ p.use(async (ctx, next) => {
116
+ await next();
117
+ ctx.state.result = ctx.state.result * 2;
118
+ });
119
+ p.use(async (ctx) => {
120
+ ctx.state.result = ctx.args.n + 1;
121
+ });
122
+ const ctx = new PipelineContext({ n: 3 });
123
+ await p.dispatch(ctx);
124
+ expect(ctx.state.result).toBe(8);
125
+ });
126
+ it("works with single terminal middleware", async () => {
127
+ const p = new Pipeline();
128
+ p.use(async (ctx) => {
129
+ ctx.state.result = ctx.args.value;
130
+ });
131
+ const ctx = new PipelineContext({ value: "ok" });
132
+ await p.dispatch(ctx);
133
+ expect(ctx.state.result).toBe("ok");
134
+ });
135
+ it("composes multiple middleware correctly", async () => {
136
+ const log = [];
137
+ const p = new Pipeline();
138
+ p.use(async (_ctx, next) => {
139
+ log.push("A:enter");
140
+ await next();
141
+ log.push("A:exit");
142
+ });
143
+ p.use(async (_ctx, next) => {
144
+ log.push("B:enter");
145
+ await next();
146
+ log.push("B:exit");
147
+ });
148
+ p.use(async (ctx) => {
149
+ log.push("terminal");
150
+ ctx.state.result = ctx.args.n * 10;
151
+ });
152
+ const ctx = new PipelineContext({ n: 3 });
153
+ await p.dispatch(ctx);
154
+ expect(ctx.state.result).toBe(30);
155
+ expect(log).toEqual(["A:enter", "B:enter", "terminal", "B:exit", "A:exit"]);
156
+ });
157
+ it("downstream layers can read ctx.state.result after next()", async () => {
158
+ const p = new Pipeline();
159
+ p.use(async (ctx, next) => {
160
+ await next();
161
+ expect(ctx.state.result).toBe(2);
162
+ ctx.state.result = ctx.state.result + 10;
163
+ });
164
+ p.use(async (ctx) => {
165
+ ctx.state.result = ctx.args.id + 1;
166
+ });
167
+ const ctx = new PipelineContext({ id: 1 });
168
+ await p.dispatch(ctx);
169
+ expect(ctx.state.result).toBe(12);
170
+ });
171
+ });
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@hile/model",
3
+ "version": "2.0.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "scripts": {
7
+ "build": "tsc -b && fix-esm-import-path --preserve-import-type ./dist",
8
+ "dev": "tsc -b --watch",
9
+ "test": "vitest run"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "SKILL.md"
15
+ ],
16
+ "license": "MIT",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "devDependencies": {
21
+ "fix-esm-import-path": "^1.10.3",
22
+ "vitest": "^4.0.18"
23
+ },
24
+ "dependencies": {
25
+ "@hile/core": "^1.1.2"
26
+ },
27
+ "gitHead": "ec6272f3e39d3afaefd12d9d70ba2b103c5f122a"
28
+ }