@nestjs-redisx/idempotency 1.5.1 → 1.6.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.
Files changed (25) hide show
  1. package/dist/idempotency/api/filters/idempotency-exception.filter.d.ts +1 -0
  2. package/dist/idempotency/api/filters/idempotency-exception.filter.d.ts.map +1 -1
  3. package/dist/idempotency/api/interceptors/idempotency.interceptor.d.ts.map +1 -1
  4. package/dist/idempotency/application/ports/idempotency-service.port.d.ts +1 -1
  5. package/dist/idempotency/application/ports/idempotency-service.port.d.ts.map +1 -1
  6. package/dist/idempotency/application/ports/idempotency-store.port.d.ts +4 -2
  7. package/dist/idempotency/application/ports/idempotency-store.port.d.ts.map +1 -1
  8. package/dist/idempotency/application/services/idempotency.service.d.ts +15 -1
  9. package/dist/idempotency/application/services/idempotency.service.d.ts.map +1 -1
  10. package/dist/idempotency/infrastructure/adapters/redis-idempotency-store.adapter.d.ts +2 -2
  11. package/dist/idempotency/infrastructure/adapters/redis-idempotency-store.adapter.d.ts.map +1 -1
  12. package/dist/idempotency/infrastructure/scripts/lua-scripts.d.ts +1 -0
  13. package/dist/idempotency/infrastructure/scripts/lua-scripts.d.ts.map +1 -1
  14. package/dist/idempotency.plugin.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +106 -34
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +106 -35
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/shared/errors/index.d.ts +7 -0
  22. package/dist/shared/errors/index.d.ts.map +1 -1
  23. package/dist/shared/types/index.d.ts +13 -1
  24. package/dist/shared/types/index.d.ts.map +1 -1
  25. package/package.json +7 -4
