@hile/redis-idempotency 3.0.1 → 3.0.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 CHANGED
@@ -2,21 +2,24 @@
2
2
 
3
3
  Redis-backed idempotency primitives for Hile services. The package name names the storage backend; the API names stay storage-neutral:
4
4
 
5
+ - `new RedisIdempotency(redis).run(key, fn, options)` for class-first service composition
5
6
  - `withIdempotency(redis, key, fn, options)` for functions, message handlers, jobs, and model `main()` bodies
6
7
  - `idempotent(options)` for Koa-style `@hile/model` `Pipeline` middleware
7
8
  - `stableHash(value)` for payload fingerprints
8
9
 
10
+ 从 3.0.0 开始,新增或重构的 Hile 架构包统一进入 3.x 版本线,2.x 时代结束。
11
+
9
12
  ## Why this package exists
10
13
 
11
14
  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
15
 
13
- This package provides a shared Redis state machine:
16
+ This package provides a shared Redis state machine on top of `@hile/redis-lock`:
14
17
 
15
18
  ```
16
19
  FREE -> IN_FLIGHT(owner token) -> DONE(cached result)
17
20
  ```
18
21
 
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.
22
+ The idempotency state lives in the idempotency key, while execution ownership is guarded by a Redis lease lock. Lua scripts still commit and clear the business state only when the caller owns the underlying lock.
20
23
 
21
24
  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
25
 
@@ -40,17 +43,17 @@ const redis = await loadService(redisService)
40
43
  ```typescript
41
44
  import { loadService } from '@hile/core'
42
45
  import redisService from '@hile/ioredis'
43
- import { stableHash, withIdempotency } from '@hile/redis-idempotency'
46
+ import { RedisIdempotency, stableHash } from '@hile/redis-idempotency'
44
47
 
45
48
  const redis = await loadService(redisService)
49
+ const idempotency = new RedisIdempotency(redis)
46
50
 
47
51
  async function debitWallet(input: {
48
52
  tenantId: string
49
53
  requestId: string
50
54
  amount: number
51
55
  }) {
52
- return withIdempotency(
53
- redis,
56
+ return idempotency.run(
54
57
  `idem:prod:wallet:debit:${input.tenantId}:${input.requestId}`,
55
58
  async () => {
56
59
  // Put the side-effecting operation here.
@@ -77,13 +80,22 @@ What happens:
77
80
 
78
81
  ### withIdempotency(redis, key, fn, options)
79
82
 
83
+ `withIdempotency()` 是兼容函数,内部会创建 `RedisIdempotency` 实例并调用 `.run()`。新代码更推荐在服务层复用 `RedisIdempotency` 实例。
84
+
85
+ ### RedisIdempotency
86
+
80
87
  ```typescript
81
- function withIdempotency<T>(
82
- redis: RedisLike,
83
- key: string,
84
- fn: () => Promise<T>,
85
- options: IdempotencyOptions<T>,
86
- ): Promise<T>
88
+ const idempotency = new RedisIdempotency(redis)
89
+
90
+ await idempotency.run(
91
+ 'idem:prod:wallet:debit:t1:r1',
92
+ async () => performDebit(input),
93
+ {
94
+ lockTtl: 60_000,
95
+ resultTtl: 86_400_000,
96
+ fingerprint,
97
+ },
98
+ )
87
99
  ```
88
100
 
89
101
  `key` must already include the full namespace. A safe format is:
@@ -148,6 +160,7 @@ const fingerprint = stableHash({
148
160
  ### idempotent(options)
149
161
 
150
162
  `idempotent()` is a Koa-style `PipelineMiddleware`. It wraps `await next()`, caches `ctx.state.result`, and writes cached results back to `ctx.state.result`.
163
+ Pass `resultCodec` when `ctx.state.result` contains non-JSON types; the middleware forwards it to the underlying `RedisIdempotency` run.
151
164
 
152
165
  ```typescript
153
166
  import { Pipeline, PipelineContext } from '@hile/model'
@@ -213,7 +226,7 @@ For normal Hile models that load Redis through `services: [redisService]`, or fo
213
226
  | Kafka / queue consumer | max handler time + margin | 24h or redelivery SLA |
