UNPKG

@imqueue/pg-cache

Version:

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

275 lines (274 loc) 11 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 JsonObject, RedisCache } from '@imqueue/rpc'; import { TagCache } from '@imqueue/tag-cache'; import { PgPubSub } from '@imqueue/pg-pubsub'; import { type ClassDecorator } from './env.js'; /** * Options for the {@link PgCache} class decorator: where PostgreSQL and redis * live, and how the change-notify triggers behave. * * Exactly one of `redis` or `redisCache` must be supplied — `redis` to let the * decorator build its own connection, `redisCache` to reuse one the service * already owns. */ export interface PgCacheOptions { /** * Redis cache key prefix to use. If not specified, decorated service * class name will be used as prefix by default. * */ prefix?: string; /** * PostgreSQL database connection string * */ postgres: string; /** * Redis connection options * */ redis?: { host: string; port: number; username?: string; password?: string; }; /** * Initialized redis cache instance. One of redis option or this redisCache * option is required to be provided * */ redisCache?: RedisCache; /** * Pass false, if database channel event should not be published by service * to connected clients. By default is enabled = true. * */ publish?: boolean; /** * SQL definition of the trigger function, in case default which is used * by this lib is not satisfying for some reason. Expected string * starting with * 'create function post_change_notify_trigger() returns trigger' * or will fall back to a default trigger definition. Spaces and case is * ignored, 'or replace statement is allowed', if needed. * */ triggerDefinition?: string; /** * Pass true to refuse to cache at all when invalidation could not be * established. Off by default. * * @remarks * Regardless of this option, `start()` does not resolve until the triggers * and channel subscriptions are confirmed, so a row changed right after * start-up is always noticed. This option covers what happens when that * setup *fails* — or when no channels were registered, so nothing could ever * invalidate the entries. * * By default such a service still caches, and its entries then expire by ttl * alone; the ttl defaults to 24 hours ({@link DEFAULT_CACHE_TTL}), so that is * how stale a value can get. Pass true where that is the wrong trade and the * service should run uncached instead, paying latency to avoid serving * something nothing will ever invalidate. */ requireInvalidation?: boolean; /** * How long `start()` waits for the change-notify triggers and the channel * subscriptions to be confirmed, in milliseconds. Defaults to * {@link DEFAULT_INVALIDATION_TIMEOUT}; a non-positive value falls back to * the same. * * @remarks * On expiry `start()` resolves with a warning rather than hanging: a database * that accepts a connection but never confirms the subscription is a broken * deployment, and a service that cannot invalidate is still a service that * works. Whether it then caches is * {@link PgCacheOptions.requireInvalidation}. */ invalidationTimeout?: number; } /** * What the {@link PgCache} decorator adds to the class it is applied to. A * decorated service gains these three members, so code inside the service can * reach the cache and the subscription directly. */ export interface PgCacheable { /** * Tagged redis cache holding the memoised method results. Each entry is * tagged with the tables it depends on, which is how a change notification * invalidates exactly the right entries. * * @remarks * Absent until invalidation is live: `start()` publishes it once the triggers * are installed and the channels subscribed, and does not resolve before * then. If that setup fails it is published anyway, so behaviour is * unchanged for a service that cannot subscribe — unless * {@link PgCacheOptions.requireInvalidation} says otherwise, in which case it * stays absent and the method decorators simply run the method, uncached. */ taggedCache: TagCache; /** * PostgreSQL LISTEN/NOTIFY subscription the triggers publish to. One channel * per watched table. */ pubSub: PgPubSub; /** * Table-to-method registry built by the {@link cacheWith} and * {@link cacheBy} method decorators at class-definition time, and read when * a notification arrives to decide what to invalidate. */ pgCacheChannels: PgCacheChannels; } /** * One registered dependency of a cached method: the method to invalidate, and an * optional filter narrowing which changes should trigger it. * * Position 0 is the decorated method name; position 1 is the filter, or * `undefined` to invalidate on every change to the table. */ export type PgCacheChannel = [ string, // called method name ChannelFilter | undefined ]; /** * Registry of cached methods keyed by the PostgreSQL notification channel that * invalidates them. The key is a table name: the installed trigger uses the * table name as its NOTIFY channel, so the two are the same string. */ export interface PgCacheChannels { [name: string]: PgCacheChannel[]; } /** * The row-level operation that produced a change notification. Matches the * PostgreSQL trigger's `TG_OP`. */ export declare enum ChannelOperation { /** A row was inserted. */ INSERT = "INSERT", /** A row was updated. */ UPDATE = "UPDATE", /** A row was deleted. */ DELETE = "DELETE" } /** * Payload delivered on a table's notification channel by the installed trigger, * describing a single row change. */ export interface ChannelPayload { /** * When the change occurred. Arrives JSON-encoded and is revived into a * `Date` before a {@link ChannelPayloadFilter} sees it. */ timestamp: Date; /** Which row-level operation fired the trigger. */ operation: ChannelOperation; /** PostgreSQL schema of the changed table. */ schema: string; /** Name of the changed table — also the notification channel name. */ table: string; /** * The changed row. `NEW` for inserts and updates, `OLD` for deletes, so this * is always the row the change is about. */ record: JsonObject; } /** * Predicate deciding whether one change should invalidate the cached method. * * Returning `true` invalidates. Unlike the array form of {@link ChannelFilter}, * this reads the way you expect — see that type for the inversion. */ export type ChannelPayloadFilter = (payload: ChannelPayload) => boolean; /** * Narrows which changes to a table invalidate a cached method. * * The two forms behave in OPPOSITE directions, which is easy to get wrong: * * - A {@link ChannelOperation} array is an **exclusion** list. Operations named * in it do NOT invalidate; everything else does. So `[ChannelOperation.DELETE]` * means "invalidate on inserts and updates, ignore deletes" — not "invalidate * on deletes". * - A {@link ChannelPayloadFilter} is an **inclusion** predicate: it invalidates * when it returns `true`. * * Omitting the filter invalidates on every change to the table. */ export type ChannelFilter = ChannelOperation[] | ChannelPayloadFilter; /** * Map of table name to the filter that decides which of its changes matter, * for method decorators that watch several tables with different rules. */ export interface FilteredChannels { [channel: string]: ChannelFilter; } /** * Class decorator turning an `@imqueue` service into a PostgreSQL-invalidated * cache: method results are memoised in redis, and PostgreSQL itself tells the * service when to drop them. * * It installs a change-notify trigger on every table the service's * {@link cacheWith} and {@link cacheBy} decorators declare a dependency on, and * subscribes to one LISTEN/NOTIFY channel per table. When a row changes, the * matching cached results are invalidated by tag — so a cache entry lives exactly * as long as the data behind it is unchanged, rather than for a guessed TTL. * * ```typescript * import { PgCache, cacheWith } from '@imqueue/pg-cache'; * * @PgCache({ * postgres: process.env.DB_URL!, * redis: { host: 'localhost', port: 6379 }, * }) * class UserService extends IMQService { * @cacheWith({ channels: ['users'] }) * public async list(): Promise<User[]> { ... } * } * ``` * * Applied to the class, it wraps `start()`: the subscription and the triggers are * established there, after any existing `start()` implementation has run. So the * cache is inert until the service is started, and a service that never calls * `start()` is never cached. * * Awaiting `start()` is enough — it does not resolve until the triggers exist and * the channels are subscribed, so a row changed immediately afterwards cannot go * unnoticed. That costs a few tens of milliseconds at boot. If the setup fails, * or is not confirmed within {@link PgCacheOptions.invalidationTimeout}, the * service still caches and reports the failure loudly; pass * {@link PgCacheOptions.requireInvalidation} to have it run uncached instead. * * Works both as a standard (TC39) decorator and as a legacy * (`experimentalDecorators`) one, matching `@imqueue/rpc`, so it can be applied in * either compilation mode. * * Redis is resolved in order: `options.redisCache`, then `options.redis`, then a * `cache` property already on the service. If none is available `start()` throws. * * @param options - PostgreSQL and redis connection details, plus the cache-key * prefix, publication and trigger-definition overrides * @returns the class decorator to apply, which augments the class with * {@link PgCacheable} */ export declare function PgCache(options: PgCacheOptions): ClassDecorator;