diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ffa47ab10..55b703b1e 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 -uroot -pmysql" + --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 -uroot -pmysql" + --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 -uroot -pmysql" + --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/.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/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..b17cddbcc 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..d759ed5df 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 mysql2 +~~~~ + +~~~~ bash [pnpm] +pnpm add @fedify/mysql mysql2 +~~~~ + +~~~~ bash [Yarn] +yarn add @fedify/mysql mysql2 +~~~~ + +~~~~ bash [Bun] +bun add @fedify/mysql mysql2 +~~~~ + +::: + +[`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..46ab68da6 --- /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 mysql2 # npm +pnpm add @fedify/mysql mysql2 # pnpm +yarn add @fedify/mysql mysql2 # Yarn +bun add @fedify/mysql mysql2 # 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..f90609aa0 --- /dev/null +++ b/packages/mysql/src/kv.test.ts @@ -0,0 +1,716 @@ +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 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!" }), + RangeError, + ); + assert.throws( + () => new MysqlKvStore({} as mysql.Pool, { tableName: "1_starts_digit" }), + RangeError, + ); + assert.throws( + () => 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 () => { + 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); + + // 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); + + // 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(); + } +}); + +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.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; + + 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.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; + + 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() - 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 }, + 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 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() 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() 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 }, + 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..0f5036bce --- /dev/null +++ b/packages/mysql/src/kv.ts @@ -0,0 +1,374 @@ +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; + + /** + * 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; +} + +/** + * 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; + readonly #expireCleanupRate: number; + #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; + 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.", + ); + } + // 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) { + 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)`, + ); + } + + /** + * {@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 { + if (value === undefined) return; + 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(); + + // 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\` = ? + FOR UPDATE`, + [serializedKey], + ); + + const row = rows[0]; + const currentValue = !row || row.is_expired + ? undefined + : (row as RowDataPacket).value; + + if (!isEqual(currentValue, expectedValue)) { + await conn.rollback(); + return false; + } + + if (newValue === undefined) { + await conn.query( + `DELETE FROM \`${this.#tableName}\` WHERE \`key\` = ?`, + [serializedKey], + ); + } else { + 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(); + await this.#expire(); + 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 ? ESCAPE '\\\\') + 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(768) NOT NULL, + \`value\` JSON NOT NULL, + \`expires\` DATETIME(6) NULL DEFAULT NULL, + 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, + }); + } + + /** + * Drops the table used by the key-value store. Does nothing if the table + * does not exist. Resets the initialized flag so that + * {@link MysqlKvStore.initialize} can recreate the table on the next call. + * + * @since 2.1.0 + */ + async drop(): Promise { + await this.#pool.query( + `DROP TABLE IF EXISTS \`${this.#tableName}\``, + ); + this.#initialized = false; + } +} + +function durationToSeconds(duration: Temporal.Duration): number { + return duration.total({ + unit: "second", + relativeTo: Temporal.Now.plainDateTimeISO(), + }); +} 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