@fedify/postgres
Version:
PostgreSQL drivers for Fedify
186 lines (182 loc) • 6.06 kB
JavaScript
const { Temporal } = require("@js-temporal/polyfill");
const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
const require_utils = require('./utils.cjs');
const __logtape_logtape = require_rolldown_runtime.__toESM(require("@logtape/logtape"));
const postgres = require_rolldown_runtime.__toESM(require("postgres"));
//#region src/mq.ts
const logger = (0, __logtape_logtape.getLogger)([
"fedify",
"postgres",
"mq"
]);
/**
* A message queue that uses PostgreSQL as the underlying storage.
*
* @example
* ```ts
* import { createFederation } from "@fedify/fedify";
* import { PostgresKvStore, PostgresMessageQueue } from "@fedify/postgres";
* import postgres from "postgres";
*
* const sql = postgres("postgres://user:pass@localhost/db");
*
* const federation = createFederation({
* kv: new PostgresKvStore(sql),
* queue: new PostgresMessageQueue(sql),
* });
* ```
*/
var PostgresMessageQueue = class {
#sql;
#tableName;
#channelName;
#pollIntervalMs;
#initialized;
#driverSerializesJson = false;
constructor(sql, options = {}) {
this.#sql = sql;
this.#tableName = options?.tableName ?? "fedify_message_v2";
this.#channelName = options?.channelName ?? "fedify_channel";
this.#pollIntervalMs = Temporal.Duration.from(options?.pollInterval ?? { seconds: 5 }).total("millisecond");
this.#initialized = options?.initialized ?? false;
}
async enqueue(message, options) {
await this.initialize();
const delay = options?.delay ?? Temporal.Duration.from({ seconds: 0 });
if (options?.delay) logger.debug("Enqueuing a message with a delay of {delay}...", {
delay,
message
});
else logger.debug("Enqueuing a message...", { message });
await this.#sql`
INSERT INTO ${this.#sql(this.#tableName)} (message, delay)
VALUES (
${this.#json(message)},
${delay.toString()}
);
`;
logger.debug("Enqueued a message.", { message });
await this.#sql.notify(this.#channelName, delay.toString());
logger.debug("Notified the message queue channel {channelName}.", {
channelName: this.#channelName,
message
});
}
async enqueueMany(messages, options) {
if (messages.length === 0) return;
await this.initialize();
const delay = options?.delay ?? Temporal.Duration.from({ seconds: 0 });
if (options?.delay) logger.debug("Enqueuing messages with a delay of {delay}...", {
delay,
messages
});
else logger.debug("Enqueuing messages...", { messages });
for (const message of messages) await this.#sql`
INSERT INTO ${this.#sql(this.#tableName)} (message, delay)
VALUES (
${this.#json(message)},
${delay.toString()}
);
`;
logger.debug("Enqueued messages.", { messages });
await this.#sql.notify(this.#channelName, delay.toString());
logger.debug("Notified the message queue channel {channelName}.", {
channelName: this.#channelName,
messages
});
}
async listen(handler, options = {}) {
await this.initialize();
const { signal } = options;
const poll = async () => {
while (!signal?.aborted) {
const query = this.#sql`
DELETE FROM ${this.#sql(this.#tableName)}
WHERE id = (
SELECT id
FROM ${this.#sql(this.#tableName)}
WHERE created + delay < CURRENT_TIMESTAMP
ORDER BY created
LIMIT 1
)
RETURNING message;
`.execute();
const cancel = query.cancel.bind(query);
signal?.addEventListener("abort", cancel);
let i = 0;
for (const message of await query) {
if (signal?.aborted) return;
await handler(message.message);
i++;
}
signal?.removeEventListener("abort", cancel);
if (i < 1) break;
}
};
const timeouts = /* @__PURE__ */ new Set();
const listen = await this.#sql.listen(this.#channelName, async (delay) => {
const duration = Temporal.Duration.from(delay);
const durationMs = duration.total("millisecond");
if (durationMs < 1) await poll();
else timeouts.add(setTimeout(poll, durationMs));
}, poll);
signal?.addEventListener("abort", () => {
listen.unlisten();
for (const timeout of timeouts) clearTimeout(timeout);
});
while (!signal?.aborted) {
let timeout;
await new Promise((resolve) => {
signal?.addEventListener("abort", resolve);
timeout = setTimeout(() => {
signal?.removeEventListener("abort", resolve);
resolve(0);
}, this.#pollIntervalMs);
timeouts.add(timeout);
});
if (timeout != null) timeouts.delete(timeout);
await poll();
}
await new Promise((resolve) => {
signal?.addEventListener("abort", () => resolve());
if (signal?.aborted) return resolve();
});
}
/**
* Initializes the message queue table if it does not already exist.
*/
async initialize() {
if (this.#initialized) return;
logger.debug("Initializing the message queue table {tableName}...", { tableName: this.#tableName });
try {
await this.#sql`
CREATE TABLE IF NOT EXISTS ${this.#sql(this.#tableName)} (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message jsonb NOT NULL,
delay interval DEFAULT '0 seconds',
created timestamp with time zone DEFAULT CURRENT_TIMESTAMP
);
`;
} catch (error) {
if (!(error instanceof postgres.default.PostgresError && error.constraint_name === "pg_type_typname_nsp_index")) {
logger.error("Failed to initialize the message queue table: {error}", { error });
throw error;
}
}
this.#driverSerializesJson = await require_utils.driverSerializesJson(this.#sql);
this.#initialized = true;
logger.debug("Initialized the message queue table {tableName}.", { tableName: this.#tableName });
}
/**
* Drops the message queue table if it exists.
*/
async drop() {
await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
}
#json(value) {
if (this.#driverSerializesJson) return this.#sql.json(value);
return this.#sql.json(JSON.stringify(value));
}
};
//#endregion
exports.PostgresMessageQueue = PostgresMessageQueue;