Separate the JSON serialization probe from the schema DDL in PostgresKvStore - #1033
Conversation
`PostgresKvStore`'s `initialized` option set the very flag that `initialize()` checks before returning early, so passing it skipped all of the method — not only the `CREATE UNLOGGED TABLE` statement but the `driverSerializesJson()` probe after it. `#driverSerializesJson` stayed `false`, `#json()` ran `JSON.stringify()` before handing the value to postgres.js, and the driver serialized it a second time. Values were stored as JSONB strings rather than JSONB objects. Unlike the queue's version of this bug, the bad row stays in the table. Every later read returns a string, including reads from a store that never passed the option, so one misconfigured writer poisons entries that correctly configured readers go on to consume, and `get()` no longer returns what `set()` was given. Gate only the DDL on the option, and let the probe and the initialized flag run unconditionally. `get()`, `set()`, `delete()` and `list()` already await `initialize()` before touching the table, so the probe runs on first use either way and no caller contract changes. Cover both halves of the contract by regression test: that `initialized: true` stores a JSONB object, and that it still does not create the table on its own. Fixes fedify-dev#1031 Assisted-by: Claude Code:claude-opus-5
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The fragment could only cite the issue when it was written, since the pull request did not exist yet. Released entries in this changelog carry both numbers. Assisted-by: Claude Code:claude-opus-5
PostgresKvStore
Codecov Report✅ All modified and coverable lines are covered by tests.
🚀 New features to boost your workflow:
|
|
Your fix is shipped with Fedify 2.0.27, 2.1.23, 2.2.12, and 2.3.7. |
Summary
PostgresKvStore'sinitializedoption skipped the driver JSON serializationprobe along with the schema DDL, so a store constructed with
initialized: truewrote every value into the JSONB column as a string ratherthan an object. This pulls the option's two jobs apart: it now gates the
CREATE UNLOGGED TABLEstatement only, and the probe runs on first use eitherway.
This is the same defect as #1014, in the same package and from the same cause,
but it is the more damaging of the two and worth fixing on its own terms. The
queue's bad row consumes itself — it is dequeued, matches no task type, and is
deleted, so the damage is bounded to whatever was enqueued while the
misconfigured process ran. The key–value store's bad row stays in the table.
It remains a JSONB string, and every later read returns a string, including
reads from a process that never passed
initialized: true. One misconfiguredwriter therefore poisons entries that correctly configured readers go on to
consume, and nothing reports an error anywhere:
get()returns successfully,just with the wrong type. That breaks the
KvStorecontract, sinceget()nolonger returns the value
set()was given, and Fedify keeps actor key pairsand remote documents in a
KvStore.Targets 2.0-maintenance rather than main, per @dahlia's direction on the
issue: "We no longer support 1.x, so please target 2.0-maintenance for this
fix." (comment)
This is a fork PR, so the workflows land as
action_required— they need amaintainer to approve the run before CI can report.
Related issue
PostgresKvStoreinitializedoption skips JSON serialization detection #1031The
PostgresMessageQueuehalf of the same defect was fixed separately in#1032, which this branch is based on.
Reproduction
The script from the issue, run unchanged against a real PostgreSQL 16.14 with
postgres.js 3.4.8:
Before this change every line prints
string; after it,object. (Theissue's
JSON.stringify(readBack) === JSON.stringify(value)line still printsfalseafter the fix because JSONB reorders object keys, butdeepStrictEqual(readBack, value)passes.)Cause
initialize()does two unrelated things. It runs theCREATE UNLOGGED TABLEstatement, and then, at the very end, it sets
#driverSerializesJsonfrom thedriverSerializesJson()probe.The constructor assigned
options.initializedstraight into#initialized,which is the same flag
initialize()checks before returning early. Soinitialized: truedid not skip the DDL — it skipped the whole method, probeincluded.
#driverSerializesJsonthen stayed at itsfalsedefault,#json()took the
JSON.stringify()branch, and postgres.jssql.json()serialized theresult a second time.
Changes
options.initializednow lands in a new#skipDdl, while#initializedalways starts
false.CREATE UNLOGGED TABLEstatementinto
#initializeTable(), and call it frominitialize()only when#skipDdlis unset. The probe and#initialized = truenow rununconditionally.
get(),set(),delete()andlist()already awaitinitialize()before touching the table, so the probe runs on first usewithout introducing a new lazy path, and no caller contract changes.
schema DDL.
the contract. There was no coverage of the
initializedoption in thisfile before.
The shape deliberately mirrors #1032 so the two halves of this fix read the
same way.
Benefits
initialized: truestops corrupting stored values, which is the documentedbehaviour it was always supposed to have: skip the schema DDL, keep
get()andset()identical to the default path. Deployments that manage their ownschema — the reason the option exists — no longer poison their own key–value
store for every other process that reads it.
Verification
Run against PostgreSQL 16.14 in Docker, with
POSTGRES_URLset so thePostgres-gated tests actually execute rather than skip.
The regression test fails without the patch. Restoring the old constructor
assignment reports exactly the symptom the issue describes:
Both halves are enforced. The second test guards the other direction: if
the
#skipDdlgate is removed so the DDL always runs, it fails withMissing expected rejection. A fix that simply stopped honouringinitializedwouldnot pass. Each mutation fails exactly one of the two tests.
The DDL really is still skipped. With
log_statement = 'all', comparingthe statements the store issues between two marker queries:
initialized: trueinitialized: falseCREATE UNLOGGED TABLESELECT $1::jsonb(the probe)INSERT/DELETE(set()and#expire())jsonb_typeofobjectobjectTests.
@fedify/postgresacross all three runtimes: Deno 27 passed / 0failed, Node.js 26 passed / 0 failed, Bun 27 passed / 0 failed.
mise run checkpasses,sacho checkincluded.mise run testdoes not come back clean here, and it does not on the basecommit either. This is the same
@fedify/vocab-toolstimeout described in#1032 —
generateClasses() imports the browser-safe jsonld entrypointtimesout only when Deno, Node.js, and Bun run the whole monorepo concurrently on
this machine. Nothing in this patch is in that package's dependency graph.
AI use
Claude Opus 5 assisted with the patch, the tests, and running the checks above;
the commit carries an
Assisted-bytrailer. Every number in this descriptionwas measured on a real PostgreSQL instance rather than inferred from reading
the code.
Checklist
feature)? — not a new feature; the
initializedoption's TSDoc is updatedto say what it skips.
fix)?
mise teston your machine? — yes; it fails identically onthis branch and on its base commit, see Verification.
Additional notes
This does not repair rows already written. Values stored by a
misconfigured process before the upgrade are already JSONB strings and will
still read back as strings. Only new writes are affected.
A read-side compatibility shim was considered and rejected:
get()cannotdistinguish a double-serialized object from a value that was legitimately
stored as a string, so parsing on read would corrupt correct data.
Affected rows are overwritten on the next
set(), and cache-shaped keysclear themselves as their TTLs expire.
Operators who suspect they hit this can find the affected rows with
SELECT key FROM <table> WHERE jsonb_typeof(value) = 'string', though alegitimately stored string value looks the same there for the same reason,
so the result needs judgement rather than a blind
DELETE.One extra statement on the
initialized: truepath. The probe is asingle
SELECT $1::jsonb, run once per store instance on first use. Thatis the entire runtime cost of the fix.
The DDL moved rather than changed; the diff reads more cleanly with
whitespace hidden.