Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,45 @@ jobs:
env:
AMQP_ORDERING_TEST: "true"

test-mariadb-kv:
name: MariaDB KV (${{ matrix.runtime }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
runtime: [node, bun, deno]
services:
mariadb:
image: mariadb:11.8
env:
MARIADB_ROOT_PASSWORD: mariadb
MARIADB_DATABASE: fedify
ports:
- 3306:3306
options: >-
--health-cmd "healthcheck.sh --connect --innodb_initialized"
--health-interval 10s
--health-timeout 5s
--health-retries 10
env:
MISE_DISABLE_TOOLS: ${{ matrix.runtime != 'deno' && 'deno' || '' }}
MYSQL_URL: mysql://root:mariadb@127.0.0.1:3306/fedify
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-mise
- name: Test MariaDB KV on Node.js
if: matrix.runtime == 'node'
run: mise exec -- node --experimental-transform-types --test packages/mysql/src/kv.test.ts packages/mysql/src/atomic-kv.test.ts
- name: Test MariaDB KV on Bun
if: matrix.runtime == 'bun'
run: mise exec -- bun test --timeout=60000 packages/mysql/src/kv.test.ts packages/mysql/src/atomic-kv.test.ts
- name: Prepare Deno workspace imports
if: matrix.runtime == 'deno'
run: mise run deno:cache
- name: Test MariaDB KV on Deno
if: matrix.runtime == 'deno'
run: mise run --skip-deps test:deno packages/mysql/src/kv.test.ts packages/mysql/src/atomic-kv.test.ts

test-cfworkers:
runs-on: ubuntu-latest
env:
Expand Down
19 changes: 19 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,25 @@ To be released.
[#1356]: https://github.com/fedify-dev/fedify/pull/1356
[#1420]: https://github.com/fedify-dev/fedify/pull/1420

### @fedify/mysql

- Added `AtomicKvStore` support to `MysqlKvStore`: `getMany()`, `setMany()`,
`deleteMany()`, and `casMany()` read or change related keys together on
MySQL and MariaDB InnoDB tables. [[#1361], [#1368], [#1433]]

Single-key operations use the same transactions and JSON codec, so reads
include transaction setup and commit round trips. `set(key, undefined)`
now rejects; use deletion instead. Initialization validates the schema
even with `initialized: true`, and custom pool connections need
`destroy()`. Use `autocommit`, default mysql2 decoding and one fixed-offset
or UTC session time zone; `NO_BACKSLASH_ESCAPES` is unsupported. Batches
accept up to 1,000 entries and serialized keys up to 768 Unicode code
points. TTLs are truncated to safe integer microseconds, approximately
±285 years. See the package README for schema and server restrictions.

[#1368]: https://github.com/fedify-dev/fedify/issues/1368
[#1433]: https://github.com/fedify-dev/fedify/pull/1433

### @fedify/nestjs

- Fixed the `ContextDataFactory` type to declare the Express `Request` and
Expand Down
19 changes: 19 additions & 0 deletions changes.d/mysql/atomic-key-value-operations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
links:
'#1361': https://github.com/fedify-dev/fedify/issues/1361
'#1368': https://github.com/fedify-dev/fedify/issues/1368
'#1433': https://github.com/fedify-dev/fedify/pull/1433
---
- Added `AtomicKvStore` support to `MysqlKvStore`: `getMany()`, `setMany()`,
`deleteMany()`, and `casMany()` read or change related keys together on
MySQL and MariaDB InnoDB tables. [[#1361], [#1368], [#1433]]

Single-key operations use the same transactions and JSON codec, so reads
include transaction setup and commit round trips. `set(key, undefined)`
now rejects; use deletion instead. Initialization validates the schema
even with `initialized: true`, and custom pool connections need
`destroy()`. Use `autocommit`, default mysql2 decoding and one fixed-offset
or UTC session time zone; `NO_BACKSLASH_ESCAPES` is unsupported. Batches
accept up to 1,000 entries and serialized keys up to 768 Unicode code
points. TTLs are truncated to safe integer microseconds, approximately
±285 years. See the package README for schema and server restrictions.
39 changes: 26 additions & 13 deletions claude-plugin/skills/fedify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,19 @@ atomicity or durability. `getMany()` returns an input-ordered snapshot;
`casMany()` checks every expectation before committing all changes. On
`false`, reread and re-evaluate application eligibility before retrying.

`MysqlKvStore` implements `AtomicKvStore` on an InnoDB table in one primary
database. It supports up to 1,000 entries per call and serialized keys up to
768 Unicode code points. Use a normal mysql2 pool with `autocommit` enabled,
default object/type decoding, and one fixed-offset or UTC session time zone
across clients. Initialization validates the schema even with
`initialized: true`; do not use caller-owned pool transactions, replica reads,
or change the schema during operations. Values round-trip through JSON and
CAS compares decoded structures; `set(undefined)` rejects and omitted TTLs
clear expiry. TTLs are truncated to safe integer microseconds. Deadlocks and
MariaDB snapshot conflicts retry with fresh comparisons up to five attempts;
backend errors reject, and lost commit responses can have unknown outcomes.
See *packages/mysql/README.md* for the full schema, codec and server limits.

`expectedValue: undefined` means absence; `newValue: undefined` means deletion
in CAS. Atomic stores reject `set(key, undefined)` and `setMany()` entries
whose value is `undefined`. They also reject TTLs on deletions and duplicate
Expand All @@ -482,17 +495,17 @@ for a shared backend. Separate calls do not form a transaction; uncertain
commit results need receipts or version checks, and ABA detection needs a
non-reused version.

`MemoryKvStore`, `SqliteKvStore`, and `PgliteKvStore` implement the full atomic
interface.
SQLite covers one configured table/database on a writable, normally locking
connection. Nonempty batches and ordinary mutations reject caller-owned
transactions and use one transaction; snapshots and mismatching CAS can reject
busy, including under rollback-journal reader contention. Configure busy
timeout on the connection; the KV store has no retry policy. Use decoded
JSON expectations, structural comparison, and the documented millisecond TTL
precision and resource limits. `SqliteMessageQueue` defaults to WAL on shared
files; concurrent WAL use needs a patched SQLite engine (Bun 1.2.22
SQLite 3.50.4 is affected by the WAL-reset bug). See the adapter documentation.
`MemoryKvStore`, `SqliteKvStore`, `PgliteKvStore`, and `MysqlKvStore` implement
the full atomic interface. SQLite covers one configured table/database on a
writable, normally locking connection. Nonempty batches and ordinary mutations
reject caller-owned transactions and use one transaction; snapshots and
mismatching CAS can reject busy, including under rollback-journal reader
contention. Configure busy timeout on the connection; the KV store has no
retry policy. Use decoded JSON expectations, structural comparison, and the
documented millisecond TTL precision and resource limits. `SqliteMessageQueue`
defaults to WAL on shared files; concurrent WAL use needs a patched SQLite
engine (Bun 1.2.22 SQLite 3.50.4 is affected by the WAL-reset bug). See the
adapter documentation.

For PGlite, share one normal owner and table across wrappers, require standard
JSONB/text-array codecs, and keep raw/manual transaction control and
Expand All @@ -504,8 +517,8 @@ engine insertion failures roll back, while mismatch does not prove proposed
writes would fit. Do not infer crash durability, browser persistence, or
worker-proxy support. Read the PGlite package README's synchronization policy.

The PostgreSQL, Redis, and MySQL atomic extensions are tracked separately in
issues #1364, #1365, and #1368; Deno KV and Netlify Blobs have no atomic adapter
The PostgreSQL and Redis atomic extensions are tracked separately in
issues #1364 and #1365; Deno KV and Netlify Blobs have no atomic adapter
child issue. `WorkersKvStore` lacks CAS. `WorkersD1KvStore` supports
single-key CAS; its atomic extension is tracked in #1379. Check the
[atomic support matrix] and installed declarations before selecting a
Expand Down
46 changes: 34 additions & 12 deletions docs/manual/kv.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ Concurrent WAL writes/checkpoints require the [SQLite WAL-reset fix]
(3.51.3 or the 3.50.7/3.44.6 backports, or a verified equivalent fix).
Bun 1.2.22's SQLite 3.50.4 is affected. `SqliteMessageQueue` enables WAL by
default on shared database files, so the prerequisite also applies to combined
KV and queue deployments. See the [package README] for the complete value,
expiry, connection and durability restrictions.
KV and queue deployments. See the [SQLite package README] for the complete
value, expiry, connection and durability restrictions.

Best for
: Development and testing.
Expand Down Expand Up @@ -199,7 +199,7 @@ const federation = createFederation<void>({
[`bun:sqlite`]: https://bun.com/docs/api/sqlite
[busy timeout]: https://sqlite.org/c3ref/busy_timeout.html
[SQLite WAL-reset fix]: https://sqlite.org/wal.html#walresetbug
[package README]: https://github.com/fedify-dev/fedify/tree/main/packages/sqlite#atomic-operations
[SQLite package README]: https://github.com/fedify-dev/fedify/tree/main/packages/sqlite#atomic-operations
[conditional exports]: https://nodejs.org/api/packages.html#conditional-exports

### [`RedisKvStore`]
Expand Down Expand Up @@ -563,7 +563,33 @@ const federation = createFederation<void>({
});
~~~~

Since Fedify 2.5.0, `MysqlKvStore` implements `AtomicKvStore`, with atomic
snapshots and conditional batches across keys in one primary database/table.
It uses InnoDB transactions with explicit `REPEATABLE READ`, including for
single-key reads and CAS. Use `getMany()` for a snapshot and `casMany()` to
compare every expectation before committing all replacements or deletions.
A mismatch returns `false`; reread and re-evaluate before retrying.

The adapter validates its InnoDB schema even with `initialized: true`, which
skips DDL but requires metadata visibility. Use the default schema, a normal
mysql2 pool with `autocommit` enabled and default object/type decoding, and the
same fixed-offset or UTC session `time_zone` across clients. Caller-owned
transactions, replica routing, `NO_BACKSLASH_ESCAPES`, custom decoding, and
schema changes during use are unsupported. Values use JSON conversion and
decoded structural equality; `set(key, undefined)` rejects. Batches have at
most 1,000 input entries, and serialized keys at most 768 Unicode code points.
TTLs are truncated to safe integer microseconds, approximately ±285 years;
omitting a replacement TTL clears its previous expiry.

Deadlocks and MariaDB snapshot conflicts retry with fresh comparisons, up to
five total attempts. Other backend failures reject, and a lost commit
response can leave the outcome unknown. Cleanup runs after commit; its
failures are logged, while logical expiry always applies. See the
[MySQL package README]
for schema, codec, clock, cleanup and resource restrictions.

[`MysqlKvStore`]: https://jsr.io/@fedify/mysql/doc/kv/~/MysqlKvStore
[MySQL package README]: https://github.com/fedify-dev/fedify/blob/main/packages/mysql/README.md#atomic-key-value-operations

### `DenoKvStore` (Deno only)

Expand Down Expand Up @@ -949,9 +975,6 @@ from `list()` too. `set()` and `setMany()` reject `undefined` on an
`AtomicKvStore`; skip the write to preserve the former no-op, or use `delete()`
or `deleteMany()` for unconditional removal. For conditional removal, use
`cas()` or `casMany()` with `newValue: undefined` and omit the TTL.
These stronger rules do not change legacy ordinary stores: for example, MySQL
currently treats
`set(key, undefined)` as a no-op.

Each set or conditional entry can supply its own `options.ttl`:

Expand Down Expand Up @@ -1008,10 +1031,10 @@ check or an operation receipt before repeating effects.

### Current support

`MemoryKvStore`, `SqliteKvStore`, and `PgliteKvStore` implement the full
`AtomicKvStore` contract.
Existing single-key CAS support does not imply atomic multi-key support.
The linked issues track separate adapter implementations:
`MemoryKvStore`, `SqliteKvStore`, `PgliteKvStore`, and `MysqlKvStore` implement
the full `AtomicKvStore` contract. Existing single-key CAS support does not
imply atomic multi-key support. The linked issues track separate adapter
implementations:

| Implementation | `AtomicKvStore` | Status |
| --------------------- | --------------- | ------------------------------------------------------------------------------------------------- |
Expand All @@ -1020,7 +1043,7 @@ The linked issues track separate adapter implementations:
| `RedisKvStore` | No | Planned in [#1365]. |
| `PgliteKvStore` | Yes | Since 2.5.0; one normal PGlite owner and table, JSONB comparison. |
| `SqliteKvStore` | Yes | One configured table/database; writable handles, JSON values; see [SQLite scope](#sqlitekvstore). |
| `MysqlKvStore` | No | Planned in [#1368]. |
| `MysqlKvStore` | Yes | InnoDB; one primary database/table, up to 1,000 entries per operation. |
| `DenoKvStore` | No | No atomic adapter child issue; deprecated since 2.5.0. |
| `NetlifyBlobsKvStore` | No | Single-key CAS only; no atomic adapter child issue. |
| `WorkersKvStore` | No | No CAS; cannot implement this contract with Workers KV. |
Expand All @@ -1029,7 +1052,6 @@ The linked issues track separate adapter implementations:
[PR #1360]: https://github.com/fedify-dev/fedify/pull/1360
[#1364]: https://github.com/fedify-dev/fedify/issues/1364
[#1365]: https://github.com/fedify-dev/fedify/issues/1365
[#1368]: https://github.com/fedify-dev/fedify/issues/1368
[#1379]: https://github.com/fedify-dev/fedify/issues/1379


Expand Down
105 changes: 105 additions & 0 deletions packages/mysql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,108 @@ yarn add @fedify/mysql mysql2 # Yarn
bun add @fedify/mysql mysql2 # Bun
deno add jsr:@fedify/mysql # Deno
~~~~


Atomic key-value operations
---------------------------

Since Fedify 2.5.0, `MysqlKvStore` implements `AtomicKvStore`. Use `getMany()`
to read related keys from one snapshot, `setMany()` or `deleteMany()` to change
them together, and `casMany()` to commit only when every expectation matches.
Single-key `cas()` follows the same rules as `casMany()`. After a `false`
result, read again and re-evaluate the application rule before retrying.

~~~~ typescript
const store = new MysqlKvStore(pool);
await store.setMany([
{ key: ["cursor"], value: 0 },
{ key: ["budget"], value: 10 },
]);
const [cursor, budget] = await store.getMany<number>([["cursor"], ["budget"]]);
const committed = await store.casMany([
{ key: ["cursor"], expectedValue: cursor, newValue: (cursor ?? 0) + 1 },
{ key: ["budget"], expectedValue: budget, newValue: (budget ?? 0) - 1 },
]);
~~~~

### Scope and configuration

All keys in the same primary database and table share an atomic scope across
independent pools and processes. Each operation runs in its own transaction on
one pooled connection, with explicit `REPEATABLE READ` isolation. `get()` uses
the same snapshot transaction as `getMany()`, so reads now require transaction
setup and commit round trips. Successive calls do not form one transaction. A
failed commit response can leave its outcome unknown; use a non-reused version
or an application receipt before repeating external effects. Crash durability
depends on the server and storage configuration.

Use a normal mysql2 pool with `autocommit` enabled, object rows and default type
casting. The `jsonStrings` option is supported. Do not supply connections
already in caller-owned transactions, share borrowed connections with raw SQL,
or route reads to replicas. All cooperating clients must use the same
fixed-offset or UTC session `time_zone`; `DATETIME` expiry values do not carry
a time zone. `NO_BACKSLASH_ESCAPES`, custom `typeCast`, and `rowsAsArray` are
unsupported. Keep server settings and the table schema stable while stores
are in use. `initialize()` and `drop()` must not overlap other operations.

Initialization requires an InnoDB base table with the adapter's schema: a
`VARCHAR(768)` key in `utf8mb4_bin`, a full-column primary `BTREE` index with
no other unique indexes, a non-null native JSON or `utf8mb4` `LONGTEXT` value,
and a nullable `DATETIME(6)` expiry. It rejects incompatible existing tables
instead of migrating them. `initialized: true` skips DDL, but still requires
metadata visibility for schema validation. Custom pool-like objects must
provide mysql2's connection `destroy()` method. The legacy
`innodb_locks_unsafe_for_binlog` setting must be disabled if present.

### Values, TTLs and limits

Values use `JSON.stringify()` on write and `JSON.parse()` on every read path,
including `list()`. JSON primitives, arrays and objects round-trip; dates and
`toJSON()` values follow normal JSON conversion. Unsupported object members
are omitted, unsupported array members and non-finite numbers become `null`,
and cycles, `BigInt` and values without a root JSON representation reject.
`set(key, undefined)` now rejects; use `delete()` instead. CAS compares decoded
values with `isEqual()`, ignoring object member order and preserving array
order and type distinctions. Expectations are not JSON-normalized: use the ISO
string returned by `get()` as the expected value for a stored date, rather than
a `Date`. Do not mutate expected values while an operation is pending.

Each batch accepts at most 1,000 input entries, including repeated keys in a
read. Mutation keys must be distinct. A JSON-serialized key, including its
punctuation and escapes, has at most 768 Unicode code points. Server packet,
JSON nesting, transaction and runtime resource limits also apply. Operations
reject rather than split a batch to fit a limit.

A stored `null` is distinct from absence. Expired entries count as absent;
`expectedValue: undefined` checks absence and `newValue: undefined` deletes.
Deletion rejects a non-null TTL, even when an expectation would mismatch.
Each replacement has its own TTL; omitting it clears previous expiry. TTLs
are truncated to safe integer microseconds, approximately ±285 years; larger
or server-unrepresentable durations reject. Calendar units use the current
local date for conversion. Non-positive TTLs expire immediately. One
server cutoff applies to each attempt: CAS captures it after acquiring all
locks, and unconditional writes capture it before any write-side lock waits, so
a short TTL can already have elapsed at commit.

### Backend verification

CI runs the ordinary and atomic key-value suites against MariaDB 11.8 on
Node.js, Bun, and Deno, alongside the full-package MySQL 8 jobs. The MariaDB
jobs run only the key-value suites because the message-queue suite has
pre-existing MariaDB compatibility failures.

### Conflicts and cleanup

CAS locks physical rows and absent key ranges before comparing. Expired rows
remain locked, and all expectations match before the first mutation. Deadlocks
(`1213`) and MariaDB snapshot conflicts (`1020`) retry the whole transaction,
with fresh comparisons and cutoff, for at most five total attempts. Other
backend errors reject. Lock timeout `1205` rejects after rollback; disabling
`innodb_deadlock_detect` can turn contention into such timeouts. A comparison
mismatch alone returns `false` and preserves values and TTLs.

Expiry cleanup runs after commit and after releasing the transaction's
connection. It is awaited and may wait for the server's lock timeout, but its
failures are logged without changing the committed result. The existing
`expireCleanupRate` option controls this housekeeping cost; `0` disables it.
Logical expiry works regardless of physical cleanup.
Loading
Loading