214
227
  | Payment/recharge callback | max handler time + margin | 24h+ or provider retry SLA |
215
228
 
216
- If a job can run longer than a predictable `lockTtl`, do not use this package as-is for that job until lease renewal exists.
229
+ If a job can run longer than a predictable `lockTtl`, do not use this package as-is for that job until the idempotency layer exposes lease renewal.
217
230
 
218
231
  ## Testing
219
232
 
package/SKILL.md CHANGED
@@ -12,14 +12,15 @@ Use `@hile/redis-idempotency` when a write operation may be retried or redeliver
12
12
  Package name names the backend; API names stay generic:
13
13
 
14
14
  ```typescript
15
- import { stableHash, withIdempotency } from '@hile/redis-idempotency'
15
+ import { RedisIdempotency, stableHash } from '@hile/redis-idempotency'
16
16
  ```
17
17
 
18
18
  Wrap the smallest side-effecting function:
19
19
 
20
20
  ```typescript
21
- return withIdempotency(
22
- redis,
21
+ const idempotency = new RedisIdempotency(redis)
22
+
23
+ return idempotency.run(
23
24
  `idem:prod:wallet:debit:${tenantId}:${requestId}`,
24
25
  () => debitWallet(input),
25
26
  {
@@ -30,6 +31,12 @@ return withIdempotency(
30
31
  )
31
32
  ```
32
33
 
34
+ `withIdempotency(redis, key, fn, options)` remains as a compatibility wrapper around `RedisIdempotency.run()`.
35
+
36
+ ## Redis Lock Base
37
+
38
+ `@hile/redis-idempotency` is a higher-level abstraction over `@hile/redis-lock`: use the lock package for execution ownership and waiting, and keep idempotency-specific `IN_FLIGHT` / `DONE` state in this package.
39
+
33
40
  ## Key Design
34
41
 
35
42
  | Do | Avoid |
