@veryfront/ext-redis 0.1.1185 → 0.1.1189

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ import { rendererLogger } from "veryfront/utils/logger";
2
+ import { escapeCacheGlobLiteral, parseSerializedCachePayload, registerRenderDistributedCacheNamespace, requirePositiveIntegerCacheTtlSeconds, serializeCachePayload, } from "veryfront/extensions/distributed/cache-support";
3
+ import { createRedisClientManager, } from "./redis-client-manager.js";
4
+ const logger = rendererLogger.component("redis");
5
+ /** Default TTL for Redis cache entries (1 hour) */
6
+ const DEFAULT_TTL_SECONDS = 3_600;
7
+ /** Number of keys to scan per Redis SCAN iteration */
8
+ const REDIS_SCAN_COUNT = 100;
9
+ /** Maximum keys passed to one DEL command. */
10
+ const REDIS_DELETE_BATCH_SIZE = 100;
11
+ /** Defensive bounds for untrusted/proxied SCAN implementations. */
12
+ const REDIS_MAX_SCAN_ITERATIONS = 1_000_000;
13
+ const REDIS_MAX_COLLECTED_KEYS = 100_000;
14
+ export class RedisCacheStore {
15
+ connectionOptions;
16
+ keyPrefix;
17
+ ttlSeconds;
18
+ clientManager;
19
+ constructor(options = {}) {
20
+ this.connectionOptions = Object.freeze({
21
+ url: options.url,
22
+ connectTimeout: options.connectTimeoutMs,
23
+ });
24
+ this.keyPrefix = registerRenderDistributedCacheNamespace(options.keyPrefix ?? "vf:cache:render:");
25
+ this.clientManager = options.clientManager ?? createRedisClientManager();
26
+ this.ttlSeconds = requirePositiveIntegerCacheTtlSeconds(options.ttlSeconds ?? DEFAULT_TTL_SECONDS);
27
+ }
28
+ async ensureClient() {
29
+ return await this.clientManager.getClient(this.connectionOptions);
30
+ }
31
+ storageKey(key) {
32
+ return `${this.keyPrefix}${key}`;
33
+ }
34
+ async resetRedisConnection() {
35
+ await this.clientManager.disconnect();
36
+ }
37
+ async resetAfterFailure(error) {
38
+ try {
39
+ await this.resetRedisConnection();
40
+ }
41
+ catch (disconnectError) {
42
+ logger.warn("failed to reset Redis connection", { error, disconnectError });
43
+ }
44
+ }
45
+ async scanKeys(client, literalPrefix) {
46
+ let cursor = 0;
47
+ let iterations = 0;
48
+ const seenCursors = new Set();
49
+ const keys = new Set();
50
+ do {
51
+ iterations++;
52
+ if (iterations > REDIS_MAX_SCAN_ITERATIONS) {
53
+ throw new Error("Redis SCAN exceeded the maximum iteration count");
54
+ }
55
+ const result = await client.scan(cursor, {
56
+ MATCH: `${escapeCacheGlobLiteral(literalPrefix)}*`,
57
+ COUNT: REDIS_SCAN_COUNT,
58
+ });
59
+ if (!result ||
60
+ !Number.isSafeInteger(result.cursor) ||
61
+ result.cursor < 0 ||
62
+ !Array.isArray(result.keys) ||
63
+ !result.keys.every((key) => typeof key === "string")) {
64
+ throw new TypeError("Redis returned an invalid SCAN result");
65
+ }
66
+ if (result.cursor !== 0 && seenCursors.has(result.cursor)) {
67
+ throw new Error("Redis SCAN repeated a cursor before completing");
68
+ }
69
+ if (result.cursor !== 0)
70
+ seenCursors.add(result.cursor);
71
+ for (const key of result.keys) {
72
+ if (!key.startsWith(literalPrefix)) {
73
+ throw new Error("Redis SCAN returned a key outside the requested cache namespace");
74
+ }
75
+ keys.add(key);
76
+ if (keys.size > REDIS_MAX_COLLECTED_KEYS) {
77
+ throw new Error("Redis SCAN exceeded the maximum collected key count");
78
+ }
79
+ }
80
+ cursor = result.cursor;
81
+ } while (cursor !== 0);
82
+ return [...keys];
83
+ }
84
+ resolveRetentionTtlSeconds(value, now = Date.now()) {
85
+ const retainUntil = value.staleUntil ?? value.expiresAt;
86
+ if (retainUntil === undefined)
87
+ return this.ttlSeconds;
88
+ const remainingSeconds = Math.ceil((retainUntil - now) / 1_000);
89
+ if (remainingSeconds <= 0) {
90
+ throw new RangeError("Redis render cache payload retention has already expired");
91
+ }
92
+ return requirePositiveIntegerCacheTtlSeconds(Math.max(this.ttlSeconds, remainingSeconds));
93
+ }
94
+ async deleteKeys(client, keys) {
95
+ let deleted = 0;
96
+ for (let index = 0; index < keys.length; index += REDIS_DELETE_BATCH_SIZE) {
97
+ const batch = keys.slice(index, index + REDIS_DELETE_BATCH_SIZE);
98
+ const count = await client.del(batch);
99
+ if (!Number.isSafeInteger(count) || count < 0 || count > batch.length) {
100
+ throw new TypeError("Redis returned an invalid DEL count");
101
+ }
102
+ deleted += count;
103
+ }
104
+ return deleted;
105
+ }
106
+ async deleteRedisKey(client, key) {
107
+ const count = await client.del(key);
108
+ if (!Number.isSafeInteger(count) || count < 0 || count > 1) {
109
+ throw new TypeError("Redis returned an invalid DEL count");
110
+ }
111
+ }
112
+ async get(key) {
113
+ try {
114
+ const client = await this.ensureClient();
115
+ const raw = await client.get(this.storageKey(key));
116
+ if (!raw)
117
+ return undefined;
118
+ const payload = parseSerializedCachePayload(raw);
119
+ if (payload)
120
+ return payload;
121
+ await this.deleteRedisKey(client, this.storageKey(key));
122
+ return undefined;
123
+ }
124
+ catch (error) {
125
+ await this.resetAfterFailure(error);
126
+ logger.warn("get failed", { key, error });
127
+ throw error;
128
+ }
129
+ }
130
+ async set(key, value) {
131
+ const serialized = serializeCachePayload(value);
132
+ const retainUntil = value.staleUntil ?? value.expiresAt;
133
+ if (retainUntil !== undefined && retainUntil <= Date.now()) {
134
+ await this.delete(key);
135
+ return;
136
+ }
137
+ try {
138
+ const client = await this.ensureClient();
139
+ // Apply TTL to prevent unbounded Redis growth
140
+ const result = await client.set(this.storageKey(key), serialized, {
141
+ EX: this.resolveRetentionTtlSeconds(value),
142
+ });
143
+ if (result !== "OK")
144
+ throw new Error("Redis SET did not acknowledge the write");
145
+ }
146
+ catch (error) {
147
+ await this.resetAfterFailure(error);
148
+ logger.warn("set failed", { key, error });
149
+ throw error;
150
+ }
151
+ }
152
+ async delete(key) {
153
+ try {
154
+ const client = await this.ensureClient();
155
+ await this.deleteRedisKey(client, this.storageKey(key));
156
+ }
157
+ catch (error) {
158
+ await this.resetAfterFailure(error);
159
+ logger.warn("delete failed; Redis invalidation is incomplete", { key, error });
160
+ throw error;
161
+ }
162
+ }
163
+ async deleteByPrefix(prefix) {
164
+ try {
165
+ const client = await this.ensureClient();
166
+ const keysToDelete = await this.scanKeys(client, `${this.keyPrefix}${prefix}`);
167
+ const deleted = await this.deleteKeys(client, keysToDelete);
168
+ return deleted;
169
+ }
170
+ catch (error) {
171
+ await this.resetAfterFailure(error);
172
+ logger.warn("deleteByPrefix failed; Redis invalidation is incomplete", { prefix, error });
173
+ throw error;
174
+ }
175
+ }
176
+ async clear() {
177
+ try {
178
+ const client = await this.ensureClient();
179
+ const keys = await this.scanKeys(client, this.keyPrefix);
180
+ await this.deleteKeys(client, keys);
181
+ }
182
+ catch (error) {
183
+ await this.resetAfterFailure(error);
184
+ logger.warn("clear failed; Redis invalidation is incomplete", { error });
185
+ throw error;
186
+ }
187
+ }
188
+ async destroy() {
189
+ await this.clientManager.disconnect();
190
+ }
191
+ }
@@ -0,0 +1,18 @@
1
+ import { type CacheRevisionSnapshot } from "veryfront/extensions/distributed/cache-support";
2
+ export type RedisRevisionedCacheRecord = Readonly<{
3
+ kind: "present";
4
+ revision: string;
5
+ value: string;
6
+ }> | Readonly<{
7
+ kind: "absent";
8
+ revision: string;
9
+ }>;
10
+ /** Test whether a raw Redis string starts with the versioned CAS frame. */
11
+ export declare function hasRevisionedCacheRecordPrefix(value: unknown): value is string;
12
+ /** Decode one strict revisioned Redis STRING without altering its payload. */
13
+ export declare function parseRevisionedCacheRecord(value: unknown): RedisRevisionedCacheRecord;
14
+ /** Parse the exact two Redis Lua read result variants. */
15
+ export declare function parseRedisRevisionReadResult(value: unknown): CacheRevisionSnapshot;
16
+ /** Parse the exact Redis integer result used by compare-exchange. */
17
+ export declare function parseRedisRevisionExchangeResult(value: unknown): boolean;
18
+ //# sourceMappingURL=revisioned-cache-record.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revisioned-cache-record.d.ts","sourceRoot":"","sources":["../src/revisioned-cache-record.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,qBAAqB,EAG3B,MAAM,gDAAgD,CAAC;AAKxD,MAAM,MAAM,0BAA0B,GAClC,QAAQ,CAAC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,GAC9D,QAAQ,CAAC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAenD,2EAA2E;AAC3E,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAE9E;AAED,8EAA8E;AAC9E,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,OAAO,GAAG,0BAA0B,CAyBrF;AAED,0DAA0D;AAC1D,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,OAAO,GAAG,qBAAqB,CAiBlF;AAED,qEAAqE;AACrE,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAKxE"}
@@ -0,0 +1,68 @@
1
+ import { requireCacheExchangeResult, snapshotCacheRevisionResult, } from "veryfront/extensions/distributed/cache-support";
2
+ const RECORD_PREFIX = "\0VFCAS1\0";
3
+ const MAX_SIGNED_REDIS_INTEGER = "9223372036854775807";
4
+ function requireCanonicalRevision(value) {
5
+ if (typeof value !== "string" ||
6
+ !/^[1-9]\d*$/.test(value) ||
7
+ value.length > MAX_SIGNED_REDIS_INTEGER.length ||
8
+ (value.length === MAX_SIGNED_REDIS_INTEGER.length &&
9
+ value > MAX_SIGNED_REDIS_INTEGER)) {
10
+ throw new TypeError("Redis cache revision must be a canonical positive signed 64-bit integer");
11
+ }
12
+ return value;
13
+ }
14
+ /** Test whether a raw Redis string starts with the versioned CAS frame. */
15
+ export function hasRevisionedCacheRecordPrefix(value) {
16
+ return typeof value === "string" && value.startsWith(RECORD_PREFIX);
17
+ }
18
+ /** Decode one strict revisioned Redis STRING without altering its payload. */
19
+ export function parseRevisionedCacheRecord(value) {
20
+ if (!hasRevisionedCacheRecordPrefix(value)) {
21
+ throw new TypeError("Redis revisioned cache record has invalid framing");
22
+ }
23
+ const stateIndex = RECORD_PREFIX.length;
24
+ const state = value[stateIndex];
25
+ if ((state !== "p" && state !== "a") || value[stateIndex + 1] !== "\0") {
26
+ throw new TypeError("Redis revisioned cache record has invalid state framing");
27
+ }
28
+ const revisionStart = stateIndex + 2;
29
+ const revisionEnd = value.indexOf("\0", revisionStart);
30
+ if (revisionEnd < 0) {
31
+ throw new TypeError("Redis revisioned cache record is missing its revision delimiter");
32
+ }
33
+ const revision = requireCanonicalRevision(value.slice(revisionStart, revisionEnd));
34
+ const payload = value.slice(revisionEnd + 1);
35
+ if (state === "a") {
36
+ if (payload.length !== 0) {
37
+ throw new TypeError("Redis absent revisioned cache record must not contain a payload");
38
+ }
39
+ return Object.freeze({ kind: "absent", revision });
40
+ }
41
+ return Object.freeze({ kind: "present", revision, value: payload });
42
+ }
43
+ /** Parse the exact two Redis Lua read result variants. */
44
+ export function parseRedisRevisionReadResult(value) {
45
+ if (!Array.isArray(value)) {
46
+ throw new TypeError("Redis revision read result must be an array");
47
+ }
48
+ if (value.length === 2 && value[0] === 0) {
49
+ return snapshotCacheRevisionResult({
50
+ value: null,
51
+ revision: requireCanonicalRevision(value[1]),
52
+ });
53
+ }
54
+ if (value.length === 3 && value[0] === 1 && typeof value[2] === "string") {
55
+ return snapshotCacheRevisionResult({
56
+ value: value[2],
57
+ revision: requireCanonicalRevision(value[1]),
58
+ });
59
+ }
60
+ throw new TypeError("Redis revision read result has an invalid shape");
61
+ }
62
+ /** Parse the exact Redis integer result used by compare-exchange. */
63
+ export function parseRedisRevisionExchangeResult(value) {
64
+ if (value !== 0 && value !== 1) {
65
+ throw new TypeError("Redis revision exchange result must be integer zero or one");
66
+ }
67
+ return requireCacheExchangeResult(value === 1);
68
+ }
@@ -0,0 +1,33 @@
1
+ import type { ProxyRoutingInvalidationEvent, ProxyRoutingInvalidationPublisher } from "veryfront/extensions/distributed/routing-invalidation-support";
2
+ type RedisListener = (message: string, channel: string) => void;
3
+ export interface RoutingInvalidationRedisClient {
4
+ connect(): Promise<void>;
5
+ publish(channel: string, message: string): Promise<number>;
6
+ subscribe(channel: string, listener: RedisListener): Promise<void>;
7
+ unsubscribe(channel: string): Promise<void>;
8
+ close(): Promise<void>;
9
+ destroy(): void;
10
+ on?(event: "error", listener: (error: unknown) => void): unknown;
11
+ }
12
+ interface RoutingInvalidationLogger {
13
+ info(message: string, extra?: Record<string, unknown>): void;
14
+ warn(message: string, extra?: Record<string, unknown>): void;
15
+ error(message: string, error?: unknown, extra?: Record<string, unknown>): void;
16
+ }
17
+ export interface ProxyRoutingInvalidationBus extends ProxyRoutingInvalidationPublisher {
18
+ close(): Promise<void>;
19
+ }
20
+ interface StartProxyRoutingInvalidationBusOptions {
21
+ acknowledgementTimeoutMs?: number;
22
+ createClient?: (redisUrl: string) => RoutingInvalidationRedisClient | Promise<RoutingInvalidationRedisClient>;
23
+ expectedReplicas?: number;
24
+ now?: () => number;
25
+ logger?: RoutingInvalidationLogger;
26
+ onInvalidate: (event: ProxyRoutingInvalidationEvent) => unknown | Promise<unknown>;
27
+ integritySecret?: string;
28
+ redisUrl?: string;
29
+ replicaId?: string;
30
+ }
31
+ export declare function startProxyRoutingInvalidationBus(options: StartProxyRoutingInvalidationBusOptions): Promise<ProxyRoutingInvalidationBus | null>;
32
+ export {};
33
+ //# sourceMappingURL=routing-invalidation-bus.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routing-invalidation-bus.d.ts","sourceRoot":"","sources":["../src/routing-invalidation-bus.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,6BAA6B,EAC7B,iCAAiC,EAElC,MAAM,+DAA+D,CAAC;AAgCvE,KAAK,aAAa,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;AAGhE,MAAM,WAAW,8BAA8B;IAC7C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3D,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,OAAO,IAAI,IAAI,CAAC;IAChB,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC;CAClE;AAED,UAAU,yBAAyB;IACjC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC7D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC7D,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAChF;AAED,MAAM,WAAW,2BAA4B,SAAQ,iCAAiC;IACpF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,UAAU,uCAAuC;IAC/C,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,YAAY,CAAC,EAAE,CACb,QAAQ,EAAE,MAAM,KACb,8BAA8B,GAAG,OAAO,CAAC,8BAA8B,CAAC,CAAC;IAC9E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,yBAAyB,CAAC;IACnC,YAAY,EAAE,CAAC,KAAK,EAAE,6BAA6B,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnF,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAwiBD,wBAAsB,gCAAgC,CACpD,OAAO,EAAE,uCAAuC,GAC/C,OAAO,CAAC,2BAA2B,GAAG,IAAI,CAAC,CAmY7C"}