Skip to content
Merged
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
21 changes: 21 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,27 @@ To be released.
[#1287]: https://github.com/fedify-dev/fedify/issues/1287
[#1357]: https://github.com/fedify-dev/fedify/pull/1357

### @fedify/pglite

- Added atomic snapshots and multi-key writes, deletions, and conditional
updates to `PgliteKvStore` through `AtomicKvStore`. Wrappers sharing one
PGlite owner can commit related keys together with JSONB comparison and
per-entry TTLs. [[#1361], [#1366], [#1431]]

Malformed keys, negative or overflowing TTLs, and TTLs on CAS deletions
now reject before mutation. Mutation cleanup shares the transaction,
so cleanup failures roll back writes; legacy invalid TTL rows require
removal together or direct SQL remediation.

Existing single-key mutations and the new batch mutations await the
PGlite owner's filesystem synchronization policy after a successful
transaction, including read-only CAS mismatches. If synchronization fails,
the operation rejects instead of returning `false`; any committed mutations
remain applied, so retrying blindly can repeat a change.

[#1366]: https://github.com/fedify-dev/fedify/issues/1366
[#1431]: https://github.com/fedify-dev/fedify/pull/1431

### @fedify/postgres

- Added an expression index for `PostgresKvStore` expiry cleanup, avoiding a
Expand Down
21 changes: 21 additions & 0 deletions changes.d/pglite/atomic-kv-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
links:
'#1361': https://github.com/fedify-dev/fedify/issues/1361
'#1366': https://github.com/fedify-dev/fedify/issues/1366
'#1431': https://github.com/fedify-dev/fedify/pull/1431
---
- Added atomic snapshots and multi-key writes, deletions, and conditional
updates to `PgliteKvStore` through `AtomicKvStore`. Wrappers sharing one
PGlite owner can commit related keys together with JSONB comparison and
per-entry TTLs. [[#1361], [#1366], [#1431]]

Malformed keys, negative or overflowing TTLs, and TTLs on CAS deletions
now reject before mutation. Mutation cleanup shares the transaction,
so cleanup failures roll back writes; legacy invalid TTL rows require
removal together or direct SQL remediation.

Existing single-key mutations and the new batch mutations await the
PGlite owner's filesystem synchronization policy after a successful
transaction, including read-only CAS mismatches. If synchronization fails,
the operation rejects instead of returning `false`; any committed mutations
remain applied, so retrying blindly can repeat a change.
23 changes: 18 additions & 5 deletions claude-plugin/skills/fedify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ in development or tests.
| Package | `KvStore` | `MessageQueue` |
| ------------------- | --------- | -------------- |
| *@fedify/sqlite* | yes | yes |
| *@fedify/pglite* | yes | no |
| *@fedify/postgres* | yes | yes |
| *@fedify/mysql* | yes | yes |
| *@fedify/redis* | yes | yes |
Expand Down Expand Up @@ -481,7 +482,8 @@ 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` and `SqliteKvStore` implement the full atomic interface.
`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
Expand All @@ -491,10 +493,21 @@ 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.
The persistent PostgreSQL, Redis, PGlite, and MySQL extensions remain tracked
separately in issues #1364–#1366 and #1368; 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

For PGlite, share one normal owner and table across wrappers, require standard
JSONB/text-array codecs, and keep raw/manual transaction control and
caller-owned transaction callbacks outside store calls. CAS ignores object
member order and preserves array order. Keys reject malformed Unicode and
NUL; negative/overflowing TTLs and deletion TTLs reject. Calendar expiry uses
the session time zone and one cutoff per call. Large batches block the owner;
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
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
production adapter.

Expand Down
35 changes: 32 additions & 3 deletions docs/manual/kv.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,31 @@ const federation = createFederation<void>({
> directory from multiple worker processes. Use one PGlite instance per data
> directory and runtime isolate.

Since Fedify 2.5.0, this store also implements `AtomicKvStore`.
Each snapshot or batch uses one PGlite transaction and one expiry cutoff.
Wrappers must share one normal PGlite owner and table. Do not call the store
inside the owner's transaction callback or bypass its query queue with raw
protocol or manual transaction control. Worker proxies and browser
persistence are outside the documented atomic scope.

CAS compares JSONB values: object member order is ignored, array order
matters, and stored null differs from absence. Keys must be nonempty arrays
of well-formed Unicode strings without U+0000. Negative TTLs, timestamp
overflow, and TTLs on CAS deletion reject; zero TTL expires immediately.
Intervals round to microseconds and calendar units follow the session time
zone. There is no fixed batch-entry cap. Engine key insertion limits depend
on compression; a mismatch does not prove that unattempted writes would fit.
Statement failures roll back the batch; commit or post-commit synchronization
failures can reject after changes have committed. Long batches occupy the
shared instance. Nonempty mutation calls await the owner's `syncToFs()` after
transaction completion, including read-only CAS mismatches. Backend failures
reject instead of returning `false`; any committed mutations remain applied.
Atomic execution does not establish crash durability. See the
[package's atomic-operation guidance] for codecs, legacy-row recovery, and the
owner's synchronization policy.

[`PgliteKvStore`]: https://jsr.io/@fedify/pglite/doc/kv/~/PgliteKvStore
[package's atomic-operation guidance]: https://github.com/fedify-dev/fedify/tree/main/packages/pglite#atomic-operations

### [`MysqlKvStore`]

Expand Down Expand Up @@ -984,7 +1008,8 @@ check or an operation receipt before repeating effects.

### Current support

`MemoryKvStore` and `SqliteKvStore` implement the full `AtomicKvStore` contract.
`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:

Expand All @@ -993,7 +1018,7 @@ The linked issues track separate adapter implementations:
| `MemoryKvStore` | Yes | Development and testing only; one instance. |
| `PostgresKvStore` | No | Planned in [#1364]. |
| `RedisKvStore` | No | Planned in [#1365]. |
| `PgliteKvStore` | No | Planned in [#1366]. |
| `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]. |
| `DenoKvStore` | No | No atomic adapter child issue; deprecated since 2.5.0. |
Expand All @@ -1004,7 +1029,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
[#1366]: https://github.com/fedify-dev/fedify/issues/1366
[#1368]: https://github.com/fedify-dev/fedify/issues/1368
[#1379]: https://github.com/fedify-dev/fedify/issues/1379

Expand Down Expand Up @@ -1613,6 +1637,11 @@ scope. A process-local mutex cannot coordinate other handles or processes.
On a deadlock retry, roll back and re-evaluate the complete expected set.
Return `false` only for a comparison mismatch; propagate backend errors.

For example, `PgliteKvStore` uses the exclusive transaction callback of
one supported PGlite owner. Independent store wrappers share that owner, whose
client queue excludes other normal queries for the callback's entire lifetime,
including checks for absent keys.

Run the [shared atomic conformance tests](./test.md), using independent
clients sharing an isolated backend for persistent adapters. Add
backend-specific competing-client, rollback, expiry, and injected-failure
Expand Down
87 changes: 85 additions & 2 deletions packages/pglite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![JSR][JSR badge]][JSR]

This package provides [`PgliteKvStore`], a PGlite-backed implementation of
Fedify's [`KvStore`]:
Fedify's [`AtomicKvStore`]:

~~~~ typescript
import { PGlite } from "@electric-sql/pglite";
Expand Down Expand Up @@ -33,7 +33,7 @@ rolling restart. See [PGlite issue #489] and [PGlite pull request #892].
[JSR badge]: https://jsr.io/badges/@fedify/pglite
[JSR]: https://jsr.io/@fedify/pglite
[`PgliteKvStore`]: https://jsr.io/@fedify/pglite/doc/~/PgliteKvStore
[`KvStore`]: https://jsr.io/@fedify/fedify/doc/federation/~/KvStore
[`AtomicKvStore`]: https://jsr.io/@fedify/fedify/doc/federation/~/AtomicKvStore
[PGlite issue #489]: https://github.com/electric-sql/pglite/issues/489#issuecomment-2587892190
[PGlite pull request #892]: https://github.com/electric-sql/pglite/pull/892

Expand All @@ -48,3 +48,86 @@ yarn add @fedify/pglite @electric-sql/pglite # Yarn
bun add @fedify/pglite @electric-sql/pglite # Bun
deno add jsr:@fedify/pglite npm:@electric-sql/pglite # Deno
~~~~


Atomic operations
-----------------

Since Fedify 2.5.0, `PgliteKvStore` implements `AtomicKvStore`. Use
`getMany()` for an ordered snapshot and `casMany()` to compare and change
related keys together. A comparison mismatch returns `false` without changing
values or TTLs; reread and re-evaluate the application rule before retrying.
`setMany()` and `deleteMany()` also commit as complete batches.

For example, compare a generation and its associated state before replacing
both. The application computes the replacement from its own rule:

~~~~ typescript
const store = new PgliteKvStore(pg);
const keys = [["example", "generation"], ["example", "state"]] as const;
const [generation, state] = await store.getMany(keys);
const committed = await store.casMany([
{ key: keys[0], expectedValue: generation, newValue: crypto.randomUUID() },
{ key: keys[1], expectedValue: state, newValue: { status: "ready" } },
]);
~~~~

All wrappers for a table must share one caller-owned PGlite instance. Each
batch uses PGlite's transaction callback, which excludes other normal queries
and transactions on that instance, including when a compared key is absent.
Do not call store methods from inside the owner's transaction callback, issue
manual transaction commands, or use concurrent raw protocol operations.
Independent PGlite owners and processes must not open the same data directory.
The standard text-array and JSONB codecs are required; custom parser or
serializer overrides are outside this guarantee.

Values use `JSON.stringify()` followed by PostgreSQL JSONB input, and CAS uses
JSONB equality. Object member order is ignored and array order matters.
JSON `null` remains a value; `undefined` checks absence or deletes in CAS and
cannot be stored. JSON serialization can omit unsupported object properties
or convert nonfinite numbers to `null`; cyclic values and `bigint` reject.
JSONB also rejects U+0000 and invalid surrogate sequences. Keys must contain
one or more well-formed Unicode strings without U+0000. Mutation batches
reject duplicate keys and sparse inputs before writing.

Each entry has its own TTL, and every operation uses one transaction-start
expiry cutoff. Omitting a replacement TTL clears the previous expiry. Zero
TTL expires immediately; negative durations reject, as do durations whose
addition exceeds the timestamp range. TTL uses PostgreSQL calendar intervals
with microsecond rounding and the session time zone; the host clock may have
only millisecond resolution. CAS deletion must not specify a non-null TTL.

There is no fixed adapter batch-entry limit. Statements run sequentially in
one transaction, so a large batch occupies the shared instance and consumes
memory proportional to its input and results. New keys are subject to the
engine's B-tree entry limit, about 2700 bytes after compression with its default
8 KiB pages; this is a storage constraint rather than an adapter input cap.
A mismatching CAS batch can return `false` without attempting an insertion or
proving that insertion would fit. An insertion error that actually occurs
rejects and rolls back the complete batch. Deterministically invalid keys,
JSONB values and TTLs still reject even on a comparison mismatch.

Atomic execution does not promise crash durability or exactly-once effects.
The supported scope is a normal PGlite instance on Node.js, Bun, or Deno,
using memory or the host filesystem. Worker proxies and browser persistence
are outside this scope. Host-filesystem storage supports clean close/reopen;
memory-only instances remain ephemeral. After a mutation transaction completes,
the adapter awaits the owner's `syncToFs()` outside the transaction, preserving
the synchronization policy previously used by `set()`, `delete()`, and `cas()`.
This includes a nonempty CAS call whose comparisons mismatch: its read-only
transaction still completes, and a backend synchronization failure rejects
instead of returning `false`.
PGlite 0.5.8 does not perform that post-commit synchronization itself.
The owner's filesystem and `relaxedDurability` setting determine what this
call guarantees. A synchronization failure rejects after transaction
completion; any committed mutations remain applied. A lost response or commit
failure can also leave commit status unknown. Do not automatically replay an
uncertain change; use a non-reused version or an operation receipt when the
application needs it.

Mutation housekeeping is part of the same transaction. A legacy row with an
overflowing TTL can make expiry cleanup fail; other mutations then roll back
until all offending keys are removed together with `deleteMany()` or direct
SQL. Inspect the raw key, creation time, and TTL columns through direct SQL
without adding the TTL. Malformed legacy keys, such as empty arrays or SQL
null segments, require direct SQL remediation.
Loading
Loading