@@ -0,0 +1,17 @@
1
+ import { RedisLock } from '@hile/redis-lock';
2
+ import type { IdempotencyOptions, IdempotencyResultCodec, RedisLike } from './types';
3
+ export interface RedisIdempotencyDependencies {
4
+ locks?: RedisLock;
5
+ }
6
+ export declare class RedisIdempotency {
7
+ private readonly locks;
8
+ private readonly store;
9
+ constructor(redis: RedisLike, dependencies?: RedisIdempotencyDependencies);
10
+ run<T>(key: string, fn: () => Promise<T>, options: IdempotencyOptions<T>): Promise<T>;
11
+ private makeLockKey;
12
+ private handleInFlight;
13
+ private runAsOwner;
14
+ private waitForResult;
15
+ private mapLockError;
16
+ }
17
+ export type RedisIdempotencyResultCodec<T = unknown> = IdempotencyResultCodec<T>;
@@ -0,0 +1,137 @@
1
+ import { LockConflictError, LockOwnershipLostError, LockTimeoutError, RedisLock, } from '@hile/redis-lock';
2
+ import { IdempotencyConflictError, IdempotencyOwnershipLostError, IdempotencyPayloadMismatchError, IdempotencyRetryableError, IdempotencyTimeoutError, } from './errors.js';
3
+ import { RedisIdempotencyStore } from './store.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 resolveOptions(options) {
11
+ assertPositiveInteger(options.lockTtl, 'lockTtl');
12
+ assertPositiveInteger(options.resultTtl, 'resultTtl');
13
+ if (!options.fingerprint)
14
+ throw new TypeError('fingerprint is required');
15
+ const resolved = {
16
+ ...options,
17
+ wait: options.wait ?? options.lockTtl,
18
+ onConflict: options.onConflict ?? 'wait',
19
+ pollInterval: options.pollInterval ?? 20,
20
+ maxPollInterval: options.maxPollInterval ?? 500,
21
+ };
22
+ assertPositiveInteger(resolved.wait, 'wait');
23
+ assertPositiveInteger(resolved.pollInterval, 'pollInterval');
24
+ assertPositiveInteger(resolved.maxPollInterval, 'maxPollInterval');
25
+ return resolved;
26
+ }
27
+ export class RedisIdempotency {
28
+ locks;
29
+ store;
30
+ constructor(redis, dependencies = {}) {
31
+ this.locks = dependencies.locks ?? new RedisLock(redis);
32
+ this.store = new RedisIdempotencyStore(redis);
33
+ }
34
+ async run(key, fn, options) {
35
+ const resolved = resolveOptions(options);
36
+ const context = {
37
+ key,
38
+ lockKey: this.makeLockKey(key),
39
+ fn,
40
+ options: resolved,
41
+ };
42
+ const existing = await this.store.read(key, resolved.fingerprint, resolved.resultCodec);
43
+ if (existing.type === 'cached')
44
+ return existing.data;
45
+ if (existing.type === 'mismatch')
46
+ throw new IdempotencyPayloadMismatchError(key);
47
+ if (existing.type === 'in-flight')
48
+ return this.handleInFlight(context);
49
+ try {
50
+ return await this.locks.withLock(context.lockKey, { ttl: resolved.lockTtl, wait: 0 }, lease => this.runAsOwner(context, lease, false));
51
+ }
52
+ catch (err) {
53
+ if (!(err instanceof LockConflictError))
54
+ throw this.mapLockError(key, err);
55
+ if (resolved.onConflict === 'reject')
56
+ throw new IdempotencyConflictError(key);
57
+ }
58
+ try {
59
+ return await this.locks.withLock(context.lockKey, {
60
+ ttl: resolved.lockTtl,
61
+ wait: resolved.wait,
62
+ pollInterval: resolved.pollInterval,
63
+ maxPollInterval: resolved.maxPollInterval,
64
+ }, lease => this.runAsOwner(context, lease, true));
65
+ }
66
+ catch (err) {
67
+ throw this.mapLockError(key, err);
68
+ }
69
+ }
70
+ makeLockKey(key) {
71
+ return `${key}:lock`;
72
+ }
73
+ async handleInFlight(context) {
74
+ if (context.options.onConflict === 'reject') {
75
+ throw new IdempotencyConflictError(context.key);
76
+ }
77
+ return this.waitForResult(context);
78
+ }
79
+ async runAsOwner(context, lease, contended) {
80
+ const current = await this.store.read(context.key, context.options.fingerprint, context.options.resultCodec);
81
+ if (current.type === 'cached')
82
+ return current.data;
83
+ if (current.type === 'mismatch')
84
+ throw new IdempotencyPayloadMismatchError(context.key);
85
+ if (current.type === 'in-flight')
86
+ return this.handleInFlight(context);
87
+ if (contended)
88
+ throw new IdempotencyRetryableError(context.key);
89
+ await this.store.markInFlight(context.key, lease.token, context.options.fingerprint, context.options.lockTtl);
90
+ let result;
91
+ try {
92
+ result = await context.fn();
93
+ }
94
+ catch (err) {
95
+ let cleared;
96
+ try {
97
+ cleared = await this.store.clearInFlightIfLockOwner(context.key, lease.key, lease.token);
98
+ }
99
+ catch (releaseErr) {
100
+ throw new AggregateError([err, releaseErr], 'Idempotency operation failed and clearing the in-flight key also failed');
101
+ }
102
+ if (!cleared) {
103
+ throw new AggregateError([err, new IdempotencyOwnershipLostError(context.key)], 'Idempotency operation failed and clearing the in-flight key also failed');
104
+ }
105
+ throw err;
106
+ }
107
+ const committed = await this.store.commitDoneIfLockOwner(context.key, lease.key, lease.token, context.options.fingerprint, result, context.options.resultTtl, context.options.resultCodec);
108
+ if (!committed)
109
+ throw new IdempotencyOwnershipLostError(context.key);
110
+ return result;
111
+ }
112
+ async waitForResult(context) {
113
+ const deadline = Date.now() + context.options.wait;
114
+ let delay = context.options.pollInterval;
115
+ while (Date.now() < deadline) {
116
+ const state = await this.store.read(context.key, context.options.fingerprint, context.options.resultCodec);
117
+ if (state.type === 'empty')
118
+ throw new IdempotencyRetryableError(context.key);
119
+ if (state.type === 'mismatch')
120
+ throw new IdempotencyPayloadMismatchError(context.key);
121
+ if (state.type === 'cached')
122
+ return state.data;
123
+ await sleep(Math.min(delay, Math.max(0, deadline - Date.now())));
124
+ delay = Math.min(delay * 2, context.options.maxPollInterval);
125
+ }
126
+ throw new IdempotencyTimeoutError(context.key);
127
+ }
128
+ mapLockError(key, err) {
129
+ if (err instanceof LockConflictError)
130
+ return new IdempotencyConflictError(key);
131
+ if (err instanceof LockTimeoutError)
132
+ return new IdempotencyTimeoutError(key);
133
+ if (err instanceof LockOwnershipLostError)
134
+ return new IdempotencyOwnershipLostError(key);
135
+ return err;
136
+ }
137
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { IdempotencyConflictError, IdempotencyError, IdempotencyOwnershipLostError, IdempotencyPayloadMismatchError, IdempotencyRetryableError, IdempotencyTimeoutError, } from './errors.js';
2
+ export { RedisIdempotency } from './idempotency.js';
2
3
  export { idempotent } from './middleware.js';
3
4
  export { stableHash } from './stable-hash.js';
4
5
  export { withIdempotency } from './with-idempotency.js';
5
6
  export type { IdempotencyOptions, IdempotencyResultCodec, IdempotencyState, IdempotentMiddlewareOptions, JsonValue, RedisLike, StoredIdempotencyResult, } from './types.js';
7
+ export type { RedisIdempotencyDependencies } from './idempotency.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { IdempotencyConflictError, IdempotencyError, IdempotencyOwnershipLostError, IdempotencyPayloadMismatchError, IdempotencyRetryableError, IdempotencyTimeoutError, } from './errors.js';
2
+ export { RedisIdempotency } from './idempotency.js';
2
3
  export { idempotent } from './middleware.js';
3
4
  export { stableHash } from './stable-hash.js';
4
5
  export { withIdempotency } from './with-idempotency.js';
@@ -1,3 +1,3 @@
1
1
  import type { PipelineMiddleware } from '@hile/model';
2
2
  import type { IdempotentMiddlewareOptions } from './types';
3
- export declare function idempotent<TInput extends object = Record<string, unknown>>(options: IdempotentMiddlewareOptions<TInput>): PipelineMiddleware<TInput>;
3
+ export declare function idempotent<TInput extends object = Record<string, unknown>, TResult = unknown>(options: IdempotentMiddlewareOptions<TInput, TResult>): PipelineMiddleware<TInput>;
@@ -15,6 +15,7 @@ export function idempotent(options) {
15
15
  pollInterval: options.pollInterval,
16
16
  maxPollInterval: options.maxPollInterval,
17
17
  fingerprint: resolveFingerprint(options.fingerprint, ctx.args),
18
+ resultCodec: options.resultCodec,
18
19
  });
