@fedify/postgres 2.5.0-dev.2500 → 2.5.0-dev.2513

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 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 = "savepoint" in this.#sql && typeof this.#sql.savepoint === "function" || "release" in this.#sql && typeof this.#sql.release === "function";
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 AND created + ttl < statement_timestamp()
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 = "savepoint" in this.#sql && typeof this.#sql.savepoint === "function" || "release" in this.#sql && typeof this.#sql.release === "function";
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 AND created + ttl < statement_timestamp()
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.2500+0a9d005b",
3
+ "version": "2.5.0-dev.2513+d7ab5950",
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.2500+0a9d005b"
74
+ "@fedify/fedify": "^2.5.0-dev.2513+d7ab5950"
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.2500+0a9d005b"
81
+ "@fedify/testing": "^2.5.0-dev.2513+d7ab5950"
83
82
  },
84
83
  "scripts": {
85
84
  "build:self": "tsdown",