UNPKG

@imqueue/pg-cache

Version:

PostgreSQL managed cache on Redis for @imqueue-based service methods

285 lines (278 loc) 10.7 kB
/*! * I'm Queue Software Project * Copyright (C) 2025 imqueue.com <support@imqueue.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * If you want to use this code in a closed source (commercial) project, you can * purchase a proprietary commercial license. Please contact us at * <support@imqueue.com> to get commercial licensing options. */ import {} from './PgCache.js'; /** * Returns true if the decorator was invoked in standard (TC39) mode, i.e. * its second argument is a decorator context object carrying a `kind`. * * @param context - the decorator's second argument */ export function isStandardDecorator(context) { return (!!context && typeof context === 'object' && 'kind' in context); } /** * Walks up from a constructed instance to the prototype that actually * declares the given method, mirroring legacy decoration where the decorator * target is the declaring prototype. Falls back to the instance's own * prototype. * * @param instance - `this` inside a standard decorator initializer * @param methodName - method to locate on the prototype chain * @returns the declaring prototype */ export function declaringPrototype(instance, methodName) { let proto = instance.constructor.prototype; while (proto && !Object.prototype.hasOwnProperty.call(proto, methodName)) { proto = Object.getPrototypeOf(proto); } return proto || instance.constructor.prototype; } /** * Registers pg-cache channel entries for a method on the given prototype * exactly once, even when called from a per-construction initializer. * * @param proto - declaring prototype to attach channel metadata to * @param methodName - decorated method name (dedup key) * @param register - pushes entries */ export function registerChannelsOnce(proto, methodName, register) { const marker = `__pgCacheRegistered$${methodName}`; if (proto[marker]) { return; } Object.defineProperty(proto, marker, { value: true, enumerable: false, configurable: true, }); proto.pgCacheChannels = proto.pgCacheChannels || {}; register(proto.pgCacheChannels); } /** * Default lifetime of a cached entry, in milliseconds — 24 hours. * * A TTL is a backstop, not the primary invalidation mechanism: entries are * normally dropped by a PostgreSQL change notification long before it expires. * It exists so an entry cannot outlive its data indefinitely if a notification * is ever missed. */ export const DEFAULT_CACHE_TTL = 86400000; // 24 hrs in milliseconds /** * Reads a boolean environment variable, accepting the human-friendly * spellings 1/true/yes/on and 0/false/no/off (case-insensitive). The * previous `!!+value` idiom parsed values like `true` as NaN, i.e. `false`. * * @param name - environment variable name * @param defaultValue - used when unset or unrecognized */ export function envBool(name, defaultValue = false) { const value = process.env[name]; if (typeof value !== 'string') { return defaultValue; } const normalized = value.trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalized)) { return true; } if (['0', 'false', 'no', 'off', ''].includes(normalized)) { return false; } return defaultValue; } /** * Whether verbose cache tracing is on, read once from the `PG_CACHE_DEBUG` * environment variable at import time. * * When enabled, cache saves, fetches and trigger installation are logged at info * level. Warnings are logged regardless. Because it is read at import time, * changing the variable afterwards has no effect. * * @see {@link envBool} for the accepted spellings */ export const PG_CACHE_DEBUG = envBool('PG_CACHE_DEBUG'); /** * Default PL/pgSQL trigger function installed on every watched table. * * It builds a JSON payload of the changed row and issues `PG_NOTIFY` on a channel * named after the table. The payload shape is {@link ChannelPayload}: timestamp, * operation, schema, table and the row itself — `NEW` for inserts and updates, * `OLD` for deletes. * * Column values are read out of `information_schema` and cast to TEXT, so every * field arrives as a string regardless of its SQL type. * * Note PostgreSQL caps a NOTIFY payload at 8000 bytes; a change to a very wide * row can exceed that and the notification will be rejected. Override with * `PgCacheOptions.triggerDefinition` if the default does not suit — see * {@link PgCacheOptions}. */ export const PG_CACHE_TRIGGER = `CREATE FUNCTION post_change_notify_trigger() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE rec RECORD; payload TEXT; payload_items TEXT[]; column_names TEXT[]; column_name TEXT; column_value TEXT; channel CHARACTER VARYING(255); BEGIN channel := TG_TABLE_NAME; CASE TG_OP WHEN 'INSERT', 'UPDATE' THEN rec := NEW; WHEN 'DELETE' THEN rec := OLD; ELSE RAISE EXCEPTION 'NOTIFY: Invalid operation "%"!', TG_OP; END CASE; SELECT array_agg("c"."column_name"::TEXT) INTO column_names FROM "information_schema"."columns" AS "c" WHERE "c"."table_name" = TG_TABLE_NAME; FOREACH column_name IN ARRAY column_names LOOP EXECUTE FORMAT('SELECT $1.%I::TEXT', column_name) INTO column_value USING rec; payload_items := ARRAY_CAT( payload_items, ARRAY [column_name, column_value] ); END LOOP; payload := json_build_object( 'timestamp', CURRENT_TIMESTAMP, 'operation', TG_OP, 'schema', TG_TABLE_SCHEMA, 'table', TG_TABLE_NAME, 'record', TO_JSON(JSON_OBJECT(payload_items)) ); PERFORM PG_NOTIFY(channel, payload); RETURN rec; END; $$; `; /** * Reports a successful cache write and passes the value straight through, so it * can be used inline in a return position. Logs only when * {@link PG_CACHE_DEBUG} is on. * * @param logger - logger to report through * @param res - value that was cached; returned unchanged * @param key - redis key it was stored under * @param decorator - decorator that performed the write, named in the message * @returns `res`, unchanged */ export function setInfo(logger, res, key, decorator) { if (PG_CACHE_DEBUG) { logger.info(`PgCache:${decorator.name}: cache key '${key}' saved!`); } return res; } /** * Reports a failed cache write at warning level. Always logs: a write failure * matters even when tracing is off. * * @param logger - logger to report through * @param err - error redis raised * @param key - redis key the write targeted * @param decorator - decorator that attempted the write */ export function setError(logger, err, key, decorator) { logger.warn(`PgCache:${decorator.name}: saving cache key '${key}' error:`, err); } /** * Reports a failed cache read at warning level. The caller then falls through to * the real method, so a read failure costs latency rather than correctness. * * @param logger - logger to report through * @param err - error redis raised * @param key - redis key the read targeted * @param decorator - decorator that attempted the read */ export function fetchError(logger, err, key, decorator) { logger.warn(`PgCache:${decorator.name}: fetching cache key '${key}' error:`, err); } /** * Reports that a cached method ran while the cache was absent — either `start()` * has not completed, or invalidation could not be established and caching was * therefore left off. The method still executes; it is simply not cached. * * @param logger - logger to report through * @param className - service class whose cache is missing * @param methodName - cached method that was called too early * @param decorator - decorator that found the cache absent */ export function initError(logger, className, methodName, decorator) { logger.warn(`PgCache:${decorator.name}: cache is not initialized on ${className}, called in ${methodName}`); } /** * Default time `start()` waits for the change-notify triggers and the channel * subscriptions to be confirmed, in milliseconds. * * @remarks * Reaching it means the database accepted a connection but the invalidation * setup never finished, which is a broken deployment rather than a slow one: * long enough not to trip on a loaded database, short enough that a service * cannot sit in `start()` indefinitely. */ export const DEFAULT_INVALIDATION_TIMEOUT = 30000; /** * Waits for invalidation to be confirmed, but never longer than `timeout`. * * @remarks * This is what keeps `start()` honest. The triggers are installed and the * channels subscribed from a `connect` event handler, so without waiting for * that work `start()` resolves while the cache is already live and nothing can * invalidate it. Waiting forever is not an option either — a database that * connects but never confirms would hang start-up — so an expired wait reports * itself through `onTimeout` and lets the caller continue with caching off. * * The timer is always cleared, so a confirmed subscription never leaves a * pending timeout behind. * * @param ready - resolves once invalidation is established; it must never * reject, because a rejection here would escape the caller's `start()` * @param timeout - milliseconds to wait; a non-positive value falls back to * {@link DEFAULT_INVALIDATION_TIMEOUT} * @param onTimeout - called if the wait expires first, to report it * @returns true if invalidation was confirmed, false if the wait expired */ export async function awaitInvalidation(ready, timeout, onTimeout) { const ms = timeout > 0 ? timeout : DEFAULT_INVALIDATION_TIMEOUT; let timer; try { return await Promise.race([ ready.then(() => true), new Promise(resolve => { timer = setTimeout(() => { onTimeout(); resolve(false); }, ms); }), ]); } finally { clearTimeout(timer); } } //# sourceMappingURL=env.js.map