From 5ca03193d577a4a98a68cf5bd1be41b57da4171b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 16:03:37 +0900 Subject: [PATCH 01/17] Add @fedify/mysql package with MysqlKvStore Add a new @fedify/mysql package that provides a MysqlKvStore class implementing the KvStore interface backed by MySQL/MariaDB. Uses the mysql2 driver and supports all KvStore operations: get, set (with TTL), delete, list (with prefix matching), and compare-and-swap (cas). The package follows the same structure as @fedify/postgres: - Table auto-creation via initialize() and cleanup via drop() - Key serialization via JSON.stringify into VARCHAR(512) - Values stored as native JSON columns - TTL via absolute DATETIME(6) expiration timestamps - CAS using SELECT ... FOR UPDATE within transactions - Prefix listing via LIKE with proper escaping Also updates: - CI workflow to run MySQL 8 service containers for all test jobs - Documentation (kv.md, federation.md, basics.md, relay.md) - fedify init to offer MySQL/MariaDB as a KvStore option - CHANGES.md, AGENTS.md, and package README files https://github.com/fedify-dev/fedify/issues/585 https://github.com/fedify-dev/fedify/issues/587 Co-Authored-By: Claude --- .github/workflows/main.yaml | 39 ++++ AGENTS.md | 8 +- CHANGES.md | 56 +++--- deno.json | 3 + deno.lock | 43 ++++ docs/.vitepress/config.mts | 1 + docs/manual/federation.md | 7 +- docs/manual/kv.md | 59 ++++++ docs/manual/relay.md | 3 +- docs/package.json | 1 + docs/tutorial/basics.md | 5 +- packages/fedify/README.md | 3 + packages/init/src/json/kv.json | 15 ++ packages/mysql/README.md | 44 +++++ packages/mysql/deno.json | 23 +++ packages/mysql/package.json | 86 ++++++++ packages/mysql/src/kv.test.ts | 335 ++++++++++++++++++++++++++++++++ packages/mysql/src/kv.ts | 299 ++++++++++++++++++++++++++++ packages/mysql/src/mod.ts | 1 + packages/mysql/tsdown.config.ts | 21 ++ packages/relay/README.md | 7 +- pnpm-lock.yaml | 103 +++++++++- pnpm-workspace.yaml | 2 + 23 files changed, 1130 insertions(+), 34 deletions(-) create mode 100644 packages/mysql/README.md create mode 100644 packages/mysql/deno.json create mode 100644 packages/mysql/package.json create mode 100644 packages/mysql/src/kv.test.ts create mode 100644 packages/mysql/src/kv.ts create mode 100644 packages/mysql/src/mod.ts create mode 100644 packages/mysql/tsdown.config.ts diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ffa47ab10..b841d5e36 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -56,8 +56,21 @@ jobs: --health-retries 5 ports: - 6379:6379 + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: mysql + MYSQL_DATABASE: fedify + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306:3306 env: AMQP_URL: amqp://guest:guest@localhost:5672 + MYSQL_URL: mysql://root:mysql@localhost:3306/fedify POSTGRES_URL: postgres://postgres:postgres@localhost:5432/postgres REDIS_URL: redis://localhost:6379 steps: @@ -127,8 +140,21 @@ jobs: --health-retries 5 ports: - 6379:6379 + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: mysql + MYSQL_DATABASE: fedify + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306:3306 env: AMQP_URL: amqp://guest:guest@localhost:5672 + MYSQL_URL: mysql://root:mysql@localhost:3306/fedify POSTGRES_URL: postgres://postgres:postgres@localhost:5432/postgres REDIS_URL: redis://localhost:6379 steps: @@ -177,8 +203,21 @@ jobs: --health-retries 5 ports: - 6379:6379 + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: mysql + MYSQL_DATABASE: fedify + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306:3306 env: AMQP_URL: amqp://guest:guest@localhost:5672 + MYSQL_URL: mysql://root:mysql@localhost:3306/fedify POSTGRES_URL: postgres://postgres:postgres@localhost:5432/postgres REDIS_URL: redis://localhost:6379 steps: diff --git a/AGENTS.md b/AGENTS.md index f6b78c18d..28d4345c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,8 +169,8 @@ Common tasks 1. For core KV/MQ interfaces: implement in *packages/fedify/src/federation/kv.ts* and *packages/fedify/src/federation/mq.ts* 2. For specific database adapters: create dedicated packages - (*packages/sqlite/*, *packages/postgres/*, *packages/redis/*, - *packages/amqp/*) + (*packages/sqlite/*, *packages/postgres/*, *packages/mysql/*, + *packages/redis/*, *packages/amqp/*) 3. Follow the pattern from existing database adapter packages 4. Implement both KV store and message queue interfaces as needed @@ -326,8 +326,8 @@ The monorepo uses different build processes for different packages: 3. **Database adapters and integrations**: Use tsdown for TypeScript compilation: - *packages/amqp/*, *packages/astro/*, *packages/elysia*, *packages/express/*, *packages/h3/*, - *packages/sqlite/*, *packages/postgres/*, *packages/redis/*, - *packages/nestjs/* + *packages/mysql/*, *packages/sqlite/*, *packages/postgres/*, + *packages/redis/*, *packages/nestjs/* - Built to support Node.js and Bun environments Ensure changes work across all distribution formats and target environments. diff --git a/CHANGES.md b/CHANGES.md index 30e28d8d4..c43bb689b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -19,28 +19,6 @@ To be released. [#473]: https://github.com/fedify-dev/fedify/issues/473 [#589]: https://github.com/fedify-dev/fedify/pull/589 -### @fedify/astro - - - Added `@fedify/astro` package for integrating Fedify with [Astro]. - It provides `fedifyIntegration()` for Vite SSR configuration and - `fedifyMiddleware()` for request handling. [[#50] by Chanhaeng Lee] - -[Astro]: https://astro.build/ -[#50]: https://github.com/fedify-dev/fedify/issues/50 - -### @fedify/init - - - Changed `fedify init` to add `"temporal"` to `deno.json`'s `"unstable"` - field only when the installed Deno version is earlier than 2.7.0. - On Deno 2.7.0 or later, it is no longer added. - - - `fedify init` now omits the `"unstable"` field entirely when no unstable - feature is required for the generated Deno project. - - - Supported [Astro] as a web framework option in `fedify init`, with - runtime-specific templates for Deno, Bun, and Node.js environments. - [[#50] by ChanHaeng Lee] - ### @fedify/vocab - Fixed `Endpoints.toJsonLd()` to no longer emit invalid @@ -63,6 +41,40 @@ To be released. compact form) in the serialized JSON-LD. This is useful for types that are not real vocabulary types but rather anonymous object structures. +### @fedify/init + + - Changed `fedify init` to add `"temporal"` to `deno.json`'s `"unstable"` + field only when the installed Deno version is earlier than 2.7.0. + On Deno 2.7.0 or later, it is no longer added. + + - `fedify init` now omits the `"unstable"` field entirely when no unstable + feature is required for the generated Deno project. + + - Supported [Astro] as a web framework option in `fedify init`, with + runtime-specific templates for Deno, Bun, and Node.js environments. + [[#50] by ChanHaeng Lee] + +[Astro]: https://astro.build/ +[#50]: https://github.com/fedify-dev/fedify/issues/50 + +### @fedify/astro + + - Added `@fedify/astro` package for integrating Fedify with [Astro]. + It provides `fedifyIntegration()` for Vite SSR configuration and + `fedifyMiddleware()` for request handling. [[#50] by Chanhaeng Lee] + +### @fedify/MySQL + + - Added `@fedify/mysql` package, a MySQL/MariaDB-backed `KvStore` + implementation. It provides `MysqlKvStore`, which stores key–value + pairs in a MySQL table using the [`mysql2`] driver. Supports TTL, + prefix listing, and compare-and-swap (`cas()`) operations. + [[#585], [#597]] + +[`mysql2`]: https://www.npmjs.com/package/mysql2 +[#585]: https://github.com/fedify-dev/fedify/issues/585 +[#597]: https://github.com/fedify-dev/fedify/pull/597 + Version 2.0.3 ------------- diff --git a/deno.json b/deno.json index c13ca3439..f51641b02 100644 --- a/deno.json +++ b/deno.json @@ -16,6 +16,7 @@ "./packages/hono", "./packages/koa", "./packages/lint", + "./packages/mysql", "./packages/postgres", "./packages/redis", "./packages/relay", @@ -63,6 +64,8 @@ "hono": "jsr:@hono/hono@^4.8.3", "ioredis": "npm:ioredis@^5.8.2", "json-preserve-indent": "npm:json-preserve-indent@^1.1.3", + "mysql2": "npm:mysql2@^3.18.0", + "mysql2/promise": "npm:mysql2@^3.18.0/promise", "postgres": "npm:postgres@^3.4.7", "preact": "npm:preact@10.19.6", "tsdown": "npm:tsdown@^0.18.4" diff --git a/deno.lock b/deno.lock index 423294125..1b93a1cc5 100644 --- a/deno.lock +++ b/deno.lock @@ -141,6 +141,7 @@ "npm:koa@2": "2.16.3", "npm:miniflare@^4.20250523.0": "4.20250906.0", "npm:multicodec@^3.2.1": "3.2.1", + "npm:mysql2@^3.18.0": "3.18.2_@types+node@22.19.10", "npm:ora@^8.2.0": "8.2.0", "npm:pkijs@^3.2.5": "3.3.3", "npm:pkijs@^3.3.3": "3.3.3", @@ -3336,6 +3337,9 @@ "await-to-js@3.0.0": { "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==" }, + "aws-ssl-profiles@1.1.2": { + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==" + }, "axobject-query@4.1.0": { "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" }, @@ -4396,6 +4400,12 @@ "function-bind@1.1.2": { "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, + "generate-function@2.3.1": { + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": [ + "is-property" + ] + }, "generator-function@2.0.1": { "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==" }, @@ -4812,6 +4822,9 @@ "is-plain-obj@4.1.0": { "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==" }, + "is-property@1.0.2": { + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, "is-reference@3.0.3": { "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "dependencies": [ @@ -5058,6 +5071,9 @@ "is-unicode-supported@1.3.0" ] }, + "long@5.3.2": { + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, "longest-streak@3.1.0": { "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==" }, @@ -5079,6 +5095,9 @@ "yallist@4.0.0" ] }, + "lru.min@1.1.4": { + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==" + }, "magic-string@0.30.21": { "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dependencies": [ @@ -5577,6 +5596,20 @@ "mute-stream@2.0.0": { "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==" }, + "mysql2@3.18.2_@types+node@22.19.10": { + "integrity": "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==", + "dependencies": [ + "@types/node@22.19.10", + "aws-ssl-profiles", + "denque", + "generate-function", + "iconv-lite@0.7.2", + "long", + "lru.min", + "named-placeholders", + "sql-escaper" + ] + }, "mz@2.7.0": { "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "dependencies": [ @@ -5585,6 +5618,12 @@ "thenify-all" ] }, + "named-placeholders@1.1.6": { + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "dependencies": [ + "lru.min" + ] + }, "nanoid@3.3.11": { "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "bin": true @@ -6613,6 +6652,9 @@ "split2@4.2.0": { "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" }, + "sql-escaper@1.3.3": { + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==" + }, "srvx@0.8.16": { "integrity": "sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ==", "bin": true @@ -7503,6 +7545,7 @@ "npm:h3@^1.15.0", "npm:ioredis@^5.8.2", "npm:json-preserve-indent@^1.1.3", + "npm:mysql2@^3.18.0", "npm:postgres@^3.4.7", "npm:preact@10.19.6", "npm:tsdown@~0.18.4" diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index c05cc6f08..db08f373f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -102,6 +102,7 @@ const REFERENCES = { { text: "@fedify/h3", link: "https://jsr.io/@fedify/h3/doc" }, { text: "@fedify/hono", link: "https://jsr.io/@fedify/hono/doc" }, { text: "@fedify/koa", link: "https://jsr.io/@fedify/koa/doc" }, + { text: "@fedify/mysql", link: "https://jsr.io/@fedify/mysql/doc" }, { text: "@fedify/postgres", link: "https://jsr.io/@fedify/postgres/doc" }, { text: "@fedify/redis", link: "https://jsr.io/@fedify/redis/doc" }, { text: "@fedify/relay", link: "https://jsr.io/@fedify/relay/doc" }, diff --git a/docs/manual/federation.md b/docs/manual/federation.md index c6d7175c6..d6cca84e1 100644 --- a/docs/manual/federation.md +++ b/docs/manual/federation.md @@ -52,9 +52,10 @@ implementation for production use (as you can guess from the name, it is only available in Deno runtime). As separate packages, [`@fedify/redis`] provides [`RedisKvStore`] class, which -is a Redis-backed implementation for production use, and [`@fedify/postgres`] +is a Redis-backed implementation for production use, [`@fedify/postgres`] provides [`PostgresKvStore`] class, which is a PostgreSQL-backed implementation -for production use. +for production use, and [`@fedify/mysql`] provides [`MysqlKvStore`] class, which +is a MySQL/MariaDB-backed implementation for production use. Further details are explained in the [*Key–value store* section](./kv.md). @@ -62,6 +63,8 @@ Further details are explained in the [*Key–value store* section](./kv.md). [`RedisKvStore`]: https://jsr.io/@fedify/redis/doc/kv/~/RedisKvStore [`@fedify/postgres`]: https://github.com/fedify-dev/fedify/tree/main/packages/postgres [`PostgresKvStore`]: https://jsr.io/@fedify/postgres/doc/kv/~/PostgresKvStore +[`@fedify/mysql`]: https://github.com/fedify-dev/fedify/tree/main/packages/mysql +[`MysqlKvStore`]: https://jsr.io/@fedify/mysql/doc/kv/~/MysqlKvStore ### `kvPrefixes` diff --git a/docs/manual/kv.md b/docs/manual/kv.md index 712707c91..e20438c4f 100644 --- a/docs/manual/kv.md +++ b/docs/manual/kv.md @@ -306,6 +306,65 @@ const federation = createFederation({ [`PostgresKvStore`]: https://jsr.io/@fedify/postgres/doc/kv/~/PostgresKvStore +### [`MysqlKvStore`] + +*This API is available since Fedify 2.1.0.* + +To use the [`MysqlKvStore`], you need to install the *@fedify/mysql* package +first: + +::: code-group + +~~~~ bash [Deno] +deno add jsr:@fedify/mysql +~~~~ + +~~~~ bash [npm] +npm add @fedify/mysql +~~~~ + +~~~~ bash [pnpm] +pnpm add @fedify/mysql +~~~~ + +~~~~ bash [Yarn] +yarn add @fedify/mysql +~~~~ + +~~~~ bash [Bun] +bun add @fedify/mysql +~~~~ + +::: + +[`MysqlKvStore`] is a key-value store implementation that uses MySQL (or +MariaDB) as the backend storage. It provides scalability and high performance, +making it suitable for production use in distributed systems. It requires +a MySQL or MariaDB server setup and maintenance. + +Best for +: Production use, a system that already uses MySQL or MariaDB. + +Pros +: Scalable, no additional setup required if already using MySQL/MariaDB. + +Cons +: Requires MySQL/MariaDB setup and maintenance. + +~~~~ typescript twoslash +import { createFederation } from "@fedify/fedify"; +import { MysqlKvStore } from "@fedify/mysql"; +import mysql from "mysql2/promise"; + +const pool = mysql.createPool("mysql://user:pass@localhost/db"); +const federation = createFederation({ + kv: new MysqlKvStore(pool), + // ... other options +}); +~~~~ + +[`MysqlKvStore`]: https://jsr.io/@fedify/mysql/doc/kv/~/MysqlKvStore + ### `WorkersKvStore` (Cloudflare Workers only) *This API is available since Fedify 1.6.0.* diff --git a/docs/manual/relay.md b/docs/manual/relay.md index fbd857581..c533ec2ee 100644 --- a/docs/manual/relay.md +++ b/docs/manual/relay.md @@ -118,7 +118,8 @@ serve({ > [!WARNING] > `MemoryKvStore` is for development only. For production, use a persistent > store like `RedisKvStore` from *@fedify/redis*, `PostgresKvStore` from -> *@fedify/postgres*, or `DenoKvStore` from *@fedify/denokv*. +> *@fedify/postgres*, `MysqlKvStore` from *@fedify/mysql*, or `DenoKvStore` +> from *@fedify/denokv*. > > See the [*Key–value store* section](./kv.md) for details. diff --git a/docs/package.json b/docs/package.json index df8676bef..ea8b978a4 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,6 +14,7 @@ "@fedify/hono": "workspace:^", "@fedify/koa": "workspace:^", "@fedify/lint": "workspace:^", + "@fedify/mysql": "workspace:^", "@fedify/nestjs": "workspace:^", "@fedify/next": "workspace:^", "@fedify/postgres": "workspace:^", diff --git a/docs/tutorial/basics.md b/docs/tutorial/basics.md index 64b85f6a8..b33bcddd1 100644 --- a/docs/tutorial/basics.md +++ b/docs/tutorial/basics.md @@ -258,7 +258,8 @@ a key–value store. > Since `MemoryKvStore` is for testing and development purposes, you should > use a persistent key–value store like `DenoKvStore` (in Deno) or > [`RedisKvStore`] (from [`@fedify/redis`] package) or [`PostgresKvStore`] -> (from [`@fedify/postgres`] package) for production use. +> (from [`@fedify/postgres`] package) or [`MysqlKvStore`] +> (from [`@fedify/mysql`] package) for production use. > > For further details, see the [*Key–value store* section](../manual/kv.md). @@ -362,6 +363,8 @@ to the next step. [`@fedify/redis`]: https://github.com/fedify-dev/fedify/tree/main/packages/redis [`PostgresKvStore`]: https://jsr.io/@fedify/postgres/doc/kv/~/PostgresKvStore [`@fedify/postgres`]: https://github.com/fedify-dev/fedify/tree/main/packages/postgres +[`MysqlKvStore`]: https://jsr.io/@fedify/mysql/doc/kv/~/MysqlKvStore +[`@fedify/mysql`]: https://github.com/fedify-dev/fedify/tree/main/packages/mysql [LogTape]: https://logtape.org/ [`configure()`]: https://jsr.io/@logtape/logtape/doc/~/configure diff --git a/packages/fedify/README.md b/packages/fedify/README.md index eb2adcd92..48bed9c98 100644 --- a/packages/fedify/README.md +++ b/packages/fedify/README.md @@ -115,6 +115,7 @@ Here is the list of packages: | [@fedify/lint](/packages/lint/) | [JSR][jsr:@fedify/lint] | [npm][npm:@fedify/lint] | Linting utilities | | [@fedify/nestjs](/packages/nestjs/) | | [npm][npm:@fedify/nestjs] | NestJS integration | | [@fedify/next](/packages/next/) | | [npm][npm:@fedify/next] | Next.js integration | +| [@fedify/mysql](/packages/mysql/) | [JSR][jsr:@fedify/mysql] | [npm][npm:@fedify/mysql] | MySQL/MariaDB driver | | [@fedify/postgres](/packages/postgres/) | [JSR][jsr:@fedify/postgres] | [npm][npm:@fedify/postgres] | PostgreSQL driver | | [@fedify/redis](/packages/redis/) | [JSR][jsr:@fedify/redis] | [npm][npm:@fedify/redis] | Redis driver | | [@fedify/relay](/packages/relay/) | [JSR][jsr:@fedify/relay] | [npm][npm:@fedify/relay] | ActivityPub relay support | @@ -156,6 +157,8 @@ Here is the list of packages: [npm:@fedify/lint]: https://www.npmjs.com/package/@fedify/lint [npm:@fedify/nestjs]: https://www.npmjs.com/package/@fedify/nestjs [npm:@fedify/next]: https://www.npmjs.com/package/@fedify/next +[jsr:@fedify/mysql]: https://jsr.io/@fedify/mysql +[npm:@fedify/mysql]: https://www.npmjs.com/package/@fedify/mysql [jsr:@fedify/postgres]: https://jsr.io/@fedify/postgres [npm:@fedify/postgres]: https://www.npmjs.com/package/@fedify/postgres [jsr:@fedify/redis]: https://jsr.io/@fedify/redis diff --git a/packages/init/src/json/kv.json b/packages/init/src/json/kv.json index fbbb79230..051c79a45 100644 --- a/packages/init/src/json/kv.json +++ b/packages/init/src/json/kv.json @@ -29,6 +29,21 @@ "POSTGRES_URL": "postgres://postgres@localhost:5432/postgres" } }, + "mysql": { + "label": "MySQL/MariaDB", + "packageManagers": ["deno", "bun", "npm", "yarn", "pnpm"], + "dependencies": { + "npm:mysql2": "^3.18.0" + }, + "imports": { + "@fedify/mysql": { "MysqlKvStore": "MysqlKvStore" }, + "mysql2/promise": { "default": "mysql" } + }, + "object": "new MysqlKvStore(mysql.createPool(process.env.MYSQL_URL))", + "env": { + "MYSQL_URL": "mysql://root@localhost/fedify" + } + }, "denokv": { "label": "Deno KV", "packageManagers": ["deno"], diff --git a/packages/mysql/README.md b/packages/mysql/README.md new file mode 100644 index 000000000..8671bc231 --- /dev/null +++ b/packages/mysql/README.md @@ -0,0 +1,44 @@ + + +@fedify/MySQL: MySQL/MariaDB drivers for Fedify +=============================================== + +[![JSR][JSR badge]][JSR] +[![npm][npm badge]][npm] + +This package provides [Fedify]'s [`KvStore`] implementation for +MySQL/MariaDB: + + - [`MysqlKvStore`] + +~~~~ typescript +import { createFederation } from "@fedify/fedify"; +import { MysqlKvStore } from "@fedify/mysql"; +import mysql from "mysql2/promise"; + +const pool = mysql.createPool("mysql://user:password@localhost/dbname"); + +const federation = createFederation({ + kv: new MysqlKvStore(pool), +}); +~~~~ + +[JSR badge]: https://jsr.io/badges/@fedify/mysql +[JSR]: https://jsr.io/@fedify/mysql +[npm badge]: https://img.shields.io/npm/v/@fedify/mysql?logo=npm +[npm]: https://www.npmjs.com/package/@fedify/mysql +[Fedify]: https://fedify.dev/ +[`KvStore`]: https://jsr.io/@fedify/fedify/doc/federation/~/KvStore +[`MysqlKvStore`]: https://jsr.io/@fedify/mysql/doc/~/MysqlKvStore + + +Installation +------------ + +~~~~ sh +deno add jsr:@fedify/mysql # Deno +npm add @fedify/mysql # npm +pnpm add @fedify/mysql # pnpm +yarn add @fedify/mysql # Yarn +bun add @fedify/mysql # Bun +~~~~ diff --git a/packages/mysql/deno.json b/packages/mysql/deno.json new file mode 100644 index 000000000..ece62a473 --- /dev/null +++ b/packages/mysql/deno.json @@ -0,0 +1,23 @@ +{ + "name": "@fedify/mysql", + "version": "2.1.0", + "license": "MIT", + "exports": { + ".": "./src/mod.ts", + "./kv": "./src/kv.ts" + }, + "exclude": [ + "dist", + "node_modules" + ], + "publish": { + "exclude": [ + "**/*.test.ts", + "tsdown.config.ts" + ] + }, + "tasks": { + "check": "deno fmt --check && deno lint && deno check src/*.ts", + "test": "deno test --allow-net --allow-env" + } +} diff --git a/packages/mysql/package.json b/packages/mysql/package.json new file mode 100644 index 000000000..6ca657445 --- /dev/null +++ b/packages/mysql/package.json @@ -0,0 +1,86 @@ +{ + "name": "@fedify/mysql", + "version": "2.1.0", + "description": "MySQL/MariaDB drivers for Fedify", + "keywords": [ + "fedify", + "mysql", + "mariadb" + ], + "license": "MIT", + "author": { + "name": "Hong Minhee", + "email": "hong@minhee.org", + "url": "https://hongminhee.org/" + }, + "homepage": "https://fedify.dev/", + "repository": { + "type": "git", + "url": "git+https://github.com/fedify-dev/fedify.git", + "directory": "packages/mysql" + }, + "bugs": { + "url": "https://github.com/fedify-dev/fedify/issues" + }, + "funding": [ + "https://opencollective.com/fedify", + "https://github.com/sponsors/dahlia" + ], + "type": "module", + "main": "./dist/mod.cjs", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", + "exports": { + ".": { + "types": { + "import": "./dist/mod.d.ts", + "require": "./dist/mod.d.cts", + "default": "./dist/mod.d.ts" + }, + "import": "./dist/mod.js", + "require": "./dist/mod.cjs", + "default": "./dist/mod.js" + }, + "./kv": { + "types": { + "import": "./dist/kv.d.ts", + "require": "./dist/kv.d.cts", + "default": "./dist/kv.d.ts" + }, + "import": "./dist/kv.js", + "require": "./dist/kv.cjs", + "default": "./dist/kv.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "package.json", + "README.md" + ], + "dependencies": { + "@js-temporal/polyfill": "catalog:", + "@logtape/logtape": "catalog:", + "es-toolkit": "catalog:" + }, + "peerDependencies": { + "@fedify/fedify": "workspace:^", + "mysql2": "catalog:" + }, + "devDependencies": { + "@fedify/fixture": "workspace:^", + "@fedify/testing": "workspace:^", + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "scripts": { + "build:self": "tsdown", + "build": "pnpm --filter @fedify/mysql... run build:self", + "prepack": "pnpm build", + "prepublish": "pnpm build", + "pretest": "pnpm build", + "test": "node --experimental-transform-types --test", + "pretest:bun": "pnpm build", + "test:bun": "bun test --timeout=60000" + } +} diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts new file mode 100644 index 000000000..d2097b6e5 --- /dev/null +++ b/packages/mysql/src/kv.test.ts @@ -0,0 +1,335 @@ +import { MysqlKvStore } from "@fedify/mysql/kv"; +import * as temporal from "@js-temporal/polyfill"; +import assert from "node:assert/strict"; +import process from "node:process"; +import { test } from "node:test"; +import mysql from "mysql2/promise"; + +let Temporal: typeof temporal.Temporal; +if ("Temporal" in globalThis) { + Temporal = globalThis.Temporal; +} else { + Temporal = temporal.Temporal; +} + +const dbUrl = process.env.MYSQL_URL; + +function getStore(): { + pool: mysql.Pool; + tableName: string; + store: MysqlKvStore; +} { + const pool = mysql.createPool(dbUrl!); + const tableName = `fedify_kv_test_${Math.random().toString(36).slice(5)}`; + return { + pool, + tableName, + store: new MysqlKvStore(pool, { tableName }), + }; +} + +test("MysqlKvStore.initialize()", { skip: dbUrl == null }, async () => { + if (dbUrl == null) return; + + const { pool, tableName, store } = getStore(); + try { + await store.initialize(); + const [rows] = await pool.query( + `SELECT COUNT(*) AS cnt + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ?`, + [tableName], + ); + assert.strictEqual(rows[0].cnt, 1); + } finally { + await store.drop(); + await pool.end(); + } +}); + +test("MysqlKvStore.get()", { skip: dbUrl == null }, async () => { + if (dbUrl == null) return; + + const { pool, tableName, store } = getStore(); + try { + await store.initialize(); + await pool.query( + `INSERT INTO \`${tableName}\` (\`key\`, \`value\`) + VALUES (?, CAST(? AS JSON))`, + [JSON.stringify(["foo", "bar"]), JSON.stringify(["foobar"])], + ); + assert.deepStrictEqual(await store.get(["foo", "bar"]), ["foobar"]); + + // Insert with immediately expired TTL + await pool.query( + `INSERT INTO \`${tableName}\` (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), DATE_SUB(NOW(6), INTERVAL 1 SECOND))`, + [ + JSON.stringify(["foo", "bar", "ttl"]), + JSON.stringify(["foobar"]), + ], + ); + assert.strictEqual(await store.get(["foo", "bar", "ttl"]), undefined); + } finally { + await store.drop(); + await pool.end(); + } +}); + +test("MysqlKvStore.set()", { skip: dbUrl == null }, async () => { + if (dbUrl == null) return; + + const { pool, tableName, store } = getStore(); + try { + await store.set(["foo", "baz"], "baz"); + const [rows] = await pool.query( + `SELECT * FROM \`${tableName}\` WHERE \`key\` = ?`, + [JSON.stringify(["foo", "baz"])], + ); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].value, "baz"); + assert.strictEqual(rows[0].expires, null); + + await store.set(["foo", "qux"], "qux", { + ttl: Temporal.Duration.from({ days: 1 }), + }); + const [rows2] = await pool.query( + `SELECT * FROM \`${tableName}\` WHERE \`key\` = ?`, + [JSON.stringify(["foo", "qux"])], + ); + assert.strictEqual(rows2.length, 1); + assert.strictEqual(rows2[0].value, "qux"); + assert.notStrictEqual(rows2[0].expires, null); + + await store.set(["foo", "quux"], true); + const [rows3] = await pool.query( + `SELECT * FROM \`${tableName}\` WHERE \`key\` = ?`, + [JSON.stringify(["foo", "quux"])], + ); + assert.strictEqual(rows3.length, 1); + assert.strictEqual(rows3[0].value, true); + assert.strictEqual(rows3[0].expires, null); + } finally { + await store.drop(); + await pool.end(); + } +}); + +test("MysqlKvStore.delete()", { skip: dbUrl == null }, async () => { + if (dbUrl == null) return; + + const { pool, store } = getStore(); + try { + await store.set(["foo", "bar"], "foobar"); + assert.deepStrictEqual(await store.get(["foo", "bar"]), "foobar"); + await store.delete(["foo", "bar"]); + assert.strictEqual(await store.get(["foo", "bar"]), undefined); + } finally { + await store.drop(); + await pool.end(); + } +}); + +test("MysqlKvStore.drop()", { skip: dbUrl == null }, async () => { + if (dbUrl == null) return; + + const { pool, tableName, store } = getStore(); + try { + await store.initialize(); + await store.drop(); + const [rows] = await pool.query( + `SELECT COUNT(*) AS cnt + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ?`, + [tableName], + ); + assert.strictEqual(rows[0].cnt, 0); + } finally { + await pool.end(); + } +}); + +test("MysqlKvStore.list()", { skip: dbUrl == null }, async () => { + if (dbUrl == null) return; + + const { pool, store } = getStore(); + try { + await store.set(["prefix", "a"], "value-a"); + await store.set(["prefix", "b"], "value-b"); + await store.set(["prefix", "nested", "c"], "value-c"); + await store.set(["other", "x"], "value-x"); + + const entries: { key: readonly string[]; value: unknown }[] = []; + for await (const entry of store.list(["prefix"])) { + entries.push({ key: entry.key, value: entry.value }); + } + + assert.strictEqual(entries.length, 3); + assert(entries.some((e) => e.key[1] === "a" && e.value === "value-a")); + assert(entries.some((e) => e.key[1] === "b")); + assert(entries.some((e) => e.key[1] === "nested")); + } finally { + await store.drop(); + await pool.end(); + } +}); + +test( + "MysqlKvStore.list() - excludes expired", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const { pool, tableName, store } = getStore(); + try { + await store.initialize(); + + // Insert expired entry directly + await pool.query( + `INSERT INTO \`${tableName}\` (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), + DATE_SUB(NOW(6), INTERVAL 30 MINUTE))`, + [ + JSON.stringify(["list-test", "expired"]), + JSON.stringify("expired-value"), + ], + ); + await store.set(["list-test", "valid"], "valid-value"); + + const entries: { key: readonly string[]; value: unknown }[] = []; + for await (const entry of store.list(["list-test"])) { + entries.push({ key: entry.key, value: entry.value }); + } + + assert.strictEqual(entries.length, 1); + assert.deepStrictEqual(entries[0].key, ["list-test", "valid"]); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + +test( + "MysqlKvStore.list() - single element key", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const { pool, store } = getStore(); + try { + await store.set(["a"], "value-a"); + await store.set(["b"], "value-b"); + + const entries: { key: readonly string[]; value: unknown }[] = []; + for await (const entry of store.list(["a"])) { + entries.push({ key: entry.key, value: entry.value }); + } + + assert.strictEqual(entries.length, 1); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + +test( + "MysqlKvStore.list() - empty prefix", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const { pool, store } = getStore(); + try { + await store.set(["a"], "value-a"); + await store.set(["b", "c"], "value-bc"); + await store.set(["d", "e", "f"], "value-def"); + + const entries: { key: readonly string[]; value: unknown }[] = []; + for await (const entry of store.list()) { + entries.push({ key: entry.key, value: entry.value }); + } + + assert.strictEqual(entries.length, 3); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + +test("MysqlKvStore.cas()", { skip: dbUrl == null }, async () => { + if (dbUrl == null) return; + + const { pool, store } = getStore(); + try { + await store.set(["foo", "bar"], "foobar"); + + // Mismatch: expected "bar" but current value is "foobar" + assert.strictEqual( + await store.cas!(["foo", "bar"], "bar", "baz"), + false, + ); + assert.deepStrictEqual(await store.get(["foo", "bar"]), "foobar"); + + // Match: expected "foobar" matches current value + assert.strictEqual( + await store.cas!(["foo", "bar"], "foobar", "baz"), + true, + ); + assert.deepStrictEqual(await store.get(["foo", "bar"]), "baz"); + + // Delete the key, then CAS with wrong expected value + await store.delete(["foo", "bar"]); + assert.strictEqual( + await store.cas!(["foo", "bar"], "foobar", "baz"), + false, + ); + assert.strictEqual(await store.get(["foo", "bar"]), undefined); + + // CAS with undefined expected value on non-existent key (create-if-absent) + assert.strictEqual( + await store.cas!(["foo", "bar"], undefined, "baz"), + true, + ); + assert.deepStrictEqual(await store.get(["foo", "bar"]), "baz"); + } finally { + await store.drop(); + await pool.end(); + } +}); + +test( + "MysqlKvStore.cas() with TTL", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const { pool, store } = getStore(); + try { + // CAS with TTL on non-existent key + assert.strictEqual( + await store.cas!(["ttl", "key"], undefined, "value", { + ttl: Temporal.Duration.from({ hours: 1 }), + }), + true, + ); + assert.deepStrictEqual(await store.get(["ttl", "key"]), "value"); + + // CAS with zero TTL should effectively expire immediately + assert.strictEqual( + await store.cas!(["ttl", "key"], "value", "new-value", { + ttl: Temporal.Duration.from({ seconds: 0 }), + }), + true, + ); + // Wait a bit for expiry + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.strictEqual(await store.get(["ttl", "key"]), undefined); + } finally { + await store.drop(); + await pool.end(); + } + }, +); diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts new file mode 100644 index 000000000..8726dc669 --- /dev/null +++ b/packages/mysql/src/kv.ts @@ -0,0 +1,299 @@ +import type { + KvKey, + KvStore, + KvStoreListEntry, + KvStoreSetOptions, +} from "@fedify/fedify"; +import { isEqual } from "es-toolkit"; +import { getLogger } from "@logtape/logtape"; +import type { Pool, PoolConnection, RowDataPacket } from "mysql2/promise"; + +const logger = getLogger(["fedify", "mysql", "kv"]); + +/** + * Options for the MySQL key-value store. + * + * @since 2.1.0 + */ +export interface MysqlKvStoreOptions { + /** + * The table name to use for the key-value store. + * `"fedify_kv"` by default. + * @default `"fedify_kv"` + * @since 2.1.0 + */ + readonly tableName?: string; + + /** + * Whether the table has been initialized. `false` by default. + * @default `false` + * @since 2.1.0 + */ + readonly initialized?: boolean; +} + +/** + * A key-value store that uses MySQL (or MariaDB) as the underlying storage. + * + * @example + * ```ts + * import { createFederation } from "@fedify/fedify"; + * import { MysqlKvStore } from "@fedify/mysql"; + * import mysql from "mysql2/promise"; + * + * const pool = mysql.createPool("mysql://user:pass@localhost/db"); + * + * const federation = createFederation({ + * // ... + * kv: new MysqlKvStore(pool), + * }); + * ``` + * + * @since 2.1.0 + */ +export class MysqlKvStore implements KvStore { + readonly #pool: Pool; + readonly #tableName: string; + #initialized: boolean; + + /** + * Creates a new MySQL key-value store. + * @param pool The MySQL connection pool to use. + * @param options The options for the key-value store. + * @since 2.1.0 + */ + constructor(pool: Pool, options: MysqlKvStoreOptions = {}) { + this.#pool = pool; + this.#tableName = options.tableName ?? "fedify_kv"; + this.#initialized = options.initialized ?? false; + } + + async #expire(): Promise { + await this.#pool.query( + `DELETE FROM \`${this.#tableName}\` + WHERE \`expires\` IS NOT NULL AND \`expires\` < NOW(6)`, + ); + } + + /** + * {@inheritDoc KvStore.get} + * @since 2.1.0 + */ + async get(key: KvKey): Promise { + await this.initialize(); + const serializedKey = JSON.stringify([...key]); + const [rows] = await this.#pool.query( + `SELECT \`value\` FROM \`${this.#tableName}\` + WHERE \`key\` = ? + AND (\`expires\` IS NULL OR \`expires\` > NOW(6))`, + [serializedKey], + ); + if (rows.length < 1) return undefined; + return rows[0].value as T; + } + + /** + * {@inheritDoc KvStore.set} + * @since 2.1.0 + */ + async set( + key: KvKey, + value: unknown, + options?: KvStoreSetOptions | undefined, + ): Promise { + await this.initialize(); + const serializedKey = JSON.stringify([...key]); + const jsonValue = JSON.stringify(value); + if (options?.ttl != null) { + const ttlSeconds = durationToSeconds(options.ttl); + await this.#pool.query( + `INSERT INTO \`${this.#tableName}\` (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), + DATE_ADD(NOW(6), INTERVAL ? SECOND)) + ON DUPLICATE KEY UPDATE + \`value\` = VALUES(\`value\`), + \`expires\` = VALUES(\`expires\`)`, + [serializedKey, jsonValue, ttlSeconds], + ); + } else { + await this.#pool.query( + `INSERT INTO \`${this.#tableName}\` (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), NULL) + ON DUPLICATE KEY UPDATE + \`value\` = VALUES(\`value\`), + \`expires\` = NULL`, + [serializedKey, jsonValue], + ); + } + await this.#expire(); + } + + /** + * {@inheritDoc KvStore.delete} + * @since 2.1.0 + */ + async delete(key: KvKey): Promise { + await this.initialize(); + const serializedKey = JSON.stringify([...key]); + await this.#pool.query( + `DELETE FROM \`${this.#tableName}\` WHERE \`key\` = ?`, + [serializedKey], + ); + await this.#expire(); + } + + /** + * {@inheritDoc KvStore.cas} + * @since 2.1.0 + */ + async cas( + key: KvKey, + expectedValue: unknown, + newValue: unknown, + options?: KvStoreSetOptions, + ): Promise { + await this.initialize(); + const serializedKey = JSON.stringify([...key]); + let conn: PoolConnection | undefined; + try { + conn = await this.#pool.getConnection(); + await conn.beginTransaction(); + + const [rows] = await conn.query( + `SELECT \`value\` FROM \`${this.#tableName}\` + WHERE \`key\` = ? + AND (\`expires\` IS NULL OR \`expires\` > NOW(6)) + FOR UPDATE`, + [serializedKey], + ); + + const currentValue = rows.length > 0 ? rows[0].value : undefined; + + if (!isEqual(currentValue, expectedValue)) { + await conn.rollback(); + return false; + } + + const jsonValue = JSON.stringify(newValue); + if (options?.ttl != null) { + const ttlSeconds = durationToSeconds(options.ttl); + await conn.query( + `INSERT INTO \`${this.#tableName}\` + (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), + DATE_ADD(NOW(6), INTERVAL ? SECOND)) + ON DUPLICATE KEY UPDATE + \`value\` = VALUES(\`value\`), + \`expires\` = VALUES(\`expires\`)`, + [serializedKey, jsonValue, ttlSeconds], + ); + } else { + await conn.query( + `INSERT INTO \`${this.#tableName}\` + (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), NULL) + ON DUPLICATE KEY UPDATE + \`value\` = VALUES(\`value\`), + \`expires\` = NULL`, + [serializedKey, jsonValue], + ); + } + + await conn.commit(); + return true; + } catch (e) { + if (conn) await conn.rollback(); + throw e; + } finally { + if (conn) conn.release(); + } + } + + /** + * {@inheritDoc KvStore.list} + * @since 2.1.0 + */ + async *list(prefix?: KvKey): AsyncIterable { + await this.initialize(); + + let rows: RowDataPacket[]; + if (prefix == null || prefix.length === 0) { + [rows] = await this.#pool.query( + `SELECT \`key\`, \`value\` FROM \`${this.#tableName}\` + WHERE \`expires\` IS NULL OR \`expires\` > NOW(6) + ORDER BY \`key\``, + ); + } else { + const serializedPrefix = JSON.stringify([...prefix]); + // Escape LIKE special characters in the prefix + const likePrefix = + serializedPrefix.slice(0, -1).replace(/[%_\\]/g, "\\$&") + ",%"; + [rows] = await this.#pool.query( + `SELECT \`key\`, \`value\` FROM \`${this.#tableName}\` + WHERE (\`key\` = ? OR \`key\` LIKE ?) + AND (\`expires\` IS NULL OR \`expires\` > NOW(6)) + ORDER BY \`key\``, + [serializedPrefix, likePrefix], + ); + } + + for (const row of rows) { + yield { + key: JSON.parse(row.key) as KvKey, + value: row.value, + }; + } + } + + /** + * Creates the table used by the key-value store if it does not already exist. + * Does nothing if the table already exists. + * + * @since 2.1.0 + */ + async initialize(): Promise { + if (this.#initialized) return; + logger.debug("Initializing the key-value store table {tableName}...", { + tableName: this.#tableName, + }); + await this.#pool.query( + `CREATE TABLE IF NOT EXISTS \`${this.#tableName}\` ( + \`key\` VARCHAR(512) NOT NULL, + \`value\` JSON NOT NULL, + \`expires\` DATETIME(6) NULL DEFAULT NULL, + PRIMARY KEY (\`key\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`, + ); + this.#initialized = true; + logger.debug("Initialized the key-value store table {tableName}.", { + tableName: this.#tableName, + }); + } + + /** + * Drops the table used by the key-value store. Does nothing if the table + * does not exist. + * + * @since 2.1.0 + */ + async drop(): Promise { + await this.#pool.query( + `DROP TABLE IF EXISTS \`${this.#tableName}\``, + ); + } +} + +function durationToSeconds(duration: Temporal.Duration): number { + const rounded = duration.round({ + largestUnit: "hour", + relativeTo: Temporal.Now.plainDateTimeISO(), + }); + return ( + rounded.hours * 3600 + + rounded.minutes * 60 + + rounded.seconds + + rounded.milliseconds / 1000 + + rounded.microseconds / 1_000_000 + + rounded.nanoseconds / 1_000_000_000 + ); +} diff --git a/packages/mysql/src/mod.ts b/packages/mysql/src/mod.ts new file mode 100644 index 000000000..b80b5dde8 --- /dev/null +++ b/packages/mysql/src/mod.ts @@ -0,0 +1 @@ +export { MysqlKvStore, type MysqlKvStoreOptions } from "./kv.ts"; diff --git a/packages/mysql/tsdown.config.ts b/packages/mysql/tsdown.config.ts new file mode 100644 index 000000000..825a0b114 --- /dev/null +++ b/packages/mysql/tsdown.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/mod.ts", "src/kv.ts"], + dts: { compilerOptions: { isolatedDeclarations: true, declaration: true } }, + unbundle: true, + format: ["esm", "cjs"], + platform: "node", + outputOptions(outputOptions, format) { + if (format === "cjs") { + outputOptions.intro = ` + const { Temporal } = require("@js-temporal/polyfill"); + `; + } else { + outputOptions.intro = ` + import { Temporal } from "@js-temporal/polyfill"; + `; + } + return outputOptions; + }, +}); diff --git a/packages/relay/README.md b/packages/relay/README.md index 54ff7c8ec..05fecd47f 100644 --- a/packages/relay/README.md +++ b/packages/relay/README.md @@ -254,11 +254,12 @@ Any `KvStore` implementation from Fedify can be used, including: - `DenoKvStore` (Deno KV) - `RedisKvStore` (Redis) - `PostgresKvStore` (PostgreSQL) + - `MysqlKvStore` (MySQL/MariaDB) - `SqliteKvStore` (SQLite) -For production use, choose a persistent storage backend like Redis or -PostgreSQL. See the [Fedify documentation on key–value stores] for more -details. +For production use, choose a persistent storage backend like Redis, +PostgreSQL, or MySQL/MariaDB. See the +[Fedify documentation on key–value stores] for more details. [Fedify documentation on key–value stores]: https://fedify.dev/manual/kv diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fc73e2d0..eef2ea79b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ catalogs: koa: specifier: ^2.16.0 version: 2.16.2 + mysql2: + specifier: ^3.18.0 + version: 3.18.2 next: specifier: ^15.4.6 version: 15.5.0 @@ -199,6 +202,9 @@ importers: '@fedify/lint': specifier: workspace:^ version: link:../packages/lint + '@fedify/mysql': + specifier: workspace:^ + version: link:../packages/mysql '@fedify/nestjs': specifier: workspace:^ version: link:../packages/nestjs @@ -1281,6 +1287,37 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/mysql: + dependencies: + '@fedify/fedify': + specifier: workspace:^ + version: link:../fedify + '@js-temporal/polyfill': + specifier: 'catalog:' + version: 0.5.1 + '@logtape/logtape': + specifier: 'catalog:' + version: 2.0.0 + es-toolkit: + specifier: 'catalog:' + version: 1.43.0 + mysql2: + specifier: 'catalog:' + version: 3.18.2(@types/node@24.3.0) + devDependencies: + '@fedify/fixture': + specifier: workspace:^ + version: link:../fixture + '@fedify/testing': + specifier: workspace:^ + version: link:../testing + tsdown: + specifier: 'catalog:' + version: 0.12.9(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/nestjs: dependencies: '@fedify/fedify': @@ -5440,6 +5477,10 @@ packages: resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} engines: {node: '>=6.0.0'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + axe-core@4.10.3: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} @@ -6727,6 +6768,9 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -6941,6 +6985,10 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -7121,6 +7169,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -7486,6 +7537,10 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -7817,9 +7872,19 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mysql2@3.18.2: + resolution: {integrity: sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -8917,6 +8982,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sql-escaper@1.3.3: + resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + srvx@0.8.16: resolution: {integrity: sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ==} engines: {node: '>=20.16.0'} @@ -14112,6 +14181,8 @@ snapshots: await-to-js@3.0.0: {} + aws-ssl-profiles@1.1.2: {} + axe-core@4.10.3: {} axobject-query@4.1.0: {} @@ -15196,7 +15267,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color @@ -15838,6 +15909,10 @@ snapshots: functions-have-names@1.2.3: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + get-caller-file@2.0.5: {} get-east-asian-width@1.4.0: {} @@ -16153,6 +16228,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -16333,6 +16412,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-property@1.0.2: {} + is-reference@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -16720,6 +16801,8 @@ snapshots: dependencies: yallist: 4.0.0 + lru.min@1.1.4: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -17259,12 +17342,28 @@ snapshots: mute-stream@2.0.0: {} + mysql2@3.18.2(@types/node@24.3.0): + dependencies: + '@types/node': 24.3.0 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.3.3 + mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + nanoid@3.3.11: {} napi-postinstall@0.3.2: {} @@ -18497,6 +18596,8 @@ snapshots: sprintf-js@1.0.3: {} + sql-escaper@1.3.3: {} + srvx@0.8.16: {} stable-hash@0.0.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1f967ef8b..e1cded5be 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,6 +15,7 @@ packages: - packages/hono - packages/koa - packages/lint +- packages/mysql - packages/nestjs - packages/next - packages/postgres @@ -84,6 +85,7 @@ catalog: koa: ^2.16.0 next: ^15.4.6 pkijs: ^3.3.3 + mysql2: ^3.18.0 postgres: ^3.4.7 tsdown: ^0.12.9 typescript: ^5.9.3 From c55c8eaf48df99fbf026ea17f65ef95eead9c561 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 16:38:18 +0900 Subject: [PATCH 02/17] @fedify/mysql should be lowercase https://github.com/fedify-dev/fedify/pull/597#discussion_r2882277008 https://github.com/fedify-dev/fedify/pull/597#discussion_r2882277026 --- .hongdown.toml | 1 + CHANGES.md | 2 +- packages/mysql/README.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.hongdown.toml b/.hongdown.toml index 410b29a5f..daf8e021d 100644 --- a/.hongdown.toml +++ b/.hongdown.toml @@ -26,6 +26,7 @@ proper_nouns = [ "@fedify/h3", "@fedify/hono", "@fedify/koa", + "@fedify/mysql", "@fedify/nestjs", "@fedify/postgres", "@fedify/redis", diff --git a/CHANGES.md b/CHANGES.md index c43bb689b..b17cddbcc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -63,7 +63,7 @@ To be released. It provides `fedifyIntegration()` for Vite SSR configuration and `fedifyMiddleware()` for request handling. [[#50] by Chanhaeng Lee] -### @fedify/MySQL +### @fedify/mysql - Added `@fedify/mysql` package, a MySQL/MariaDB-backed `KvStore` implementation. It provides `MysqlKvStore`, which stores key–value diff --git a/packages/mysql/README.md b/packages/mysql/README.md index 8671bc231..78d88ff47 100644 --- a/packages/mysql/README.md +++ b/packages/mysql/README.md @@ -1,6 +1,6 @@ -@fedify/MySQL: MySQL/MariaDB drivers for Fedify +@fedify/mysql: MySQL/MariaDB drivers for Fedify =============================================== [![JSR][JSR badge]][JSR] From 7763cf486aaa631bd6942536ad184ac00a6d3140 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 16:54:53 +0900 Subject: [PATCH 03/17] Validate table name to prevent SQL injection The tableName option was interpolated directly into SQL statements without any sanitization, which could allow SQL injection if the caller passes a malicious table name. Added a regex check in the constructor that restricts table names to identifiers starting with a letter or underscore, followed by letters, digits, or underscores. A RangeError is thrown for invalid names. https://github.com/fedify-dev/fedify/pull/597#discussion_r2882299011 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 19 +++++++++++++++++++ packages/mysql/src/kv.ts | 10 +++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index d2097b6e5..a452574c2 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -28,6 +28,25 @@ function getStore(): { }; } +test("MysqlKvStore rejects invalid table names", () => { + assert.throws( + () => new MysqlKvStore({} as mysql.Pool, { tableName: "bad-name!" }), + RangeError, + ); + assert.throws( + () => new MysqlKvStore({} as mysql.Pool, { tableName: "1_starts_digit" }), + RangeError, + ); + assert.throws( + () => new MysqlKvStore({} as mysql.Pool, { tableName: "has space" }), + RangeError, + ); + // valid names should not throw + new MysqlKvStore({} as mysql.Pool, { tableName: "valid_name" }); + new MysqlKvStore({} as mysql.Pool, { tableName: "_leading_underscore" }); + new MysqlKvStore({} as mysql.Pool, { tableName: "CamelCase123" }); +}); + test("MysqlKvStore.initialize()", { skip: dbUrl == null }, async () => { if (dbUrl == null) return; diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index 8726dc669..89354a2bd 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -64,7 +64,15 @@ export class MysqlKvStore implements KvStore { */ constructor(pool: Pool, options: MysqlKvStoreOptions = {}) { this.#pool = pool; - this.#tableName = options.tableName ?? "fedify_kv"; + const tableName = options.tableName ?? "fedify_kv"; + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + throw new RangeError( + `Invalid table name: ${JSON.stringify(tableName)}. ` + + "Table names must start with a letter or underscore and contain " + + "only letters, digits, and underscores.", + ); + } + this.#tableName = tableName; this.#initialized = options.initialized ?? false; } From 2627ace7c0036de76c8bd553d33bf78b2e688ebf Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 16:56:03 +0900 Subject: [PATCH 04/17] Increase key column size from VARCHAR(512) to VARCHAR(768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 512-character limit was too tight for keys containing long URLs or other lengthy strings. VARCHAR(768) aligns with the InnoDB index prefix limit for utf8mb4 (768 characters × 4 bytes = 3072 bytes), making full use of the available index space without risking index prefix truncation errors. https://github.com/fedify-dev/fedify/pull/597#discussion_r2882298512 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 11 +++++++++++ packages/mysql/src/kv.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index a452574c2..56fedd9ba 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -60,6 +60,17 @@ test("MysqlKvStore.initialize()", { skip: dbUrl == null }, async () => { [tableName], ); assert.strictEqual(rows[0].cnt, 1); + + // Verify key column length is at least 768 + const [cols] = await pool.query( + `SELECT CHARACTER_MAXIMUM_LENGTH + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + AND column_name = 'key'`, + [tableName], + ); + assert.ok(cols[0].CHARACTER_MAXIMUM_LENGTH >= 768); } finally { await store.drop(); await pool.end(); diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index 89354a2bd..416a6ef11 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -266,7 +266,7 @@ export class MysqlKvStore implements KvStore { }); await this.#pool.query( `CREATE TABLE IF NOT EXISTS \`${this.#tableName}\` ( - \`key\` VARCHAR(512) NOT NULL, + \`key\` VARCHAR(768) NOT NULL, \`value\` JSON NOT NULL, \`expires\` DATETIME(6) NULL DEFAULT NULL, PRIMARY KEY (\`key\`) From c8f9b4822f41251509d8d4217eacc16a01e8f230 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 16:57:00 +0900 Subject: [PATCH 05/17] Add index on expires column for efficient expiration cleanup Without an index on the expires column, the DELETE query in #expire() had to perform a full table scan on every write, degrading performance as the table grows. initialize() now creates an index named idx__expires on the expires column, making the periodic cleanup query an efficient index range scan. https://github.com/fedify-dev/fedify/pull/597#discussion_r2882298958 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 11 +++++++++++ packages/mysql/src/kv.ts | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index 56fedd9ba..f1b569c71 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -71,6 +71,17 @@ test("MysqlKvStore.initialize()", { skip: dbUrl == null }, async () => { [tableName], ); assert.ok(cols[0].CHARACTER_MAXIMUM_LENGTH >= 768); + + // Verify that an index on the expires column exists + const [idxRows] = await pool.query( + `SELECT COUNT(*) AS cnt + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = ? + AND column_name = 'expires'`, + [tableName], + ); + assert.strictEqual(idxRows[0].cnt, 1); } finally { await store.drop(); await pool.end(); diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index 416a6ef11..a110d5c98 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -272,6 +272,15 @@ export class MysqlKvStore implements KvStore { PRIMARY KEY (\`key\`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`, ); + try { + await this.#pool.query( + `CREATE INDEX \`idx_${this.#tableName}_expires\` + ON \`${this.#tableName}\` (\`expires\`)`, + ); + } catch (e) { + // Ignore if the index already exists (ER_DUP_KEYNAME) + if ((e as { code?: string }).code !== "ER_DUP_KEYNAME") throw e; + } this.#initialized = true; logger.debug("Initialized the key-value store table {tableName}.", { tableName: this.#tableName, From c073101c2bbe5164d5d908cc02a57f76982d6216 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 16:58:10 +0900 Subject: [PATCH 06/17] Add explicit ESCAPE clause to LIKE query in list() When a MySQL server runs with NO_BACKSLASH_ESCAPES, the implicit backslash escape behaviour used in the LIKE pattern is disabled, causing prefix lookups for keys containing %, _, or \ to return incorrect results. Added an explicit ESCAPE '\\' clause to make the escape character unambiguous regardless of the server's SQL mode. https://github.com/fedify-dev/fedify/pull/597#discussion_r2882298935 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 39 +++++++++++++++++++++++++++++++++++ packages/mysql/src/kv.ts | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index f1b569c71..0dcb304a8 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -275,6 +275,45 @@ test( }, ); +test( + "MysqlKvStore.list() - keys with LIKE special characters", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const { pool, store } = getStore(); + try { + // Keys whose serialized form contains %, _, or \ characters + await store.set(["50%", "off"], "discount"); + await store.set(["50%", "extra"], "extra-discount"); + await store.set(["snake_case", "key"], "snake"); + await store.set(["back\\slash", "key"], "backslash"); + await store.set(["unrelated"], "noise"); + + const percentEntries: unknown[] = []; + for await (const entry of store.list(["50%"])) { + percentEntries.push(entry.key); + } + assert.strictEqual(percentEntries.length, 2); + + const underscoreEntries: unknown[] = []; + for await (const entry of store.list(["snake_case"])) { + underscoreEntries.push(entry.key); + } + assert.strictEqual(underscoreEntries.length, 1); + + const backslashEntries: unknown[] = []; + for await (const entry of store.list(["back\\slash"])) { + backslashEntries.push(entry.key); + } + assert.strictEqual(backslashEntries.length, 1); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + test( "MysqlKvStore.list() - empty prefix", { skip: dbUrl == null }, diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index a110d5c98..b7130a6d6 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -238,7 +238,7 @@ export class MysqlKvStore implements KvStore { serializedPrefix.slice(0, -1).replace(/[%_\\]/g, "\\$&") + ",%"; [rows] = await this.#pool.query( `SELECT \`key\`, \`value\` FROM \`${this.#tableName}\` - WHERE (\`key\` = ? OR \`key\` LIKE ?) + WHERE (\`key\` = ? OR \`key\` LIKE ? ESCAPE '\\\\') AND (\`expires\` IS NULL OR \`expires\` > NOW(6)) ORDER BY \`key\``, [serializedPrefix, likePrefix], From d04d2bd7d7a306fc6d88e0b3c907964ba6271755 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 17:00:02 +0900 Subject: [PATCH 07/17] Make MysqlKvStore.set() a no-op when value is undefined When value is undefined, setting a key should be treated as a no-op rather than attempting to store JSON.stringify(undefined) (which produces undefined, not a valid JSON value), which would fail with a NOT NULL constraint violation on the value column. Addresses: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882298981 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 23 +++++++++++++++++++++++ packages/mysql/src/kv.ts | 1 + 2 files changed, 24 insertions(+) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index 0dcb304a8..ea3d250d5 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -156,6 +156,29 @@ test("MysqlKvStore.set()", { skip: dbUrl == null }, async () => { } }); +test( + "MysqlKvStore.set() - undefined value is a no-op", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const { pool, store } = getStore(); + try { + // Setting undefined on a nonexistent key should leave the key absent + await store.set(["foo"], undefined); + assert.strictEqual(await store.get(["foo"]), undefined); + + // Setting a real value then overwriting with undefined should be a no-op + await store.set(["bar"], "value"); + await store.set(["bar"], undefined); + assert.deepStrictEqual(await store.get(["bar"]), "value"); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + test("MysqlKvStore.delete()", { skip: dbUrl == null }, async () => { if (dbUrl == null) return; diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index b7130a6d6..0e5fdec6e 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -109,6 +109,7 @@ export class MysqlKvStore implements KvStore { value: unknown, options?: KvStoreSetOptions | undefined, ): Promise { + if (value === undefined) return; await this.initialize(); const serializedKey = JSON.stringify([...key]); const jsonValue = JSON.stringify(value); From f29e39ca1d0b8eb388fd44fcf726b524abc5efde Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 17:01:00 +0900 Subject: [PATCH 08/17] Make MysqlKvStore.cas() delete the key when newValue is undefined When newValue is undefined in a compare-and-swap operation, the intended semantics are to delete the key (if the expected value matches) rather than inserting NULL into the NOT NULL value column, which would cause a constraint violation. Addresses: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882298998 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 30 ++++++++++++++++++++++ packages/mysql/src/kv.ts | 47 ++++++++++++++++++++--------------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index ea3d250d5..8a8600ca0 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -403,6 +403,36 @@ test("MysqlKvStore.cas()", { skip: dbUrl == null }, async () => { } }); +test( + "MysqlKvStore.cas() with undefined newValue deletes the key", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const { pool, store } = getStore(); + try { + // Set up a key then CAS it away with undefined newValue + await store.set(["foo"], "bar"); + assert.strictEqual( + await store.cas!(["foo"], "bar", undefined), + true, + ); + assert.strictEqual(await store.get(["foo"]), undefined); + + // CAS with wrong expected value and undefined newValue should fail + await store.set(["baz"], "qux"); + assert.strictEqual( + await store.cas!(["baz"], "wrong", undefined), + false, + ); + assert.deepStrictEqual(await store.get(["baz"]), "qux"); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + test( "MysqlKvStore.cas() with TTL", { skip: dbUrl == null }, diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index 0e5fdec6e..fb7b96acd 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -183,29 +183,36 @@ export class MysqlKvStore implements KvStore { return false; } - const jsonValue = JSON.stringify(newValue); - if (options?.ttl != null) { - const ttlSeconds = durationToSeconds(options.ttl); + if (newValue === undefined) { await conn.query( - `INSERT INTO \`${this.#tableName}\` - (\`key\`, \`value\`, \`expires\`) - VALUES (?, CAST(? AS JSON), - DATE_ADD(NOW(6), INTERVAL ? SECOND)) - ON DUPLICATE KEY UPDATE - \`value\` = VALUES(\`value\`), - \`expires\` = VALUES(\`expires\`)`, - [serializedKey, jsonValue, ttlSeconds], + `DELETE FROM \`${this.#tableName}\` WHERE \`key\` = ?`, + [serializedKey], ); } else { - await conn.query( - `INSERT INTO \`${this.#tableName}\` - (\`key\`, \`value\`, \`expires\`) - VALUES (?, CAST(? AS JSON), NULL) - ON DUPLICATE KEY UPDATE - \`value\` = VALUES(\`value\`), - \`expires\` = NULL`, - [serializedKey, jsonValue], - ); + const jsonValue = JSON.stringify(newValue); + if (options?.ttl != null) { + const ttlSeconds = durationToSeconds(options.ttl); + await conn.query( + `INSERT INTO \`${this.#tableName}\` + (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), + DATE_ADD(NOW(6), INTERVAL ? SECOND)) + ON DUPLICATE KEY UPDATE + \`value\` = VALUES(\`value\`), + \`expires\` = VALUES(\`expires\`)`, + [serializedKey, jsonValue, ttlSeconds], + ); + } else { + await conn.query( + `INSERT INTO \`${this.#tableName}\` + (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), NULL) + ON DUPLICATE KEY UPDATE + \`value\` = VALUES(\`value\`), + \`expires\` = NULL`, + [serializedKey, jsonValue], + ); + } } await conn.commit(); From 5ad704b130f01be1a7d8ff615040b3af8910eb77 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 17:02:26 +0900 Subject: [PATCH 09/17] Make MysqlKvStore.cas() call #expire() after a successful commit The set() and delete() methods both call #expire() to clean up stale entries after each mutation, but cas() was missing this call. Without it, expired entries accumulate until the next set() or delete(). Addresses: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882298971 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 45 +++++++++++++++++++++++++++++++++++ packages/mysql/src/kv.ts | 1 + 2 files changed, 46 insertions(+) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index 8a8600ca0..48b52a9a6 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -433,6 +433,51 @@ test( }, ); +test( + "MysqlKvStore.cas() triggers expiry cleanup", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const { pool, tableName, store } = getStore(); + try { + // Set up the target key first (this triggers #expire() in set()) + await store.set(["target"], "original"); + + // Now insert an already-expired row directly — after the set() call so + // set()'s #expire() does not clean it up + await pool.query( + `INSERT INTO \`${tableName}\` (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), DATE_SUB(NOW(6), INTERVAL 1 SECOND))`, + [JSON.stringify(["expired-key"]), JSON.stringify("expired-value")], + ); + + // Verify the expired row is physically present before the CAS + const [before] = await pool.query( + `SELECT COUNT(*) AS cnt FROM \`${tableName}\` + WHERE \`key\` = ?`, + [JSON.stringify(["expired-key"])], + ); + assert.strictEqual(before[0].cnt, 1); + + // Perform a successful CAS; this should trigger #expire() + const result = await store.cas!(["target"], "original", "updated"); + assert.strictEqual(result, true); + + // The expired row should have been cleaned up by cas()'s #expire() call + const [after] = await pool.query( + `SELECT COUNT(*) AS cnt FROM \`${tableName}\` + WHERE \`key\` = ?`, + [JSON.stringify(["expired-key"])], + ); + assert.strictEqual(after[0].cnt, 0); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + test( "MysqlKvStore.cas() with TTL", { skip: dbUrl == null }, diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index fb7b96acd..9fb9b6729 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -216,6 +216,7 @@ export class MysqlKvStore implements KvStore { } await conn.commit(); + await this.#expire(); return true; } catch (e) { if (conn) await conn.rollback(); From c8f08770b716b64cae03700263f8b8eb94c1f109 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 17:03:59 +0900 Subject: [PATCH 10/17] Add expireCleanupRate option to MysqlKvStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an expireCleanupRate option (0–1, default 1) that controls the probability of running expiry cleanup on each mutation. Setting it to 0 disables automatic cleanup entirely (useful when a separate cleanup job handles expiry), while values between 0 and 1 perform cleanup probabilistically to reduce per-write overhead on high-traffic tables. Addresses: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882298519 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 68 +++++++++++++++++++++++++++++++++++ packages/mysql/src/kv.ts | 24 +++++++++++++ 2 files changed, 92 insertions(+) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index 48b52a9a6..bc97f86cb 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -28,6 +28,74 @@ function getStore(): { }; } +test("MysqlKvStore rejects invalid expireCleanupRate", () => { + assert.throws( + () => + new MysqlKvStore({} as mysql.Pool, { + tableName: "valid_name", + expireCleanupRate: -0.1, + }), + RangeError, + ); + assert.throws( + () => + new MysqlKvStore({} as mysql.Pool, { + tableName: "valid_name", + expireCleanupRate: 1.1, + }), + RangeError, + ); + // valid rates should not throw + new MysqlKvStore({} as mysql.Pool, { + tableName: "valid_name", + expireCleanupRate: 0, + }); + new MysqlKvStore({} as mysql.Pool, { + tableName: "valid_name", + expireCleanupRate: 0.5, + }); + new MysqlKvStore({} as mysql.Pool, { + tableName: "valid_name", + expireCleanupRate: 1, + }); +}); + +test( + "MysqlKvStore with expireCleanupRate=0 skips expiry cleanup", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const pool = mysql.createPool(dbUrl!); + const tableName = `fedify_kv_test_${Math.random().toString(36).slice(5)}`; + // rate=0 means never clean up + const store = new MysqlKvStore(pool, { tableName, expireCleanupRate: 0 }); + try { + await store.initialize(); + + // Insert an already-expired row directly + await pool.query( + `INSERT INTO \`${tableName}\` (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), DATE_SUB(NOW(6), INTERVAL 1 SECOND))`, + [JSON.stringify(["expired-key"]), JSON.stringify("expired-value")], + ); + + // set() should not clean up when rate=0 + await store.set(["another"], "value"); + + const [rows] = await pool.query( + `SELECT COUNT(*) AS cnt FROM \`${tableName}\` + WHERE \`key\` = ?`, + [JSON.stringify(["expired-key"])], + ); + assert.strictEqual(rows[0].cnt, 1); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + test("MysqlKvStore rejects invalid table names", () => { assert.throws( () => new MysqlKvStore({} as mysql.Pool, { tableName: "bad-name!" }), diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index 9fb9b6729..e1fc7fa35 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -30,6 +30,15 @@ export interface MysqlKvStoreOptions { * @since 2.1.0 */ readonly initialized?: boolean; + + /** + * The probability (between 0 and 1, inclusive) that expired entries are + * cleaned up on each mutation. Defaults to `1` (always clean up). + * Set to `0` to disable automatic expiry cleanup entirely. + * @default `1` + * @since 2.1.0 + */ + readonly expireCleanupRate?: number; } /** @@ -54,6 +63,7 @@ export interface MysqlKvStoreOptions { export class MysqlKvStore implements KvStore { readonly #pool: Pool; readonly #tableName: string; + readonly #expireCleanupRate: number; #initialized: boolean; /** @@ -73,10 +83,24 @@ export class MysqlKvStore implements KvStore { ); } this.#tableName = tableName; + const expireCleanupRate = options.expireCleanupRate ?? 1; + if (expireCleanupRate < 0 || expireCleanupRate > 1) { + throw new RangeError( + `Invalid expireCleanupRate: ${expireCleanupRate}. ` + + "Must be a number between 0 and 1 inclusive.", + ); + } + this.#expireCleanupRate = expireCleanupRate; this.#initialized = options.initialized ?? false; } async #expire(): Promise { + if (this.#expireCleanupRate <= 0) return; + if ( + this.#expireCleanupRate < 1 && Math.random() >= this.#expireCleanupRate + ) { + return; + } await this.#pool.query( `DELETE FROM \`${this.#tableName}\` WHERE \`expires\` IS NOT NULL AND \`expires\` < NOW(6)`, From bf47b75371aff35370d7bba1cf14a62e5b91042a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 18:16:31 +0900 Subject: [PATCH 11/17] Simplify durationToSeconds() using Temporal.Duration.prototype.total() Use total({ unit: "second", relativeTo: ... }) instead of round() followed by manual arithmetic, which is simpler and less error-prone. The relativeTo option is required when the duration contains calendar units (years, months, weeks, or days) and ensures correct conversion regardless of DST or leap seconds. Reviewed-at: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882417948 Co-Authored-By: Claude --- packages/mysql/src/kv.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index e1fc7fa35..1b0de4c61 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -334,16 +334,8 @@ export class MysqlKvStore implements KvStore { } function durationToSeconds(duration: Temporal.Duration): number { - const rounded = duration.round({ - largestUnit: "hour", + return duration.total({ + unit: "second", relativeTo: Temporal.Now.plainDateTimeISO(), }); - return ( - rounded.hours * 3600 + - rounded.minutes * 60 + - rounded.seconds + - rounded.milliseconds / 1000 + - rounded.microseconds / 1_000_000 + - rounded.nanoseconds / 1_000_000_000 - ); } From aeb40466832ba5807f6c4e1849bd97466590952d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 18:17:42 +0900 Subject: [PATCH 12/17] Reset `#initialized` flag in MysqlKvStore.drop() After drop() the table no longer exists, so the next call to initialize() must recreate it. Without resetting #initialized the flag stays true and initialize() is skipped, causing all subsequent operations to fail with a "table not found" error. Reviewed-at: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882442287 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 27 +++++++++++++++++++++++++++ packages/mysql/src/kv.ts | 4 +++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index bc97f86cb..1c39d34be 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -281,6 +281,33 @@ test("MysqlKvStore.drop()", { skip: dbUrl == null }, async () => { } }); +test( + "MysqlKvStore.drop() resets initialized flag so re-initialize works", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const { pool, tableName, store } = getStore(); + try { + await store.initialize(); + await store.drop(); + + // After drop(), calling initialize() again must recreate the table + await store.initialize(); + const [rows] = await pool.query( + `SELECT COUNT(*) AS cnt + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ?`, + [tableName], + ); + assert.strictEqual(rows[0].cnt, 1); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + test("MysqlKvStore.list()", { skip: dbUrl == null }, async () => { if (dbUrl == null) return; diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index 1b0de4c61..219114597 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -322,7 +322,8 @@ export class MysqlKvStore implements KvStore { /** * Drops the table used by the key-value store. Does nothing if the table - * does not exist. + * does not exist. Resets the initialized flag so that + * {@link MysqlKvStore.initialize} can recreate the table on the next call. * * @since 2.1.0 */ @@ -330,6 +331,7 @@ export class MysqlKvStore implements KvStore { await this.#pool.query( `DROP TABLE IF EXISTS \`${this.#tableName}\``, ); + this.#initialized = false; } } From 0e69dc93671b063cc172ab774d7148c34da2aa21 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 18:18:51 +0900 Subject: [PATCH 13/17] Validate table name length in MysqlKvStore constructor MySQL identifier names are limited to 64 characters. The derived index name is "idx__expires" which uses 12 additional characters, so the table name itself must be at most 50 characters long. Now a RangeError is thrown at construction time if the name exceeds this limit, preventing a cryptic ER_TOO_LONG_IDENT error from MySQL at initialization time. Reviewed-at: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882442317 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 10 ++++++++++ packages/mysql/src/kv.ts | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index 1c39d34be..918b461ca 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -109,10 +109,20 @@ test("MysqlKvStore rejects invalid table names", () => { () => new MysqlKvStore({} as mysql.Pool, { tableName: "has space" }), RangeError, ); + // table name > 50 chars: derived index name would exceed MySQL's 64-char limit + assert.throws( + () => + new MysqlKvStore({} as mysql.Pool, { + tableName: "a".repeat(51), + }), + RangeError, + ); // valid names should not throw new MysqlKvStore({} as mysql.Pool, { tableName: "valid_name" }); new MysqlKvStore({} as mysql.Pool, { tableName: "_leading_underscore" }); new MysqlKvStore({} as mysql.Pool, { tableName: "CamelCase123" }); + // exactly 50 chars is valid + new MysqlKvStore({} as mysql.Pool, { tableName: "a".repeat(50) }); }); test("MysqlKvStore.initialize()", { skip: dbUrl == null }, async () => { diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index 219114597..5760101d2 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -82,6 +82,17 @@ export class MysqlKvStore implements KvStore { "only letters, digits, and underscores.", ); } + // MySQL identifiers are limited to 64 characters. The derived index name + // is "idx__expires" (12 extra chars), so the table name itself + // must be at most 50 characters long. + if (tableName.length > 50) { + throw new RangeError( + `Invalid table name: ${JSON.stringify(tableName)}. ` + + "Table names must be at most 50 characters long (MySQL identifier " + + 'limit is 64 chars; the derived index "idx__expires" uses ' + + "12 more).", + ); + } this.#tableName = tableName; const expireCleanupRate = options.expireCleanupRate ?? 1; if (expireCleanupRate < 0 || expireCleanupRate > 1) { From 37bfbf12c0f75038d386585adc394fcb699e4c7c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 18:20:51 +0900 Subject: [PATCH 14/17] Add credentials to MySQL healthcheck in CI workflows The --health-cmd previously used "mysqladmin ping -h 127.0.0.1" without credentials. While ping itself doesn't require auth, supplying -uroot -pmysql makes the intent explicit and avoids potential failures on MySQL configurations that do require auth for any connection. Reviewed-at: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882442363 https://github.com/fedify-dev/fedify/pull/597#discussion_r2882442375 https://github.com/fedify-dev/fedify/pull/597#discussion_r2882442433 Co-Authored-By: Claude --- .github/workflows/main.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index b841d5e36..55b703b1e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -62,7 +62,7 @@ jobs: MYSQL_ROOT_PASSWORD: mysql MYSQL_DATABASE: fedify options: >- - --health-cmd "mysqladmin ping -h 127.0.0.1" + --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pmysql" --health-interval 10s --health-timeout 5s --health-retries 5 @@ -146,7 +146,7 @@ jobs: MYSQL_ROOT_PASSWORD: mysql MYSQL_DATABASE: fedify options: >- - --health-cmd "mysqladmin ping -h 127.0.0.1" + --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pmysql" --health-interval 10s --health-timeout 5s --health-retries 5 @@ -209,7 +209,7 @@ jobs: MYSQL_ROOT_PASSWORD: mysql MYSQL_DATABASE: fedify options: >- - --health-cmd "mysqladmin ping -h 127.0.0.1" + --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pmysql" --health-interval 10s --health-timeout 5s --health-retries 5 From 932e26b51d861d48c0c4d2ba061f07e220f97614 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 18:21:01 +0900 Subject: [PATCH 15/17] Document mysql2 peer dependency in installation instructions npm, pnpm, Yarn, and Bun users must install mysql2 alongside @fedify/mysql because mysql2 is a peer dependency not automatically pulled in by those package managers. Deno users do not need a separate step since mysql2/promise is available through the import map bundled with the package. Updated: - docs/manual/kv.md (MysqlKvStore section) - packages/mysql/README.md (Installation section) Reviewed-at: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882442398 https://github.com/fedify-dev/fedify/pull/597#discussion_r2882442446 Co-Authored-By: Claude --- docs/manual/kv.md | 8 ++++---- packages/mysql/README.md | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/manual/kv.md b/docs/manual/kv.md index e20438c4f..d759ed5df 100644 --- a/docs/manual/kv.md +++ b/docs/manual/kv.md @@ -320,19 +320,19 @@ deno add jsr:@fedify/mysql ~~~~ ~~~~ bash [npm] -npm add @fedify/mysql +npm add @fedify/mysql mysql2 ~~~~ ~~~~ bash [pnpm] -pnpm add @fedify/mysql +pnpm add @fedify/mysql mysql2 ~~~~ ~~~~ bash [Yarn] -yarn add @fedify/mysql +yarn add @fedify/mysql mysql2 ~~~~ ~~~~ bash [Bun] -bun add @fedify/mysql +bun add @fedify/mysql mysql2 ~~~~ ::: diff --git a/packages/mysql/README.md b/packages/mysql/README.md index 78d88ff47..46ab68da6 100644 --- a/packages/mysql/README.md +++ b/packages/mysql/README.md @@ -36,9 +36,9 @@ Installation ------------ ~~~~ sh -deno add jsr:@fedify/mysql # Deno -npm add @fedify/mysql # npm -pnpm add @fedify/mysql # pnpm -yarn add @fedify/mysql # Yarn -bun add @fedify/mysql # Bun +deno add jsr:@fedify/mysql # Deno +npm add @fedify/mysql mysql2 # npm +pnpm add @fedify/mysql mysql2 # pnpm +yarn add @fedify/mysql mysql2 # Yarn +bun add @fedify/mysql mysql2 # Bun ~~~~ From 0317eb2f9fd39bb78d5c107a76e0d02f2fce3d3a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 18:21:08 +0900 Subject: [PATCH 16/17] Document REPEATABLE READ prerequisite for MysqlKvStore.cas() The SELECT ... FOR UPDATE in cas() acquires a gap lock on missing rows under InnoDB REPEATABLE READ (the default isolation level), which prevents concurrent insertions for the same key. Under READ COMMITTED this gap lock is not applied, so concurrent CAS operations on absent keys can race. Added a comment explaining this assumption so operators who change the pool's isolation level are warned. Reviewed-at: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882442422 Co-Authored-By: Claude --- packages/mysql/src/kv.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index 5760101d2..81bfb0ead 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -203,6 +203,12 @@ export class MysqlKvStore implements KvStore { conn = await this.#pool.getConnection(); await conn.beginTransaction(); + // NOTE: `FOR UPDATE` acquires a gap lock on a missing row under + // InnoDB REPEATABLE READ (the default isolation level), preventing + // concurrent inserts for the same key until this transaction commits + // or rolls back. If the connection pool is configured to use + // READ COMMITTED, the gap lock is not applied for missing rows and + // concurrent CAS operations on the same key may race. const [rows] = await conn.query( `SELECT \`value\` FROM \`${this.#tableName}\` WHERE \`key\` = ? From ea46b1d8f5672aeb4838926722ac5f56796f8bd2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 4 Mar 2026 18:40:11 +0900 Subject: [PATCH 17/17] Fix cas() to lock expired rows and treat them as absent When SELECT ... FOR UPDATE included the expiry predicate (AND expires > NOW(6)), a physically-present but logically-expired row was not locked. Two concurrent cas(key, undefined, ...) calls could therefore both see "no locked row" and both succeed, violating the create-if-absent atomicity guarantee. Fix: remove the expiry condition from the WHERE clause of SELECT ... FOR UPDATE so the lock is always acquired on a physically present row. Introduce an is_expired computed column and evaluate expiry in application code after acquiring the lock. Regression tests added: - test 19: cas() treats physically-present but expired rows as undefined - test 20: concurrent create-if-absent on expired key is atomic Reviewed-at: https://github.com/fedify-dev/fedify/pull/597#discussion_r2882744453 Co-Authored-By: Claude --- packages/mysql/src/kv.test.ts | 98 +++++++++++++++++++++++++++++++++++ packages/mysql/src/kv.ts | 34 ++++++++---- 2 files changed, 122 insertions(+), 10 deletions(-) diff --git a/packages/mysql/src/kv.test.ts b/packages/mysql/src/kv.test.ts index 918b461ca..f90609aa0 100644 --- a/packages/mysql/src/kv.test.ts +++ b/packages/mysql/src/kv.test.ts @@ -583,6 +583,104 @@ test( }, ); +test( + "MysqlKvStore.cas() treats physically-present but expired rows as undefined", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const { pool, tableName, store } = getStore(); + try { + await store.initialize(); + + // Insert a row that is already expired — it physically exists in the + // table but should be treated as absent by cas(). + await pool.query( + `INSERT INTO \`${tableName}\` (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), DATE_SUB(NOW(6), INTERVAL 1 SECOND))`, + [JSON.stringify(["key"]), JSON.stringify("old-value")], + ); + + // cas with the actual (stale) value should fail — expired ≡ undefined + assert.strictEqual( + await store.cas!(["key"], "old-value", "new-value"), + false, + ); + // Regardless of whether the physical row was cleaned up by #expire(), + // the logical value must be absent (expired ≡ undefined). + assert.strictEqual(await store.get(["key"]), undefined); + + // cas with expectedValue=undefined should succeed (expired ≡ undefined), + // locking the physical row so concurrent inserts can't race. + assert.strictEqual( + await store.cas!(["key"], undefined, "new-value"), + true, + ); + assert.deepStrictEqual(await store.get(["key"]), "new-value"); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + +test( + "MysqlKvStore.cas() concurrent create-if-absent on expired key is atomic", + { skip: dbUrl == null }, + async () => { + if (dbUrl == null) return; + + const pool = mysql.createPool(dbUrl!); + const tableName = `fedify_kv_test_${Math.random().toString(36).slice(5)}`; + // Disable expiry cleanup so the expired row is never auto-deleted during + // the test; we need it to remain physically present throughout. + const store = new MysqlKvStore(pool, { tableName, expireCleanupRate: 0 }); + try { + await store.initialize(); + + // Insert a row that is physically present but logically expired, so + // both concurrent CAS calls will see expectedValue=undefined as matching. + // This is the scenario that triggered the locking bug: without locking + // the expired row, SELECT ... FOR UPDATE (with the expiry predicate) + // returned no rows and acquired no lock, allowing both concurrent + // cas(undefined, ...) calls to proceed past the comparison and both + // write — violating CAS atomicity. + await pool.query( + `INSERT INTO \`${tableName}\` (\`key\`, \`value\`, \`expires\`) + VALUES (?, CAST(? AS JSON), DATE_SUB(NOW(6), INTERVAL 1 SECOND))`, + [JSON.stringify(["key"]), JSON.stringify("stale")], + ); + + // Fire two concurrent cas(undefined → value) calls on the same key. + // Both treat the expired row as "absent" (undefined), so both will + // pass the expected-value check. With correct locking, only one + // INSERT wins; the other must see the committed value and return false. + const [a, b] = await Promise.all([ + store.cas!(["key"], undefined, "value-A"), + store.cas!(["key"], undefined, "value-B"), + ]); + + const wins = (a ? 1 : 0) + (b ? 1 : 0); + assert.strictEqual( + wins, + 1, + `Expected exactly one concurrent CAS to succeed, got a=${a} b=${b}`, + ); + + const stored = await store.get(["key"]); + assert.ok( + stored === "value-A" || stored === "value-B", + `Stored value must be one of the two attempted values, got ${ + JSON.stringify(stored) + }`, + ); + } finally { + await store.drop(); + await pool.end(); + } + }, +); + test( "MysqlKvStore.cas() with TTL", { skip: dbUrl == null }, diff --git a/packages/mysql/src/kv.ts b/packages/mysql/src/kv.ts index 81bfb0ead..0f5036bce 100644 --- a/packages/mysql/src/kv.ts +++ b/packages/mysql/src/kv.ts @@ -203,21 +203,35 @@ export class MysqlKvStore implements KvStore { conn = await this.#pool.getConnection(); await conn.beginTransaction(); - // NOTE: `FOR UPDATE` acquires a gap lock on a missing row under - // InnoDB REPEATABLE READ (the default isolation level), preventing - // concurrent inserts for the same key until this transaction commits - // or rolls back. If the connection pool is configured to use - // READ COMMITTED, the gap lock is not applied for missing rows and - // concurrent CAS operations on the same key may race. - const [rows] = await conn.query( - `SELECT \`value\` FROM \`${this.#tableName}\` + // NOTE: The expiry predicate is intentionally omitted from the WHERE + // clause here so that `FOR UPDATE` always locks the physical row (if + // it exists), even when that row has an expired `expires` value. + // Including `AND (expires IS NULL OR expires > NOW(6))` in the WHERE + // would cause MySQL to skip (and therefore not lock) physically-present + // but expired rows, allowing two concurrent `cas(key, undefined, ...)` + // calls to both pass the expected-value check and both insert — a CAS + // atomicity violation. + // + // NOTE: Under InnoDB REPEATABLE READ (the default isolation level), a + // `FOR UPDATE` on a missing row acquires a gap lock preventing concurrent + // inserts. Under READ COMMITTED, that gap lock is not applied, so + // concurrent CAS operations on a logically-absent key may race. + const [rows] = await conn.query< + (RowDataPacket & { is_expired: 0 | 1 })[] + >( + `SELECT + \`value\`, + (\`expires\` IS NOT NULL AND \`expires\` <= NOW(6)) AS \`is_expired\` + FROM \`${this.#tableName}\` WHERE \`key\` = ? - AND (\`expires\` IS NULL OR \`expires\` > NOW(6)) FOR UPDATE`, [serializedKey], ); - const currentValue = rows.length > 0 ? rows[0].value : undefined; + const row = rows[0]; + const currentValue = !row || row.is_expired + ? undefined + : (row as RowDataPacket).value; if (!isEqual(currentValue, expectedValue)) { await conn.rollback();