Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add dragonfly driver #353

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions docs/2.drivers/dragonfly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
icon: mingcute:dragonfly-line
---

# Dragonfly

> Store data in a drop-in Redis replacement - Dragonfly.

## Usage

::read-more{to="https://www.dragonflydb.io"}
Learn more about Dragonfly.
::

::note
Unstorage uses [`ioredis`](https://github.com/luin/ioredis) internally to connect to Dragonfly.
::

To use it, you will need to install `ioredis` in your project:

:pm-install{name="ioredis"}

Usage with single Dragonfly instance:

```js
import { createStorage } from "unstorage";
import dragonflyDriver from "unstorage/drivers/dragonfly";

const storage = createStorage({
driver: dragonflyDriver({
base: "unstorage",
host: 'HOSTNAME',
tls: true as any,
port: 6379,
password: 'DRAGONFLY_PASSWORD'
}),
});
```

Usage with Dragonfly cluster:

⚠️ If you connect to a cluster, you have to use `hastags` as prefix to avoid the dragonfly error `CROSSSLOT Keys in request don't hash to the same slot`. This means, the prefix has to be surrounded by curly braces, which forces the keys into the same hash slot.

```js
const storage = createStorage({
driver: dragonflyDriver({
base: "{unstorage}",
cluster: [
{
port: 6380,
host: "HOSTNAME",
},
],
clusterOptions: {
dragonflyOptions: {
tls: { servername: "HOSTNAME" },
password: "DRAGONFLY_PASSWORD",
},
},
}),
});
```

**Options:**

::note
Dragonfly is wire-compatible with Redis.
::

- `base`: Optional prefix to use for all keys. Can be used for namespacing. Has to be used as hastag prefix for dragonfly cluster mode.
- `url`: Url to use for connecting to dragonfly. Takes precedence over `host` option. Has the format `redis://<DRAGONFLY_USER>:<DRAGONFLY_PASSWORD>@<DRAGONFLY_HOST>:<DRAGONFLY_PORT>`
- `cluster`: List of dragonfly nodes to use for cluster mode. Takes precedence over `url` and `host` options.
- `clusterOptions`: Options to use for cluster mode.
- `ttl`: Default TTL for all items in **seconds**.

See [ioredis](https://github.com/luin/ioredis/blob/master/API.md#new-redisport-host-options) for all available options.

`lazyConnect` option is enabled by default so that connection happens on first redis operation.

**Transaction options:**

- `ttl`: Supported for `setItem(key, value, { ttl: number /* seconds */ })`
97 changes: 97 additions & 0 deletions src/drivers/dragonfly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { defineDriver, joinKeys } from "./utils";
import Redis, {
Cluster,
ClusterNode,
ClusterOptions,
RedisOptions,
} from "ioredis";

export interface DragonflyOptions extends RedisOptions {
/**
* Optional prefix to use for all keys. Can be used for namespacing.
*/
base?: string;

/**
* Url to use for connecting to dragonfly. Takes precedence over `host` option. Has the format `redis://<DRAGONFLY_USER>:<DRAGONFLY_PASSWORD>@<DRAGONFLY_HOST>:<DRAGONFLY_PORT>`
*/
url?: string;

/**
* List of dragonfly nodes to use for cluster mode. Takes precedence over `url` and `host` options.
*/
cluster?: ClusterNode[];

/**
* Options to use for cluster mode.
*/
clusterOptions?: ClusterOptions;

/**
* Default TTL for all items in seconds.
*/
ttl?: number;
}

const DRIVER_NAME = "dragonfly";

export default defineDriver((opts: DragonflyOptions) => {
let redisClient: Redis | Cluster;
const getRedisClient = () => {
if (redisClient) {
return redisClient;
}
if (opts.cluster) {
redisClient = new Redis.Cluster(opts.cluster, opts.clusterOptions);
} else if (opts.url) {
redisClient = new Redis(opts.url, opts);
} else {
redisClient = new Redis(opts);
}
return redisClient;
};

const base = (opts.base || "").replace(/:$/, "");
const p = (...keys: string[]) => joinKeys(base, ...keys); // Prefix a key. Uses base for backwards compatibility
const d = (key: string) => (base ? key.replace(base, "") : key); // Deprefix a key

return {
name: DRIVER_NAME,
options: opts,
getInstance: getRedisClient,
async hasItem(key) {
return Boolean(await getRedisClient().exists(p(key)));
},
async getItem(key) {
const value = await getRedisClient().get(p(key));
return value ?? null;
},
async setItem(key, value, tOptions) {
const ttl = tOptions?.ttl ?? opts.ttl;
if (ttl) {
await getRedisClient().set(p(key), value, "EX", ttl);
} else {
await getRedisClient().set(p(key), value);
}
},
async removeItem(key) {
await getRedisClient().del(p(key));
},
async getKeys(base) {
const keys: string[] = await getRedisClient().keys(p(base, "*"));
return keys.map((key) => d(key));
},
async clear(base) {
const keys = await getRedisClient().keys(p(base, "*"));
if (keys.length === 0) {
return;
}
return getRedisClient()
.del(keys)
.then(() => {});
},
dispose() {
return getRedisClient().disconnect();
},
};
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const builtinDrivers = {
cloudflareKVBinding: "unstorage/drivers/cloudflare-kv-binding",
cloudflareKVHTTP: "unstorage/drivers/cloudflare-kv-http",
cloudflareR2Binding: "unstorage/drivers/cloudflare-r2-binding",
dragonfly: "unstorage/drivers/dragonfly",
fs: "unstorage/drivers/fs",
fsLite: "unstorage/drivers/fs-lite",
github: "unstorage/drivers/github",
Expand Down Expand Up @@ -61,6 +62,7 @@ export type BuiltinDriverOptions = {
cloudflareR2Binding: ExtractOpts<
(typeof import("./drivers/cloudflare-r2-binding"))["default"]
>;
dragonfly: ExtractOpts<(typeof import("./drivers/dragonfly"))["default"]>;
fs: ExtractOpts<(typeof import("./drivers/fs"))["default"]>;
fsLite: ExtractOpts<(typeof import("./drivers/fs-lite"))["default"]>;
github: ExtractOpts<(typeof import("./drivers/github"))["default"]>;
Expand Down
49 changes: 49 additions & 0 deletions test/drivers/dragonfly.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, vi, it, expect } from "vitest";
import * as ioredis from "ioredis-mock";
import dragonflyDriver from "../../src/drivers/dragonfly";
import { testDriver } from "./utils";

vi.mock("ioredis", () => ioredis);

describe("drivers: redis", () => {
const driver = dragonflyDriver({
base: "test:",
url: "ioredis://localhost:6379/0",
lazyConnect: false,
});

testDriver({
driver,
additionalTests() {
it("verify stored keys", async () => {
const client = new ioredis.default("ioredis://localhost:6379/0");
const keys = await client.keys("*");
expect(keys).toMatchInlineSnapshot(`
[
"test:s1:a",
"test:s2:a",
"test:s3:a",
"test:data:test.json",
"test:data:true.json",
"test:data:serialized1.json",
"test:data:serialized2.json",
"test:data:raw.bin",
"test:t:1",
"test:t:2",
"test:t:3",
"test:v1:a",
"test:v2:a",
"test:v3:a",
"test:zero",
"test:my-false-flag",
]
`);
await client.disconnect();
});

it("exposes instance", () => {
expect(driver.getInstance()).toBeInstanceOf(ioredis.default);
});
},
});
});