@fedify/postgres 2.5.0-dev.2504 → 2.5.0-dev.2516
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/kv.cjs +94 -2
- package/dist/kv.d.cts +2 -0
- package/dist/kv.d.ts +2 -0
- package/dist/kv.js +94 -2
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -34,6 +34,12 @@ next initialization converts that table to logged storage. PostgreSQL rewrites
|
|
|
34
34
|
the table and takes an `ACCESS EXCLUSIVE` lock during this one-time migration,
|
|
35
35
|
so schedule the upgrade accordingly for a large or busy table.
|
|
36
36
|
|
|
37
|
+
Expiry cleanup can use an expression index without changing the table schema.
|
|
38
|
+
New empty tables receive it automatically with a pooled client. Existing
|
|
39
|
+
populated tables and deployments using `initialized: true` need an explicit
|
|
40
|
+
index migration. See the [expiry-index guide] for concurrent index creation,
|
|
41
|
+
rolling upgrades, and calendar-time semantics.
|
|
42
|
+
|
|
37
43
|
[JSR badge]: https://jsr.io/badges/@fedify/postgres
|
|
38
44
|
[JSR]: https://jsr.io/@fedify/postgres
|
|
39
45
|
[npm badge]: https://img.shields.io/npm/v/@fedify/postgres?logo=npm
|
|
@@ -43,6 +49,7 @@ so schedule the upgrade accordingly for a large or busy table.
|
|
|
43
49
|
[`MessageQueue`]: https://jsr.io/@fedify/fedify/doc/federation/~/MessageQueue
|
|
44
50
|
[`PostgresKvStore`]: https://jsr.io/@fedify/postgres/doc/~/PostgresKvStore
|
|
45
51
|
[`PostgresMessageQueue`]: https://jsr.io/@fedify/postgres/doc/~/PostgresMessageQueue
|
|
52
|
+
[expiry-index guide]: https://fedify.dev/manual/kv#postgresql-expiry-index
|
|
46
53
|
|
|
47
54
|
|
|
48
55
|
Installation
|
package/dist/kv.cjs
CHANGED
|
@@ -53,7 +53,7 @@ var PostgresKvStore = class {
|
|
|
53
53
|
this.#skipDdl = options.initialized ?? false;
|
|
54
54
|
}
|
|
55
55
|
#expire() {
|
|
56
|
-
const callerOwned =
|
|
56
|
+
const callerOwned = this.#callerOwned();
|
|
57
57
|
if (this.#expiring) return callerOwned ? this.#expiring : void 0;
|
|
58
58
|
if (performance.now() < this.#nextExpiry) return;
|
|
59
59
|
this.#expiring = (async () => {
|
|
@@ -85,7 +85,10 @@ var PostgresKvStore = class {
|
|
|
85
85
|
SELECT tableoid AS relation, ctid AS tid
|
|
86
86
|
FROM ${this.#sql(this.#tableName)}
|
|
87
87
|
WHERE (SELECT acquired FROM sweep_lock)
|
|
88
|
-
AND ttl IS NOT NULL
|
|
88
|
+
AND ttl IS NOT NULL
|
|
89
|
+
AND timezone('UTC', timezone('UTC', created) + ttl)
|
|
90
|
+
< statement_timestamp()
|
|
91
|
+
AND created + ttl < statement_timestamp()
|
|
89
92
|
LIMIT ${expiryBatchSize} FOR UPDATE SKIP LOCKED
|
|
90
93
|
), deleted AS (
|
|
91
94
|
DELETE FROM ${this.#sql(this.#tableName)}
|
|
@@ -221,6 +224,7 @@ var PostgresKvStore = class {
|
|
|
221
224
|
this.#initializing ??= (async () => {
|
|
222
225
|
logger.debug("Initializing the key–value store table {tableName}...", { tableName: this.#tableName });
|
|
223
226
|
if (!this.#skipDdl) await this.#initializeTable();
|
|
227
|
+
if (!this.#skipDdl && !this.#callerOwned()) await this.#initializeExpiryIndex();
|
|
224
228
|
this.#driverSerializesJson = await require_utils.driverSerializesJson(this.#sql);
|
|
225
229
|
this.#initialized = true;
|
|
226
230
|
logger.debug("Initialized the key–value store table {tableName}.", { tableName: this.#tableName });
|
|
@@ -259,6 +263,94 @@ var PostgresKvStore = class {
|
|
|
259
263
|
`;
|
|
260
264
|
}
|
|
261
265
|
}
|
|
266
|
+
#callerOwned() {
|
|
267
|
+
return "savepoint" in this.#sql && typeof this.#sql.savepoint === "function" || "release" in this.#sql && typeof this.#sql.release === "function";
|
|
268
|
+
}
|
|
269
|
+
async #initializeExpiryIndex() {
|
|
270
|
+
try {
|
|
271
|
+
await this.#sql.begin("isolation level read committed", async (sql) => {
|
|
272
|
+
await sql`SET LOCAL lock_timeout = '250ms'`;
|
|
273
|
+
await sql`SET LOCAL statement_timeout = '2s'`;
|
|
274
|
+
const [table] = await sql`
|
|
275
|
+
SELECT c.oid, c.relkind, c.relrowsecurity,
|
|
276
|
+
pg_has_role(current_user, c.relowner, 'USAGE') AS owner,
|
|
277
|
+
EXISTS (SELECT 1 FROM pg_inherits WHERE inhparent = c.oid) AS children,
|
|
278
|
+
format('%I.%I', n.nspname, c.relname) AS name,
|
|
279
|
+
'idx_fedify_kv_expiry_' || substr(md5(
|
|
280
|
+
format('%I.%I', n.nspname, c.relname)
|
|
281
|
+
), 1, 16) AS idx
|
|
282
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
283
|
+
WHERE c.oid = (SELECT typrelid FROM pg_type
|
|
284
|
+
WHERE oid = pg_typeof(NULL::${sql(this.#tableName)})::oid)
|
|
285
|
+
`;
|
|
286
|
+
const indexed = async () => {
|
|
287
|
+
const [result] = await sql`
|
|
288
|
+
SELECT EXISTS (
|
|
289
|
+
SELECT 1 FROM pg_index i
|
|
290
|
+
JOIN pg_class c ON c.oid = i.indexrelid
|
|
291
|
+
JOIN pg_am a ON a.oid = c.relam
|
|
292
|
+
JOIN pg_opclass o ON o.oid = i.indclass[0]
|
|
293
|
+
WHERE i.indrelid = ${table.oid}::oid
|
|
294
|
+
AND i.indisvalid AND i.indisready AND i.indislive
|
|
295
|
+
AND NOT i.indisunique AND i.indnatts = 1
|
|
296
|
+
AND a.amname = 'btree' AND i.indkey[0] = 0
|
|
297
|
+
AND o.opcdefault AND o.opcintype = 'timestamptz'::regtype
|
|
298
|
+
AND pg_get_indexdef(i.indexrelid, 1, false) IN (
|
|
299
|
+
${"timezone('UTC'::text, (timezone('UTC'::text, created) + ttl))"},
|
|
300
|
+
${"(((created AT TIME ZONE 'UTC'::text) + ttl) AT TIME ZONE 'UTC'::text)"}
|
|
301
|
+
)
|
|
302
|
+
AND (i.indpred IS NULL OR
|
|
303
|
+
pg_get_expr(i.indpred, i.indrelid) = '(ttl IS NOT NULL)')
|
|
304
|
+
) AS present
|
|
305
|
+
`;
|
|
306
|
+
return result.present;
|
|
307
|
+
};
|
|
308
|
+
if (await indexed()) return;
|
|
309
|
+
const manual = () => logger.warn("Provision the expiry index for {tableName} manually; see {guide}.", {
|
|
310
|
+
tableName: table.name,
|
|
311
|
+
guide: "https://fedify.dev/manual/kv#postgresql-expiry-index"
|
|
312
|
+
});
|
|
313
|
+
if (!table.owner || table.relrowsecurity || table.relkind !== "r" && table.relkind !== "p" || table.relkind === "r" && table.children) {
|
|
314
|
+
manual();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const empty = async () => {
|
|
318
|
+
const [result] = await sql.unsafe(`SELECT NOT EXISTS (SELECT 1 FROM ${table.name} LIMIT 1) AS empty`);
|
|
319
|
+
return result.empty;
|
|
320
|
+
};
|
|
321
|
+
if (!await empty()) {
|
|
322
|
+
manual();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const [lock] = await sql`
|
|
326
|
+
SELECT pg_try_advisory_xact_lock(
|
|
327
|
+
(hashtext('fedify.postgres.kv.index')::bigint << 32) |
|
|
328
|
+
${table.oid}::bigint
|
|
329
|
+
) AS acquired
|
|
330
|
+
`;
|
|
331
|
+
if (!lock.acquired) return;
|
|
332
|
+
await sql.unsafe(`LOCK TABLE ${table.name} IN SHARE MODE`);
|
|
333
|
+
if (await indexed()) return;
|
|
334
|
+
if (!await empty()) {
|
|
335
|
+
manual();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const [ddl] = await sql`
|
|
339
|
+
SELECT format(
|
|
340
|
+
'CREATE INDEX %I ON %s ((timezone(''UTC'', timezone(''UTC'', created) + ttl))) WHERE ttl IS NOT NULL',
|
|
341
|
+
${table.idx}::text, ${table.name}::text
|
|
342
|
+
) AS command
|
|
343
|
+
`;
|
|
344
|
+
await sql.unsafe(ddl.command);
|
|
345
|
+
});
|
|
346
|
+
} catch (error) {
|
|
347
|
+
logger.warn("Failed to provision the expiry index for {tableName}: {error}. See {guide}.", {
|
|
348
|
+
tableName: this.#tableName,
|
|
349
|
+
error,
|
|
350
|
+
guide: "https://fedify.dev/manual/kv#postgresql-expiry-index"
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
262
354
|
/**
|
|
263
355
|
* Drops the table used by the key–value store. Does nothing if the table
|
|
264
356
|
* does not exist.
|
package/dist/kv.d.cts
CHANGED
|
@@ -19,6 +19,8 @@ interface PostgresKvStoreOptions {
|
|
|
19
19
|
* This skips only the table's schema DDL. Driver-specific runtime setup,
|
|
20
20
|
* such as detecting whether the driver serializes JSON parameters on its
|
|
21
21
|
* own, still runs before the first key is read or written.
|
|
22
|
+
* It also skips automatic expiry-index provisioning. See the PostgreSQL
|
|
23
|
+
* key–value store migration guide for the index to add to your own DDL.
|
|
22
24
|
* @default `false`
|
|
23
25
|
*/
|
|
24
26
|
readonly initialized?: boolean;
|
package/dist/kv.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ interface PostgresKvStoreOptions {
|
|
|
19
19
|
* This skips only the table's schema DDL. Driver-specific runtime setup,
|
|
20
20
|
* such as detecting whether the driver serializes JSON parameters on its
|
|
21
21
|
* own, still runs before the first key is read or written.
|
|
22
|
+
* It also skips automatic expiry-index provisioning. See the PostgreSQL
|
|
23
|
+
* key–value store migration guide for the index to add to your own DDL.
|
|
22
24
|
* @default `false`
|
|
23
25
|
*/
|
|
24
26
|
readonly initialized?: boolean;
|
package/dist/kv.js
CHANGED
|
@@ -50,7 +50,7 @@ var PostgresKvStore = class {
|
|
|
50
50
|
this.#skipDdl = options.initialized ?? false;
|
|
51
51
|
}
|
|
52
52
|
#expire() {
|
|
53
|
-
const callerOwned =
|
|
53
|
+
const callerOwned = this.#callerOwned();
|
|
54
54
|
if (this.#expiring) return callerOwned ? this.#expiring : void 0;
|
|
55
55
|
if (performance.now() < this.#nextExpiry) return;
|
|
56
56
|
this.#expiring = (async () => {
|
|
@@ -82,7 +82,10 @@ var PostgresKvStore = class {
|
|
|
82
82
|
SELECT tableoid AS relation, ctid AS tid
|
|
83
83
|
FROM ${this.#sql(this.#tableName)}
|
|
84
84
|
WHERE (SELECT acquired FROM sweep_lock)
|
|
85
|
-
AND ttl IS NOT NULL
|
|
85
|
+
AND ttl IS NOT NULL
|
|
86
|
+
AND timezone('UTC', timezone('UTC', created) + ttl)
|
|
87
|
+
< statement_timestamp()
|
|
88
|
+
AND created + ttl < statement_timestamp()
|
|
86
89
|
LIMIT ${expiryBatchSize} FOR UPDATE SKIP LOCKED
|
|
87
90
|
), deleted AS (
|
|
88
91
|
DELETE FROM ${this.#sql(this.#tableName)}
|
|
@@ -218,6 +221,7 @@ var PostgresKvStore = class {
|
|
|
218
221
|
this.#initializing ??= (async () => {
|
|
219
222
|
logger.debug("Initializing the key–value store table {tableName}...", { tableName: this.#tableName });
|
|
220
223
|
if (!this.#skipDdl) await this.#initializeTable();
|
|
224
|
+
if (!this.#skipDdl && !this.#callerOwned()) await this.#initializeExpiryIndex();
|
|
221
225
|
this.#driverSerializesJson = await driverSerializesJson(this.#sql);
|
|
222
226
|
this.#initialized = true;
|
|
223
227
|
logger.debug("Initialized the key–value store table {tableName}.", { tableName: this.#tableName });
|
|
@@ -256,6 +260,94 @@ var PostgresKvStore = class {
|
|
|
256
260
|
`;
|
|
257
261
|
}
|
|
258
262
|
}
|
|
263
|
+
#callerOwned() {
|
|
264
|
+
return "savepoint" in this.#sql && typeof this.#sql.savepoint === "function" || "release" in this.#sql && typeof this.#sql.release === "function";
|
|
265
|
+
}
|
|
266
|
+
async #initializeExpiryIndex() {
|
|
267
|
+
try {
|
|
268
|
+
await this.#sql.begin("isolation level read committed", async (sql) => {
|
|
269
|
+
await sql`SET LOCAL lock_timeout = '250ms'`;
|
|
270
|
+
await sql`SET LOCAL statement_timeout = '2s'`;
|
|
271
|
+
const [table] = await sql`
|
|
272
|
+
SELECT c.oid, c.relkind, c.relrowsecurity,
|
|
273
|
+
pg_has_role(current_user, c.relowner, 'USAGE') AS owner,
|
|
274
|
+
EXISTS (SELECT 1 FROM pg_inherits WHERE inhparent = c.oid) AS children,
|
|
275
|
+
format('%I.%I', n.nspname, c.relname) AS name,
|
|
276
|
+
'idx_fedify_kv_expiry_' || substr(md5(
|
|
277
|
+
format('%I.%I', n.nspname, c.relname)
|
|
278
|
+
), 1, 16) AS idx
|
|
279
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
280
|
+
WHERE c.oid = (SELECT typrelid FROM pg_type
|
|
281
|
+
WHERE oid = pg_typeof(NULL::${sql(this.#tableName)})::oid)
|
|
282
|
+
`;
|
|
283
|
+
const indexed = async () => {
|
|
284
|
+
const [result] = await sql`
|
|
285
|
+
SELECT EXISTS (
|
|
286
|
+
SELECT 1 FROM pg_index i
|
|
287
|
+
JOIN pg_class c ON c.oid = i.indexrelid
|
|
288
|
+
JOIN pg_am a ON a.oid = c.relam
|
|
289
|
+
JOIN pg_opclass o ON o.oid = i.indclass[0]
|
|
290
|
+
WHERE i.indrelid = ${table.oid}::oid
|
|
291
|
+
AND i.indisvalid AND i.indisready AND i.indislive
|
|
292
|
+
AND NOT i.indisunique AND i.indnatts = 1
|
|
293
|
+
AND a.amname = 'btree' AND i.indkey[0] = 0
|
|
294
|
+
AND o.opcdefault AND o.opcintype = 'timestamptz'::regtype
|
|
295
|
+
AND pg_get_indexdef(i.indexrelid, 1, false) IN (
|
|
296
|
+
${"timezone('UTC'::text, (timezone('UTC'::text, created) + ttl))"},
|
|
297
|
+
${"(((created AT TIME ZONE 'UTC'::text) + ttl) AT TIME ZONE 'UTC'::text)"}
|
|
298
|
+
)
|
|
299
|
+
AND (i.indpred IS NULL OR
|
|
300
|
+
pg_get_expr(i.indpred, i.indrelid) = '(ttl IS NOT NULL)')
|
|
301
|
+
) AS present
|
|
302
|
+
`;
|
|
303
|
+
return result.present;
|
|
304
|
+
};
|
|
305
|
+
if (await indexed()) return;
|
|
306
|
+
const manual = () => logger.warn("Provision the expiry index for {tableName} manually; see {guide}.", {
|
|
307
|
+
tableName: table.name,
|
|
308
|
+
guide: "https://fedify.dev/manual/kv#postgresql-expiry-index"
|
|
309
|
+
});
|
|
310
|
+
if (!table.owner || table.relrowsecurity || table.relkind !== "r" && table.relkind !== "p" || table.relkind === "r" && table.children) {
|
|
311
|
+
manual();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const empty = async () => {
|
|
315
|
+
const [result] = await sql.unsafe(`SELECT NOT EXISTS (SELECT 1 FROM ${table.name} LIMIT 1) AS empty`);
|
|
316
|
+
return result.empty;
|
|
317
|
+
};
|
|
318
|
+
if (!await empty()) {
|
|
319
|
+
manual();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const [lock] = await sql`
|
|
323
|
+
SELECT pg_try_advisory_xact_lock(
|
|
324
|
+
(hashtext('fedify.postgres.kv.index')::bigint << 32) |
|
|
325
|
+
${table.oid}::bigint
|
|
326
|
+
) AS acquired
|
|
327
|
+
`;
|
|
328
|
+
if (!lock.acquired) return;
|
|
329
|
+
await sql.unsafe(`LOCK TABLE ${table.name} IN SHARE MODE`);
|
|
330
|
+
if (await indexed()) return;
|
|
331
|
+
if (!await empty()) {
|
|
332
|
+
manual();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const [ddl] = await sql`
|
|
336
|
+
SELECT format(
|
|
337
|
+
'CREATE INDEX %I ON %s ((timezone(''UTC'', timezone(''UTC'', created) + ttl))) WHERE ttl IS NOT NULL',
|
|
338
|
+
${table.idx}::text, ${table.name}::text
|
|
339
|
+
) AS command
|
|
340
|
+
`;
|
|
341
|
+
await sql.unsafe(ddl.command);
|
|
342
|
+
});
|
|
343
|
+
} catch (error) {
|
|
344
|
+
logger.warn("Failed to provision the expiry index for {tableName}: {error}. See {guide}.", {
|
|
345
|
+
tableName: this.#tableName,
|
|
346
|
+
error,
|
|
347
|
+
guide: "https://fedify.dev/manual/kv#postgresql-expiry-index"
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
259
351
|
/**
|
|
260
352
|
* Drops the table used by the key–value store. Does nothing if the table
|
|
261
353
|
* does not exist.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/postgres",
|
|
3
|
-
"version": "2.5.0-dev.
|
|
3
|
+
"version": "2.5.0-dev.2516+42d3ed0b",
|
|
4
4
|
"description": "PostgreSQL drivers for Fedify",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fedify",
|
|
@@ -71,15 +71,14 @@
|
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"postgres": "^3.4.7",
|
|
74
|
-
"@fedify/fedify": "^2.5.0-dev.
|
|
74
|
+
"@fedify/fedify": "^2.5.0-dev.2516+42d3ed0b"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"@js-temporal/polyfill": "^0.5.1",
|
|
78
|
-
"@std/async": "npm:@jsr/std__async@^1.0.13",
|
|
79
78
|
"tsdown": "^0.22.0",
|
|
80
79
|
"typescript": "^6.0.0",
|
|
81
80
|
"@fedify/fixture": "^2.0.0",
|
|
82
|
-
"@fedify/testing": "^2.5.0-dev.
|
|
81
|
+
"@fedify/testing": "^2.5.0-dev.2516+42d3ed0b"
|
|
83
82
|
},
|
|
84
83
|
"scripts": {
|
|
85
84
|
"build:self": "tsdown",
|