@hile/redis-idempotency 2.1.2

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,224 @@
1
+ # @hile/redis-idempotency
2
+
3
+ Redis-backed idempotency primitives for Hile services. The package name names the storage backend; the API names stay storage-neutral:
4
+
5
+ - `withIdempotency(redis, key, fn, options)` for functions, message handlers, jobs, and model `main()` bodies
6
+ - `idempotent(options)` for Koa-style `@hile/model` `Pipeline` middleware
7
+ - `stableHash(value)` for payload fingerprints
8
+
9
+ ## Why this package exists
10
+
11
+ Retries are normal in distributed systems. `Application.call()` may retry after timeout, queues may redeliver a message, and users may submit the same form twice. If the retried operation writes money, quota, notifications, or orders, duplicate execution becomes a real business bug.
12
+
13
+ This package provides a shared Redis state machine:
14
+
15
+ ```
16
+ FREE -> IN_FLIGHT(owner token) -> DONE(cached result)
17
+ ```
18
+
19
+ The state lives in a single Redis key. Lua scripts atomically acquire, read, commit, and release the key so callers across processes share the same view.
20
+
21
+ Important boundary: Redis idempotency is not exactly-once. If the business side effect succeeds and the process crashes before the `DONE` state is committed, a later retry may run the function again. For money, quota, orders, and notifications, keep database unique constraints, transactions, outbox records, or provider idempotency keys as the final wall.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pnpm add @hile/redis-idempotency @hile/ioredis
27
+ ```
28
+
29
+ The package accepts any Redis client matching `RedisLike`. In Hile apps, the usual client comes from `@hile/ioredis`:
30
+
31
+ ```typescript
32
+ import { loadService } from '@hile/core'
33
+ import redisService from '@hile/ioredis'
34
+
35
+ const redis = await loadService(redisService)
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```typescript
41
+ import { loadService } from '@hile/core'
42
+ import redisService from '@hile/ioredis'
43
+ import { stableHash, withIdempotency } from '@hile/redis-idempotency'
44
+
45
+ const redis = await loadService(redisService)
46
+
47
+ async function debitWallet(input: {
48
+ tenantId: string
49
+ requestId: string
50
+ amount: number
51
+ }) {
52
+ return withIdempotency(
53
+ redis,
54
+ `idem:prod:wallet:debit:${input.tenantId}:${input.requestId}`,
55
+ async () => {
56
+ // Put the side-effecting operation here.
57
+ return performDebit(input)
58
+ },
59
+ {
60
+ lockTtl: 60_000,
61
+ resultTtl: 24 * 60 * 60 * 1000,
62
+ fingerprint: stableHash(input),
63
+ },
64
+ )
65
+ }
66
+ ```
67
+
68
+ What happens:
69
+
70
+ 1. First caller writes `IN_FLIGHT` and runs `performDebit`.
71
+ 2. Concurrent callers with the same key and fingerprint wait for `DONE`.
72
+ 3. Later callers with the same key and fingerprint receive the cached result.
73
+ 4. Same key with a different fingerprint throws `IdempotencyPayloadMismatchError`.
74
+ 5. If `performDebit` throws, the `IN_FLIGHT` key is released so a retry can run.
75
+
76
+ ## API
77
+
78
+ ### withIdempotency(redis, key, fn, options)
79
+
80
+ ```typescript
81
+ function withIdempotency<T>(
82
+ redis: RedisLike,
83
+ key: string,
84
+ fn: () => Promise<T>,
85
+ options: IdempotencyOptions<T>,
86
+ ): Promise<T>
87
+ ```
88
+
89
+ `key` must already include the full namespace. A safe format is:
90
+
91
+ ```
92
+ idem:{env}:{service}:{operation}:{tenantId}:{businessId}
93
+ ```
94
+
95
+ Do not use transport message IDs as business keys. Prefer request IDs, order IDs, provider transaction IDs, or ledger IDs.
96
+
97
+ #### IdempotencyOptions
98
+
99
+ | Option | Required | Meaning |
100
+ |---|---:|---|
101
+ | `lockTtl` | yes | How long the `IN_FLIGHT` owner may run before Redis expires the key. Must exceed normal business execution time. |
102
+ | `resultTtl` | yes | How long the successful `DONE` result is cached. Must cover the retry/redelivery window. |
103
+ | `fingerprint` | yes | Stable hash of the payload. Prevents reusing one key for different requests. |
104
+ | `wait` | no | How long a concurrent caller waits for `DONE`. Defaults to `lockTtl`. |
105
+ | `onConflict` | no | `'wait'` or `'reject'`. Defaults to `'wait'`. |
106
+ | `pollInterval` | no | Initial polling delay in ms. Defaults to `20`. |
107
+ | `maxPollInterval` | no | Max polling delay in ms. Defaults to `500`. |
108
+ | `resultCodec` | no | Custom result serializer/deserializer for non-JSON return values. |
109
+
110
+ #### Result serialization
111
+
112
+ By default, cached results must be plain JSON values: `null`, strings, finite numbers, booleans, arrays, and plain objects. The package rejects `Date`, `Map`, `Set`, `BigInt`, functions, symbols, circular references, sparse arrays, class instances, and nested `undefined` values instead of caching a lossy value.
113
+
114
+ Use `resultCodec` when the function returns richer types:
115
+
116
+ ```typescript
117
+ const resultCodec = {
118
+ serialize: (value: { createdAt: Date }) => JSON.stringify({
119
+ createdAt: value.createdAt.toISOString(),
120
+ }),
121
+ deserialize: (raw: string) => {
122
+ const value = JSON.parse(raw) as { createdAt: string }
123
+ return { createdAt: new Date(value.createdAt) }
124
+ },
125
+ }
126
+
127
+ await withIdempotency(redis, key, createRecord, {
128
+ lockTtl: 60_000,
129
+ resultTtl: 86_400_000,
130
+ fingerprint,
131
+ resultCodec,
132
+ })
133
+ ```
134
+
135
+ ### stableHash(value)
136
+
137
+ ```typescript
138
+ const fingerprint = stableHash({
139
+ method: 'POST',
140
+ path: '/-/wallet/debit',
141
+ tenantId,
142
+ body,
143
+ })
144
+ ```
145
+
146
+ `stableHash` sorts plain object keys before hashing, so `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same fingerprint. It rejects unsupported object types, sparse arrays, and circular references instead of hashing them as `{}`; build fingerprints from plain request DTOs.
147
+
148
+ ### idempotent(options)
149
+
150
+ `idempotent()` is a Koa-style `PipelineMiddleware`. It wraps `await next()`, caches `ctx.state.result`, and writes cached results back to `ctx.state.result`.
151
+
152
+ ```typescript
153
+ import { Pipeline, PipelineContext } from '@hile/model'
154
+ import { idempotent, stableHash } from '@hile/redis-idempotency'
155
+
156
+ const pipeline = new Pipeline<{ tenantId: string; requestId: string }>()
157
+ pipeline.use(idempotent({
158
+ redis,
159
+ key: (input) => `idem:prod:wallet:debit:${input.tenantId}:${input.requestId}`,
160
+ fingerprint: stableHash,
161
+ lockTtl: 60_000,
162
+ resultTtl: 86_400_000,
163
+ }))
164
+ pipeline.use(async (ctx) => {
165
+ ctx.state.result = await performDebit(ctx.args)
166
+ })
167
+
168
+ const ctx = new PipelineContext({ tenantId: 't1', requestId: 'r1' })
169
+ await pipeline.dispatch(ctx)
170
+ console.log(ctx.state.result)
171
+ ```
172
+
173
+ Current `@hile/model@2.1.1` `defineModel()` returns a local `result` variable from its terminal middleware. That means a short-circuited middleware cannot make `model.handler()` return `ctx.state.result`. Until `@hile/model` returns `ctx.state.result`, prefer function-level `withIdempotency()` inside `main()`:
174
+
175
+ ```typescript
176
+ const debitModel = defineModel({
177
+ services: [redisService],
178
+ async main([redis], input: DebitInput) {
179
+ return withIdempotency(
180
+ redis,
181
+ `idem:prod:wallet:debit:${input.tenantId}:${input.requestId}`,
182
+ () => performDebit(input),
183
+ {
184
+ lockTtl: 60_000,
185
+ resultTtl: 86_400_000,
186
+ fingerprint: stableHash(input),
187
+ },
188
+ )
189
+ },
190
+ })
191
+ ```
192
+
193
+ ## Error Types
194
+
195
+ | Error | When it happens | Recommended handling |
196
+ |---|---|---|
197
+ | `IdempotencyConflictError` | Same key is already `IN_FLIGHT` and `onConflict: 'reject'` | Return 409 or let queue retry later |
198
+ | `IdempotencyTimeoutError` | Waited for `DONE` longer than `wait` | Retry with the same key |
199
+ | `IdempotencyPayloadMismatchError` | Same key was reused with a different fingerprint | Treat as caller bug or replay risk |
200
+ | `IdempotencyOwnershipLostError` | Owner finished after losing the Redis key | Investigate `lockTtl`; business side effect may have run |
201
+ | `IdempotencyRetryableError` | In-flight key disappeared before `DONE` was visible | Retry with the same key |
202
+ | `AggregateError` | The wrapped function failed and releasing the `IN_FLIGHT` key also failed | Inspect both `errors`; the key may remain until `lockTtl` |
203
+
204
+ ## TTL Guidance
205
+
206
+ | Scenario | `lockTtl` | `resultTtl` |
207
+ |---|---:|---:|
208
+ | HTTP form submit | max handler time + margin | 5-30 minutes |
209
+ | Internal `Application.call()` write | max handler time + margin | max retry window |
210
+ | Kafka / queue consumer | max handler time + margin | 24h or redelivery SLA |
211
+ | Payment/recharge callback | max handler time + margin | 24h+ or provider retry SLA |
212
+
213
+ If a job can run longer than a predictable `lockTtl`, do not use this package as-is for that job until lease renewal exists.
214
+
215
+ ## Testing
216
+
217
+ ```bash
218
+ pnpm --filter @hile/redis-idempotency test
219
+ pnpm --filter @hile/redis-idempotency build
220
+ ```
221
+
222
+ ## License
223
+
224
+ MIT
package/SKILL.md ADDED
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: redis-idempotency
3
+ description: Use when implementing duplicate-execution protection for Redis-backed Hile workflows, retries, queue consumers, message handlers, or write models.
4
+ ---
5
+
6
+ # Redis Idempotency
7
+
8
+ Use `@hile/redis-idempotency` when a write operation may be retried or redelivered and must not run side effects twice.
9
+
10
+ ## Core Rule
11
+
12
+ Package name names the backend; API names stay generic:
13
+
14
+ ```typescript
15
+ import { stableHash, withIdempotency } from '@hile/redis-idempotency'
16
+ ```
17
+
18
+ Wrap the smallest side-effecting function:
19
+
20
+ ```typescript
21
+ return withIdempotency(
22
+ redis,
23
+ `idem:prod:wallet:debit:${tenantId}:${requestId}`,
24
+ () => debitWallet(input),
25
+ {
26
+ lockTtl: 60_000,
27
+ resultTtl: 86_400_000,
28
+ fingerprint: stableHash(input),
29
+ },
30
+ )
31
+ ```
32
+
33
+ ## Key Design
34
+
35
+ | Do | Avoid |
36
+ |---|---|
37
+ | Use business keys: `tenantId + requestId`, `orderId`, `providerTxnId` | Transport IDs, random UUID per retry |
38
+ | Include environment/service/operation prefix | Global keys like `idem:${id}` |
39
+ | Bind payload with `fingerprint` | Same key for different bodies |
40
+
41
+ ## Serialization Boundaries
42
+
43
+ Build `stableHash()` fingerprints from plain DTOs. It rejects unsupported object types, sparse arrays, and circular references instead of silently treating `Map`, `Set`, or `RegExp` as `{}`.
44
+
45
+ Default cached results must be plain JSON values. If the wrapped function returns `Date`, `BigInt`, class instances, `Map` / `Set`, or any other rich type, pass `resultCodec` so cache hits deserialize to the same shape as the first call.
46
+
47
+ ## Current Model Guidance
48
+
49
+ Prefer function-level `withIdempotency()` inside `defineModel().main`. Current `@hile/model@2.1.1` does not return `ctx.state.result` on middleware short-circuit, so `idempotent()` is safe for raw `Pipeline` usage and future model versions that return `ctx.state.result`, but not for current `defineModel` cache hits.
50
+
51
+ ## Failure Policy
52
+
53
+ High-risk writes should fail closed when Redis is unavailable. Do not bypass idempotency for money, quota, recharge, order creation, or notifications unless there is a stronger business-level unique constraint.
54
+
55
+ If the business function fails and releasing the `IN_FLIGHT` key also fails, `withIdempotency()` throws `AggregateError`; inspect both `errors` entries and assume the key may remain until `lockTtl`.
56
+
57
+ ## Final Wall
58
+
59
+ Redis idempotency is not exactly-once. Keep DB unique constraints, transaction records, outbox rows, or provider idempotency keys for irreversible side effects.
@@ -0,0 +1,19 @@
1
+ export declare class IdempotencyError extends Error {
2
+ readonly key: string;
3
+ constructor(message: string, key: string);
4
+ }
5
+ export declare class IdempotencyConflictError extends IdempotencyError {
6
+ constructor(key: string);
7
+ }
8
+ export declare class IdempotencyTimeoutError extends IdempotencyError {
9
+ constructor(key: string);
10
+ }
11
+ export declare class IdempotencyPayloadMismatchError extends IdempotencyError {
12
+ constructor(key: string);
13
+ }
14
+ export declare class IdempotencyOwnershipLostError extends IdempotencyError {
15
+ constructor(key: string);
16
+ }
17
+ export declare class IdempotencyRetryableError extends IdempotencyError {
18
+ constructor(key: string);
19
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,33 @@
1
+ export class IdempotencyError extends Error {
2
+ key;
3
+ constructor(message, key) {
4
+ super(message);
5
+ this.key = key;
6
+ this.name = new.target.name;
7
+ }
8
+ }
9
+ export class IdempotencyConflictError extends IdempotencyError {
10
+ constructor(key) {
11
+ super(`Idempotency key is already in flight: ${key}`, key);
12
+ }
13
+ }
14
+ export class IdempotencyTimeoutError extends IdempotencyError {
15
+ constructor(key) {
16
+ super(`Timed out waiting for idempotency result: ${key}`, key);
17
+ }
18
+ }
19
+ export class IdempotencyPayloadMismatchError extends IdempotencyError {
20
+ constructor(key) {
21
+ super(`Idempotency key was reused with a different payload: ${key}`, key);
22
+ }
23
+ }
24
+ export class IdempotencyOwnershipLostError extends IdempotencyError {
25
+ constructor(key) {
26
+ super(`Idempotency owner lost the in-flight key before commit: ${key}`, key);
27
+ }
28
+ }
29
+ export class IdempotencyRetryableError extends IdempotencyError {
30
+ constructor(key) {
31
+ super(`Idempotency in-flight key disappeared before a result was available: ${key}`, key);
32
+ }
33
+ }
@@ -0,0 +1,5 @@
1
+ export { IdempotencyConflictError, IdempotencyError, IdempotencyOwnershipLostError, IdempotencyPayloadMismatchError, IdempotencyRetryableError, IdempotencyTimeoutError, } from './errors.js';
2
+ export { idempotent } from './middleware.js';
3
+ export { stableHash } from './stable-hash.js';
4
+ export { withIdempotency } from './with-idempotency.js';
5
+ export type { IdempotencyOptions, IdempotencyResultCodec, IdempotencyState, IdempotentMiddlewareOptions, JsonValue, RedisLike, StoredIdempotencyResult, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { IdempotencyConflictError, IdempotencyError, IdempotencyOwnershipLostError, IdempotencyPayloadMismatchError, IdempotencyRetryableError, IdempotencyTimeoutError, } from './errors.js';
2
+ export { idempotent } from './middleware.js';
3
+ export { stableHash } from './stable-hash.js';
4
+ export { withIdempotency } from './with-idempotency.js';
@@ -0,0 +1,3 @@
1
+ import type { PipelineMiddleware } from '@hile/model';
2
+ import type { IdempotentMiddlewareOptions } from './types';
3
+ export declare function idempotent<TInput extends object = Record<string, unknown>>(options: IdempotentMiddlewareOptions<TInput>): PipelineMiddleware<TInput>;
@@ -0,0 +1,21 @@
1
+ import { withIdempotency } from './with-idempotency.js';
2
+ function resolveFingerprint(fingerprint, input) {
3
+ return typeof fingerprint === 'function' ? fingerprint(input) : fingerprint;
4
+ }
5
+ export function idempotent(options) {
6
+ return async (ctx, next) => {
7
+ const result = await withIdempotency(options.redis, options.key(ctx.args), async () => {
8
+ await next();
9
+ return ctx.state.result;
10
+ }, {
11
+ lockTtl: options.lockTtl,
12
+ resultTtl: options.resultTtl,
13
+ wait: options.wait,
14
+ onConflict: options.onConflict,
15
+ pollInterval: options.pollInterval,
16
+ maxPollInterval: options.maxPollInterval,
17
+ fingerprint: resolveFingerprint(options.fingerprint, ctx.args),
18
+ });
19
+ ctx.state.result = result;
20
+ };
21
+ }
@@ -0,0 +1,3 @@
1
+ export declare const ACQUIRE_OR_READ = "\n-- ACQUIRE_OR_READ\nlocal raw = redis.call('GET', KEYS[1])\nif not raw then\n redis.call('SET', KEYS[1], ARGV[3], 'PX', ARGV[4])\n return { 'ACQUIRED' }\nend\n\nlocal value = cjson.decode(raw)\nif value.fingerprint ~= ARGV[2] then\n return { 'MISMATCH' }\nend\nif value.state == 'DONE' then\n return { 'CACHED', raw }\nend\nreturn { 'IN_FLIGHT' }\n";
2
+ export declare const COMMIT_IF_OWNER = "\n-- COMMIT_IF_OWNER\nlocal raw = redis.call('GET', KEYS[1])\nif not raw then return 0 end\nlocal value = cjson.decode(raw)\nif value.state == 'IN_FLIGHT' and value.token == ARGV[1] then\n redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])\n return 1\nend\nreturn 0\n";
3
+ export declare const RELEASE_IF_OWNER = "\n-- RELEASE_IF_OWNER\nlocal raw = redis.call('GET', KEYS[1])\nif not raw then return 0 end\nlocal value = cjson.decode(raw)\nif value.state == 'IN_FLIGHT' and value.token == ARGV[1] then\n return redis.call('DEL', KEYS[1])\nend\nreturn 0\n";
@@ -0,0 +1,38 @@
1
+ export const ACQUIRE_OR_READ = `
2
+ -- ACQUIRE_OR_READ
3
+ local raw = redis.call('GET', KEYS[1])
4
+ if not raw then
5
+ redis.call('SET', KEYS[1], ARGV[3], 'PX', ARGV[4])
6
+ return { 'ACQUIRED' }
7
+ end
8
+
9
+ local value = cjson.decode(raw)
10
+ if value.fingerprint ~= ARGV[2] then
11
+ return { 'MISMATCH' }
12
+ end
13
+ if value.state == 'DONE' then
14
+ return { 'CACHED', raw }
15
+ end
16
+ return { 'IN_FLIGHT' }
17
+ `;
18
+ export const COMMIT_IF_OWNER = `
19
+ -- COMMIT_IF_OWNER
20
+ local raw = redis.call('GET', KEYS[1])
21
+ if not raw then return 0 end
22
+ local value = cjson.decode(raw)
23
+ if value.state == 'IN_FLIGHT' and value.token == ARGV[1] then
24
+ redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
25
+ return 1
26
+ end
27
+ return 0
28
+ `;
29
+ export const RELEASE_IF_OWNER = `
30
+ -- RELEASE_IF_OWNER
31
+ local raw = redis.call('GET', KEYS[1])
32
+ if not raw then return 0 end
33
+ local value = cjson.decode(raw)
34
+ if value.state == 'IN_FLIGHT' and value.token == ARGV[1] then
35
+ return redis.call('DEL', KEYS[1])
36
+ end
37
+ return 0
38
+ `;
@@ -0,0 +1 @@
1
+ export declare function stableHash(value: unknown): string;
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto';
2
+ function isPlainObject(value) {
3
+ if (typeof value !== 'object' || value === null)
4
+ return false;
5
+ const prototype = Object.getPrototypeOf(value);
6
+ return prototype === Object.prototype || prototype === null;
7
+ }
8
+ function stableStringify(value, seen = new WeakSet()) {
9
+ if (value === undefined)
10
+ return 'undefined';
11
+ if (value === null)
12
+ return 'null';
13
+ const type = typeof value;
14
+ if (type === 'string')
15
+ return `string:${JSON.stringify(value)}`;
16
+ if (type === 'number') {
17
+ if (Number.isNaN(value))
18
+ return 'number:NaN';
19
+ if (Object.is(value, -0))
20
+ return 'number:-0';
21
+ return `number:${String(value)}`;
22
+ }
23
+ if (type === 'boolean')
24
+ return `boolean:${String(value)}`;
25
+ if (type === 'bigint')
26
+ return `bigint:${String(value)}`;
27
+ if (type === 'symbol' || type === 'function') {
28
+ throw new TypeError(`stableHash does not support ${type} values`);
29
+ }
30
+ if (typeof value !== 'object') {
31
+ return `${type}:${String(value)}`;
32
+ }
33
+ if (seen.has(value)) {
34
+ throw new TypeError('stableHash does not support circular values');
35
+ }
36
+ seen.add(value);
37
+ if (value instanceof Date) {
38
+ if (Number.isNaN(value.getTime()))
39
+ throw new TypeError('stableHash does not support invalid Date values');
40
+ seen.delete(value);
41
+ return `date:${value.toISOString()}`;
42
+ }
43
+ if (Buffer.isBuffer(value)) {
44
+ seen.delete(value);
45
+ return `buffer:${value.toString('base64')}`;
46
+ }
47
+ if (Array.isArray(value)) {
48
+ const items = [];
49
+ for (let i = 0; i < value.length; i += 1) {
50
+ if (!(i in value))
51
+ throw new TypeError('stableHash does not support sparse arrays');
52
+ items.push(stableStringify(value[i], seen));
53
+ }
54
+ const result = `array:[${items.join(',')}]`;
55
+ seen.delete(value);
56
+ return result;
57
+ }
58
+ if (!isPlainObject(value)) {
59
+ seen.delete(value);
60
+ throw new TypeError(`stableHash does not support ${Object.prototype.toString.call(value)} values`);
61
+ }
62
+ const object = value;
63
+ const keys = Object.keys(object).sort();
64
+ const result = `object:{${keys.map(key => `${JSON.stringify(key)}:${stableStringify(object[key], seen)}`).join(',')}}`;
65
+ seen.delete(value);
66
+ return result;
67
+ }
68
+ export function stableHash(value) {
69
+ return createHash('sha256').update(stableStringify(value)).digest('hex');
70
+ }
@@ -0,0 +1,49 @@
1
+ export type RedisEvalResult = unknown;
2
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
3
+ [key: string]: JsonValue;
4
+ };
5
+ export type StoredIdempotencyResult = {
6
+ encoding: 'undefined';
7
+ } | {
8
+ encoding: 'json';
9
+ value: JsonValue;
10
+ } | {
11
+ encoding: 'custom';
12
+ value: string;
13
+ };
14
+ export interface RedisLike {
15
+ get(key: string): Promise<string | null>;
16
+ set(key: string, value: string, px: 'PX', ttl: number): Promise<unknown>;
17
+ del(key: string): Promise<unknown>;
18
+ eval(script: string, numberOfKeys: number, key: string, ...args: Array<string | number>): Promise<RedisEvalResult>;
19
+ }
20
+ export interface IdempotencyResultCodec<T = unknown> {
21
+ serialize(value: T): string;
22
+ deserialize(value: string): T;
23
+ }
24
+ export interface IdempotencyOptions<T = unknown> {
25
+ lockTtl: number;
26
+ resultTtl: number;
27
+ fingerprint: string;
28
+ wait?: number;
29
+ onConflict?: 'wait' | 'reject';
30
+ pollInterval?: number;
31
+ maxPollInterval?: number;
32
+ resultCodec?: IdempotencyResultCodec<T>;
33
+ }
34
+ export interface IdempotentMiddlewareOptions<TInput extends object = Record<string, unknown>> extends Omit<IdempotencyOptions, 'fingerprint'> {
35
+ redis: RedisLike;
36
+ key: (input: TInput) => string;
37
+ fingerprint: string | ((input: TInput) => string);
38
+ }
39
+ export type IdempotencyState<T = unknown> = {
40
+ state: 'IN_FLIGHT';
41
+ token: string;
42
+ fingerprint: string;
43
+ startedAt: number;
44
+ } | {
45
+ state: 'DONE';
46
+ fingerprint: string;
47
+ data: StoredIdempotencyResult;
48
+ finishedAt: number;
49
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { IdempotencyOptions, RedisLike } from './types';
2
+ export declare function withIdempotency<T>(redis: RedisLike, key: string, fn: () => Promise<T>, options: IdempotencyOptions<T>): Promise<T>;
@@ -0,0 +1,213 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { IdempotencyConflictError, IdempotencyOwnershipLostError, IdempotencyPayloadMismatchError, IdempotencyRetryableError, IdempotencyTimeoutError, } from './errors.js';
3
+ import { ACQUIRE_OR_READ, COMMIT_IF_OWNER, RELEASE_IF_OWNER } from './scripts.js';
4
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
5
+ function assertPositiveInteger(value, name) {
6
+ if (!Number.isFinite(value) || value <= 0 || Math.trunc(value) !== value) {
7
+ throw new TypeError(`${name} must be a positive integer`);
8
+ }
9
+ }
10
+ function isPlainObject(value) {
11
+ if (typeof value !== 'object' || value === null)
12
+ return false;
13
+ const prototype = Object.getPrototypeOf(value);
14
+ return prototype === Object.prototype || prototype === null;
15
+ }
16
+ function resultSerializationError(path, reason) {
17
+ return new TypeError(`Idempotency result must be JSON-serializable at ${path}: ${reason}. Provide resultCodec for custom result types.`);
18
+ }
19
+ function toJsonValue(value, path = 'result', seen = new WeakSet()) {
20
+ if (value === null)
21
+ return null;
22
+ if (typeof value === 'string' || typeof value === 'boolean')
23
+ return value;
24
+ if (typeof value === 'number') {
25
+ if (!Number.isFinite(value))
26
+ throw resultSerializationError(path, 'non-finite numbers are not JSON values');
27
+ return value;
28
+ }
29
+ const type = typeof value;
30
+ if (type === 'undefined')
31
+ throw resultSerializationError(path, 'undefined is only supported as the top-level result');
32
+ if (type === 'bigint' || type === 'symbol' || type === 'function') {
33
+ throw resultSerializationError(path, `${type} is not a JSON value`);
34
+ }
35
+ if (typeof value !== 'object') {
36
+ throw resultSerializationError(path, `${type} is not a JSON value`);
37
+ }
38
+ if (seen.has(value)) {
39
+ throw resultSerializationError(path, 'circular references are not JSON values');
40
+ }
41
+ seen.add(value);
42
+ if (Array.isArray(value)) {
43
+ const array = [];
44
+ for (let i = 0; i < value.length; i += 1) {
45
+ if (!(i in value))
46
+ throw resultSerializationError(`${path}[${i}]`, 'sparse arrays are not JSON values');
47
+ array.push(toJsonValue(value[i], `${path}[${i}]`, seen));
48
+ }
49
+ seen.delete(value);
50
+ return array;
51
+ }
52
+ if (!isPlainObject(value)) {
53
+ seen.delete(value);
54
+ throw resultSerializationError(path, `${Object.prototype.toString.call(value)} is not a plain JSON object`);
55
+ }
56
+ const symbols = Object.getOwnPropertySymbols(value);
57
+ if (symbols.length > 0) {
58
+ seen.delete(value);
59
+ throw resultSerializationError(path, 'symbol keys are not JSON values');
60
+ }
61
+ const object = {};
62
+ for (const key of Object.keys(value)) {
63
+ object[key] = toJsonValue(value[key], `${path}.${key}`, seen);
64
+ }
65
+ seen.delete(value);
66
+ return object;
67
+ }
68
+ function encodeResult(value, codec) {
69
+ if (codec) {
70
+ const encoded = codec.serialize(value);
71
+ if (typeof encoded !== 'string') {
72
+ throw new TypeError('resultCodec.serialize must return a string');
73
+ }
74
+ return { encoding: 'custom', value: encoded };
75
+ }
76
+ if (value === undefined)
77
+ return { encoding: 'undefined' };
78
+ return { encoding: 'json', value: toJsonValue(value) };
79
+ }
80
+ function decodeResult(stored, codec) {
81
+ if (stored.encoding === 'undefined')
82
+ return undefined;
83
+ if (stored.encoding === 'json')
84
+ return stored.value;
85
+ if (stored.encoding === 'custom') {
86
+ if (!codec) {
87
+ throw new TypeError('Cached idempotency result requires resultCodec to deserialize');
88
+ }
89
+ return codec.deserialize(stored.value);
90
+ }
91
+ throw new Error('Invalid cached idempotency result');
92
+ }
93
+ function assertStoredResult(value) {
94
+ if (typeof value !== 'object' || value === null || !('encoding' in value)) {
95
+ throw new Error('Invalid cached idempotency result');
96
+ }
97
+ const stored = value;
98
+ if (stored.encoding === 'undefined')
99
+ return stored;
100
+ if (stored.encoding === 'json' && 'value' in stored)
101
+ return stored;
102
+ if (stored.encoding === 'custom' && typeof stored.value === 'string')
103
+ return stored;
104
+ throw new Error('Invalid cached idempotency result');
105
+ }
106
+ function normalizeEvalArray(value) {
107
+ if (!Array.isArray(value)) {
108
+ throw new Error('Invalid Redis idempotency script result');
109
+ }
110
+ return value;
111
+ }
112
+ function parseState(raw) {
113
+ return JSON.parse(raw);
114
+ }
115
+ async function acquireOrRead(redis, key, token, fingerprint, lockTtl, resultCodec) {
116
+ const inFlight = {
117
+ state: 'IN_FLIGHT',
118
+ token,
119
+ fingerprint,
120
+ startedAt: Date.now(),
121
+ };
122
+ const result = normalizeEvalArray(await redis.eval(ACQUIRE_OR_READ, 1, key, token, fingerprint, JSON.stringify(inFlight), lockTtl));
123
+ const status = result[0];
124
+ if (status === 'ACQUIRED')
125
+ return { type: 'acquired' };
126
+ if (status === 'MISMATCH')
127
+ return { type: 'mismatch' };
128
+ if (status === 'IN_FLIGHT')
129
+ return { type: 'in-flight' };
130
+ if (status === 'CACHED') {
131
+ const raw = result[1];
132
+ if (typeof raw !== 'string')
133
+ throw new Error('Invalid cached idempotency state');
134
+ const state = parseState(raw);
135
+ if (state.state !== 'DONE')
136
+ throw new Error('Invalid cached idempotency state');
137
+ return { type: 'cached', data: decodeResult(assertStoredResult(state.data), resultCodec) };
138
+ }
139
+ throw new Error(`Unknown idempotency script status: ${String(status)}`);
140
+ }
141
+ async function commitIfOwner(redis, key, token, fingerprint, result, resultTtl, resultCodec) {
142
+ const done = {
143
+ state: 'DONE',
144
+ fingerprint,
145
+ data: encodeResult(result, resultCodec),
146
+ finishedAt: Date.now(),
147
+ };
148
+ const committed = await redis.eval(COMMIT_IF_OWNER, 1, key, token, JSON.stringify(done), resultTtl);
149
+ return committed === 1;
150
+ }
151
+ async function releaseIfOwner(redis, key, token) {
152
+ await redis.eval(RELEASE_IF_OWNER, 1, key, token);
153
+ }
154
+ async function readState(redis, key) {
155
+ const raw = await redis.get(key);
156
+ if (!raw)
157
+ return null;
158
+ return parseState(raw);
159
+ }
160
+ async function waitForResult(redis, key, fingerprint, timeout, pollInterval, maxPollInterval, resultCodec) {
161
+ const deadline = Date.now() + timeout;
162
+ let delay = pollInterval;
163
+ while (Date.now() < deadline) {
164
+ const state = await readState(redis, key);
165
+ if (!state)
166
+ throw new IdempotencyRetryableError(key);
167
+ if (state.fingerprint !== fingerprint)
168
+ throw new IdempotencyPayloadMismatchError(key);
169
+ if (state.state === 'DONE')
170
+ return decodeResult(assertStoredResult(state.data), resultCodec);
171
+ await sleep(delay);
172
+ delay = Math.min(delay * 2, maxPollInterval);
173
+ }
174
+ throw new IdempotencyTimeoutError(key);
175
+ }
176
+ export async function withIdempotency(redis, key, fn, options) {
177
+ assertPositiveInteger(options.lockTtl, 'lockTtl');
178
+ assertPositiveInteger(options.resultTtl, 'resultTtl');
179
+ if (!options.fingerprint)
180
+ throw new TypeError('fingerprint is required');
181
+ const { lockTtl, resultTtl, fingerprint, wait = lockTtl, onConflict = 'wait', pollInterval = 20, maxPollInterval = 500, resultCodec, } = options;
182
+ assertPositiveInteger(wait, 'wait');
183
+ assertPositiveInteger(pollInterval, 'pollInterval');
184
+ assertPositiveInteger(maxPollInterval, 'maxPollInterval');
185
+ const token = randomUUID();
186
+ const acquired = await acquireOrRead(redis, key, token, fingerprint, lockTtl, resultCodec);
187
+ if (acquired.type === 'cached')
188
+ return acquired.data;
189
+ if (acquired.type === 'mismatch')
190
+ throw new IdempotencyPayloadMismatchError(key);
191
+ if (acquired.type === 'acquired') {
192
+ let result;
193
+ try {
194
+ result = await fn();
195
+ }
196
+ catch (err) {
197
+ try {
198
+ await releaseIfOwner(redis, key, token);
199
+ }
200
+ catch (releaseErr) {
201
+ throw new AggregateError([err, releaseErr], 'Idempotency operation failed and releasing the in-flight key also failed');
202
+ }
203
+ throw err;
204
+ }
205
+ const committed = await commitIfOwner(redis, key, token, fingerprint, result, resultTtl, resultCodec);
206
+ if (!committed)
207
+ throw new IdempotencyOwnershipLostError(key);
208
+ return result;
209
+ }
210
+ if (onConflict === 'reject')
211
+ throw new IdempotencyConflictError(key);
212
+ return waitForResult(redis, key, fingerprint, wait, pollInterval, maxPollInterval, resultCodec);
213
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@hile/redis-idempotency",
3
+ "version": "2.1.2",
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/model": "^2.1.1"
26
+ },
27
+ "gitHead": "93da114ef293e48682e7df94d89418dfbf9a0226"
28
+ }