@imqueue/pg-cache
Version:
PostgreSQL managed cache on Redis for @imqueue-based service methods
211 lines (210 loc) • 11.1 kB
TypeScript
/*!
* 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 { type PgCacheable } from './PgCache.js';
/**
* Minimal logger interface accepted by this package. Structurally
* compatible with the console object and with `@imqueue` loggers, so any of
* them can be passed without depending on `@imqueue/core.`
*/
export interface ILogger {
/** General-purpose message, equivalent to `console.log`. */
log(...args: unknown[]): void;
/**
* Informational message. Used for cache hits, misses and trigger
* installation, and only emitted when `PG_CACHE_DEBUG` is on.
*/
info(...args: unknown[]): void;
/**
* Recoverable problem. Every swallowed cache error is reported at this
* level, so a redis outage shows up here rather than as a thrown error.
*/
warn(...args: unknown[]): void;
/** Unrecoverable problem. */
error(...args: unknown[]): void;
}
/**
* A dual-mode class decorator: called as `(constructor)` by legacy
* (`experimentalDecorators`) TypeScript and as `(value, context)` by standard
* (TC39) decorators. In both forms the first argument is the class, and the
* result is the class augmented with {@link PgCacheable}.
*
* Supporting both is what lets this package decorate `@imqueue` services compiled
* in either mode, the same way `@imqueue/rpc` and `@imqueue/core` decorators do.
*/
export type ClassDecorator = <T extends new (...args: any[]) => {}>(constructor: T, context?: unknown) => T & PgCacheable;
/**
* A dual-mode method decorator: called as `(target, propertyKey, descriptor)` by
* legacy (`experimentalDecorators`) TypeScript and as `(value, context)` by
* standard (TC39) decorators.
*
* Use {@link isStandardDecorator} on the second argument to tell the two apart.
*/
export type MethodDecorator = (target: any, context: any, descriptor?: TypedPropertyDescriptor<(...args: any[]) => any>) => any;
/**
* 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 declare function isStandardDecorator(context: unknown): boolean;
/**
* 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 declare function declaringPrototype(instance: any, methodName: string): any;
/**
* 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 declare function registerChannelsOnce(proto: any, methodName: string, register: (channels: Record<string, unknown[]>) => void): void;
/**
* 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 declare const DEFAULT_CACHE_TTL = 86400000;
/**
* 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 declare function envBool(name: string, defaultValue?: boolean): boolean;
/**
* 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 declare const PG_CACHE_DEBUG: boolean;
/**
* 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 declare const PG_CACHE_TRIGGER = "CREATE FUNCTION post_change_notify_trigger()\nRETURNS TRIGGER\nLANGUAGE plpgsql\nAS $$\nDECLARE\n rec RECORD;\n payload TEXT;\n payload_items TEXT[];\n column_names TEXT[];\n column_name TEXT;\n column_value TEXT;\n channel CHARACTER VARYING(255);\nBEGIN\n channel := TG_TABLE_NAME;\n\n CASE TG_OP\n WHEN 'INSERT', 'UPDATE' THEN rec := NEW;\n WHEN 'DELETE' THEN rec := OLD;\n ELSE RAISE EXCEPTION 'NOTIFY: Invalid operation \"%\"!',\n TG_OP;\n END CASE;\n\n SELECT array_agg(\"c\".\"column_name\"::TEXT)\n INTO column_names\n FROM \"information_schema\".\"columns\" AS \"c\"\n WHERE \"c\".\"table_name\" = TG_TABLE_NAME;\n\n FOREACH column_name IN ARRAY column_names\n LOOP\n EXECUTE FORMAT('SELECT $1.%I::TEXT', column_name)\n INTO column_value\n USING rec;\n\n payload_items := ARRAY_CAT(\n payload_items,\n ARRAY [column_name, column_value]\n );\n END LOOP;\n\n payload := json_build_object(\n 'timestamp', CURRENT_TIMESTAMP,\n 'operation', TG_OP,\n 'schema', TG_TABLE_SCHEMA,\n 'table', TG_TABLE_NAME,\n 'record', TO_JSON(JSON_OBJECT(payload_items))\n );\n\n PERFORM PG_NOTIFY(channel, payload);\n\n RETURN rec;\nEND;\n$$;\n";
/**
* 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 declare function setInfo(logger: ILogger, res: any, key: string, decorator: Function): any;
/**
* 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 declare function setError(logger: ILogger, err: any, key: string, decorator: Function): void;
/**
* 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 declare function fetchError(logger: ILogger, err: any, key: string, decorator: Function): void;
/**
* 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 declare function initError(logger: ILogger, className: string, methodName: string, decorator: Function): void;
/**
* 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 declare 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 declare function awaitInvalidation(ready: Promise<void>, timeout: number, onTimeout: () => void): Promise<boolean>;