19
20
  ctx.state.result = result;
20
21
  };
@@ -0,0 +1,4 @@
1
+ import type { IdempotencyResultCodec, StoredIdempotencyResult } from './types';
2
+ export declare function encodeResult<T>(value: T, codec?: IdempotencyResultCodec<T>): StoredIdempotencyResult;
3
+ export declare function decodeResult<T>(stored: StoredIdempotencyResult, codec?: IdempotencyResultCodec<T>): T;
4
+ export declare function assertStoredResult(value: unknown): StoredIdempotencyResult;
@@ -0,0 +1,96 @@
1
+ function isPlainObject(value) {
2
+ if (typeof value !== 'object' || value === null)
3
+ return false;
4
+ const prototype = Object.getPrototypeOf(value);
5
+ return prototype === Object.prototype || prototype === null;
6
+ }
7
+ function resultSerializationError(path, reason) {
8
+ return new TypeError(`Idempotency result must be JSON-serializable at ${path}: ${reason}. Provide resultCodec for custom result types.`);
9
+ }
10
+ function toJsonValue(value, path = 'result', seen = new WeakSet()) {
11
+ if (value === null)
12
+ return null;
13
+ if (typeof value === 'string' || typeof value === 'boolean')
14
+ return value;
15
+ if (typeof value === 'number') {
16
+ if (!Number.isFinite(value))
17
+ throw resultSerializationError(path, 'non-finite numbers are not JSON values');
18
+ return value;
19
+ }
20
+ const type = typeof value;
21
+ if (type === 'undefined')
22
+ throw resultSerializationError(path, 'undefined is only supported as the top-level result');
23
+ if (type === 'bigint' || type === 'symbol' || type === 'function') {
24
+ throw resultSerializationError(path, `${type} is not a JSON value`);
25
+ }
26
+ if (typeof value !== 'object') {
27
+ throw resultSerializationError(path, `${type} is not a JSON value`);
28
+ }
29
+ if (seen.has(value)) {
30
+ throw resultSerializationError(path, 'circular references are not JSON values');
31
+ }
32
+ seen.add(value);
33
+ if (Array.isArray(value)) {
34
+ const array = [];
35
+ for (let i = 0; i < value.length; i += 1) {
36
+ if (!(i in value))
37
+ throw resultSerializationError(`${path}[${i}]`, 'sparse arrays are not JSON values');
38
+ array.push(toJsonValue(value[i], `${path}[${i}]`, seen));
39
+ }
40
+ seen.delete(value);
41
+ return array;
42
+ }
43
+ if (!isPlainObject(value)) {
44
+ seen.delete(value);
45
+ throw resultSerializationError(path, `${Object.prototype.toString.call(value)} is not a plain JSON object`);
46
+ }
47
+ const symbols = Object.getOwnPropertySymbols(value);
48
+ if (symbols.length > 0) {
49
+ seen.delete(value);
50
+ throw resultSerializationError(path, 'symbol keys are not JSON values');
51
+ }
52
+ const object = {};
53
+ for (const key of Object.keys(value)) {
54
+ object[key] = toJsonValue(value[key], `${path}.${key}`, seen);
55
+ }
56
+ seen.delete(value);
57
+ return object;
58
+ }
59
+ export function encodeResult(value, codec) {
60
+ if (codec) {
61
+ const encoded = codec.serialize(value);
62
+ if (typeof encoded !== 'string') {
63
+ throw new TypeError('resultCodec.serialize must return a string');
64
+ }
65
+ return { encoding: 'custom', value: encoded };
66
+ }
67
+ if (value === undefined)
68
+ return { encoding: 'undefined' };
69
+ return { encoding: 'json', value: toJsonValue(value) };
70
+ }
71
+ export function decodeResult(stored, codec) {
72
+ if (stored.encoding === 'undefined')
73
+ return undefined;
74
+ if (stored.encoding === 'json')
75
+ return stored.value;
76
+ if (stored.encoding === 'custom') {
77
+ if (!codec) {
78
+ throw new TypeError('Cached idempotency result requires resultCodec to deserialize');
79
+ }
80
+ return codec.deserialize(stored.value);
81
+ }
82
+ throw new Error('Invalid cached idempotency result');
83
+ }
84
+ export function assertStoredResult(value) {
85
+ if (typeof value !== 'object' || value === null || !('encoding' in value)) {
86
+ throw new Error('Invalid cached idempotency result');
87
+ }
88
+ const stored = value;
89
+ if (stored.encoding === 'undefined')
90
+ return stored;
91
+ if (stored.encoding === 'json' && 'value' in stored)
92
+ return stored;
93
+ if (stored.encoding === 'custom' && typeof stored.value === 'string')
94
+ return stored;
95
+ throw new Error('Invalid cached idempotency result');
96
+ }
package/dist/scripts.d.ts CHANGED
@@ -1,3 +1,2 @@
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";
1
+ export declare const COMMIT_DONE_IF_LOCK_OWNER = "\n-- COMMIT_DONE_IF_LOCK_OWNER\nif redis.call('GET', KEYS[2]) ~= ARGV[1] then\n return 0\nend\n\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";
2
+ export declare const CLEAR_IN_FLIGHT_IF_LOCK_OWNER = "\n-- CLEAR_IN_FLIGHT_IF_LOCK_OWNER\nif redis.call('GET', KEYS[2]) ~= ARGV[1] then\n return 0\nend\n\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";
package/dist/scripts.js CHANGED
@@ -1,22 +1,9 @@
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' }
1
+ export const COMMIT_DONE_IF_LOCK_OWNER = `
2
+ -- COMMIT_DONE_IF_LOCK_OWNER
3
+ if redis.call('GET', KEYS[2]) ~= ARGV[1] then
4
+ return 0
7
5
  end
8
6
 
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
7
  local raw = redis.call('GET', KEYS[1])
21
8
  if not raw then return 0 end
22
9
  local value = cjson.decode(raw)
@@ -26,8 +13,12 @@ if value.state == 'IN_FLIGHT' and value.token == ARGV[1] then
26
13
  end
27
14
  return 0
28
15
  `;
