@imqueue/pg-cache
Version:
PostgreSQL managed cache on Redis for @imqueue-based service methods
285 lines (278 loc) • 10.7 kB
JavaScript
/*!
* 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