diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 43b8143..92b2674 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -25,11 +25,21 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + redis: + image: redis:8 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 permissions: checks: write pull-requests: write env: POSTGRES_URL: postgres://postgres:postgres@localhost:5432/botkit_test + REDIS_URL: redis://localhost:6379/0 steps: - uses: actions/checkout@v4 - uses: jdx/mise-action@v4 diff --git a/CHANGES.md b/CHANGES.md index 968d738..4ea8268 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -188,6 +188,18 @@ To be released. - Upgraded Fedify to 2.3.1, LogTape to 2.2.3, and Postgres.js to 3.4.9. +### @fedify/botkit-redis + + - Added the new *@fedify/botkit-redis* package, which provides + `RedisRepository`, a Redis-backed implementation of BotKit's + `Repository` interface for bots running on Deno or Node.js. It supports + URL-managed and caller-managed Redis clients, configurable key prefixes, + bot-scoped storage, quote authorization storage, reverse lookups, and + poll votes. [[#12], [#35]] + +[#12]: https://github.com/fedify-dev/botkit/issues/12 +[#35]: https://github.com/fedify-dev/botkit/pull/35 + Version 0.4.4 ------------- diff --git a/deno.lock b/deno.lock index 40130cb..9908a13 100644 --- a/deno.lock +++ b/deno.lock @@ -57,6 +57,7 @@ "npm:mime-db@^1.54.0": "1.54.0", "npm:pkijs@^3.2.5": "3.4.0", "npm:postgres@^3.4.9": "3.4.9", + "npm:redis@^6.1.0": "6.1.0_@opentelemetry+api@1.9.1", "npm:structured-field-values@^2.0.4": "2.0.4", "npm:tsdown@~0.12.8": "0.12.9_rolldown@1.0.0-beta.55", "npm:url-template@^3.1.1": "3.1.1", @@ -546,6 +547,40 @@ "quansync" ] }, + "@redis/bloom@6.1.0_@redis+client@6.1.0__@opentelemetry+api@1.9.1_@opentelemetry+api@1.9.1": { + "integrity": "sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/client@6.1.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==", + "dependencies": [ + "@opentelemetry/api", + "cluster-key-slot" + ], + "optionalPeers": [ + "@opentelemetry/api" + ] + }, + "@redis/json@6.1.0_@redis+client@6.1.0__@opentelemetry+api@1.9.1_@opentelemetry+api@1.9.1": { + "integrity": "sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/search@6.1.0_@redis+client@6.1.0__@opentelemetry+api@1.9.1_@opentelemetry+api@1.9.1": { + "integrity": "sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/time-series@6.1.0_@redis+client@6.1.0__@opentelemetry+api@1.9.1_@opentelemetry+api@1.9.1": { + "integrity": "sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==", + "dependencies": [ + "@redis/client" + ] + }, "@rolldown/binding-android-arm64@1.0.0-beta.55": { "integrity": "sha512-5cPpHdO+zp+klznZnIHRO1bMHDq5hS9cqXodEKAaa/dQTPDjnE91OwAsy3o1gT2x4QaY8NzdBXAvutYdaw0WeA==", "os": ["android"], @@ -678,6 +713,9 @@ "readdirp" ] }, + "cluster-key-slot@1.1.2": { + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, "commander@2.20.3": { "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, @@ -868,6 +906,16 @@ "readdirp@4.1.2": { "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" }, + "redis@6.1.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==", + "dependencies": [ + "@redis/bloom", + "@redis/client", + "@redis/json", + "@redis/search", + "@redis/time-series" + ] + }, "resolve-pkg-maps@1.0.0": { "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==" }, @@ -1056,6 +1104,19 @@ ] } }, + "packages/botkit-redis": { + "dependencies": [ + "jsr:@fedify/fedify@^2.3.1", + "jsr:@fedify/vocab@^2.3.1", + "jsr:@logtape/logtape@^2.2.3", + "npm:redis@^6.1.0" + ], + "packageJson": { + "dependencies": [ + "npm:redis@^6.1.0" + ] + } + }, "packages/botkit-sqlite": { "dependencies": [ "jsr:@fedify/vocab@^2.3.1" diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 1ed9ca7..0668c9e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -62,6 +62,10 @@ const references = { text: "@fedify/botkit-postgres", link: "https://jsr.io/@fedify/botkit-postgres/doc", }, + { + text: "@fedify/botkit-redis", + link: "https://jsr.io/@fedify/botkit-redis/doc", + }, { text: "@fedify/botkit-sqlite", link: "https://jsr.io/@fedify/botkit-sqlite/doc", diff --git a/docs/concepts/repository.md b/docs/concepts/repository.md index 6a73d87..e463af3 100644 --- a/docs/concepts/repository.md +++ b/docs/concepts/repository.md @@ -164,6 +164,78 @@ properties: [`node:sqlite`]: https://nodejs.org/api/sqlite.html +`RedisRepository` +----------------- + +*This API is available since BotKit 0.5.0.* + +The `RedisRepository` is a repository that stores data in [Redis] using +the [node-redis] client. It is suited for deployments where multiple bot +processes need to share the same repository state, or where you already +operate Redis for other BotKit infrastructure. + +Unlike [`KvRepository`](#kvrepository), `RedisRepository` stores BotKit data +directly in Redis data structures such as strings, sets, and sorted sets. +It supports either an internally owned Redis client created from a connection +URL or an injected client whose lifecycle stays under your control. + +In order to use `RedisRepository`, you need to install the +*@fedify/botkit-redis* package: + +::: code-group + +~~~~ sh [Deno] +deno add jsr:@fedify/botkit-redis +~~~~ + +~~~~ sh [npm] +npm add @fedify/botkit-redis +~~~~ + +~~~~ sh [pnpm] +pnpm add @fedify/botkit-redis +~~~~ + +~~~~ sh [Yarn] +yarn add @fedify/botkit-redis +~~~~ + +::: + +The `RedisRepository` constructor accepts the following properties: + +`url` +: A Redis connection string used to create an internal client. Exactly one + of `url` and `client` must be provided. + +`client` +: An existing node-redis compatible client. When this is provided, the + repository does not own the client and calling `close()` will not shut it + down. + +`prefix` (optional) +: The Redis key prefix used for BotKit data. Defaults to `"botkit"`. + +`clientOptions` (optional) +: Additional node-redis client options. This option is only valid when + `url` is used. + +`lockTimeoutMs` (optional) +: How long a Redis lock can live without renewal, in milliseconds. + Defaults to `30000`. + +`lockPollIntervalMs` (optional) +: How long to wait before retrying a held Redis lock, in milliseconds. + Defaults to `20`. + +`lockRenewIntervalMs` (optional) +: How often to renew a held Redis lock, in milliseconds. Defaults to + `10000`. + +[Redis]: https://redis.io/ +[node-redis]: https://github.com/redis/node-redis + + `PostgresRepository` -------------------- diff --git a/docs/package.json b/docs/package.json index 67836e3..e5d0de0 100644 --- a/docs/package.json +++ b/docs/package.json @@ -2,6 +2,7 @@ "devDependencies": { "@fedify/botkit": "workspace:", "@fedify/botkit-postgres": "workspace:", + "@fedify/botkit-redis": "workspace:", "@fedify/botkit-sqlite": "workspace:", "@fedify/denokv": "jsr:@fedify/denokv@^2.3.1", "@fedify/fedify": "catalog:", @@ -19,6 +20,7 @@ "markdown-it-footnote": "^4.0.0", "markdown-it-jsr-ref": "^0.5.0", "postgres": "^3.4.9", + "redis": "^6.1.0", "srvx": "^0.11.20", "typescript": "^5.8.3", "vitepress": "^2.0.0-alpha.17", diff --git a/mise.toml b/mise.toml index c4c4f81..e972fde 100644 --- a/mise.toml +++ b/mise.toml @@ -110,6 +110,21 @@ outputs = [ ] run = "pnpm --filter @fedify/botkit-postgres run build" +[tasks."build:botkit-redis"] +description = "Build @fedify/botkit-redis" +depends = ["build:botkit"] +sources = [ + "packages/botkit-redis/deno.json", + "packages/botkit-redis/package.json", + "packages/botkit-redis/tsdown.config.ts", + "packages/botkit-redis/src/**/*", +] +outputs = [ + "packages/botkit-redis/dist/mod.js", + "packages/botkit-redis/dist/mod.d.ts", +] +run = "pnpm --filter @fedify/botkit-redis run build" + [tasks."test:node"] description = "Build packages and run Node.js tests" depends = ["build:*"] diff --git a/packages/botkit-redis/README.md b/packages/botkit-redis/README.md new file mode 100644 index 0000000..4e93c8d --- /dev/null +++ b/packages/botkit-redis/README.md @@ -0,0 +1,115 @@ +@fedify/botkit-redis +==================== + +[![JSR][JSR badge]][JSR] +[![npm][npm badge]][npm] +[![GitHub Actions][GitHub Actions badge]][GitHub Actions] + +This package is a [Redis]-based repository implementation for [BotKit]. It +provides shared persistent storage for bots running on either [Deno] or +[Node.js], using Redis data structures for BotKit repository data. + +[JSR badge]: https://jsr.io/badges/@fedify/botkit-redis +[JSR]: https://jsr.io/@fedify/botkit-redis +[npm badge]: https://img.shields.io/npm/v/@fedify/botkit-redis?logo=npm +[npm]: https://www.npmjs.com/package/@fedify/botkit-redis +[GitHub Actions badge]: https://github.com/fedify-dev/botkit/actions/workflows/main.yaml/badge.svg +[GitHub Actions]: https://github.com/fedify-dev/botkit/actions/workflows/main.yaml +[Redis]: https://redis.io/ +[BotKit]: https://botkit.fedify.dev/ +[Deno]: https://deno.land/ +[Node.js]: https://nodejs.org/ + + +Installation +------------ + +~~~~ sh +deno add jsr:@fedify/botkit-redis +npm add @fedify/botkit-redis +pnpm add @fedify/botkit-redis +yarn add @fedify/botkit-redis +~~~~ + + +Usage +----- + +The `RedisRepository` can be used as a drop-in repository implementation for +BotKit: + +~~~~ typescript +import { createBot, MemoryKvStore } from "@fedify/botkit"; +import { RedisRepository } from "@fedify/botkit-redis"; + +const bot = createBot({ + username: "mybot", + kv: new MemoryKvStore(), + repository: new RedisRepository({ + url: "redis://localhost:6379/0", + prefix: "botkit", + }), +}); +~~~~ + +You can also inject an existing node-redis client. In that case the +repository does not own the client and `close()` will not shut it down: + +~~~~ typescript +import { RedisRepository } from "@fedify/botkit-redis"; +import { createClient } from "redis"; + +const client = createClient({ url: "redis://localhost:6379/0" }); +await client.connect(); + +const repository = new RedisRepository({ + client, + prefix: "botkit", +}); +~~~~ + + +Options +------- + +The `RedisRepository` constructor accepts the following options: + + - **`url`**: A Redis connection string for an internally managed client. + + - **`client`**: An existing node-redis compatible client to use. + + - **`prefix`** (optional): Redis key prefix used for BotKit data. Defaults + to `"botkit"`. + + - **`clientOptions`** (optional): Additional node-redis client options. + This option is only valid together with `url`. + + - **`lockTimeoutMs`** (optional): How long a Redis lock can live without + renewal, in milliseconds. Defaults to `30000`. + + - **`lockPollIntervalMs`** (optional): How long to wait before retrying a + held Redis lock, in milliseconds. Defaults to `20`. + + - **`lockRenewIntervalMs`** (optional): How often to renew a held Redis lock, + in milliseconds. Defaults to `10000`. + +The options are mutually exclusive: use either `client` or `url`. + + +Features +-------- + + - **Cross-runtime**: Works with both Deno and Node.js using node-redis. + + - **Shared persistent storage**: Suitable for multi-process deployments + backed by Redis. + + - **Key namespacing**: Keeps BotKit data under a configurable Redis prefix. + + - **Full `Repository` API**: Implements BotKit repository storage for key + pairs, messages, followers, follows, followees, quote authorizations, and + poll votes. + + - **Explicit resource ownership**: Repositories created from a URL own their + Redis client, while repositories created from an injected client leave + lifecycle control to the caller. diff --git a/packages/botkit-redis/deno.json b/packages/botkit-redis/deno.json new file mode 100644 index 0000000..18c8d2b --- /dev/null +++ b/packages/botkit-redis/deno.json @@ -0,0 +1,26 @@ +{ + "name": "@fedify/botkit-redis", + "version": "0.5.0", + "license": "AGPL-3.0-only", + "exports": { + ".": "./src/mod.ts" + }, + "imports": { + "@fedify/fedify": "jsr:@fedify/fedify@^2.3.1", + "@fedify/vocab": "jsr:@fedify/vocab@^2.3.1", + "@logtape/logtape": "jsr:@logtape/logtape@^2.2.3", + "redis": "npm:redis@^6.1.0" + }, + "exclude": [ + "dist", + "junit.xml", + "package.json" + ], + "fmt": { + "exclude": [ + "*.md", + "*.yaml", + "*.yml" + ] + } +} diff --git a/packages/botkit-redis/package.json b/packages/botkit-redis/package.json new file mode 100644 index 0000000..243d816 --- /dev/null +++ b/packages/botkit-redis/package.json @@ -0,0 +1,64 @@ +{ + "name": "@fedify/botkit-redis", + "version": "0.5.0", + "description": "Redis-based repository for BotKit", + "license": "AGPL-3.0-only", + "author": { + "name": "Hong Minhee", + "email": "hong@minhee.org", + "url": "https://hongminhee.org/" + }, + "homepage": "https://botkit.fedify.dev/", + "repository": { + "type": "git", + "url": "git+https://github.com/fedify-dev/botkit.git", + "directory": "packages/botkit-redis" + }, + "bugs": { + "url": "https://github.com/fedify-dev/botkit/issues" + }, + "funding": [ + "https://opencollective.com/fedify", + "https://github.com/sponsors/dahlia" + ], + "engines": { + "deno": ">=2.0.0", + "node": ">=22.0.0" + }, + "type": "module", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", + "exports": { + ".": { + "types": "./dist/mod.d.ts", + "import": "./dist/mod.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": true, + "files": [ + "dist", + "LICENSE", + "package.json", + "README.md" + ], + "peerDependencies": { + "@fedify/botkit": "workspace:^" + }, + "dependencies": { + "@fedify/fedify": "catalog:", + "@fedify/vocab": "catalog:", + "@js-temporal/polyfill": "catalog:", + "@logtape/logtape": "catalog:", + "redis": "^6.1.0" + }, + "devDependencies": { + "tsdown": "catalog:" + }, + "scripts": { + "build": "tsdown", + "prepack": "tsdown", + "prepublish": "mise run check && tsdown", + "test": "tsdown && cd src/ && node --test --experimental-transform-types" + } +} diff --git a/packages/botkit-redis/src/mod.test.ts b/packages/botkit-redis/src/mod.test.ts new file mode 100644 index 0000000..f3aca3e --- /dev/null +++ b/packages/botkit-redis/src/mod.test.ts @@ -0,0 +1,1528 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { RedisRepository } from "@fedify/botkit-redis"; +import { exportJwk } from "@fedify/fedify/sig"; +import { configureSync, type LogRecord, resetSync } from "@logtape/logtape"; +import { + Create, + Follow, + Note, + Person, + PUBLIC_COLLECTION, + QuoteAuthorization, +} from "@fedify/vocab"; +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { createClient } from "redis"; + +if (!("Temporal" in globalThis)) { + globalThis.Temporal = (await import("@js-temporal" + "/polyfill")).Temporal; +} + +function getRedisUrl(): string | undefined { + if ("process" in globalThis) return globalThis.process.env.REDIS_URL; + if ("Deno" in globalThis) return globalThis.Deno.env.get("REDIS_URL"); + return undefined; +} + +const redisUrl = getRedisUrl(); + +interface TestRedisClient { + readonly isOpen: boolean; + sendCommand(args: readonly string[]): Promise; +} + +interface DelayedGetState { + count: number; + readonly waiters: (() => void)[]; +} + +interface Deferred { + readonly promise: Promise; + resolve(): void; +} + +async function createKeyPairs(): Promise { + return [ + await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ), + ]; +} + +function createMessage(id: string, content: string, published: string): Create { + return new Create({ + id: new URL(`https://example.com/ap/actor/bot/create/${id}`), + actor: new URL("https://example.com/ap/actor/bot"), + to: new URL("https://example.com/ap/actor/bot/followers"), + cc: PUBLIC_COLLECTION, + object: new Note({ + id: new URL(`https://example.com/ap/actor/bot/note/${id}`), + attribution: new URL("https://example.com/ap/actor/bot"), + content, + published: Temporal.Instant.from(published), + }), + published: Temporal.Instant.from(published), + }); +} + +async function getMessageContent(message: Create): Promise { + const json = await message.toJsonLd({ format: "compact" }); + if (typeof json !== "object" || json == null) { + throw new TypeError("Expected a JSON-LD object."); + } + if (!("object" in json)) { + throw new TypeError("Expected a Create object."); + } + const object = json.object; + if (typeof object !== "object" || object == null) { + throw new TypeError("Expected a nested object."); + } + if (!("content" in object) || typeof object.content !== "string") { + throw new TypeError("Expected string content."); + } + return object.content; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function cleanupPrefix(url: string, prefix: string): Promise { + const client = createClient({ url }); + await client.connect(); + try { + let cursor = "0"; + for (;;) { + const reply = await client.sendCommand([ + "SCAN", + cursor, + "MATCH", + `${prefix}:*`, + "COUNT", + "1000", + ]); + assert.ok(Array.isArray(reply)); + const keys = reply[1]; + assert.ok(Array.isArray(keys)); + if (keys.length > 0) { + await client.sendCommand(["DEL", ...keys.map(String)]); + } + cursor = String(reply[0]); + if (cursor === "0") break; + } + } finally { + await client.quit(); + } +} + +function createHarness() { + if (redisUrl == null) throw new Error("REDIS_URL is not set."); + const prefix = `botkit_test_${crypto.randomUUID()}`; + const repository = new RedisRepository({ url: redisUrl, prefix }); + return { + prefix, + repository, + async cleanup() { + await repository.close(); + await cleanupPrefix(redisUrl, prefix); + }, + }; +} + +function createDelayedGetClient( + client: TestRedisClient, + key: string, + state: DelayedGetState, +) { + return { + get isOpen(): boolean { + return client.isOpen; + }, + + sendCommand(args: readonly string[]): Promise { + return (async () => { + if (args[0] === "GET" && args[1] === key) { + state.count++; + if (state.count < 2) { + await new Promise((resolve) => { + state.waiters.push(resolve); + setTimeout(resolve, 50); + }); + } else { + for (const resolve of state.waiters) resolve(); + state.waiters.length = 0; + } + } + return await client.sendCommand([...args]); + })(); + }, + }; +} + +function createDeferred(): Deferred { + let resolve = () => {}; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function createDelayedDeleteClient( + client: TestRedisClient, + key: string, + observed: Deferred, + release: Deferred, +) { + return { + get isOpen(): boolean { + return client.isOpen; + }, + + sendCommand(args: readonly string[]): Promise { + return (async () => { + if (args[0] === "DEL" && args[1] === key) { + observed.resolve(); + await release.promise; + } + return await client.sendCommand([...args]); + })(); + }, + }; +} + +function createDelayedCommandClient( + client: TestRedisClient, + matches: (args: readonly string[]) => boolean, + observed: Deferred, + release: Deferred, +) { + return { + get isOpen(): boolean { + return client.isOpen; + }, + + sendCommand(args: readonly string[]): Promise { + return (async () => { + if (matches(args)) { + observed.resolve(); + await release.promise; + } + return await client.sendCommand([...args]); + })(); + }, + }; +} + +function createRecordingClient( + client: TestRedisClient, + commands: string[][], +) { + return { + get isOpen(): boolean { + return client.isOpen; + }, + + async sendCommand(args: readonly string[]): Promise { + commands.push([...args]); + return await client.sendCommand([...args]); + }, + }; +} + +if (redisUrl == null) { + test("RedisRepository integration tests", { skip: true }, () => {}); +} else { + describe("RedisRepository", () => { + test("rejects invalid constructor option combinations", () => { + assert.throws( + () => Reflect.construct(RedisRepository, [undefined]), + new TypeError("RedisRepositoryOptions must be provided."), + ); + assert.throws( + () => Reflect.construct(RedisRepository, [null]), + new TypeError("RedisRepositoryOptions must be provided."), + ); + assert.throws( + () => Reflect.construct(RedisRepository, [{}]), + new TypeError( + "RedisRepositoryOptions must provide exactly one of client or url.", + ), + ); + assert.throws( + () => + Reflect.construct(RedisRepository, [{ + client: { sendCommand: () => Promise.resolve(undefined) }, + url: redisUrl, + }]), + new TypeError( + "RedisRepositoryOptions must provide exactly one of client or url.", + ), + ); + assert.throws( + () => + new RedisRepository({ + client: { sendCommand: () => Promise.resolve(undefined) }, + lockTimeoutMs: 0, + }), + new RangeError("lockTimeoutMs must be a positive number."), + ); + assert.throws( + () => + new RedisRepository({ + client: { sendCommand: () => Promise.resolve(undefined) }, + lockTimeoutMs: 1_000, + lockRenewIntervalMs: 1_000, + }), + new RangeError( + "lockRenewIntervalMs must be less than lockTimeoutMs.", + ), + ); + }); + + test("key pairs", async () => { + const { repository, cleanup } = createHarness(); + try { + const keyPairs = await createKeyPairs(); + assert.deepStrictEqual(await repository.getKeyPairs("bot"), undefined); + await repository.setKeyPairs("bot", keyPairs); + assert.deepStrictEqual( + await Promise.all( + (await repository.getKeyPairs("bot"))!.map((pair) => + exportJwk(pair.publicKey) + ), + ), + await Promise.all(keyPairs.map((pair) => exportJwk(pair.publicKey))), + ); + } finally { + await cleanup(); + } + }); + + test("does not close injected clients", async () => { + if (redisUrl == null) throw new Error("REDIS_URL is not set."); + const prefix = `botkit_test_${crypto.randomUUID()}`; + const client = createClient({ url: redisUrl }); + await client.connect(); + const repository = new RedisRepository({ client, prefix }); + const rejectedReady = Promise.reject(new TypeError("Unexpected await.")); + rejectedReady.catch(() => {}); + Reflect.set(repository, "ready", rejectedReady); + try { + assert.ok(client.isOpen); + await repository.close(); + assert.ok(client.isOpen); + await assert.rejects( + () => repository.countMessages("bot"), + new TypeError("RedisRepository is closed."), + ); + } finally { + await client.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("closes owned clients after failed initial connection", async () => { + const repository = new RedisRepository({ + url: new URL("redis://127.0.0.1:1"), + clientOptions: { + socket: { + connectTimeout: 50, + reconnectStrategy: false, + }, + }, + }); + await assert.doesNotReject(repository.close()); + }); + + test("uses already open owned clients without reconnecting", async () => { + const { repository, cleanup } = createHarness(); + try { + assert.deepStrictEqual(await repository.countMessages("bot"), 0); + Reflect.set(repository, "ready", undefined); + await assert.doesNotReject(repository.countMessages("bot")); + } finally { + await cleanup(); + } + }); + + test("awaits pending readiness before commands", async () => { + const ready = createDeferred(); + let readyResolved = false; + let connectCalled = false; + let commandFinished = false; + const repository = new RedisRepository({ + client: { + get isOpen(): boolean { + return true; + }, + + connect(): Promise { + connectCalled = true; + return Promise.reject(new TypeError("Unexpected reconnect.")); + }, + + sendCommand(args: readonly string[]): Promise { + assert.deepStrictEqual(args[0], "ZCARD"); + assert.ok(readyResolved); + return Promise.resolve(0); + }, + }, + }); + Reflect.set(repository, "ownsClient", true); + Reflect.set( + repository, + "ready", + ready.promise.then(() => { + readyResolved = true; + }), + ); + const countPromise = repository.countMessages("bot").then((count) => { + commandFinished = true; + return count; + }); + await delay(50); + assert.ok(!commandFinished); + assert.ok(!connectCalled); + ready.resolve(); + assert.deepStrictEqual(await countPromise, 0); + assert.ok(!connectCalled); + }); + + test("messages basic operations and ordering", async () => { + const { repository, cleanup } = createHarness(); + try { + const firstId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const secondId = "01942976-3400-7f34-872e-2cbf0f9eeac4"; + const first = createMessage(firstId, "first", "2025-01-01T00:00:00Z"); + const second = createMessage( + secondId, + "second", + "2025-01-02T00:00:00Z", + ); + + assert.deepStrictEqual(await repository.countMessages("bot"), 0); + await repository.addMessage("bot", firstId, first); + await repository.addMessage("bot", secondId, second); + assert.deepStrictEqual(await repository.countMessages("bot"), 2); + assert.deepStrictEqual( + await repository.getMessage("other", firstId), + undefined, + ); + assert.deepStrictEqual( + await (await repository.getMessage("bot", firstId))?.toJsonLd(), + await first.toJsonLd(), + ); + assert.deepStrictEqual( + (await Array.fromAsync( + repository.getMessages("bot", { order: "oldest" }), + )).length, + 2, + ); + assert.deepStrictEqual( + (await Array.fromAsync(repository.getMessages("bot", { + since: Temporal.Instant.from("2025-01-02T00:00:00Z"), + }))).length, + 1, + ); + assert.deepStrictEqual( + await (await repository.removeMessage("bot", firstId))?.toJsonLd(), + await first.toJsonLd(), + ); + assert.deepStrictEqual(await repository.countMessages("bot"), 1); + } finally { + await cleanup(); + } + }); + + test("fetches listed messages in batched Redis commands", async () => { + const prefix = `botkit_test_${crypto.randomUUID()}`; + const client = createClient({ url: redisUrl }); + await client.connect(); + const commands: string[][] = []; + const repository = new RedisRepository({ + client: createRecordingClient(client, commands), + prefix, + }); + try { + for (let i = 0; i < 101; i++) { + const sequence = i.toString(16).padStart(4, "0"); + const id: `${string}-${string}-${string}-${string}-${string}` = + `01941f29-${sequence}-7fe8-ab0a-7b593990a3c0`; + await repository.addMessage( + "bot", + id, + createMessage(id, `message ${i}`, "2025-01-01T00:00:00Z"), + ); + } + + commands.length = 0; + assert.deepStrictEqual( + (await Array.fromAsync(repository.getMessages("bot"))).length, + 101, + ); + const mgets = commands.filter(([command]) => command === "MGET"); + assert.deepStrictEqual(mgets.length, 2); + assert.ok(mgets.every((mget) => mget.length <= 101)); + assert.ok( + !commands.some(([command, key]) => + command === "GET" && key?.includes(":messages:") + ), + ); + } finally { + await repository.close(); + await client.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("removes corrupted messages without throwing", async () => { + const { repository, cleanup, prefix } = createHarness(); + const client = createClient({ url: redisUrl }); + await client.connect(); + try { + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const key = `${prefix}:bots:bot:messages:${messageId}`; + await client.sendCommand(["SET", key, "{"]); + await client.sendCommand([ + "ZADD", + `${prefix}:bots:bot:messages`, + "0", + messageId, + ]); + assert.deepStrictEqual( + await repository.removeMessage("bot", messageId), + undefined, + ); + assert.deepStrictEqual(await client.sendCommand(["GET", key]), null); + assert.deepStrictEqual(await repository.countMessages("bot"), 0); + } finally { + await client.quit(); + await cleanup(); + } + }); + + test("ignores corrupted messages during updates", async () => { + const { repository, cleanup, prefix } = createHarness(); + const client = createClient({ url: redisUrl }); + await client.connect(); + try { + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + await client.sendCommand([ + "SET", + `${prefix}:bots:bot:messages:${messageId}`, + "{", + ]); + assert.deepStrictEqual( + await repository.updateMessage("bot", messageId, () => { + throw new TypeError("Unexpected updater call."); + }), + false, + ); + } finally { + await client.quit(); + await cleanup(); + } + }); + + test("serializes concurrent message updates", async () => { + const { repository, cleanup } = createHarness(); + try { + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const message = createMessage( + messageId, + "base", + "2025-01-01T00:00:00Z", + ); + await repository.addMessage("bot", messageId, message); + + let activeUpdaters = 0; + let maxActiveUpdaters = 0; + const append = (suffix: string) => + repository.updateMessage("bot", messageId, async (existing) => { + assert.ok(existing instanceof Create); + activeUpdaters++; + maxActiveUpdaters = Math.max(maxActiveUpdaters, activeUpdaters); + const content = await getMessageContent(existing); + await delay(50); + activeUpdaters--; + return existing.clone({ + object: new Note({ + content: `${content}${suffix}`, + }), + }); + }); + + assert.deepStrictEqual(await Promise.all([append("A"), append("B")]), [ + true, + true, + ]); + assert.deepStrictEqual(maxActiveUpdaters, 1); + + const updated = await repository.getMessage("bot", messageId); + assert.ok(updated instanceof Create); + const content = await getMessageContent(updated); + assert.ok(content === "baseAB" || content === "baseBA"); + } finally { + await cleanup(); + } + }); + + test("renews Redis locks during long message updates", async () => { + const prefix = `botkit_test_${crypto.randomUUID()}`; + const firstClient = createClient({ url: redisUrl }); + const secondClient = createClient({ url: redisUrl }); + await firstClient.connect(); + await secondClient.connect(); + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const firstRepository = new RedisRepository({ + client: firstClient, + prefix, + lockTimeoutMs: 1_500, + lockRenewIntervalMs: 500, + }); + const secondRepository = new RedisRepository({ + client: secondClient, + prefix, + lockTimeoutMs: 1_500, + lockRenewIntervalMs: 500, + }); + try { + const message = createMessage( + messageId, + "base", + "2025-01-01T00:00:00Z", + ); + await firstRepository.addMessage("bot", messageId, message); + + let activeUpdaters = 0; + let maxActiveUpdaters = 0; + const append = ( + repository: RedisRepository, + suffix: string, + waitMs: number, + ) => + repository.updateMessage("bot", messageId, async (existing) => { + assert.ok(existing instanceof Create); + activeUpdaters++; + maxActiveUpdaters = Math.max(maxActiveUpdaters, activeUpdaters); + const content = await getMessageContent(existing); + await delay(waitMs); + activeUpdaters--; + return existing.clone({ + object: new Note({ + content: `${content}${suffix}`, + }), + }); + }); + + assert.deepStrictEqual( + await Promise.all([ + append(firstRepository, "A", 2_200), + append(secondRepository, "B", 50), + ]), + [true, true], + ); + assert.deepStrictEqual(maxActiveUpdaters, 1); + + const updated = await firstRepository.getMessage("bot", messageId); + assert.ok(updated instanceof Create); + const content = await getMessageContent(updated); + assert.ok(content === "baseAB" || content === "baseBA"); + } finally { + await firstRepository.close(); + await secondRepository.close(); + await firstClient.quit(); + await secondClient.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("stops renewing Redis locks after close", async () => { + const prefix = `botkit_test_${crypto.randomUUID()}`; + const client = createClient({ url: redisUrl }); + await client.connect(); + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const repository = new RedisRepository({ + client, + prefix, + lockTimeoutMs: 500, + lockRenewIntervalMs: 50, + }); + const records: LogRecord[] = []; + const release = createDeferred(); + let update: Promise | undefined; + try { + await repository.addMessage( + "bot", + messageId, + createMessage(messageId, "base", "2025-01-01T00:00:00Z"), + ); + configureSync({ + sinks: { + capture: (record) => records.push(record), + }, + loggers: [ + { + category: ["botkit", "redis"], + sinks: ["capture"], + lowestLevel: "warning", + }, + { + category: ["logtape", "meta"], + lowestLevel: null, + }, + ], + reset: true, + }); + + let updaterStarted = false; + update = repository.updateMessage( + "bot", + messageId, + async (existing) => { + assert.ok(existing instanceof Create); + updaterStarted = true; + await release.promise; + return existing; + }, + ); + while (!updaterStarted) await delay(1); + + await repository.close(); + await delay(150); + + assert.deepStrictEqual(records, []); + release.resolve(); + await assert.rejects( + update, + new TypeError("RedisRepository is closed."), + ); + } finally { + release.resolve(); + await update?.catch(() => undefined); + resetSync(); + await client.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("serializes message removal with concurrent updates", async () => { + const { repository, cleanup } = createHarness(); + try { + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const message = createMessage( + messageId, + "base", + "2025-01-01T00:00:00Z", + ); + await repository.addMessage("bot", messageId, message); + + let updaterStarted = false; + const update = repository.updateMessage( + "bot", + messageId, + async (existing) => { + assert.ok(existing instanceof Create); + updaterStarted = true; + await delay(50); + const content = await getMessageContent(existing); + return existing.clone({ + object: new Note({ + content: `${content}A`, + }), + }); + }, + ); + while (!updaterStarted) await delay(1); + const removal = repository.removeMessage("bot", messageId); + const [updated, removed] = await Promise.all([update, removal]); + + assert.ok(updated); + assert.deepStrictEqual( + await removed?.toJsonLd(), + await message.clone({ + object: new Note({ + content: "baseA", + }), + }).toJsonLd(), + ); + assert.deepStrictEqual( + await repository.getMessage("bot", messageId), + undefined, + ); + assert.deepStrictEqual(await repository.countMessages("bot"), 0); + } finally { + await cleanup(); + } + }); + + test("serializes message indexing with removal", async () => { + const prefix = `botkit_test_${crypto.randomUUID()}`; + const addClient = createClient({ url: redisUrl }); + const removeClient = createClient({ url: redisUrl }); + await addClient.connect(); + await removeClient.connect(); + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const indexKey = [ + prefix, + "bots", + "bot", + "messages", + ].join(":"); + const observed = createDeferred(); + const release = createDeferred(); + const addRepository = new RedisRepository({ + client: createDelayedCommandClient( + addClient, + (args) => + args[0] === "ZADD" && args[1] === indexKey && + args.at(-1) === messageId, + observed, + release, + ), + prefix, + }); + const removeRepository = new RedisRepository({ + client: removeClient, + prefix, + }); + try { + const message = createMessage( + messageId, + "base", + "2025-01-01T00:00:00Z", + ); + + const add = addRepository.addMessage("bot", messageId, message); + await observed.promise; + const removal = removeRepository.removeMessage("bot", messageId); + await delay(50); + release.resolve(); + + await add; + assert.deepStrictEqual( + await (await removal)?.toJsonLd(), + await message.toJsonLd(), + ); + assert.deepStrictEqual( + await addRepository.getMessage("bot", messageId), + undefined, + ); + assert.deepStrictEqual(await addRepository.countMessages("bot"), 0); + assert.deepStrictEqual( + await Array.fromAsync(addRepository.getMessages("bot")), + [], + ); + } finally { + release.resolve(); + await addRepository.close(); + await removeRepository.close(); + await addClient.quit(); + await removeClient.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("persists data across repository instances", async () => { + if (redisUrl == null) throw new Error("REDIS_URL is not set."); + const prefix = `botkit_test_${crypto.randomUUID()}`; + const firstRepository = new RedisRepository({ url: redisUrl, prefix }); + const secondRepository = new RedisRepository({ url: redisUrl, prefix }); + try { + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const message = createMessage( + messageId, + "persisted", + "2025-01-01T00:00:00Z", + ); + await firstRepository.addMessage("bot", messageId, message); + assert.deepStrictEqual( + await (await secondRepository.getMessage("bot", messageId)) + ?.toJsonLd(), + await message.toJsonLd(), + ); + } finally { + await firstRepository.close(); + await secondRepository.close(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("followers with multiple follow requests", async () => { + const { repository, cleanup, prefix } = createHarness(); + const client = createClient({ url: redisUrl }); + await client.connect(); + try { + const follower = new Person({ + id: new URL("https://example.com/ap/actor/alice"), + preferredUsername: "alice", + }); + const followA = new URL( + "https://example.com/ap/follow/f2fb7255-d3ad-4fef-8f9a-1d0f2c2ec0b4", + ); + const followB = new URL( + "https://example.com/ap/follow/a3d4cc4f-af93-4a9f-a7b3-0b7c0fe4901d", + ); + + await repository.addFollower("bot", followA, follower); + await repository.addFollower("bot", followB, follower); + assert.deepStrictEqual(await repository.countFollowers("bot"), 1); + assert.ok(await repository.hasFollower("bot", follower.id!)); + assert.deepStrictEqual( + await repository.removeFollower("bot", followA, follower.id!), + undefined, + ); + assert.ok(await repository.hasFollower("bot", follower.id!)); + assert.deepStrictEqual( + await (await repository.removeFollower("bot", followB, follower.id!)) + ?.toJsonLd(), + await follower.toJsonLd(), + ); + assert.deepStrictEqual(await repository.countFollowers("bot"), 0); + + await repository.addFollower("bot", followB, follower); + await client.sendCommand([ + "DEL", + `${prefix}:bots:bot:followRequests:${ + encodeURIComponent(followB.href) + }`, + ]); + assert.deepStrictEqual( + await (await repository.removeFollower("bot", followB, follower.id!)) + ?.toJsonLd(), + await follower.toJsonLd(), + ); + assert.deepStrictEqual(await repository.countFollowers("bot"), 0); + } finally { + await client.quit(); + await cleanup(); + } + }); + + test("fetches listed followers in batched Redis commands", async () => { + const prefix = `botkit_test_${crypto.randomUUID()}`; + const client = createClient({ url: redisUrl }); + await client.connect(); + const commands: string[][] = []; + const repository = new RedisRepository({ + client: createRecordingClient(client, commands), + prefix, + }); + try { + const alice = new Person({ + id: new URL("https://example.com/ap/actor/alice"), + preferredUsername: "alice", + }); + const bob = new Person({ + id: new URL("https://example.com/ap/actor/bob"), + preferredUsername: "bob", + }); + await repository.addFollower( + "bot", + new URL("https://example.com/ap/follow/alice"), + alice, + ); + await repository.addFollower( + "bot", + new URL("https://example.com/ap/follow/bob"), + bob, + ); + + commands.length = 0; + assert.deepStrictEqual( + (await Array.fromAsync(repository.getFollowers("bot"))).length, + 2, + ); + const mget = commands.find(([command]) => command === "MGET"); + assert.ok(mget != null); + assert.deepStrictEqual(mget.length, 3); + assert.ok(mget.slice(1).every((key) => key.includes(":followers:"))); + assert.ok( + !commands.some(([command, key]) => + command === "GET" && key?.includes(":followers:") + ), + ); + } finally { + await repository.close(); + await client.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("returns no followers for zero limits", async () => { + const { repository, cleanup } = createHarness(); + try { + const follower = new Person({ + id: new URL("https://example.com/ap/actor/alice"), + preferredUsername: "alice", + }); + const follow = new URL( + "https://example.com/ap/follow/f2fb7255-d3ad-4fef-8f9a-1d0f2c2ec0b4", + ); + + await repository.addFollower("bot", follow, follower); + assert.deepStrictEqual( + await Array.fromAsync(repository.getFollowers("bot", { limit: 0 })), + [], + ); + } finally { + await cleanup(); + } + }); + + test("serializes racing follower replacement and removal", async () => { + const { repository, cleanup } = createHarness(); + try { + const follow = new URL( + "https://example.com/ap/follow/f2fb7255-d3ad-4fef-8f9a-1d0f2c2ec0b4", + ); + const alice = new Person({ + id: new URL("https://example.com/ap/actor/alice"), + preferredUsername: "alice", + }); + const bob = new Person({ + id: new URL("https://example.com/ap/actor/bob"), + preferredUsername: "bob", + }); + + for (let i = 0; i < 20; i++) { + await repository.addFollower("bot", follow, alice); + await Promise.all([ + repository.removeFollower("bot", follow, alice.id!), + repository.addFollower("bot", follow, bob), + ]); + + assert.ok(await repository.hasFollower("bot", bob.id!)); + assert.deepStrictEqual( + await (await repository.removeFollower("bot", follow, bob.id!)) + ?.toJsonLd(), + await bob.toJsonLd(), + ); + assert.deepStrictEqual(await repository.countFollowers("bot"), 0); + } + } finally { + await cleanup(); + } + }); + + test("sent follows, followees, and reverse indexes", async () => { + const { repository, cleanup } = createHarness(); + try { + const followeeId = new URL("https://example.com/ap/actor/john"); + const follow = new Follow({ + id: new URL( + "https://example.com/ap/actor/bot/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + ), + actor: new URL("https://example.com/ap/actor/bot"), + object: followeeId, + }); + const id = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + + await repository.addSentFollow("bot", id, follow); + assert.deepStrictEqual( + await (await repository.getSentFollow("bot", id))?.toJsonLd(), + await follow.toJsonLd(), + ); + assert.deepStrictEqual( + await (await repository.removeSentFollow("bot", id))?.toJsonLd(), + await follow.toJsonLd(), + ); + + await repository.addFollowee("bot", followeeId, follow); + await repository.addFollowee("other", followeeId, follow); + assert.deepStrictEqual( + await Array.fromAsync(repository.findFollowedBots(followeeId)), + ["bot", "other"], + ); + assert.deepStrictEqual( + await (await repository.removeFollowee("bot", followeeId)) + ?.toJsonLd(), + await follow.toJsonLd(), + ); + assert.deepStrictEqual( + await Array.fromAsync(repository.findFollowedBots(followeeId)), + ["other"], + ); + } finally { + await cleanup(); + } + }); + + test("serializes followee indexes with removals", async () => { + const prefix = `botkit_test_${crypto.randomUUID()}`; + const addClient = createClient({ url: redisUrl }); + const removeClient = createClient({ url: redisUrl }); + await addClient.connect(); + await removeClient.connect(); + const followeeId = new URL("https://example.com/ap/actor/john"); + const indexKey = [ + prefix, + "index", + "followees", + encodeURIComponent(followeeId.href), + ].join(":"); + const observed = createDeferred(); + const release = createDeferred(); + const addRepository = new RedisRepository({ + client: createDelayedCommandClient( + addClient, + (args) => + args[0] === "ZADD" && args[1] === indexKey && + args.at(-1) === "bot", + observed, + release, + ), + prefix, + }); + const removeRepository = new RedisRepository({ + client: removeClient, + prefix, + }); + try { + const follow = new Follow({ + id: new URL( + "https://example.com/ap/actor/bot/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + ), + actor: new URL("https://example.com/ap/actor/bot"), + object: followeeId, + }); + + const add = addRepository.addFollowee("bot", followeeId, follow); + await observed.promise; + const removal = removeRepository.removeFollowee("bot", followeeId); + await delay(50); + release.resolve(); + + await add; + assert.deepStrictEqual( + await (await removal)?.toJsonLd(), + await follow.toJsonLd(), + ); + assert.deepStrictEqual( + await addRepository.getFollowee("bot", followeeId), + undefined, + ); + assert.deepStrictEqual( + await Array.fromAsync(addRepository.findFollowedBots(followeeId)), + [], + ); + } finally { + release.resolve(); + await addRepository.close(); + await removeRepository.close(); + await addClient.quit(); + await removeClient.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("poll voting", async () => { + const { repository, cleanup } = createHarness(); + try { + const messageId = "01945678-1234-7890-abcd-ef0123456789"; + const voter1 = new URL("https://example.com/ap/actor/alice"); + const voter2 = new URL("https://example.com/ap/actor/bob"); + await repository.vote("bot", messageId, voter1, "option1"); + await repository.vote("bot", messageId, voter1, "option1"); + await repository.vote("bot", messageId, voter2, "option1"); + await repository.vote("bot", messageId, voter1, "option2"); + assert.deepStrictEqual( + await repository.countVoters("bot", messageId), + 2, + ); + assert.deepStrictEqual(await repository.countVotes("bot", messageId), { + option1: 2, + option2: 1, + }); + } finally { + await cleanup(); + } + }); + + test("quote authorizations and references", async () => { + const { repository, cleanup, prefix } = createHarness(); + const client = createClient({ url: redisUrl }); + await client.connect(); + try { + const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac4"; + const secondId = "01942976-3400-7f34-872e-2cbf0f9eeac5"; + const quote = new URL("https://remote.example/notes/quote"); + const target = new URL("https://example.com/ap/note/1"); + const first = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${firstId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: target, + }); + const second = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${secondId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: target, + }); + + await repository.addQuoteAuthorization("bot", firstId, first); + await repository.addQuoteAuthorization("bot", secondId, second); + assert.deepStrictEqual( + (await repository.findQuoteAuthorization("bot", quote))?.id?.href, + first.id?.href, + ); + assert.deepStrictEqual( + await repository.getQuoteAuthorization("bot", secondId), + undefined, + ); + + const stamp = new URL("https://remote.example/stamps/1"); + const attribution = new URL("https://remote.example/actor/alice"); + await repository.addQuoteAuthorizationReference( + "bot", + stamp, + firstId, + attribution, + ); + await repository.addQuoteAuthorizationReference( + "other", + stamp, + firstId, + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", stamp), + firstId, + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReferenceAttribution( + "bot", + stamp, + ), + attribution, + ); + await client.sendCommand([ + "SET", + `${prefix}:bots:bot:quoteAuthorizationRefs:${ + encodeURIComponent(stamp.href) + }`, + "null", + ]); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", stamp), + undefined, + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReferenceAttribution( + "bot", + stamp, + ), + undefined, + ); + assert.deepStrictEqual( + await Array.fromAsync( + repository.findQuoteAuthorizationReferenceIdentifiers(stamp), + ), + ["bot", "other"], + ); + await repository.removeQuoteAuthorizationReference("bot", stamp); + assert.deepStrictEqual( + await Array.fromAsync( + repository.findQuoteAuthorizationReferenceIdentifiers(stamp), + ), + ["other"], + ); + } finally { + await client.quit(); + await cleanup(); + } + }); + + test("serializes quote authorization reference indexes with removals", async () => { + const prefix = `botkit_test_${crypto.randomUUID()}`; + const addRepository = new RedisRepository({ url: redisUrl, prefix }); + const removeClient = createClient({ url: redisUrl }); + await removeClient.connect(); + const stamp = new URL("https://remote.example/stamps/serialized"); + const indexKey = `${prefix}:index:quoteAuthorizationRefs:${ + encodeURIComponent(stamp.href) + }`; + const removeObserved = createDeferred(); + const releaseRemove = createDeferred(); + const removeRepository = new RedisRepository({ + client: createDelayedCommandClient( + removeClient, + (args) => + args[0] === "ZREM" && args[1] === indexKey && args[2] === "bot", + removeObserved, + releaseRemove, + ), + prefix, + }); + try { + const oldId = "01942976-3400-7f34-872e-2cbf0f9eeac4"; + const newId = "01942976-3400-7f34-872e-2cbf0f9eeac5"; + await addRepository.addQuoteAuthorizationReference("bot", stamp, oldId); + const removePromise = removeRepository + .removeQuoteAuthorizationReference( + "bot", + stamp, + ); + await removeObserved.promise; + let addFinished = false; + const addPromise = addRepository + .addQuoteAuthorizationReference("bot", stamp, newId) + .then(() => { + addFinished = true; + }); + await delay(50); + assert.ok(!addFinished); + releaseRemove.resolve(); + await Promise.all([removePromise, addPromise]); + assert.deepStrictEqual( + await addRepository.findQuoteAuthorizationReference("bot", stamp), + newId, + ); + assert.deepStrictEqual( + await Array.fromAsync( + addRepository.findQuoteAuthorizationReferenceIdentifiers(stamp), + ), + ["bot"], + ); + } finally { + releaseRemove.resolve(); + await addRepository.close(); + await removeRepository.close(); + await removeClient.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("serializes concurrent quote authorization inserts", async () => { + const prefix = `botkit_test_${crypto.randomUUID()}`; + const firstClient = createClient({ url: redisUrl }); + const secondClient = createClient({ url: redisUrl }); + await firstClient.connect(); + await secondClient.connect(); + const quote = new URL("https://remote.example/notes/raced-quote"); + const state: DelayedGetState = { count: 0, waiters: [] }; + const indexKey = [ + prefix, + "bots", + "bot", + "quoteAuthorizationsByInteractingObject", + encodeURIComponent(quote.href), + ].join(":"); + const firstRepository = new RedisRepository({ + client: createDelayedGetClient(firstClient, indexKey, state), + prefix, + }); + const secondRepository = new RedisRepository({ + client: createDelayedGetClient(secondClient, indexKey, state), + prefix, + }); + try { + const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac6"; + const secondId = "01942976-3400-7f34-872e-2cbf0f9eeac7"; + const target = new URL("https://example.com/ap/note/1"); + const first = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${firstId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: target, + }); + const second = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${secondId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: target, + }); + + await Promise.all([ + firstRepository.addQuoteAuthorization("bot", firstId, first), + secondRepository.addQuoteAuthorization("bot", secondId, second), + ]); + + const found = await firstRepository.findQuoteAuthorization( + "bot", + quote, + ); + const stored = (await Promise.all([ + firstRepository.getQuoteAuthorization("bot", firstId), + firstRepository.getQuoteAuthorization("bot", secondId), + ])).filter((authorization) => authorization != null); + assert.deepStrictEqual(stored.length, 1); + assert.deepStrictEqual(found?.id?.href, stored[0].id?.href); + } finally { + await firstRepository.close(); + await secondRepository.close(); + await firstClient.quit(); + await secondClient.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("serializes quote authorization removal with inserts", async () => { + const prefix = `botkit_test_${crypto.randomUUID()}`; + const addRepository = new RedisRepository({ url: redisUrl, prefix }); + const removeClient = createClient({ url: redisUrl }); + await removeClient.connect(); + const quote = new URL("https://remote.example/notes/remove-race"); + const target = new URL("https://example.com/ap/note/remove-race"); + const oldId = "01942976-3400-7f34-872e-2cbf0f9eeac6"; + const newId = "01942976-3400-7f34-872e-2cbf0f9eeac7"; + const oldAuthorization = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${oldId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: target, + }); + const newAuthorization = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${newId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: target, + }); + const authorizationKey = + `${prefix}:bots:bot:quoteAuthorizations:${oldId}`; + const removeObserved = createDeferred(); + const releaseRemove = createDeferred(); + const removeRepository = new RedisRepository({ + client: createDelayedCommandClient( + removeClient, + (args) => + args[0] === "DEL" && + args.includes(authorizationKey), + removeObserved, + releaseRemove, + ), + prefix, + }); + try { + await addRepository.addQuoteAuthorization( + "bot", + oldId, + oldAuthorization, + ); + const removePromise = removeRepository.removeQuoteAuthorization( + "bot", + oldId, + ); + await removeObserved.promise; + let addFinished = false; + const addPromise = addRepository + .addQuoteAuthorization("bot", newId, newAuthorization) + .then(() => { + addFinished = true; + }); + await delay(50); + assert.ok(!addFinished); + releaseRemove.resolve(); + await Promise.all([removePromise, addPromise]); + assert.deepStrictEqual( + (await addRepository.findQuoteAuthorization("bot", quote))?.id?.href, + newAuthorization.id?.href, + ); + } finally { + releaseRemove.resolve(); + await addRepository.close(); + await removeRepository.close(); + await removeClient.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + + test("preserves quote authorization indexes during stale cleanup", async () => { + const prefix = `botkit_test_${crypto.randomUUID()}`; + const setupClient = createClient({ url: redisUrl }); + const findClient = createClient({ url: redisUrl }); + await setupClient.connect(); + await findClient.connect(); + const quote = new URL("https://remote.example/notes/cleanup-race"); + const indexKey = [ + prefix, + "bots", + "bot", + "quoteAuthorizationsByInteractingObject", + encodeURIComponent(quote.href), + ].join(":"); + const observed = createDeferred(); + const release = createDeferred(); + const findRepository = new RedisRepository({ + client: createDelayedDeleteClient( + findClient, + indexKey, + observed, + release, + ), + prefix, + }); + const addRepository = new RedisRepository({ url: redisUrl, prefix }); + try { + const id = "01942976-3400-7f34-872e-2cbf0f9eeac8"; + const staleId = "01942976-3400-7f34-872e-2cbf0f9eeac9"; + const authorization = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${id}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: new URL("https://example.com/ap/note/1"), + }); + await setupClient.sendCommand(["SET", indexKey, staleId]); + + const findPromise = findRepository.findQuoteAuthorization("bot", quote); + await observed.promise; + const addPromise = addRepository.addQuoteAuthorization( + "bot", + id, + authorization, + ); + await delay(50); + release.resolve(); + + assert.deepStrictEqual(await findPromise, undefined); + await addPromise; + assert.deepStrictEqual( + (await addRepository.findQuoteAuthorization("bot", quote))?.id?.href, + authorization.id?.href, + ); + } finally { + release.resolve(); + await findRepository.close(); + await addRepository.close(); + await setupClient.quit(); + await findClient.quit(); + await cleanupPrefix(redisUrl, prefix); + } + }); + }); +} diff --git a/packages/botkit-redis/src/mod.ts b/packages/botkit-redis/src/mod.ts new file mode 100644 index 0000000..a0ff8e0 --- /dev/null +++ b/packages/botkit-redis/src/mod.ts @@ -0,0 +1,1204 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { + ActorScopedRepository, + type Repository, + type RepositoryGetFollowersOptions, + type RepositoryGetMessagesOptions, + type Uuid, +} from "@fedify/botkit/repository"; +import { exportJwk, importJwk } from "@fedify/fedify/sig"; +import { + Activity, + type Actor, + Announce, + Create, + Follow, + isActor, + Object, + QuoteAuthorization, +} from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import { createClient, type RedisClientOptions } from "redis"; + +const logger = getLogger(["botkit", "redis"]); +const defaultRedisLockTimeoutMs = 30_000; +const defaultRedisLockPollIntervalMs = 20; +const defaultRedisLockRenewIntervalMs = 10_000; +const redisMGetBatchSize = 100; +const uuidV7Pattern = + /^([0-9a-f]{8})-([0-9a-f]{4})-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +interface RedisClientLike { + readonly isOpen?: boolean; + connect?(): Promise; + quit?(): Promise; + close?(): Promise | unknown; + on?(event: "error", listener: (error: unknown) => void): unknown; + sendCommand(args: readonly string[]): Promise; +} + +interface KeyPair { + readonly private: JsonWebKey; + readonly public: JsonWebKey; +} + +interface QuoteAuthorizationReferenceData { + readonly messageId: Uuid; + readonly attribution?: string; +} + +interface RedisRepositoryOptionsBase { + /** + * The Redis key prefix under which all BotKit data is stored. + * @default `"botkit"` + */ + readonly prefix?: string; + + /** + * How long a Redis lock can live without renewal, in milliseconds. + * @default `30000` + */ + readonly lockTimeoutMs?: number; + + /** + * How long to wait before retrying a held Redis lock, in milliseconds. + * @default `20` + */ + readonly lockPollIntervalMs?: number; + + /** + * How often to renew a held Redis lock, in milliseconds. + * @default `10000` + */ + readonly lockRenewIntervalMs?: number; +} + +interface RedisRepositoryOptionsWithClient extends RedisRepositoryOptionsBase { + /** + * A pre-configured Redis client to use. It must already be connected. + */ + readonly client: RedisClientLike; + + /** + * Disallowed when `client` is provided. + */ + readonly url?: never; + + /** + * Disallowed when `client` is provided. + */ + readonly clientOptions?: never; +} + +interface RedisRepositoryOptionsWithUrl extends RedisRepositoryOptionsBase { + /** + * A Redis connection string to connect with. + */ + readonly url: string | URL; + + /** + * Disallowed when `url` is provided. + */ + readonly client?: never; + + /** + * Additional node-redis client options. + */ + readonly clientOptions?: Omit; +} + +/** + * Options for creating a Redis repository. + * @since 0.5.0 + */ +export type RedisRepositoryOptions = + | RedisRepositoryOptionsWithClient + | RedisRepositoryOptionsWithUrl; + +/** + * A repository for storing bot data using Redis. + * @since 0.5.0 + */ +export class RedisRepository implements Repository, AsyncDisposable { + private readonly client: RedisClientLike; + private readonly ownsClient: boolean; + private ready: Promise | undefined; + private readonly lockTimeoutMs: number; + private readonly lockPollIntervalMs: number; + private readonly lockRenewIntervalMs: number; + private closed = false; + + /** + * The Redis key prefix under which all BotKit data is stored. + */ + readonly prefix: string; + + /** + * Creates a new Redis repository. + * @param options The options for creating the repository. + * @throws {TypeError} If exactly one of `client` and `url` is not provided. + * @throws {RangeError} If a lock timing option is not a positive number, or + * if `lockRenewIntervalMs` is not less than `lockTimeoutMs`. + */ + constructor(options: RedisRepositoryOptions) { + if (options == null) { + throw new TypeError("RedisRepositoryOptions must be provided."); + } + const hasClient = "client" in options && options.client != null; + const hasUrl = "url" in options && options.url != null; + if (hasClient === hasUrl) { + throw new TypeError( + "RedisRepositoryOptions must provide exactly one of client or url.", + ); + } + this.prefix = options.prefix ?? "botkit"; + this.lockTimeoutMs = options.lockTimeoutMs ?? defaultRedisLockTimeoutMs; + this.lockPollIntervalMs = options.lockPollIntervalMs ?? + defaultRedisLockPollIntervalMs; + this.lockRenewIntervalMs = options.lockRenewIntervalMs ?? + defaultRedisLockRenewIntervalMs; + validatePositiveMilliseconds("lockTimeoutMs", this.lockTimeoutMs); + validatePositiveMilliseconds( + "lockPollIntervalMs", + this.lockPollIntervalMs, + ); + validatePositiveMilliseconds( + "lockRenewIntervalMs", + this.lockRenewIntervalMs, + ); + if (this.lockRenewIntervalMs >= this.lockTimeoutMs) { + throw new RangeError( + "lockRenewIntervalMs must be less than lockTimeoutMs.", + ); + } + if (hasClient) { + this.client = options.client; + this.ownsClient = false; + } else { + const client: RedisClientLike = createClient({ + ...options.clientOptions, + url: options.url.toString(), + }); + this.client = client; + this.ownsClient = true; + client.on?.("error", (error) => { + logger.warn("Owned Redis client emitted an error: {error}", { error }); + }); + const ready = this.connect(); + ready.catch(() => { + if (this.ready === ready) this.ready = undefined; + }); + } + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } + + /** + * Closes the owned Redis connection. + */ + async close(): Promise { + if (this.closed) return; + this.closed = true; + if (!this.ownsClient) return; + const ready = this.ready; + try { + await ready; + } catch (error) { + logger.warn( + "Owned Redis client did not become ready before close: {error}", + { + error, + }, + ); + if (this.ready === ready) this.ready = undefined; + } + try { + if (this.client.quit != null) { + await this.client.quit(); + } else { + await this.client.close?.(); + } + } catch (error) { + logger.warn("Failed to close owned Redis client: {error}", { error }); + } + } + + private key(...segments: readonly string[]): string { + return [ + this.prefix, + ...segments.map((segment) => encodeURIComponent(segment)), + ].join(":"); + } + + private botKey(identifier: string, ...segments: readonly string[]): string { + return this.key("bots", identifier, ...segments); + } + + private connect(): Promise { + const ready = this.client.connect?.() ?? Promise.resolve(); + this.ready = ready; + return ready; + } + + private async ensureReady(): Promise { + if (this.closed) { + throw new TypeError("RedisRepository is closed."); + } + if (!this.ownsClient) return; + const ready = this.ready; + if (ready == null) { + if (this.client.isOpen) return; + const nextReady = this.connect(); + try { + await nextReady; + } catch (error) { + if (this.ready === nextReady) this.ready = undefined; + throw error; + } + return; + } + try { + await ready; + } catch (error) { + if (this.ready === ready) this.ready = undefined; + throw error; + } + } + + private async command(args: readonly string[]): Promise { + await this.ensureReady(); + try { + return await this.client.sendCommand(args); + } catch (error) { + logger.error("Redis command failed: {command}.", { + command: args[0], + error, + }); + throw error; + } + } + + private async get(key: string): Promise { + const value = await this.command(["GET", key]); + return typeof value === "string" ? value : undefined; + } + + private async mGet(keys: readonly string[]): Promise { + if (keys.length < 1) return []; + const chunks: string[][] = []; + for (let i = 0; i < keys.length; i += redisMGetBatchSize) { + chunks.push(keys.slice(i, i + redisMGetBatchSize)); + } + const replies = await Promise.all( + chunks.map((chunk) => this.command(["MGET", ...chunk])), + ); + return replies.flatMap(toStringArray); + } + + private async set(key: string, value: string): Promise { + await this.command(["SET", key, value]); + } + + private async del(...keys: readonly string[]): Promise { + if (keys.length < 1) return; + await this.command(["DEL", ...keys]); + } + + private async sAdd(key: string, member: string): Promise { + await this.command(["SADD", key, member]); + } + + private async sRem(key: string, member: string): Promise { + await this.command(["SREM", key, member]); + } + + private async sCard(key: string): Promise { + return toNumber(await this.command(["SCARD", key])); + } + + private async zAdd( + key: string, + score: number, + member: string, + nx = false, + ): Promise { + await this.command([ + "ZADD", + key, + ...(nx ? ["NX"] : []), + score.toString(), + member, + ]); + } + + private async zRem(key: string, member: string): Promise { + await this.command(["ZREM", key, member]); + } + + private async zRange(key: string): Promise { + return toStringArray(await this.command(["ZRANGE", key, "0", "-1"])); + } + + private async zRangeByScore( + key: string, + min: string, + max: string, + reverse: boolean, + limit?: number, + ): Promise { + const command = reverse ? "ZREVRANGEBYSCORE" : "ZRANGEBYSCORE"; + const args = [command, key, reverse ? max : min, reverse ? min : max]; + if (limit != null) args.push("LIMIT", "0", limit.toString()); + return toStringArray(await this.command(args)); + } + + private async zCard(key: string): Promise { + return toNumber(await this.command(["ZCARD", key])); + } + + private async nextSequence(key: string): Promise { + return toNumber(await this.command(["INCR", key])); + } + + private async withRedisLock( + lockKey: string, + operation: () => Promise, + ): Promise { + const token = crypto.randomUUID(); + while ( + await this.command([ + "SET", + lockKey, + token, + "NX", + "PX", + this.lockTimeoutMs.toString(), + ]) !== "OK" + ) { + await delay(this.lockPollIntervalMs); + } + const renew = setInterval(() => { + void (async () => { + try { + if (this.closed) { + clearInterval(renew); + return; + } + const renewed = toNumber( + await this.command([ + "EVAL", + "if redis.call('GET', KEYS[1]) == ARGV[1] then " + + "return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end", + "1", + lockKey, + token, + this.lockTimeoutMs.toString(), + ]), + ); + if (renewed === 0) clearInterval(renew); + } catch (error) { + if (this.closed) { + clearInterval(renew); + } else { + logger.warn("Failed to renew Redis lock: {error}", { error }); + } + } + })(); + }, this.lockRenewIntervalMs); + try { + return await operation(); + } finally { + clearInterval(renew); + try { + await this.command([ + "EVAL", + "if redis.call('GET', KEYS[1]) == ARGV[1] then " + + "return redis.call('DEL', KEYS[1]) else return 0 end", + "1", + lockKey, + token, + ]); + } catch (error) { + logger.warn("Failed to release Redis lock: {error}", { error }); + } + } + } + + async setKeyPairs( + identifier: string, + keyPairs: CryptoKeyPair[], + ): Promise { + const pairs: KeyPair[] = await Promise.all( + keyPairs.map(async (keyPair) => ({ + private: await exportJwk(keyPair.privateKey), + public: await exportJwk(keyPair.publicKey), + })), + ); + await this.set(this.botKey(identifier, "keyPairs"), JSON.stringify(pairs)); + } + + async getKeyPairs(identifier: string): Promise { + const json = await this.get(this.botKey(identifier, "keyPairs")); + if (json == null) return undefined; + const pairs = JSON.parse(json) as KeyPair[]; + return await Promise.all(pairs.map(async (pair) => ({ + privateKey: await importJwk(pair.private, "private"), + publicKey: await importJwk(pair.public, "public"), + }))); + } + + async addMessage( + identifier: string, + id: Uuid, + activity: Create | Announce, + ): Promise { + await this.withRedisLock( + this.botKey(identifier, "messages", id, "lock"), + async () => { + await this.set( + this.botKey(identifier, "messages", id), + JSON.stringify(await activity.toJsonLd({ format: "compact" })), + ); + await this.zAdd( + this.botKey(identifier, "messages"), + getMessageScore(id, activity), + id, + ); + }, + ); + } + + async updateMessage( + identifier: string, + id: Uuid, + updater: ( + existing: Create | Announce, + ) => Create | Announce | undefined | Promise, + ): Promise { + const key = this.botKey(identifier, "messages", id); + return await this.withRedisLock( + this.botKey(identifier, "messages", id, "lock"), + async () => { + const json = await this.get(key); + if (json == null) return false; + let activity: Activity; + try { + activity = await Activity.fromJsonLd(JSON.parse(json)); + } catch { + return false; + } + if (!(activity instanceof Create || activity instanceof Announce)) { + return false; + } + const updated = await updater(activity); + if (updated == null) return false; + await this.set( + key, + JSON.stringify(await updated.toJsonLd({ format: "compact" })), + ); + await this.zAdd( + this.botKey(identifier, "messages"), + getMessageScore(id, updated), + id, + ); + return true; + }, + ); + } + + async removeMessage( + identifier: string, + id: Uuid, + ): Promise { + const key = this.botKey(identifier, "messages", id); + return await this.withRedisLock( + this.botKey(identifier, "messages", id, "lock"), + async () => { + const json = await this.get(key); + await this.del(key); + await this.zRem(this.botKey(identifier, "messages"), id); + if (json == null) return undefined; + try { + const activity = await Activity.fromJsonLd(JSON.parse(json)); + if (activity instanceof Create || activity instanceof Announce) { + return activity; + } + } catch { + return undefined; + } + return undefined; + }, + ); + } + + async *getMessages( + identifier: string, + options: RepositoryGetMessagesOptions = {}, + ): AsyncIterable { + const min = options.since == null + ? "-inf" + : options.since.epochMilliseconds.toString(); + const max = options.until == null + ? "+inf" + : options.until.epochMilliseconds.toString(); + const ids = await this.zRangeByScore( + this.botKey(identifier, "messages"), + min, + max, + options.order == null || options.order === "newest", + options.limit, + ); + if (ids.length < 1) return; + const keys = ids.map((id) => this.botKey(identifier, "messages", id)); + const jsons = await this.mGet(keys); + for (const json of jsons) { + try { + const activity = await Activity.fromJsonLd(JSON.parse(json)); + if (activity instanceof Create || activity instanceof Announce) { + yield activity; + } + } catch { + continue; + } + } + } + + async getMessage( + identifier: string, + id: Uuid, + ): Promise { + const json = await this.get(this.botKey(identifier, "messages", id)); + if (json == null) return undefined; + try { + const activity = await Activity.fromJsonLd(JSON.parse(json)); + if (activity instanceof Create || activity instanceof Announce) { + return activity; + } + } catch { + return undefined; + } + return undefined; + } + + countMessages(identifier: string): Promise { + return this.zCard(this.botKey(identifier, "messages")); + } + + /** + * Adds a follower for a follow request. + * + * @throws {TypeError} If the follower actor has no ID. + */ + async addFollower( + identifier: string, + followId: URL, + follower: Actor, + ): Promise { + if (follower.id == null) { + throw new TypeError("The follower ID is missing."); + } + const followerId = follower.id.href; + const followIdString = followId.href; + const requestKey = this.botKey( + identifier, + "followRequests", + followIdString, + ); + await this.withRedisLock( + this.botKey(identifier, "followers", "lock"), + async () => { + const previousFollowerId = await this.get(requestKey); + await this.set( + this.botKey(identifier, "followers", followerId), + JSON.stringify(await follower.toJsonLd({ format: "compact" })), + ); + await this.zAdd( + this.botKey(identifier, "followers"), + await this.nextSequence(this.botKey(identifier, "followers", "seq")), + followerId, + true, + ); + await this.sAdd( + this.botKey(identifier, "followerRequests", followerId), + followIdString, + ); + await this.set(requestKey, followerId); + if (previousFollowerId != null && previousFollowerId !== followerId) { + await this.sRem( + this.botKey(identifier, "followerRequests", previousFollowerId), + followIdString, + ); + await this.cleanupFollower(identifier, previousFollowerId); + } + }, + ); + } + + async removeFollower( + identifier: string, + followId: URL, + followerId: URL, + ): Promise { + const followIdString = followId.href; + const requestKey = this.botKey( + identifier, + "followRequests", + followIdString, + ); + return await this.withRedisLock( + this.botKey(identifier, "followers", "lock"), + async () => { + const currentFollowerId = await this.get(requestKey); + if ( + currentFollowerId != null && currentFollowerId !== followerId.href + ) { + return undefined; + } + const followerKey = this.botKey( + identifier, + "followers", + followerId.href, + ); + const json = await this.get(followerKey); + if (currentFollowerId === followerId.href) await this.del(requestKey); + await this.sRem( + this.botKey(identifier, "followerRequests", followerId.href), + followIdString, + ); + const removed = await this.cleanupFollower(identifier, followerId.href); + if (!removed || json == null) return undefined; + try { + const actor = await Object.fromJsonLd(JSON.parse(json)); + if (isActor(actor)) return actor; + } catch { + return undefined; + } + return undefined; + }, + ); + } + + private async cleanupFollower( + identifier: string, + followerId: string, + ): Promise { + const requestsKey = this.botKey(identifier, "followerRequests", followerId); + if (await this.sCard(requestsKey) > 0) return false; + await this.del( + requestsKey, + this.botKey(identifier, "followers", followerId), + ); + await this.zRem(this.botKey(identifier, "followers"), followerId); + return true; + } + + async hasFollower(identifier: string, followerId: URL): Promise { + return await this.get( + this.botKey(identifier, "followers", followerId.href), + ) != + null; + } + + async *getFollowers( + identifier: string, + options: RepositoryGetFollowersOptions = {}, + ): AsyncIterable { + if (options.limit === 0) return; + const start = options.offset ?? 0; + const stop = options.limit == null ? -1 : start + options.limit - 1; + const ids = toStringArray( + await this.command([ + "ZRANGE", + this.botKey(identifier, "followers"), + start.toString(), + stop.toString(), + ]), + ); + if (ids.length < 1) return; + const keys = ids.map((id) => this.botKey(identifier, "followers", id)); + const jsons = await this.mGet(keys); + for (const json of jsons) { + try { + const actor = await Object.fromJsonLd(JSON.parse(json)); + if (isActor(actor)) yield actor; + } catch { + continue; + } + } + } + + countFollowers(identifier: string): Promise { + return this.zCard(this.botKey(identifier, "followers")); + } + + async addSentFollow( + identifier: string, + id: Uuid, + follow: Follow, + ): Promise { + await this.set( + this.botKey(identifier, "follows", id), + JSON.stringify(await follow.toJsonLd({ format: "compact" })), + ); + } + + async removeSentFollow( + identifier: string, + id: Uuid, + ): Promise { + const follow = await this.getSentFollow(identifier, id); + if (follow == null) return undefined; + await this.del(this.botKey(identifier, "follows", id)); + return follow; + } + + async getSentFollow( + identifier: string, + id: Uuid, + ): Promise { + const json = await this.get(this.botKey(identifier, "follows", id)); + if (json == null) return undefined; + try { + return await Follow.fromJsonLd(JSON.parse(json)); + } catch { + return undefined; + } + } + + async addFollowee( + identifier: string, + followeeId: URL, + follow: Follow, + ): Promise { + await this.withRedisLock( + this.botKey(identifier, "followees", followeeId.href, "lock"), + async () => { + await this.set( + this.botKey(identifier, "followees", followeeId.href), + JSON.stringify(await follow.toJsonLd({ format: "compact" })), + ); + await this.zAdd( + this.key("index", "followees", followeeId.href), + 0, + identifier, + ); + }, + ); + } + + async removeFollowee( + identifier: string, + followeeId: URL, + ): Promise { + return await this.withRedisLock( + this.botKey(identifier, "followees", followeeId.href, "lock"), + async () => { + const follow = await this.getFollowee(identifier, followeeId); + await this.del(this.botKey(identifier, "followees", followeeId.href)); + await this.zRem( + this.key("index", "followees", followeeId.href), + identifier, + ); + return follow; + }, + ); + } + + async getFollowee( + identifier: string, + followeeId: URL, + ): Promise { + const json = await this.get( + this.botKey(identifier, "followees", followeeId.href), + ); + if (json == null) return undefined; + try { + return await Follow.fromJsonLd(JSON.parse(json)); + } catch { + return undefined; + } + } + + async *findFollowedBots(followeeId: URL): AsyncIterable { + yield* await this.zRange(this.key("index", "followees", followeeId.href)); + } + + /** + * Stores a quote authorization. + * + * @throws {TypeError} If the quote authorization has no interacting object. + */ + async addQuoteAuthorization( + identifier: string, + id: Uuid, + authorization: QuoteAuthorization, + ): Promise { + const interactingObject = authorization.interactingObjectId; + if (interactingObject == null) { + throw new TypeError( + "The quote authorization interacting object is missing.", + ); + } + const indexKey = this.botKey( + identifier, + "quoteAuthorizationsByInteractingObject", + interactingObject.href, + ); + await this.withRedisLock( + this.quoteAuthorizationLockKey(indexKey), + async () => { + const existing = await this.get(indexKey); + if (existing != null) { + if ( + await this.getQuoteAuthorization(identifier, existing as Uuid) != + null + ) { + return; + } + await this.del( + indexKey, + this.botKey( + identifier, + "quoteAuthorizationInteractingObjects", + existing, + ), + ); + } + await this.set( + this.botKey(identifier, "quoteAuthorizations", id), + JSON.stringify(await authorization.toJsonLd({ format: "compact" })), + ); + await this.set( + this.botKey(identifier, "quoteAuthorizationInteractingObjects", id), + interactingObject.href, + ); + await this.set(indexKey, id); + }, + ); + } + + async getQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + const json = await this.get( + this.botKey(identifier, "quoteAuthorizations", id), + ); + if (json == null) return undefined; + try { + return await QuoteAuthorization.fromJsonLd(JSON.parse(json)); + } catch { + return undefined; + } + } + + async findQuoteAuthorization( + identifier: string, + interactingObject: URL, + ): Promise { + const indexKey = this.botKey( + identifier, + "quoteAuthorizationsByInteractingObject", + interactingObject.href, + ); + return await this.withRedisLock( + this.quoteAuthorizationLockKey(indexKey), + async () => { + const id = await this.get(indexKey); + if (id == null) return undefined; + const authorization = await this.getQuoteAuthorization( + identifier, + id as Uuid, + ); + if (authorization == null && await this.get(indexKey) === id) { + await this.del( + indexKey, + this.botKey(identifier, "quoteAuthorizationInteractingObjects", id), + ); + } + return authorization; + }, + ); + } + + async removeQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + const key = this.botKey(identifier, "quoteAuthorizations", id); + const interactingObjectKey = this.botKey( + identifier, + "quoteAuthorizationInteractingObjects", + id, + ); + const storedInteractingObject = await this.get(interactingObjectKey) ?? + await this.findStoredQuoteAuthorizationInteractingObject(key); + if (storedInteractingObject == null) return undefined; + const indexKey = this.botKey( + identifier, + "quoteAuthorizationsByInteractingObject", + storedInteractingObject, + ); + const parsed = await this.withRedisLock( + this.quoteAuthorizationLockKey(indexKey), + async () => { + const json = await this.get(key); + if (json == null) { + await this.del(interactingObjectKey); + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return undefined; + } + const interactingObject = getRawQuoteAuthorizationInteractingObject( + parsed, + ); + if (interactingObject?.href !== storedInteractingObject) { + return undefined; + } + await this.del(key, interactingObjectKey); + if (await this.get(indexKey) === id) await this.del(indexKey); + return parsed; + }, + ); + if (parsed == null) return undefined; + try { + return await QuoteAuthorization.fromJsonLd(parsed); + } catch { + return undefined; + } + } + + private async findStoredQuoteAuthorizationInteractingObject( + key: string, + ): Promise { + const json = await this.get(key); + if (json == null) return undefined; + try { + const interactingObject = getRawQuoteAuthorizationInteractingObject( + JSON.parse(json), + ); + return interactingObject?.href; + } catch { + return undefined; + } + } + + async addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + attribution?: URL, + ): Promise { + await this.withRedisLock( + this.quoteAuthorizationReferenceLockKey(identifier, authorization), + async () => { + const value: QuoteAuthorizationReferenceData = attribution == null + ? { messageId } + : { messageId, attribution: attribution.href }; + await this.set( + this.botKey(identifier, "quoteAuthorizationRefs", authorization.href), + JSON.stringify(value), + ); + await this.zAdd( + this.key("index", "quoteAuthorizationRefs", authorization.href), + 0, + identifier, + ); + }, + ); + } + + async findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + const json = await this.get( + this.botKey(identifier, "quoteAuthorizationRefs", authorization.href), + ); + if (json == null) return undefined; + try { + const value = JSON.parse(json) as QuoteAuthorizationReferenceData; + if (typeof value !== "object" || value == null) return undefined; + return value.messageId; + } catch { + return undefined; + } + } + + async *findQuoteAuthorizationReferenceIdentifiers( + authorization: URL, + ): AsyncIterable { + yield* await this.zRange( + this.key("index", "quoteAuthorizationRefs", authorization.href), + ); + } + + async findQuoteAuthorizationReferenceAttribution( + identifier: string, + authorization: URL, + ): Promise { + const json = await this.get( + this.botKey(identifier, "quoteAuthorizationRefs", authorization.href), + ); + if (json == null) return undefined; + let value: QuoteAuthorizationReferenceData; + try { + value = JSON.parse(json) as QuoteAuthorizationReferenceData; + } catch { + return undefined; + } + if (typeof value !== "object" || value == null) return undefined; + if (value.attribution == null) return undefined; + try { + return new URL(value.attribution); + } catch { + return undefined; + } + } + + async removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + await this.withRedisLock( + this.quoteAuthorizationReferenceLockKey(identifier, authorization), + async () => { + await this.del( + this.botKey(identifier, "quoteAuthorizationRefs", authorization.href), + ); + await this.zRem( + this.key("index", "quoteAuthorizationRefs", authorization.href), + identifier, + ); + }, + ); + } + + async vote( + identifier: string, + messageId: Uuid, + voterId: URL, + option: string, + ): Promise { + await this.sAdd( + this.botKey(identifier, "polls", messageId, "voters"), + voterId.href, + ); + await this.sAdd( + this.botKey(identifier, "polls", messageId, "options", option), + voterId.href, + ); + await this.zAdd( + this.botKey(identifier, "polls", messageId, "options"), + 0, + option, + ); + } + + countVoters(identifier: string, messageId: Uuid): Promise { + return this.sCard(this.botKey(identifier, "polls", messageId, "voters")); + } + + async countVotes( + identifier: string, + messageId: Uuid, + ): Promise>> { + const result: Record = {}; + const options = await this.zRange( + this.botKey(identifier, "polls", messageId, "options"), + ); + await Promise.all(options.map(async (option) => { + result[option] = await this.sCard( + this.botKey(identifier, "polls", messageId, "options", option), + ); + })); + return result; + } + + forIdentifier(identifier: string): ActorScopedRepository { + return new ActorScopedRepository(this, identifier); + } + + private quoteAuthorizationLockKey(indexKey: string): string { + return `${indexKey}:lock`; + } + + private quoteAuthorizationReferenceLockKey( + identifier: string, + authorization: URL, + ): string { + return this.botKey( + identifier, + "quoteAuthorizationRefs", + authorization.href, + "lock", + ); + } +} + +function toNumber(value: unknown): number { + if (typeof value === "number") return value; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string") return Number.parseInt(value, 10); + return 0; +} + +function validatePositiveMilliseconds(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${name} must be a positive number.`); + } +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getMessageScore(id: Uuid, activity: Create | Announce): number { + if (activity.published != null) return activity.published.epochMilliseconds; + return extractTimestamp(id); +} + +function extractTimestamp(uuid: string): number { + const match = uuidV7Pattern.exec(uuid); + if (match == null) return 0; + return Number.parseInt(`${match[1]}${match[2]}`, 16); +} + +function getRawQuoteAuthorizationInteractingObject( + json: unknown, +): URL | undefined { + if (typeof json !== "object" || json == null) return undefined; + if (!("interactingObject" in json)) return undefined; + const interactingObject = json.interactingObject; + if (typeof interactingObject !== "string") return undefined; + try { + return new URL(interactingObject); + } catch { + return undefined; + } +} diff --git a/packages/botkit-redis/tsdown.config.ts b/packages/botkit-redis/tsdown.config.ts new file mode 100644 index 0000000..6a44d92 --- /dev/null +++ b/packages/botkit-redis/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: "src/mod.ts", + dts: { + sourcemap: true, + }, + format: "esm", + platform: "node", +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fed9fd3..ba59004 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@fedify/botkit-postgres': specifier: 'workspace:' version: link:../packages/botkit-postgres + '@fedify/botkit-redis': + specifier: 'workspace:' + version: link:../packages/botkit-redis '@fedify/botkit-sqlite': specifier: 'workspace:' version: link:../packages/botkit-sqlite @@ -92,6 +95,9 @@ importers: postgres: specifier: ^3.4.9 version: 3.4.9 + redis: + specifier: ^6.1.0 + version: 6.1.0(@opentelemetry/api@1.9.1) srvx: specifier: ^0.11.20 version: 0.11.20 @@ -185,6 +191,31 @@ importers: specifier: ^0.12.8 version: 0.12.8(typescript@5.8.3) + packages/botkit-redis: + dependencies: + '@fedify/botkit': + specifier: workspace:^ + version: link:../botkit + '@fedify/fedify': + specifier: 'catalog:' + version: 2.3.1 + '@fedify/vocab': + specifier: 'catalog:' + version: 2.3.1 + '@js-temporal/polyfill': + specifier: 'catalog:' + version: 0.5.1 + '@logtape/logtape': + specifier: 'catalog:' + version: 2.2.3 + redis: + specifier: ^6.1.0 + version: 6.1.0(@opentelemetry/api@1.9.1) + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.12.8(typescript@5.8.3) + packages/botkit-sqlite: dependencies: '@fedify/botkit': @@ -639,6 +670,42 @@ packages: resolution: {integrity: sha512-G0OnZbMWEs5LhDyqy2UL17vGhSVHkQIfVojMtEWVenvj0V5S84VBgy86kJIuNsGDp2p7sTKlpSIpBUWdC35OKg==} engines: {node: '>=20.0.0'} + '@redis/bloom@6.1.0': + resolution: {integrity: sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.1.0 + + '@redis/client@6.1.0': + resolution: {integrity: sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + + '@redis/json@6.1.0': + resolution: {integrity: sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.1.0 + + '@redis/search@6.1.0': + resolution: {integrity: sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.1.0 + + '@redis/time-series@6.1.0': + resolution: {integrity: sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.1.0 + '@rolldown/binding-darwin-arm64@1.0.0-beta.15': resolution: {integrity: sha512-YInZppDBLp5DadbJZGc7xBfDrMCSj3P6i2rPlvOCMlvjBQxJi2kX8Jquh+LufsWUiHD3JsvvH5EuUUc/tF5fkA==} cpu: [arm64] @@ -1131,6 +1198,10 @@ packages: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1665,6 +1736,10 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redis@6.1.0: + resolution: {integrity: sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==} + engines: {node: '>= 20.0.0'} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -2451,6 +2526,28 @@ snapshots: dependencies: quansync: 0.2.10 + '@redis/bloom@6.1.0(@redis/client@6.1.0(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 6.1.0(@opentelemetry/api@1.9.1) + + '@redis/client@6.1.0(@opentelemetry/api@1.9.1)': + dependencies: + cluster-key-slot: 1.1.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + + '@redis/json@6.1.0(@redis/client@6.1.0(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 6.1.0(@opentelemetry/api@1.9.1) + + '@redis/search@6.1.0(@redis/client@6.1.0(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 6.1.0(@opentelemetry/api@1.9.1) + + '@redis/time-series@6.1.0(@redis/client@6.1.0(@opentelemetry/api@1.9.1))': + dependencies: + '@redis/client': 6.1.0(@opentelemetry/api@1.9.1) + '@rolldown/binding-darwin-arm64@1.0.0-beta.15': optional: true @@ -2891,6 +2988,8 @@ snapshots: cluster-key-slot@1.1.1: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3552,6 +3651,17 @@ snapshots: dependencies: redis-errors: 1.2.0 + redis@6.1.0(@opentelemetry/api@1.9.1): + dependencies: + '@redis/bloom': 6.1.0(@redis/client@6.1.0(@opentelemetry/api@1.9.1)) + '@redis/client': 6.1.0(@opentelemetry/api@1.9.1) + '@redis/json': 6.1.0(@redis/client@6.1.0(@opentelemetry/api@1.9.1)) + '@redis/search': 6.1.0(@redis/client@6.1.0(@opentelemetry/api@1.9.1)) + '@redis/time-series': 6.1.0(@redis/client@6.1.0(@opentelemetry/api@1.9.1)) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0