29
- export const RELEASE_IF_OWNER = `
30
- -- RELEASE_IF_OWNER
16
+ export const CLEAR_IN_FLIGHT_IF_LOCK_OWNER = `
17
+ -- CLEAR_IN_FLIGHT_IF_LOCK_OWNER
18
+ if redis.call('GET', KEYS[2]) ~= ARGV[1] then
19
+ return 0
20
+ end
21
+
31
22
  local raw = redis.call('GET', KEYS[1])
32
23
  if not raw then return 0 end
33
24
  local value = cjson.decode(raw)
@@ -0,0 +1,4 @@
1
+ import type { IdempotencyResultCodec, IdempotencyState } from './types';
2
+ export declare function parseState<T>(raw: string): IdempotencyState<T>;
3
+ export declare function createInFlightState(token: string, fingerprint: string): IdempotencyState;
4
+ export declare function createDoneState<T>(fingerprint: string, result: T, resultCodec: IdempotencyResultCodec<T> | undefined): IdempotencyState<T>;
package/dist/state.js ADDED
@@ -0,0 +1,20 @@
1
+ import { encodeResult } from './result-codec.js';
2
+ export function parseState(raw) {
3
+ return JSON.parse(raw);
4
+ }
5
+ export function createInFlightState(token, fingerprint) {
6
+ return {
7
+ state: 'IN_FLIGHT',
8
+ token,
9
+ fingerprint,
10
+ startedAt: Date.now(),
11
+ };
12
+ }
13
+ export function createDoneState(fingerprint, result, resultCodec) {
14
+ return {
15
+ state: 'DONE',
16
+ fingerprint,
17
+ data: encodeResult(result, resultCodec),
18
+ finishedAt: Date.now(),
19
+ };
20
+ }
@@ -0,0 +1,22 @@
1
+ import type { IdempotencyResultCodec, IdempotencyState, RedisLike } from './types';
2
+ export type IdempotencyReadResult<T> = {
3
+ type: 'empty';
4
+ } | {
5
+ type: 'cached';
6
+ data: T;
7
+ } | {
8
+ type: 'mismatch';
9
+ } | {
10
+ type: 'in-flight';
11
+ state: Extract<IdempotencyState<T>, {
12
+ state: 'IN_FLIGHT';
13
+ }>;
14
+ };
15
+ export declare class RedisIdempotencyStore {
16
+ private readonly redis;
17
+ constructor(redis: RedisLike);
18
+ read<T>(key: string, fingerprint: string, resultCodec: IdempotencyResultCodec<T> | undefined): Promise<IdempotencyReadResult<T>>;
19
+ markInFlight(key: string, token: string, fingerprint: string, lockTtl: number): Promise<void>;
20
+ commitDoneIfLockOwner<T>(key: string, lockKey: string, token: string, fingerprint: string, result: T, resultTtl: number, resultCodec: IdempotencyResultCodec<T> | undefined): Promise<boolean>;
21
+ clearInFlightIfLockOwner(key: string, lockKey: string, token: string): Promise<boolean>;
22
+ }
package/dist/store.js ADDED
@@ -0,0 +1,33 @@
1
+ import { decodeResult, assertStoredResult } from './result-codec.js';
2
+ import { CLEAR_IN_FLIGHT_IF_LOCK_OWNER, COMMIT_DONE_IF_LOCK_OWNER } from './scripts.js';
3
+ import { createDoneState, createInFlightState, parseState } from './state.js';
4
+ export class RedisIdempotencyStore {
5
+ redis;
6
+ constructor(redis) {
7
+ this.redis = redis;
8
+ }
9
+ async read(key, fingerprint, resultCodec) {
10
+ const raw = await this.redis.get(key);
11
+ if (!raw)
12
+ return { type: 'empty' };
13
+ const state = parseState(raw);
14
+ if (state.fingerprint !== fingerprint)
15
+ return { type: 'mismatch' };
16
+ if (state.state === 'DONE') {
17
+ return { type: 'cached', data: decodeResult(assertStoredResult(state.data), resultCodec) };
18
+ }
19
+ return { type: 'in-flight', state };
20
+ }
21
+ async markInFlight(key, token, fingerprint, lockTtl) {
22
+ await this.redis.set(key, JSON.stringify(createInFlightState(token, fingerprint)), 'PX', lockTtl);
23
+ }
24
+ async commitDoneIfLockOwner(key, lockKey, token, fingerprint, result, resultTtl, resultCodec) {
25
+ const done = createDoneState(fingerprint, result, resultCodec);
26
+ const committed = await this.redis.eval(COMMIT_DONE_IF_LOCK_OWNER, 2, key, lockKey, token, JSON.stringify(done), resultTtl);
27
+ return committed === 1;
28
+ }
29
+ async clearInFlightIfLockOwner(key, lockKey, token) {
30
+ const cleared = await this.redis.eval(CLEAR_IN_FLIGHT_IF_LOCK_OWNER, 2, key, lockKey, token);
31
+ return cleared === 1;
32
+ }
33
+ }
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { RedisLockLike } from '@hile/redis-lock';
1
2
  export type RedisEvalResult = unknown;
