@imqueue/pg-cache
Version:
PostgreSQL managed cache on Redis for @imqueue-based service methods
275 lines (274 loc) • 11 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 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;