@@ -22,6 +22,7 @@ export declare class IdempotencyExceptionFilter implements ExceptionFilter {
22
22
  * - Reused key with a different request -> 422 Unprocessable Entity
23
23
  * - Concurrent request still in progress (timeout) -> 409 Conflict
24
24
  * - Previous request with the same key failed -> 409 Conflict
25
+ * - In-flight record vanished mid-wait -> 409 Conflict (retryable)
25
26
  * - Anything else (unexpected) -> 500 Internal Server Error
26
27
  */
27
28
  private resolveStatus;
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency-exception.filter.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/api/filters/idempotency-exception.filter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,eAAe,EAAE,aAAa,EAAsB,MAAM,gBAAgB,CAAC;AAC3F,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C,OAAO,EAAE,gBAAgB,EAAqH,MAAM,wBAAwB,CAAC;AAE7K;;;;;;;;;GASG;AACH,qBACa,0BAA2B,YAAW,eAAe;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW;gBAAX,WAAW,EAAE,eAAe;IAElF,KAAK,CAAC,SAAS,EAAE,gBAAgB,EAAE,IAAI,EAAE,aAAa,GAAG,IAAI;IAmB7D;;;;;;;;OAQG;IACH,OAAO,CAAC,aAAa;IAgBrB,OAAO,CAAC,YAAY;CAYrB"}
1
+ {"version":3,"file":"idempotency-exception.filter.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/api/filters/idempotency-exception.filter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,eAAe,EAAE,aAAa,EAAsB,MAAM,gBAAgB,CAAC;AAC3F,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C,OAAO,EAAE,gBAAgB,EAAqJ,MAAM,wBAAwB,CAAC;AAE7M;;;;;;;;;GASG;AACH,qBACa,0BAA2B,YAAW,eAAe;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW;gBAAX,WAAW,EAAE,eAAe;IAElF,KAAK,CAAC,SAAS,EAAE,gBAAgB,EAAE,IAAI,EAAE,aAAa,GAAG,IAAI;IAmB7D;;;;;;;;;OASG;IACH,OAAO,CAAC,aAAa;IAqBrB,OAAO,CAAC,YAAY;CAYrB"}
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency.interceptor.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/api/interceptors/idempotency.interceptor.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,eAAe,EAAE,gBAAgB,EAAE,WAAW,EAAkB,MAAM,gBAAgB,CAAC;AAC5G,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAM,MAAM,MAAM,CAAC;AAKtC,OAAO,EAAE,yBAAyB,EAAsB,MAAM,uBAAuB,CAAC;AACtF,OAAO,EAAE,mBAAmB,EAAE,MAAM,kDAAkD,CAAC;AAGvF,qBACa,sBAAuB,YAAW,eAAe;IAI7B,OAAO,CAAC,QAAQ,CAAC,kBAAkB;IAC5B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACxC,OAAO,CAAC,QAAQ,CAAC,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,WAAW;IANvD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2C;gBAGlB,kBAAkB,EAAE,mBAAmB,EAChC,MAAM,EAAE,yBAAyB,EAClD,SAAS,EAAE,SAAS,EACd,WAAW,EAAE,eAAe;IAGlE,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;IAkE3F,OAAO,CAAC,UAAU;YAIJ,UAAU;YAWV,mBAAmB;IAmBjC,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,cAAc;IAmBtB,OAAO,CAAC,cAAc;CAWvB"}
1
+ {"version":3,"file":"idempotency.interceptor.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/api/interceptors/idempotency.interceptor.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,eAAe,EAAE,gBAAgB,EAAE,WAAW,EAAkB,MAAM,gBAAgB,CAAC;AAC5G,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAM,MAAM,MAAM,CAAC;AAKtC,OAAO,EAAE,yBAAyB,EAAsB,MAAM,uBAAuB,CAAC;AACtF,OAAO,EAAE,mBAAmB,EAAE,MAAM,kDAAkD,CAAC;AAcvF,qBACa,sBAAuB,YAAW,eAAe;IAI7B,OAAO,CAAC,QAAQ,CAAC,kBAAkB;IAC5B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACxC,OAAO,CAAC,QAAQ,CAAC,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,WAAW;IANvD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2C;gBAGlB,kBAAkB,EAAE,mBAAmB,EAChC,MAAM,EAAE,yBAAyB,EAClD,SAAS,EAAE,SAAS,EACd,WAAW,EAAE,eAAe;IAGlE,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;IAsF3F,OAAO,CAAC,UAAU;YAIJ,UAAU;YAgBV,mBAAmB;IAmBjC,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,cAAc;IAmBtB,OAAO,CAAC,cAAc;CAWvB"}
@@ -26,7 +26,7 @@ export interface IIdempotencyService {
26
26
  * @param key - Idempotency key
27
27
  * @param error - Error message
28
28
  */
29
- fail(key: string, error: string): Promise<void>;
29
+ fail(key: string, error: string, options?: IIdempotencyOptions): Promise<void>;
30
30
  /**
31
31
  * Get existing record by key.
32
32
  *
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency-service.port.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/application/ports/idempotency-service.port.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAE/H;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;IAEhH;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,oBAAoB,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpG;;;;;OAKG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhD;;;;;OAKG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAAC;IAErD;;;;;OAKG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACvC"}
1
+ {"version":3,"file":"idempotency-service.port.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/application/ports/idempotency-service.port.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAE/H;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;IAEhH;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,oBAAoB,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpG;;;;;OAKG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/E;;;;;OAKG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAAC;IAErD;;;;;OAKG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACvC"}
@@ -12,6 +12,8 @@ export interface ICheckAndLockResult {
12
12
  * Data for completing a request
13
13
  */
14
14
  export interface ICompleteData {
15
+ /** Request fingerprint (persisted so replays keep matching after rewrites) */
16
+ fingerprint?: string;
15
17
  /** HTTP status code */
16
18
  statusCode: number;
17
19
  /** Response body (JSON string) */
@@ -33,7 +35,7 @@ export interface IIdempotencyStore {
33
35
  * @param lockTimeoutMs - Lock timeout in milliseconds
34
36
  * @returns Check result
35
37
  */
36
- checkAndLock(key: string, fingerprint: string, lockTimeoutMs: number): Promise<ICheckAndLockResult>;
38
+ checkAndLock(key: string, fingerprint: string, lockTimeoutMs: number, validateFingerprint?: boolean): Promise<ICheckAndLockResult>;
37
39
  /**
38
40
  * Mark record as completed with response data.
39
41
  *
@@ -50,7 +52,7 @@ export interface IIdempotencyStore {
50
52
  * @param ttlSeconds - TTL in seconds after which the failed record expires,
51
53
  * allowing a fresh attempt with the same key
52
54
  */
53
- fail(key: string, error: string, ttlSeconds: number): Promise<void>;
55
+ fail(key: string, error: string, ttlSeconds: number, fingerprint?: string): Promise<void>;
54
56
  /**
55
57
  * Get record by key.
56
58
  *
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency-store.port.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/application/ports/idempotency-store.port.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAE3D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,0BAA0B;IAC1B,MAAM,EAAE,KAAK,GAAG,YAAY,GAAG,WAAW,GAAG,QAAQ,GAAG,sBAAsB,CAAC;IAE/E,uBAAuB;IACvB,MAAM,CAAC,EAAE,kBAAkB,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,uBAAuB;IACvB,UAAU,EAAE,MAAM,CAAC;IAEnB,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;IAEjB,qCAAqC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB,gCAAgC;IAChC,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;;OAOG;IACH,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAEpG;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9E;;;;;;;OAOG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpE;;;;;OAKG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAAC;IAErD;;;;;OAKG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACvC"}
1
+ {"version":3,"file":"idempotency-store.port.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/application/ports/idempotency-store.port.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAE3D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,0BAA0B;IAC1B,MAAM,EAAE,KAAK,GAAG,YAAY,GAAG,WAAW,GAAG,QAAQ,GAAG,sBAAsB,CAAC;IAE/E,uBAAuB;IACvB,MAAM,CAAC,EAAE,kBAAkB,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB,uBAAuB;IACvB,UAAU,EAAE,MAAM,CAAC;IAEnB,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;IAEjB,qCAAqC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB,gCAAgC;IAChC,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;;OAOG;IACH,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,mBAAmB,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAEnI;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9E;;;;;;;OAOG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1F;;;;;OAKG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAAC;IAErD;;;;;OAKG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACvC"}
@@ -16,9 +16,23 @@ export declare class IdempotencyService implements IIdempotencyService {
16
16
  checkAndLock(key: string, fingerprint: string, options?: IIdempotencyOptions): Promise<IIdempotencyCheckResult>;
17
17
  private recordDuration;
18
18
  complete(key: string, response: IIdempotencyResponse, options?: IIdempotencyOptions): Promise<void>;
19
- fail(key: string, error: string): Promise<void>;
19
+ fail(key: string, error: string, options?: IIdempotencyOptions): Promise<void>;
20
20
  get(key: string): Promise<IIdempotencyRecord | null>;
21
21
  delete(key: string): Promise<boolean>;
22
+ /**
23
+ * Polls until the in-flight attempt completes or fails, bounded by
24
+ * waitTimeout.
25
+ *
26
+ * If the processing record VANISHES (the first attempt died between
27
+ * checkAndLock and complete, so its lock TTL expired), the waiter attempts
28
+ * an atomic takeover via checkAndLock: the winner returns 'takeover' (the
29
+ * caller executes the handler itself), losers keep waiting on the new
30
+ * owner's record. This turns a dead first attempt into a self-healing
31
+ * retry instead of surfacing an unexplained error to every waiter.
32
+ *
33
+ * @returns the completed/failed record to replay, or 'takeover' when this
34
+ * caller now owns the key and must execute the request
35
+ */
22
36
  private waitForCompletion;
23
37
  private buildKey;
24
38
  private sleep;
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency.service.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/application/services/idempotency.service.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC1J,OAAO,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AACxE,OAAO,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AAKpE,UAAU,eAAe;IACvB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtF,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CACtF;AAED;;GAEG;AACH,qBACa,kBAAmB,YAAW,mBAAmB;IAG1D,OAAO,CAAC,QAAQ,CAAC,MAAM;IAEvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACe,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;gBAH7C,MAAM,EAAE,yBAAyB,EAEjC,KAAK,EAAE,iBAAiB,EACa,OAAO,CAAC,EAAE,eAAe,YAAA;IAG3E,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,uBAAuB,CAAC;IAiCzH,OAAO,CAAC,cAAc;IAKhB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,oBAAoB,EAAE,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBvG,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ/C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAKpD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;YAK7B,iBAAiB;IAoB/B,OAAO,CAAC,QAAQ;IAKhB,OAAO,CAAC,KAAK;CAGd"}
1
+ {"version":3,"file":"idempotency.service.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/application/services/idempotency.service.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC1J,OAAO,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AACxE,OAAO,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AAKpE,UAAU,eAAe;IACvB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtF,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CACtF;AAED;;GAEG;AACH,qBACa,kBAAmB,YAAW,mBAAmB;IAG1D,OAAO,CAAC,QAAQ,CAAC,MAAM;IAEvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACe,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;gBAH7C,MAAM,EAAE,yBAAyB,EAEjC,KAAK,EAAE,iBAAiB,EACa,OAAO,CAAC,EAAE,eAAe,YAAA;IAG3E,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,uBAAuB,CAAC;IA2CzH,OAAO,CAAC,cAAc;IAKhB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,oBAAoB,EAAE,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBvG,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC;IAQlF,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAKpD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAK3C;;;;;;;;;;;;;OAaG;YACW,iBAAiB;IA+B/B,OAAO,CAAC,QAAQ;IAKhB,OAAO,CAAC,KAAK;CAGd"}
@@ -13,9 +13,9 @@ export declare class RedisIdempotencyStoreAdapter implements IIdempotencyStore,
13
13
  * Pre-load Lua script on module initialization
14
14
  */
15
15
  onModuleInit(): Promise<void>;
16
- checkAndLock(key: string, fingerprint: string, lockTimeoutMs: number): Promise<ICheckAndLockResult>;
16
+ checkAndLock(key: string, fingerprint: string, lockTimeoutMs: number, validateFingerprint?: boolean): Promise<ICheckAndLockResult>;
17
17
  complete(key: string, data: ICompleteData, ttlSeconds: number): Promise<void>;
18
- fail(key: string, error: string, ttlSeconds: number): Promise<void>;
18
+ fail(key: string, error: string, ttlSeconds: number, fingerprint?: string): Promise<void>;
19
19
  get(key: string): Promise<IIdempotencyRecord | null>;
20
20
  delete(key: string): Promise<boolean>;
21
21
  }
@@ -1 +1 @@
1
- {"version":3,"file":"redis-idempotency-store.adapter.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/infrastructure/adapters/redis-idempotency-store.adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,gDAAgD,CAAC;AAGvH;;GAEG;AACH,qBACa,4BAA6B,YAAW,iBAAiB,EAAE,YAAY;IAGpC,OAAO,CAAC,QAAQ,CAAC,MAAM;IAFrE,OAAO,CAAC,eAAe,CAAuB;gBAEiB,MAAM,EAAE,YAAY;IAEnF;;OAEG;IACG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAI7B,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAqCnG,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7E,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAWnE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAmBpD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAI5C"}
1
+ {"version":3,"file":"redis-idempotency-store.adapter.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/infrastructure/adapters/redis-idempotency-store.adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,gDAAgD,CAAC;AAGvH;;GAEG;AACH,qBACa,4BAA6B,YAAW,iBAAiB,EAAE,YAAY;IAGpC,OAAO,CAAC,QAAQ,CAAC,MAAM;IAFrE,OAAO,CAAC,eAAe,CAAuB;gBAEiB,MAAM,EAAE,YAAY;IAEnF;;OAEG;IACG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAI7B,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,mBAAmB,UAAO,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAqC/H,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkB7E,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAezF,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAmBpD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAI5C"}
@@ -13,6 +13,7 @@
13
13
  * ARGV[1] = fingerprint
14
14
  * ARGV[2] = lock timeout (ms)
15
15
  * ARGV[3] = current timestamp (ms)
16
+ * ARGV[4] = validate fingerprint flag ('1' = compare fingerprints, '0' = skip)
16
17
  *
17
18
  * Returns:
18
19
  * - ['new'] - new request, lock acquired
@@ -1 +1 @@
1
- {"version":3,"file":"lua-scripts.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/infrastructure/scripts/lua-scripts.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,qBAAqB,QAuD1B,CAAC"}
1
+ {"version":3,"file":"lua-scripts.d.ts","sourceRoot":"","sources":["../../../../src/idempotency/infrastructure/scripts/lua-scripts.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,qBAAqB,QAwD1B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency.plugin.d.ts","sourceRoot":"","sources":["../src/idempotency.plugin.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAEjF,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAoE,MAAM,qBAAqB,CAAC;AAQ3I,OAAO,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAa3D;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,qBAAa,iBAAkB,YAAW,aAAa;IAOzC,OAAO,CAAC,QAAQ,CAAC,OAAO;IANpC,QAAQ,CAAC,IAAI,iBAAiB;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAW;IACnC,QAAQ,CAAC,WAAW,0EAA0E;IAE9F,OAAO,CAAC,YAAY,CAAC,CAAiD;gBAEzC,OAAO,GAAE,yBAA8B;IAEpE,MAAM,CAAC,aAAa,CAAC,YAAY,EAAE,mBAAmB,CAAC,yBAAyB,CAAC,GAAG,iBAAiB;IAMrG,OAAO,CAAC,MAAM,CAAC,aAAa;IAe5B,UAAU,IAAI,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,aAAa,GAAG,gBAAgB,CAAC;IAIrE,YAAY,IAAI,QAAQ,EAAE;IAyC1B,UAAU,IAAI,KAAK,CAAC,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAC;CAGhD"}
1
+ {"version":3,"file":"idempotency.plugin.d.ts","sourceRoot":"","sources":["../src/idempotency.plugin.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAEjF,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAoE,MAAM,qBAAqB,CAAC;AAS3I,OAAO,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAa3D;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,qBAAa,iBAAkB,YAAW,aAAa;IAOzC,OAAO,CAAC,QAAQ,CAAC,OAAO;IANpC,QAAQ,CAAC,IAAI,iBAAiB;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAW;IACnC,QAAQ,CAAC,WAAW,0EAA0E;IAE9F,OAAO,CAAC,YAAY,CAAC,CAAiD;gBAEzC,OAAO,GAAE,yBAA8B;IAEpE,MAAM,CAAC,aAAa,CAAC,YAAY,EAAE,mBAAmB,CAAC,yBAAyB,CAAC,GAAG,iBAAiB;IAMrG,OAAO,CAAC,MAAM,CAAC,aAAa;IA0B5B,UAAU,IAAI,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,aAAa,GAAG,gBAAgB,CAAC;IAIrE,YAAY,IAAI,QAAQ,EAAE;IAyC1B,UAAU,IAAI,KAAK,CAAC,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAC;CAGhD"}
package/dist/index.d.ts CHANGED
@@ -5,6 +5,6 @@ export type { IIdempotencyStore } from './idempotency/application/ports/idempote
5
5
  export { Idempotent, type IIdempotentOptions, IDEMPOTENT_OPTIONS } from './idempotency/api/decorators/idempotent.decorator';
6
6
  export { IdempotencyInterceptor } from './idempotency/api/interceptors/idempotency.interceptor';
7
7
  export type { IIdempotencyPluginOptions, IIdempotencyRecord, IIdempotencyCheckResult, IIdempotencyResponse, IIdempotencyOptions } from './shared/types';
8
- export { IdempotencyError, IdempotencyKeyRequiredError, IdempotencyFingerprintMismatchError, IdempotencyTimeoutError, IdempotencyFailedError, IdempotencyRecordNotFoundError } from './shared/errors';
8
+ export { IdempotencyError, IdempotencyKeyRequiredError, IdempotencyFingerprintMismatchError, IdempotencyTimeoutError, IdempotencyFailedError, IdempotencyRecordNotFoundError, IdempotencyConfigError } from './shared/errors';
9
9
  export { IDEMPOTENCY_PLUGIN_OPTIONS, IDEMPOTENCY_REDIS_DRIVER, IDEMPOTENCY_SERVICE, IDEMPOTENCY_STORE } from './shared/constants';
10
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAGzD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAG5F,YAAY,EAAE,mBAAmB,EAAE,MAAM,0DAA0D,CAAC;AACpG,YAAY,EAAE,iBAAiB,EAAE,MAAM,wDAAwD,CAAC;AAGhG,OAAO,EAAE,UAAU,EAAE,KAAK,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,mDAAmD,CAAC;AAG5H,OAAO,EAAE,sBAAsB,EAAE,MAAM,wDAAwD,CAAC;AAGhG,YAAY,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAGxJ,OAAO,EAAE,gBAAgB,EAAE,2BAA2B,EAAE,mCAAmC,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,8BAA8B,EAAE,MAAM,iBAAiB,CAAC;AAGtM,OAAO,EAAE,0BAA0B,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAGzD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAG5F,YAAY,EAAE,mBAAmB,EAAE,MAAM,0DAA0D,CAAC;AACpG,YAAY,EAAE,iBAAiB,EAAE,MAAM,wDAAwD,CAAC;AAGhG,OAAO,EAAE,UAAU,EAAE,KAAK,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,mDAAmD,CAAC;AAG5H,OAAO,EAAE,sBAAsB,EAAE,MAAM,wDAAwD,CAAC;AAGhG,YAAY,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAGxJ,OAAO,EAAE,gBAAgB,EAAE,2BAA2B,EAAE,mCAAmC,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,8BAA8B,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAG9N,OAAO,EAAE,0BAA0B,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC"}
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ var __decorateClass = (decorators, target, key, kind) => {
18
18
  var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
19
19
 
20
20
  // package.json
21
- var version = "1.5.0";
21
+ var version = "1.6.1";
22
22
  var IdempotencyError = class extends core$1.RedisXError {
23
23
  constructor(message, code, idempotencyKey, cause) {
24
24
  super(message, code, cause, { idempotencyKey });
@@ -50,6 +50,11 @@ var IdempotencyRecordNotFoundError = class extends IdempotencyError {
50
50
  super(`Idempotency record not found for key "${key}"`, core$1.ErrorCode.OP_KEY_NOT_FOUND, key);
51
51
  }
52
52
  };
53
+ var IdempotencyConfigError = class extends IdempotencyError {
54
+ constructor(message) {
55
+ super(message, core$1.ErrorCode.CFG_INVALID, "");
56
+ }
57
+ };
53
58
 
54
59
  // src/idempotency/api/filters/idempotency-exception.filter.ts
55
60
  var IdempotencyExceptionFilter = class {
@@ -78,6 +83,7 @@ var IdempotencyExceptionFilter = class {
78
83
  * - Reused key with a different request -> 422 Unprocessable Entity
79
84
  * - Concurrent request still in progress (timeout) -> 409 Conflict
80
85
  * - Previous request with the same key failed -> 409 Conflict
86
+ * - In-flight record vanished mid-wait -> 409 Conflict (retryable)
81
87
  * - Anything else (unexpected) -> 500 Internal Server Error
82
88
  */
83
89
  resolveStatus(exception) {
@@ -93,6 +99,9 @@ var IdempotencyExceptionFilter = class {
93
99
  if (exception instanceof IdempotencyFailedError) {
94
100
  return common.HttpStatus.CONFLICT;
95
101
  }
102
+ if (exception instanceof IdempotencyRecordNotFoundError) {
103
+ return common.HttpStatus.CONFLICT;
104
+ }
96
105
  return common.HttpStatus.INTERNAL_SERVER_ERROR;
97
106
  }
98
107
  reasonPhrase(status) {
@@ -124,6 +133,7 @@ function Idempotent(options = {}) {
124
133
  }
125
134
 
126
135
  // src/idempotency/api/interceptors/idempotency.interceptor.ts
136
+ var IDEMPOTENCY_HANDLED = /* @__PURE__ */ Symbol("redisx.idempotencyHandled");
127
137
  exports.IdempotencyInterceptor = class IdempotencyInterceptor {
128
138
  constructor(idempotencyService, config, reflector, adapterHost) {
129
139
  this.idempotencyService = idempotencyService;
@@ -133,6 +143,10 @@ exports.IdempotencyInterceptor = class IdempotencyInterceptor {
133
143
  }
134
144
  logger = new common.Logger(exports.IdempotencyInterceptor.name);
135
145
  async intercept(context, next) {
146
+ const request = context.switchToHttp().getRequest();
147
+ if (request[IDEMPOTENCY_HANDLED]) {
148
+ return next.handle();
149
+ }
136
150
  const options = this.getOptions(context);
137
151
  const response = context.switchToHttp().getResponse();
138
152
  const key = await this.extractKey(context, options);
@@ -142,11 +156,13 @@ exports.IdempotencyInterceptor = class IdempotencyInterceptor {
142
156
  if (options.skip && await options.skip(context)) {
143
157
  return next.handle();
144
158
  }
159
+ request[IDEMPOTENCY_HANDLED] = true;
145
160
  const fingerprint = await this.generateFingerprint(context, options);
146
161
  let checkResult;
147
162
  try {
148
163
  checkResult = await this.idempotencyService.checkAndLock(key, fingerprint, {
149
- ttl: options.ttl
164
+ ttl: options.ttl,
165
+ validateFingerprint: options.validateFingerprint
150
166
  });
151
167
  } catch (error) {
152
168
  if ((this.config.errorPolicy ?? "fail-closed") === "fail-open") {
@@ -168,18 +184,24 @@ exports.IdempotencyInterceptor = class IdempotencyInterceptor {
168
184
  return next.handle().pipe(
169
185
  operators.tap({
170
186
  next: (data) => {
171
- void this.idempotencyService.complete(
172
- key,
173
- {
174
- statusCode: response.statusCode,
175
- body: data,
176
- headers: this.extractHeaders(response, options)
177
- },
178
- { ttl: options.ttl }
179
- );
187
+ Promise.resolve(
188
+ this.idempotencyService.complete(
189
+ key,
190
+ {
191
+ statusCode: response.statusCode,
192
+ body: data,
193
+ headers: this.extractHeaders(response, options)
194
+ },
195
+ { ttl: options.ttl, fingerprint }
196
+ )
197
+ ).catch((err) => {
198
+ this.logger.error(`Failed to persist idempotency completion for key "${key}": ${err.message}`);
199
+ });
180
200
  },
181
201
  error: (error) => {
182
- void this.idempotencyService.fail(key, error.message);
202
+ Promise.resolve(this.idempotencyService.fail(key, error.message, { fingerprint })).catch((err) => {
203
+ this.logger.error(`Failed to persist idempotency failure for key "${key}": ${err.message}`);
204
+ });
183
205
  }
184
206
  })
185
207
  );
@@ -193,7 +215,11 @@ exports.IdempotencyInterceptor = class IdempotencyInterceptor {
193
215
  }
194
216
  const request = context.switchToHttp().getRequest();
195
217
  const headerName = this.config.headerName ?? "Idempotency-Key";
196
- return request.headers[headerName.toLowerCase()] ?? null;
218
+ const value = request.headers[headerName.toLowerCase()];
219
+ if (Array.isArray(value)) {
220
+ return value[0] ?? null;
221
+ }
222
+ return value ?? null;
197
223
  }
198
224
  async generateFingerprint(context, options) {
199
225
  if (this.config.fingerprintGenerator) {
@@ -261,7 +287,8 @@ exports.IdempotencyService = class IdempotencyService {
261
287
  const startTime = Date.now();
262
288
  const fullKey = this.buildKey(key);
263
289
  const lockTimeout = options.lockTimeout ?? this.config.lockTimeout ?? 3e4;
264
- const result = await this.store.checkAndLock(fullKey, fingerprint, lockTimeout);
290
+ const validateFingerprint = options.validateFingerprint ?? this.config.validateFingerprint ?? true;
291
+ const result = await this.store.checkAndLock(fullKey, fingerprint, lockTimeout, validateFingerprint);
265
292
  if (result.status === "new") {
266
293
  this.metrics?.incrementCounter("redisx_idempotency_requests_total", { status: "new" });
267
294
  this.recordDuration(startTime);
@@ -273,10 +300,15 @@ exports.IdempotencyService = class IdempotencyService {
273
300
  return { isNew: false, fingerprintMismatch: true };
274
301
  }
275
302
  if (result.status === "processing") {
276
- const record = await this.waitForCompletion(fullKey);
303
+ const outcome = await this.waitForCompletion(fullKey, fingerprint, lockTimeout, validateFingerprint);
304
+ if (outcome === "takeover") {
305
+ this.metrics?.incrementCounter("redisx_idempotency_requests_total", { status: "takeover" });
306
+ this.recordDuration(startTime);
307
+ return { isNew: true };
308
+ }
277
309
  this.metrics?.incrementCounter("redisx_idempotency_requests_total", { status: "replay" });
278
310
  this.recordDuration(startTime);
279
- return { isNew: false, record };
311
+ return { isNew: false, record: outcome };
280
312
  }
281
313
  this.metrics?.incrementCounter("redisx_idempotency_requests_total", { status: "replay" });
282
314
  this.recordDuration(startTime);
@@ -292,6 +324,7 @@ exports.IdempotencyService = class IdempotencyService {
292
324
  await this.store.complete(
293
325
  fullKey,
294
326
  {
327
+ fingerprint: options.fingerprint,
295
328
  statusCode: response.statusCode,
296
329
  response: JSON.stringify(response.body),
297
330
  headers: response.headers ? JSON.stringify(response.headers) : void 0,
@@ -300,10 +333,10 @@ exports.IdempotencyService = class IdempotencyService {
300
333
  ttl
301
334
  );
302
335
  }
303
- async fail(key, error) {
336
+ async fail(key, error, options = {}) {
304
337
  const fullKey = this.buildKey(key);
305
338
  const ttlSeconds = Math.ceil((this.config.lockTimeout ?? 3e4) / 1e3);
306
- await this.store.fail(fullKey, error, ttlSeconds);
339
+ await this.store.fail(fullKey, error, ttlSeconds, options.fingerprint);
307
340
  }
308
341
  async get(key) {
309
342
  const fullKey = this.buildKey(key);
@@ -313,15 +346,37 @@ exports.IdempotencyService = class IdempotencyService {
313
346
  const fullKey = this.buildKey(key);
314
347
  return this.store.delete(fullKey);
315
348
  }
316
- async waitForCompletion(key) {
349
+ /**
350
+ * Polls until the in-flight attempt completes or fails, bounded by
351
+ * waitTimeout.
352
+ *
353
+ * If the processing record VANISHES (the first attempt died between
354
+ * checkAndLock and complete, so its lock TTL expired), the waiter attempts
355
+ * an atomic takeover via checkAndLock: the winner returns 'takeover' (the
356
+ * caller executes the handler itself), losers keep waiting on the new
357
+ * owner's record. This turns a dead first attempt into a self-healing
358
+ * retry instead of surfacing an unexplained error to every waiter.
359
+ *
360
+ * @returns the completed/failed record to replay, or 'takeover' when this
361
+ * caller now owns the key and must execute the request
362
+ */
363
+ async waitForCompletion(key, fingerprint, lockTimeoutMs, validateFingerprint) {
317
364
  const waitTimeout = this.config.waitTimeout ?? 6e4;
318
365
  const startTime = Date.now();
319
366
  while (Date.now() - startTime < waitTimeout) {
320
367
  const record = await this.store.get(key);
321
368
  if (!record) {
322
- throw new IdempotencyRecordNotFoundError(key);
323
- }
324
- if (record.status === "completed" || record.status === "failed") {
369
+ const retry = await this.store.checkAndLock(key, fingerprint, lockTimeoutMs, validateFingerprint);
370
+ if (retry.status === "new") {
371
+ return "takeover";
372
+ }
373
+ if (retry.status === "completed" || retry.status === "failed") {
374
+ return retry.record;
375
+ }
376
+ if (retry.status === "fingerprint_mismatch") {
377
+ throw new IdempotencyFingerprintMismatchError(key);
378
+ }
379
+ } else if (record.status === "completed" || record.status === "failed") {
325
380
  return record;
326
381
  }
327
382
  await this.sleep(POLL_INTERVAL_MS);
@@ -350,6 +405,7 @@ local key = KEYS[1]
350
405
  local fingerprint = ARGV[1]
351
406
  local lock_timeout = tonumber(ARGV[2])
352
407
  local now = tonumber(ARGV[3])
408
+ local validate = ARGV[4] ~= '0'
353
409
 
354
410
  -- Check if key exists
355
411
  local existing = redis.call('HGETALL', key)
@@ -371,8 +427,8 @@ for i = 1, #existing, 2 do
371
427
  record[existing[i]] = existing[i + 1]
372
428
  end
373
429
 
374
- -- Check fingerprint
375
- if record.fingerprint ~= fingerprint then
430
+ -- Check fingerprint (skipped when validateFingerprint is disabled)
431
+ if validate and record.fingerprint ~= fingerprint then
376
432
  return {'fingerprint_mismatch'}
377
433
  end
378
434
 
@@ -414,9 +470,9 @@ var RedisIdempotencyStoreAdapter = class {
414
470
  async onModuleInit() {
415
471
  this.checkAndLockSha = await this.driver.scriptLoad(CHECK_AND_LOCK_SCRIPT);
416
472
  }
417
- async checkAndLock(key, fingerprint, lockTimeoutMs) {
473
+ async checkAndLock(key, fingerprint, lockTimeoutMs, validateFingerprint = true) {
418
474
  const now = Date.now();
419
- const rawResult = await this.driver.evalsha(this.checkAndLockSha, [key], [fingerprint, lockTimeoutMs, now]);
475
+ const rawResult = await this.driver.evalsha(this.checkAndLockSha, [key], [fingerprint, lockTimeoutMs, now, validateFingerprint ? "1" : "0"]);
420
476
  const result = rawResult.map((v) => v === null || v === void 0 ? "" : String(v));
421
477
  const status = result[0];
422
478
  if (status === "new") {
@@ -444,21 +500,29 @@ var RedisIdempotencyStoreAdapter = class {
444
500
  };
445
501
  }
446
502
  async complete(key, data, ttlSeconds) {
447
- await this.driver.hmset(key, {
503
+ const fields = {
448
504
  status: "completed",
449
505
  statusCode: String(data.statusCode),
450
506
  response: data.response,
451
507
  headers: data.headers || "",
452
508
  completedAt: String(data.completedAt)
453
- });
509
+ };
510
+ if (data.fingerprint) {
511
+ fields.fingerprint = data.fingerprint;
512
+ }
513
+ await this.driver.hmset(key, fields);
454
514
  await this.driver.expire(key, ttlSeconds);
455
515
  }
456
- async fail(key, error, ttlSeconds) {
457
- await this.driver.hmset(key, {
516
+ async fail(key, error, ttlSeconds, fingerprint) {
517
+ const fields = {
458
518
  status: "failed",
459
519
  error,
460
520
  completedAt: String(Date.now())
461
- });
521
+ };
522
+ if (fingerprint) {
523
+ fields.fingerprint = fingerprint;
524
+ }
525
+ await this.driver.hmset(key, fields);
462
526
  await this.driver.expire(key, ttlSeconds);
463
527
  }
464
528
  async get(key) {
@@ -468,12 +532,12 @@ var RedisIdempotencyStoreAdapter = class {
468
532
  }
469
533
  return {
470
534
  key,
471
- fingerprint: data.fingerprint,
535
+ fingerprint: data.fingerprint ?? "",
472
536
  status: data.status,
473
537
  statusCode: data.statusCode ? parseInt(data.statusCode, 10) : void 0,
474
538
  response: data.response || void 0,
475
539
  headers: data.headers || void 0,
476
- startedAt: parseInt(data.startedAt, 10),
540
+ startedAt: data.startedAt ? parseInt(data.startedAt, 10) : 0,
477
541
  completedAt: data.completedAt ? parseInt(data.completedAt, 10) : void 0,
478
542
  error: data.error || void 0
479
543
  };
@@ -513,7 +577,7 @@ var IdempotencyPlugin = class _IdempotencyPlugin {
513
577
  return plugin;
514
578
  }
515
579
  static mergeDefaults(options) {
516
- return {
580
+ const merged = {
517
581
  client: options.client,
518
582
  defaultTtl: options.defaultTtl ?? DEFAULT_IDEMPOTENCY_CONFIG.defaultTtl,
519
583
  keyPrefix: options.keyPrefix ?? DEFAULT_IDEMPOTENCY_CONFIG.keyPrefix,
@@ -525,6 +589,13 @@ var IdempotencyPlugin = class _IdempotencyPlugin {
525
589
  errorPolicy: options.errorPolicy ?? DEFAULT_IDEMPOTENCY_CONFIG.errorPolicy,
526
590
  fingerprintGenerator: options.fingerprintGenerator
527
591
  };
592
+ for (const knob of ["defaultTtl", "lockTimeout", "waitTimeout"]) {
593
+ const value = merged[knob];
594
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
595
+ throw new IdempotencyConfigError(`${knob} must be a positive number (got ${String(value)})`);
596
+ }
597
+ }
598
+ return merged;
528
599
  }
529
600
  getImports() {
530
601
  return this.asyncOptions?.imports ?? [];
@@ -576,6 +647,7 @@ exports.IDEMPOTENCY_REDIS_DRIVER = IDEMPOTENCY_REDIS_DRIVER;
576
647
  exports.IDEMPOTENCY_SERVICE = IDEMPOTENCY_SERVICE;
577
648
  exports.IDEMPOTENCY_STORE = IDEMPOTENCY_STORE;
578
649
  exports.IDEMPOTENT_OPTIONS = IDEMPOTENT_OPTIONS;
650
+ exports.IdempotencyConfigError = IdempotencyConfigError;
579
651
  exports.IdempotencyError = IdempotencyError;
580
652
  exports.IdempotencyFailedError = IdempotencyFailedError;
581
653
  exports.IdempotencyFingerprintMismatchError = IdempotencyFingerprintMismatchError;