2
3
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
3
4
  [key: string]: JsonValue;
@@ -11,11 +12,10 @@ export type StoredIdempotencyResult = {
11
12
  encoding: 'custom';
12
13
  value: string;
13
14
  };
14
- export interface RedisLike {
15
+ export interface RedisLike extends RedisLockLike {
15
16
  get(key: string): Promise<string | null>;
16
17
  set(key: string, value: string, px: 'PX', ttl: number): Promise<unknown>;
17
18
  del(key: string): Promise<unknown>;
18
- eval(script: string, numberOfKeys: number, key: string, ...args: Array<string | number>): Promise<RedisEvalResult>;
19
19
  }
20
20
  export interface IdempotencyResultCodec<T = unknown> {
21
21
  serialize(value: T): string;
@@ -31,7 +31,7 @@ export interface IdempotencyOptions<T = unknown> {
31
31
  maxPollInterval?: number;
32
32
  resultCodec?: IdempotencyResultCodec<T>;
33
33
  }
34
- export interface IdempotentMiddlewareOptions<TInput extends object = Record<string, unknown>> extends Omit<IdempotencyOptions, 'fingerprint'> {
34
+ export interface IdempotentMiddlewareOptions<TInput extends object = Record<string, unknown>, TResult = unknown> extends Omit<IdempotencyOptions<TResult>, 'fingerprint'> {
35
35
  redis: RedisLike;
36
36
  key: (input: TInput) => string;
37
37
  fingerprint: string | ((input: TInput) => string);
@@ -1,213 +1,4 @@
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
- }
1
+ import { RedisIdempotency } from './idempotency.js';
176
2
  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);
3
+ return new RedisIdempotency(redis).run(key, fn, options);
213
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hile/redis-idempotency",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -22,7 +22,8 @@
22
22
  "vitest": "^4.0.18"
23
23
  },
24
24
  "dependencies": {
25
- "@hile/model": "^3.0.1"
25
+ "@hile/model": "^3.0.1",
26
+ "@hile/redis-lock": "^3.0.1"
26
27
  },
27
- "gitHead": "ffc9f14ed2591075290c77c1bcc0afc67ecf1794"
28
+ "gitHead": "88f52fb95743f86761778776aff23631fcf9d821"
28
29
  }