diff --git a/package.json b/package.json index bea89d9e..e14f37b8 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "typecheck": "tsc -p ." }, "dependencies": { + "@types/ioredis": "^4.17.6", "@types/redis": "^2.8.30" }, "devDependencies": { @@ -78,6 +79,7 @@ "eslint-plugin-unicorn": "33.0.1", "expect-type": "0.11.0", "fs-syncer": "0.3.4-next.1", + "ioredis": "4.17.3", "jest": "27.0.5", "lodash": "4.17.21", "npm-run-all": "4.1.5", diff --git a/src/index.ts b/src/index.ts index eaa5f5aa..9dbcf859 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ export { CreateNodeRedisClient, } from "./node_redis"; +export { createIORedisClient, WrappedIORedisClient } from "./ioredis"; + // aliases for backwards-compatibility with v1.x /** @deprecated use `createNodeRedisClient` */ diff --git a/src/ioredis/index.ts b/src/ioredis/index.ts new file mode 100644 index 00000000..99ab69d3 --- /dev/null +++ b/src/ioredis/index.ts @@ -0,0 +1,22 @@ +import * as IORedis from "ioredis"; +import { Commands } from "../generated/interface"; +import { WrappedIORedisMulti } from "./multi"; + +export interface WrappedIORedisClient extends Omit { + ioredis: IORedis.Redis; + multi(): WrappedIORedisMulti; +} + +export interface CreateIORedisClient { + (): WrappedIORedisClient; + (ioredis: IORedis.Redis): WrappedIORedisClient; + (port?: number, host?: string, options?: IORedis.RedisOptions): WrappedIORedisClient; + (host?: string, options?: IORedis.RedisOptions): WrappedIORedisClient; + (options?: IORedis.RedisOptions): WrappedIORedisClient; +} + +export const createIORedisClient: CreateIORedisClient = (...clientArgs: any[]): WrappedIORedisClient => { + const ioredis = clientArgs[0] instanceof IORedis ? clientArgs[0] : new IORedis(...clientArgs); + + return Object.assign(ioredis, { ioredis }) as any; +}; diff --git a/src/ioredis/multi.ts b/src/ioredis/multi.ts new file mode 100644 index 00000000..cc76bcdf --- /dev/null +++ b/src/ioredis/multi.ts @@ -0,0 +1,31 @@ +import { Commands } from "../generated/interface"; +import { Push } from "../push"; + +declare module "../generated/interface" { + export interface ResultTypes { + /** + * This determines the correct type for a ioredis multi result. e.g. `multi.keys('foo:*')` should be a multi instance + * which will include include a `string[]` value in the array eventually returned by `.exec()`. + */ + ioredis_multi: WrappedIORedisMulti< + Push< + // get the original results from client context + Extract["results"], + // Then push in the new result (e.g. for `keys`, `Result = string[]`) + MultiResult + > + >; + } +} + +export type MultiResult = [Error, undefined] | [null, T]; + +export interface WrappedIORedisMulti + extends Omit, "exec"> { + /** Execute all commands issued after multi */ + exec(): Promise; +} + +export interface IORedisMultiMixins { + multi(): WrappedIORedisMulti; +} diff --git a/test/ioredis.test.ts b/test/ioredis.test.ts new file mode 100644 index 00000000..bc9a3d1d --- /dev/null +++ b/test/ioredis.test.ts @@ -0,0 +1,89 @@ +import { createIORedisClient } from "../src"; +import * as IORedis from "ioredis"; +import { expectTypeOf } from "expect-type"; +import { Commands } from "../src/generated/interface"; + +const client = createIORedisClient(); + +test("get and set", async () => { + expect(await client.set("foo", "bar")).toEqual("OK"); + expect(await client.get("foo")).toEqual("bar"); +}); + +test("consruct with existing ioredis instance", async () => { + const ioredis = new IORedis(); + + const client = createIORedisClient(ioredis); + + expect(client.ioredis).toBe(ioredis); + + // we don't need to worry too much about functionality - with ioredis the wrapper is purely a type helper + expect(client).toBe(ioredis); +}); + +test("type", async () => { + expectTypeOf(client).toMatchTypeOf>(); + + expectTypeOf(client.ioredis).toEqualTypeOf(); +}); + +test("multi", async () => { + await client.set("z:foo", "abc"); + + const multiResult = await client + .multi() + .setex("z:foo", "NOTANUMBER" as any, "xyz") + .keys("z:*") + .get("z:foo") + .exec(); + + expect(multiResult).toMatchInlineSnapshot(` + Array [ + Array [ + [ReplyError: ERR value is not an integer or out of range], + ], + Array [ + null, + Array [ + "z:foo", + ], + ], + Array [ + null, + "abc", + ], + ] + `); + + expectTypeOf(multiResult).toMatchTypeOf<{ length: 3 }>(); + + expectTypeOf(multiResult[0]).toEqualTypeOf<[Error, undefined] | [null, "OK"]>(); + expectTypeOf(multiResult[1]).toEqualTypeOf<[Error, undefined] | [null, string[]]>(); + expectTypeOf(multiResult[2]).toEqualTypeOf<[Error, undefined] | [null, string | null]>(); + + // usage - result requires null-checking + const [err, res] = multiResult[0]; + + expectTypeOf(res).toEqualTypeOf<"OK" | undefined>(); + expectTypeOf(err).toEqualTypeOf(); + + // @ts-expect-error + const _accessWithoutNullCheckError = () => res.slice(); + + const _accessWithNullCheckOk = () => res?.slice(); + + const firstResult = multiResult[0]; + const _accessTupleWithErrorCheckOk = () => (firstResult[0] ? undefined : firstResult[1].slice()); +}); + +test("hgetall is consistent with node_redis", async () => { + // node_redis transforms hgetall results into a dictionary. luckily ioredis does too + await client.hset("myhash", ["field1", "Hello"], ["field2", "Goodbye"]); + await client.hset("myhash", ["field3", "foo"]); + + expect(await client.hgetall("myhash")).toMatchObject({ + field1: "Hello", + field2: "Goodbye", + field3: "foo", + }); +}); diff --git a/yarn.lock b/yarn.lock index ff762a1d..4cf1735b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1448,6 +1448,13 @@ dependencies: "@types/node" "*" +"@types/ioredis@^4.17.6": + version "4.17.6" + resolved "https://registry.yarnpkg.com/@types/ioredis/-/ioredis-4.17.6.tgz#b3b8d6e9fed7f494da08d991d8110daab2e273b8" + integrity sha512-Vg3vuhveOaQeiubzGpVU9Hn3UJCi7rPe2TOphc3m3Y2EniHmytHp/wj5ZtfqDGBhFJGaSwbJvGQhhqd/Nqfy4Q== + dependencies: + "@types/node" "*" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -2472,6 +2479,11 @@ clone@^1.0.2: resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= +cluster-key-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d" + integrity sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw== + cmd-shim@^3.0.0, cmd-shim@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-3.0.3.tgz#2c35238d3df37d98ecdd7d5f6b8dc6b21cadc7cb" @@ -2933,7 +2945,7 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= -denque@^1.5.0: +denque@^1.1.0, denque@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de" integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ== @@ -4414,6 +4426,21 @@ io-ts@^2.2.4: resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-2.2.11.tgz#968a02f57b740fd422c8f9cf7ee99187854636f9" integrity sha512-lGxyPvZNhmo1U1Hy0ovjRtXljG6F59R3bZErK9XjiIQPlt4nuzbDNSwvalXvS7Z8iSZhx2S5GpNz5tVhpnDf6w== +ioredis@4.17.3: + version "4.17.3" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.17.3.tgz#9938c60e4ca685f75326337177bdc2e73ae9c9dc" + integrity sha512-iRvq4BOYzNFkDnSyhx7cmJNOi1x/HWYe+A4VXHBu4qpwJaGT1Mp+D2bVGJntH9K/Z/GeOM/Nprb8gB3bmitz1Q== + dependencies: + cluster-key-slot "^1.1.0" + debug "^4.1.1" + denque "^1.1.0" + lodash.defaults "^4.2.0" + lodash.flatten "^4.4.0" + redis-commands "1.5.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.0.1" + ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" @@ -5641,11 +5668,21 @@ lodash.clonedeep@^4.5.0, lodash.clonedeep@~4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= + lodash.escaperegexp@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c= +lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + lodash.ismatch@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" @@ -7283,6 +7320,11 @@ redeyed@~2.1.0: dependencies: esprima "~4.0.0" +redis-commands@1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.5.0.tgz#80d2e20698fe688f227127ff9e5164a7dd17e785" + integrity sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg== + redis-commands@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89" @@ -7849,6 +7891,11 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +standard-as-callback@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + stream-combiner2@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/stream-combiner2/-/stream-combiner2-1.1.1.tgz#fb4d8a1420ea362764e21ad4780397bebcb41cbe"