Skip to content

Commit 5c417eb

Browse files
committed
Merge tag '2.3.7'
Fedify 2.3.7
2 parents 3eeeda9 + 3ee9f66 commit 5c417eb

5 files changed

Lines changed: 367 additions & 39 deletions

File tree

‎CHANGES.md‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,34 @@ To be released.
424424
[#935]: https://github.com/fedify-dev/fedify/pull/935
425425

426426

427+
Version 2.3.7
428+
-------------
429+
430+
Released on September 15, 2026.
431+
432+
### @fedify/postgres
433+
434+
- Fixed `PostgresKvStore` storing values as JSONB strings rather than JSONB
435+
objects when it was constructed with the `initialized: true` option. The
436+
option skipped the driver's JSON serialization probe along with the table's
437+
schema DDL, so every value was serialized twice and every later read of the
438+
row returned a string, including reads from a store that never passed the
439+
option. The option now skips only the DDL.
440+
[[#1031], [#1033] by Heewon Chae\]
441+
- Fixed `PostgresMessageQueue` storing messages as JSONB strings rather than
442+
JSONB objects when it was constructed with the `initialized: true` option.
443+
The option skipped the driver's JSON serialization probe along with the
444+
table's schema DDL, so every message was serialized twice and a listener
445+
received a string with no recognizable task type, silently dropping the
446+
queued work. The option now skips only the DDL.
447+
[[#1014], [#1032] by Heewon Chae\]
448+
449+
[#1014]: https://github.com/fedify-dev/fedify/issues/1014
450+
[#1031]: https://github.com/fedify-dev/fedify/issues/1031
451+
[#1032]: https://github.com/fedify-dev/fedify/issues/1032
452+
[#1033]: https://github.com/fedify-dev/fedify/issues/1033
453+
454+
427455
Version 2.3.6
428456
-------------
429457

@@ -1220,6 +1248,29 @@ Released on June 25, 2026.
12201248
[#756]: https://github.com/fedify-dev/fedify/pull/756
12211249
12221250
1251+
Version 2.2.12
1252+
--------------
1253+
1254+
Released on September 15, 2026.
1255+
1256+
### @fedify/postgres
1257+
1258+
- Fixed `PostgresKvStore` storing values as JSONB strings rather than JSONB
1259+
objects when it was constructed with the `initialized: true` option. The
1260+
option skipped the driver's JSON serialization probe along with the table's
1261+
schema DDL, so every value was serialized twice and every later read of the
1262+
row returned a string, including reads from a store that never passed the
1263+
option. The option now skips only the DDL.
1264+
[[#1031], [#1033] by Heewon Chae\]
1265+
- Fixed `PostgresMessageQueue` storing messages as JSONB strings rather than
1266+
JSONB objects when it was constructed with the `initialized: true` option.
1267+
The option skipped the driver's JSON serialization probe along with the
1268+
table's schema DDL, so every message was serialized twice and a listener
1269+
received a string with no recognizable task type, silently dropping the
1270+
queued work. The option now skips only the DDL.
1271+
[[#1014], [#1032] by Heewon Chae\]
1272+
1273+
12231274
Version 2.2.11
12241275
--------------
12251276
@@ -1812,6 +1863,29 @@ Released on April 28, 2026.
18121863
[#722]: https://github.com/fedify-dev/fedify/pull/722
18131864
18141865
1866+
Version 2.1.23
1867+
--------------
1868+
1869+
Released on September 15, 2026.
1870+
1871+
### @fedify/postgres
1872+
1873+
- Fixed `PostgresKvStore` storing values as JSONB strings rather than JSONB
1874+
objects when it was constructed with the `initialized: true` option. The
1875+
option skipped the driver's JSON serialization probe along with the table's
1876+
schema DDL, so every value was serialized twice and every later read of the
1877+
row returned a string, including reads from a store that never passed the
1878+
option. The option now skips only the DDL.
1879+
[[#1031], [#1033] by Heewon Chae\]
1880+
- Fixed `PostgresMessageQueue` storing messages as JSONB strings rather than
1881+
JSONB objects when it was constructed with the `initialized: true` option.
1882+
The option skipped the driver's JSON serialization probe along with the
1883+
table's schema DDL, so every message was serialized twice and a listener
1884+
received a string with no recognizable task type, silently dropping the
1885+
queued work. The option now skips only the DDL.
1886+
[[#1014], [#1032] by Heewon Chae\]
1887+
1888+
18151889
Version 2.1.22
18161890
--------------
18171891
@@ -2561,6 +2635,29 @@ Released on March 24, 2026.
25612635
[#599]: https://github.com/fedify-dev/fedify/pull/599
25622636
25632637
2638+
Version 2.0.27
2639+
--------------
2640+
2641+
Released on September 15, 2026.
2642+
2643+
### @fedify/postgres
2644+
2645+
- Fixed `PostgresKvStore` storing values as JSONB strings rather than JSONB
2646+
objects when it was constructed with the `initialized: true` option. The
2647+
option skipped the driver's JSON serialization probe along with the table's
2648+
schema DDL, so every value was serialized twice and every later read of the
2649+
row returned a string, including reads from a store that never passed the
2650+
option. The option now skips only the DDL.
2651+
[[#1031], [#1033] by Heewon Chae\]
2652+
- Fixed `PostgresMessageQueue` storing messages as JSONB strings rather than
2653+
JSONB objects when it was constructed with the `initialized: true` option.
2654+
The option skipped the driver's JSON serialization probe along with the
2655+
table's schema DDL, so every message was serialized twice and a listener
2656+
received a string with no recognizable task type, silently dropping the
2657+
queued work. The option now skips only the DDL.
2658+
[[#1014], [#1032] by Heewon Chae\]
2659+
2660+
25642661
Version 2.0.26
25652662
--------------
25662663

‎packages/postgres/src/kv.test.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,4 +528,107 @@ test(
528528
},
529529
);
530530

531+
// Regression test for the driver JSON serialization probe being skipped
532+
// together with the schema DDL when `initialized: true` is passed.
533+
//
534+
// `initialize()` does two unrelated things: it runs the `CREATE UNLOGGED
535+
// TABLE` statement, and it sets `#driverSerializesJson` from the
536+
// `driverSerializesJson()` probe. Because the constructor assigned
537+
// `options.initialized` straight into `#initialized`, `initialize()` returned
538+
// at its first line and reached neither, so the flag stayed `false`, `#json()`
539+
// called `JSON.stringify()` before handing the value to postgres.js, and the
540+
// driver serialized it a second time. The value was stored as a JSONB string
541+
// instead of a JSONB object, and unlike the queue's version of this bug the
542+
// bad row stays in the table: every later `get()` returns a string, including
543+
// one from a store that never passed the option.
544+
//
545+
// See: https://github.com/fedify-dev/fedify/issues/1031
546+
test(
547+
"PostgresKvStore stores JSONB objects when initialized is true",
548+
{ skip: dbUrl == null },
549+
async () => {
550+
if (dbUrl == null) return; // Bun does not support skip option
551+
552+
const sql = postgres(dbUrl!);
553+
const tableName = `fedify_kv_test_${Math.random().toString(36).slice(5)}`;
554+
const store = new PostgresKvStore(sql, { tableName, initialized: true });
555+
try {
556+
// Create the table up front, which is the situation `initialized: true`
557+
// describes. The DDL is the same one `initialize()` would have run.
558+
await sql`
559+
CREATE UNLOGGED TABLE IF NOT EXISTS ${sql(tableName)} (
560+
key text[] PRIMARY KEY,
561+
value jsonb NOT NULL,
562+
created timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
563+
ttl interval
564+
);
565+
`;
566+
567+
const value = { keyPair: { id: "https://example.com/actor#main-key" } };
568+
await store.set(["cache", "a"], value);
569+
570+
const [row] = await sql`
571+
SELECT value, jsonb_typeof(value) AS json_type
572+
FROM ${sql(tableName)}
573+
WHERE key = ${["cache", "a"]};
574+
`;
575+
assert.strictEqual(
576+
row.json_type,
577+
"object",
578+
"initialized: true should still store the value as a JSONB object",
579+
);
580+
assert.deepStrictEqual(
581+
row.value,
582+
value,
583+
"the stored value should round-trip as the original object",
584+
);
585+
assert.deepStrictEqual(
586+
await store.get(["cache", "a"]),
587+
value,
588+
"get() should return the object that set() was given",
589+
);
590+
} finally {
591+
await store.drop();
592+
await sql.end();
593+
}
594+
},
595+
);
596+
597+
// The other half of the same contract: running the probe unconditionally must
598+
// not drag the DDL along with it. If `initialized: true` ever starts creating
599+
// the table again, callers that pass it precisely because they manage their own
600+
// schema would silently get a table they did not ask for, so the missing table
601+
// has to surface as an error instead.
602+
test(
603+
"PostgresKvStore initialized true still skips the schema DDL",
604+
{ skip: dbUrl == null },
605+
async () => {
606+
if (dbUrl == null) return; // Bun does not support skip option
607+
608+
const sql = postgres(dbUrl!);
609+
const tableName = `fedify_kv_test_${Math.random().toString(36).slice(5)}`;
610+
const store = new PostgresKvStore(sql, { tableName, initialized: true });
611+
try {
612+
// The table is deliberately never created.
613+
await assert.rejects(
614+
() => store.set(["cache", "a"], { n: 1 }),
615+
(error: unknown) =>
616+
error instanceof postgres.PostgresError && error.code === "42P01",
617+
"initialized: true should not create the table on its own",
618+
);
619+
620+
const rows = await sql`
621+
SELECT 1
622+
FROM pg_tables
623+
WHERE schemaname = current_schema()
624+
AND tablename = ${tableName};
625+
`;
626+
assert.strictEqual(rows.length, 0, "no table should have been created");
627+
} finally {
628+
await store.drop();
629+
await sql.end();
630+
}
631+
},
632+
);
633+
531634
// cSpell: ignore regclass

‎packages/postgres/src/kv.ts‎

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export interface PostgresKvStoreOptions {
2727

2828
/**
2929
* Whether the table has been initialized. `false` by default.
30+
*
31+
* This skips only the table's schema DDL. Driver-specific runtime setup,
32+
* such as detecting whether the driver serializes JSON parameters on its
33+
* own, still runs before the first key is read or written.
3034
* @default `false`
3135
*/
3236
readonly initialized?: boolean;
@@ -60,7 +64,8 @@ export class PostgresKvStore implements KvStore {
6064
readonly #sql: Sql<{}>;
6165
readonly #tableName: string;
6266
readonly #unlogged: boolean;
63-
#initialized: boolean;
67+
readonly #skipDdl: boolean;
68+
#initialized = false;
6469
#initializing?: Promise<void>;
6570
#driverSerializesJson = false;
6671

@@ -77,7 +82,7 @@ export class PostgresKvStore implements KvStore {
7782
this.#sql = sql;
7883
this.#tableName = options.tableName ?? "fedify_kv_v2";
7984
this.#unlogged = options.unlogged ?? false;
80-
this.#initialized = options.initialized ?? false;
85+
this.#skipDdl = options.initialized ?? false;
8186
}
8287

8388
async #expire(): Promise<void> {
@@ -240,35 +245,7 @@ export class PostgresKvStore implements KvStore {
240245
logger.debug("Initializing the key–value store table {tableName}...", {
241246
tableName: this.#tableName,
242247
});
243-
if (this.#unlogged) {
244-
await this.#sql`
245-
CREATE UNLOGGED TABLE IF NOT EXISTS ${this.#sql(this.#tableName)} (
246-
key text[] PRIMARY KEY,
247-
value jsonb NOT NULL,
248-
created timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
249-
ttl interval
250-
);
251-
`;
252-
} else {
253-
await this.#sql`
254-
CREATE TABLE IF NOT EXISTS ${this.#sql(this.#tableName)} (
255-
key text[] PRIMARY KEY,
256-
value jsonb NOT NULL,
257-
created timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
258-
ttl interval
259-
);
260-
`;
261-
const persistence = await this.#sql`
262-
SELECT relpersistence
263-
FROM pg_class
264-
WHERE oid = to_regclass(${quoteIdentifier(this.#tableName)});
265-
`;
266-
if (persistence[0]?.relpersistence === "u") {
267-
await this.#sql`
268-
ALTER TABLE ${this.#sql(this.#tableName)} SET LOGGED;
269-
`;
270-
}
271-
}
248+
if (!this.#skipDdl) await this.#initializeTable();
272249
this.#driverSerializesJson = await driverSerializesJson(this.#sql);
273250
this.#initialized = true;
274251
logger.debug("Initialized the key–value store table {tableName}.", {
@@ -283,6 +260,38 @@ export class PostgresKvStore implements KvStore {
283260
}
284261
}
285262

263+
async #initializeTable(): Promise<void> {
264+
if (this.#unlogged) {
265+
await this.#sql`
266+
CREATE UNLOGGED TABLE IF NOT EXISTS ${this.#sql(this.#tableName)} (
267+
key text[] PRIMARY KEY,
268+
value jsonb NOT NULL,
269+
created timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
270+
ttl interval
271+
);
272+
`;
273+
} else {
274+
await this.#sql`
275+
CREATE TABLE IF NOT EXISTS ${this.#sql(this.#tableName)} (
276+
key text[] PRIMARY KEY,
277+
value jsonb NOT NULL,
278+
created timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
279+
ttl interval
280+
);
281+
`;
282+
const persistence = await this.#sql`
283+
SELECT relpersistence
284+
FROM pg_class
285+
WHERE oid = to_regclass(${quoteIdentifier(this.#tableName)});
286+
`;
287+
if (persistence[0]?.relpersistence === "u") {
288+
await this.#sql`
289+
ALTER TABLE ${this.#sql(this.#tableName)} SET LOGGED;
290+
`;
291+
}
292+
}
293+
}
294+
286295
/**
287296
* Drops the table used by the key–value store. Does nothing if the table
288297
* does not exist.

0 commit comments

Comments
 (0)