UNPKG

@imqueue/pg-cache

Version:

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

211 lines (210 loc) 11.1 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 { 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>;