@omni-api/plugin-idempotency 0.0.1

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,63 @@
1
+ # @omni/plugin-idempotency
2
+
3
+ OmniAPI 幂等键中间件。**让客户端重试不会重复扣款 / 重复下单**。
4
+
5
+ ## 用法
6
+
7
+ ### Procedure 上挂中间件
8
+ ```ts
9
+ import { idempotent } from '@omni/plugin-idempotency';
10
+
11
+ defineProcedure({
12
+ name: 'order.create',
13
+ middleware: [auth(), idempotent({ ttlMs: 5 * 60_000 })],
14
+ // ...
15
+ });
16
+ ```
17
+
18
+ ### HTTP Adapter 注入 key
19
+ HTTP 客户端通过 `Idempotency-Key: <random-uuid>` header 传 key。Adapter 把它写到 `ctx.state.idempotencyKey`:
20
+
21
+ ```ts
22
+ createHttpAdapter({
23
+ registry,
24
+ // 在 authenticate 之后或自定义 hook 中注入:
25
+ fastify: {
26
+ onRequest: [(req, _reply, done) => {
27
+ const k = req.headers['idempotency-key'];
28
+ if (typeof k === 'string') (req as any).idempotencyKey = k;
29
+ done();
30
+ }],
31
+ },
32
+ });
33
+ // 然后在你的 buildHttpContext wrapper 里把它转到 ctx.state
34
+ ```
35
+
36
+ ### 客户端
37
+ ```bash
38
+ curl -X POST /orders \
39
+ -H "Idempotency-Key: $(uuidgen)" \
40
+ -H "Content-Type: application/json" \
41
+ -d '{"sku":"SKU001","qty":2}'
42
+
43
+ # 网络抖动重试:用同一个 key 再试
44
+ curl -X POST /orders -H "Idempotency-Key: <same>" ... # → 返回上次结果,不重复创建
45
+ ```
46
+
47
+ ## 行为
48
+ | 场景 | 结果 |
49
+ |---|---|
50
+ | 无 key(默认) | 放行,不保护 |
51
+ | 无 key + `required: true` | 409 IDEMPOTENCY_KEY_REQUIRED |
52
+ | 同 key 第二次(已完成) | 直接返回缓存结果 |
53
+ | 同 key 第二次(处理中) | 409 IDEMPOTENCY_IN_PROGRESS |
54
+ | 同 key 并发竞争 | 一个赢,其他 409 IDEMPOTENCY_RACE |
55
+ | handler 抛错 | 释放 key,可重试 |
56
+
57
+ ## 后端
58
+ - `createMemoryStore()`:单进程默认
59
+ - 自己实现 `IdempotencyStore` 接口接 Redis(用 `SET key NX PX ttl` 实现原子 acquire)
60
+
61
+ ## 注意
62
+ - TTL 默认 5 分钟。重试窗口大于 TTL 后会"二次执行" —— 这是约定权衡(避免 key 永驻存储)。
63
+ - 不同 procedure 的同名 key 自动隔离。
@@ -0,0 +1,68 @@
1
+ import { Context, Middleware } from '@omni-api/core';
2
+
3
+ /**
4
+ * 幂等存储后端约定。
5
+ *
6
+ * 状态机:
7
+ * - 不存在 → setInProgress() → 'in_progress'
8
+ * - 'in_progress' → setComplete(result) → 'complete' (持续 ttl)
9
+ * - 'in_progress' → 第二次进来 → 返回 'in_progress' 让中间件抛 409
10
+ * - 'complete' → 第二次进来 → 直接返回 result
11
+ */
12
+ type IdempotencyEntry = {
13
+ status: 'in_progress';
14
+ } | {
15
+ status: 'complete';
16
+ result: unknown;
17
+ };
18
+ interface IdempotencyStore {
19
+ /** 拿当前状态;不存在返回 undefined */
20
+ get(key: string): Promise<IdempotencyEntry | undefined>;
21
+ /**
22
+ * 原子设置 in_progress(如果 key 已存在则返回 false 表示有人抢先)。
23
+ * 实现要点:必须保证多个并发请求只有一个返回 true。
24
+ */
25
+ tryAcquire(key: string, ttlMs: number): Promise<boolean>;
26
+ /** 标记完成并存结果 */
27
+ setComplete(key: string, result: unknown, ttlMs: number): Promise<void>;
28
+ /** 释放(回滚 in_progress 状态) */
29
+ release(key: string): Promise<void>;
30
+ }
31
+ /**
32
+ * 内存版 Store —— 单进程默认实现。
33
+ *
34
+ * 多实例部署需替换为 Redis 实现(用 SET key value NX PX ttl 即可原子 acquire)。
35
+ */
36
+ declare function createMemoryStore(): IdempotencyStore;
37
+
38
+ interface IdempotencyOptions {
39
+ /** 缓存 TTL(毫秒),默认 5 分钟 */
40
+ ttlMs?: number;
41
+ /**
42
+ * key 来源函数。默认从 ctx.state.idempotencyKey 读取(由 Adapter 注入)。
43
+ * 返回 undefined 时跳过幂等(不强制要求所有请求都带 key)。
44
+ */
45
+ keyFrom?: (ctx: Context) => string | undefined;
46
+ /** 后端,默认共享 module-level 内存 store */
47
+ store?: IdempotencyStore;
48
+ /**
49
+ * 严格模式:true 时未提供 idempotencyKey 直接拒绝(强制业务带 key)。
50
+ * 默认 false(容忍未带 key 的请求,但失去幂等保护)。
51
+ */
52
+ required?: boolean;
53
+ }
54
+ /**
55
+ * 幂等键中间件 —— 让客户端重试不会造成业务重复执行。
56
+ *
57
+ * 工作流程:
58
+ * 1. 取 key(默认 ctx.state.idempotencyKey)
59
+ * 2. 命中 'complete' → 直接返回缓存结果,不跑 handler
60
+ * 3. 命中 'in_progress' → 抛 409 ConflictError(请求正在处理中)
61
+ * 4. 都没有 → 标记 in_progress,跑 handler,结果存为 'complete'
62
+ * 5. handler 抛错 → 释放 key,让客户端重试时不会被永久卡住
63
+ *
64
+ * 通常挂在写操作(create/cancel/transfer)上。
65
+ */
66
+ declare function idempotent(options?: IdempotencyOptions): Middleware;
67
+
68
+ export { type IdempotencyEntry, type IdempotencyOptions, type IdempotencyStore, createMemoryStore, idempotent };
package/dist/index.js ADDED
@@ -0,0 +1,91 @@
1
+ import { ConflictError } from '@omni-api/core';
2
+
3
+ // src/middleware.ts
4
+
5
+ // src/store.ts
6
+ function createMemoryStore() {
7
+ const map = /* @__PURE__ */ new Map();
8
+ const cleanIfExpired = (key) => {
9
+ const now = Date.now();
10
+ const e = map.get(key);
11
+ if (!e) return void 0;
12
+ if (e.expiresAt <= now) {
13
+ map.delete(key);
14
+ return void 0;
15
+ }
16
+ return e;
17
+ };
18
+ return {
19
+ async get(key) {
20
+ return cleanIfExpired(key)?.entry;
21
+ },
22
+ async tryAcquire(key, ttlMs) {
23
+ const existing = cleanIfExpired(key);
24
+ if (existing) return false;
25
+ map.set(key, { entry: { status: "in_progress" }, expiresAt: Date.now() + ttlMs });
26
+ return true;
27
+ },
28
+ async setComplete(key, result, ttlMs) {
29
+ map.set(key, {
30
+ entry: { status: "complete", result },
31
+ expiresAt: Date.now() + ttlMs
32
+ });
33
+ },
34
+ async release(key) {
35
+ map.delete(key);
36
+ }
37
+ };
38
+ }
39
+
40
+ // src/middleware.ts
41
+ var defaultStore = createMemoryStore();
42
+ var defaultKeyFrom = (ctx) => {
43
+ const k = ctx.state.idempotencyKey;
44
+ return typeof k === "string" && k.length > 0 ? k : void 0;
45
+ };
46
+ function idempotent(options = {}) {
47
+ const ttlMs = options.ttlMs ?? 5 * 6e4;
48
+ const store = options.store ?? defaultStore;
49
+ const keyFrom = options.keyFrom ?? defaultKeyFrom;
50
+ const required = options.required ?? false;
51
+ return async (ctx, next) => {
52
+ const procName = ctx.procedure?.name ?? "unknown";
53
+ const rawKey = keyFrom(ctx);
54
+ if (!rawKey) {
55
+ if (required) {
56
+ throw new ConflictError("Idempotency-Key required for this operation", {
57
+ code: "IDEMPOTENCY_KEY_REQUIRED"
58
+ });
59
+ }
60
+ return next();
61
+ }
62
+ const key = `${procName}:${rawKey}`;
63
+ const existing = await store.get(key);
64
+ if (existing?.status === "complete") {
65
+ return existing.result;
66
+ }
67
+ if (existing?.status === "in_progress") {
68
+ throw new ConflictError("Request with same Idempotency-Key is in progress", {
69
+ code: "IDEMPOTENCY_IN_PROGRESS"
70
+ });
71
+ }
72
+ const acquired = await store.tryAcquire(key, ttlMs);
73
+ if (!acquired) {
74
+ throw new ConflictError("Concurrent request with same Idempotency-Key", {
75
+ code: "IDEMPOTENCY_RACE"
76
+ });
77
+ }
78
+ try {
79
+ const result = await next();
80
+ await store.setComplete(key, result, ttlMs);
81
+ return result;
82
+ } catch (err) {
83
+ await store.release(key);
84
+ throw err;
85
+ }
86
+ };
87
+ }
88
+
89
+ export { createMemoryStore, idempotent };
90
+ //# sourceMappingURL=index.js.map
91
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/store.ts","../src/middleware.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * 幂等存储后端约定。\n *\n * 状态机:\n * - 不存在 → setInProgress() → 'in_progress'\n * - 'in_progress' → setComplete(result) → 'complete' (持续 ttl)\n * - 'in_progress' → 第二次进来 → 返回 'in_progress' 让中间件抛 409\n * - 'complete' → 第二次进来 → 直接返回 result\n */\nexport type IdempotencyEntry =\n | { status: 'in_progress' }\n | { status: 'complete'; result: unknown };\n\nexport interface IdempotencyStore {\n /** 拿当前状态;不存在返回 undefined */\n get(key: string): Promise<IdempotencyEntry | undefined>;\n /**\n * 原子设置 in_progress(如果 key 已存在则返回 false 表示有人抢先)。\n * 实现要点:必须保证多个并发请求只有一个返回 true。\n */\n tryAcquire(key: string, ttlMs: number): Promise<boolean>;\n /** 标记完成并存结果 */\n setComplete(key: string, result: unknown, ttlMs: number): Promise<void>;\n /** 释放(回滚 in_progress 状态) */\n release(key: string): Promise<void>;\n}\n\ninterface MemEntry {\n entry: IdempotencyEntry;\n expiresAt: number;\n}\n\n/**\n * 内存版 Store —— 单进程默认实现。\n *\n * 多实例部署需替换为 Redis 实现(用 SET key value NX PX ttl 即可原子 acquire)。\n */\nexport function createMemoryStore(): IdempotencyStore {\n const map = new Map<string, MemEntry>();\n\n const cleanIfExpired = (key: string): MemEntry | undefined => {\n const now = Date.now();\n const e = map.get(key);\n if (!e) return undefined;\n if (e.expiresAt <= now) {\n map.delete(key);\n return undefined;\n }\n return e;\n };\n\n return {\n async get(key) {\n return cleanIfExpired(key)?.entry;\n },\n async tryAcquire(key, ttlMs) {\n const existing = cleanIfExpired(key);\n if (existing) return false;\n map.set(key, { entry: { status: 'in_progress' }, expiresAt: Date.now() + ttlMs });\n return true;\n },\n async setComplete(key, result, ttlMs) {\n map.set(key, {\n entry: { status: 'complete', result },\n expiresAt: Date.now() + ttlMs,\n });\n },\n async release(key) {\n map.delete(key);\n },\n };\n}\n","import { ConflictError, type Context, type Middleware } from '@omni-api/core';\nimport { createMemoryStore, type IdempotencyStore } from './store.js';\n\nexport interface IdempotencyOptions {\n /** 缓存 TTL(毫秒),默认 5 分钟 */\n ttlMs?: number;\n /**\n * key 来源函数。默认从 ctx.state.idempotencyKey 读取(由 Adapter 注入)。\n * 返回 undefined 时跳过幂等(不强制要求所有请求都带 key)。\n */\n keyFrom?: (ctx: Context) => string | undefined;\n /** 后端,默认共享 module-level 内存 store */\n store?: IdempotencyStore;\n /**\n * 严格模式:true 时未提供 idempotencyKey 直接拒绝(强制业务带 key)。\n * 默认 false(容忍未带 key 的请求,但失去幂等保护)。\n */\n required?: boolean;\n}\n\nconst defaultStore = createMemoryStore();\n\n/**\n * 默认 key 来源:ctx.state.idempotencyKey。\n * Adapter(如 HTTP)应当从 header `Idempotency-Key` 读出后写入此字段。\n *\n * 例:HTTP Adapter 可在 buildHttpContext 之后加:\n * ```ts\n * const k = req.headers['idempotency-key'];\n * if (typeof k === 'string') ctx.state.idempotencyKey = k;\n * ```\n */\nconst defaultKeyFrom = (ctx: Context): string | undefined => {\n const k = (ctx.state as { idempotencyKey?: unknown }).idempotencyKey;\n return typeof k === 'string' && k.length > 0 ? k : undefined;\n};\n\n/**\n * 幂等键中间件 —— 让客户端重试不会造成业务重复执行。\n *\n * 工作流程:\n * 1. 取 key(默认 ctx.state.idempotencyKey)\n * 2. 命中 'complete' → 直接返回缓存结果,不跑 handler\n * 3. 命中 'in_progress' → 抛 409 ConflictError(请求正在处理中)\n * 4. 都没有 → 标记 in_progress,跑 handler,结果存为 'complete'\n * 5. handler 抛错 → 释放 key,让客户端重试时不会被永久卡住\n *\n * 通常挂在写操作(create/cancel/transfer)上。\n */\nexport function idempotent(options: IdempotencyOptions = {}): Middleware {\n const ttlMs = options.ttlMs ?? 5 * 60_000; // 5 min\n const store = options.store ?? defaultStore;\n const keyFrom = options.keyFrom ?? defaultKeyFrom;\n const required = options.required ?? false;\n\n return async (ctx, next) => {\n const procName = ctx.procedure?.name ?? 'unknown';\n const rawKey = keyFrom(ctx);\n\n if (!rawKey) {\n if (required) {\n throw new ConflictError('Idempotency-Key required for this operation', {\n code: 'IDEMPOTENCY_KEY_REQUIRED',\n });\n }\n return next();\n }\n\n // 加 procedure 名前缀,防止跨接口 key 冲突\n const key = `${procName}:${rawKey}`;\n const existing = await store.get(key);\n\n if (existing?.status === 'complete') {\n // 直接返回上次结果\n return existing.result;\n }\n if (existing?.status === 'in_progress') {\n throw new ConflictError('Request with same Idempotency-Key is in progress', {\n code: 'IDEMPOTENCY_IN_PROGRESS',\n });\n }\n\n // 抢占\n const acquired = await store.tryAcquire(key, ttlMs);\n if (!acquired) {\n // 高并发下输给别人,类似 in_progress\n throw new ConflictError('Concurrent request with same Idempotency-Key', {\n code: 'IDEMPOTENCY_RACE',\n });\n }\n\n try {\n const result = await next();\n await store.setComplete(key, result, ttlMs);\n return result;\n } catch (err) {\n // 失败释放,让客户端能用同一 key 重试\n await store.release(key);\n throw err;\n }\n };\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@omni-api/plugin-idempotency",
3
+ "version": "0.0.1",
4
+ "description": "Idempotency-Key middleware for OmniAPI: prevent duplicate writes from client retries",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "README.md"],
16
+ "scripts": {
17
+ "build": "tsup",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "typecheck": "tsc --noEmit",
21
+ "clean": "rm -rf dist .turbo coverage"
22
+ },
23
+ "dependencies": {
24
+ "@omni-api/core": "workspace:*"
25
+ },
26
+ "devDependencies": {
27
+ "tsup": "^8.3.0",
28
+ "typescript": "^5.7.0",
29
+ "vitest": "^2.1.0",
30
+ "zod": "^3.23.8",
31
+ "@types/node": "^22.10.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }