@beespoke/micro-server-redis 0.0.0-stage → 2.0.5
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.
- package/README.md +84 -2
- package/dist/PluginContext.d.ts +13 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +697 -0
- package/dist/plugin/index.d.ts +10 -0
- package/dist/plugin/index.js +115 -0
- package/dist/types/RedisTypes.d.ts +47 -0
- package/dist/utils/BaseRedis/BaseRedisConnector.d.ts +17 -0
- package/dist/utils/BaseRedis/BaseRedisCore.d.ts +79 -0
- package/dist/utils/BaseRedis/BaseRedisCrud.d.ts +84 -0
- package/dist/utils/BaseRedis/BaseRedisIndexing.d.ts +75 -0
- package/dist/utils/BaseRedis/RedisQueryHelper.d.ts +13 -0
- package/dist/utils/BaseRedis/index.d.ts +4 -0
- package/package.json +36 -4
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @beespoke/micro-server-redis
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared Redis connection lifecycle, application Redis subclasses, and query/index helpers.
|
|
4
|
+
|
|
5
|
+
Supports Node 24+ and Bun 1.3.14+.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @beespoke/micro-server @beespoke/micro-server-redis
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Use `bun add` in place of `npm install` for Bun projects.
|
|
14
|
+
|
|
15
|
+
## Configure the plugin
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { defineConfig } from '@beespoke/micro-server';
|
|
19
|
+
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
plugins: ['@beespoke/micro-server-redis'],
|
|
22
|
+
pluginOptions: {
|
|
23
|
+
'@beespoke/micro-server-redis': {
|
|
24
|
+
url: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires a running Redis server. Options use the native Redis client's shape, including `url`, `socket`, `username`, `password`, and `database`. Empty options retain the environment fallback. Redis search/index helpers additionally require a server supporting Redis Search commands.
|
|
31
|
+
|
|
32
|
+
## Application connector
|
|
33
|
+
|
|
34
|
+
The shared connection is available to subclasses of `BaseRedisConnector`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { BaseRedisConnector } from '@beespoke/micro-server-redis';
|
|
38
|
+
|
|
39
|
+
class Cache extends BaseRedisConnector {
|
|
40
|
+
constructor() { super(); }
|
|
41
|
+
|
|
42
|
+
async ping() {
|
|
43
|
+
await this.ensureConnected();
|
|
44
|
+
return this.client.ping();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// After bootstrap, reuse the plugin's shared connection.
|
|
49
|
+
const cache = new Cache();
|
|
50
|
+
console.log(await cache.ping());
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The base constructor and client accessor are protected. Standalone subclasses can pass native client options to `super(options)` and must call `disconnect()` when finished. The package also exports `BaseRedis` helpers, `parseQueryOptions`, `parseRediSearchQueryConfig`, and Redis client/search types.
|
|
54
|
+
|
|
55
|
+
## Start the application
|
|
56
|
+
|
|
57
|
+
Save the configuration as `micro-server.config.ts` next to your application's `package.json`, then create `index.ts`:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { ApplicationServer } from '@beespoke/micro-server';
|
|
61
|
+
|
|
62
|
+
await ApplicationServer.bootstrap();
|
|
63
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
64
|
+
process.once(signal, () => {
|
|
65
|
+
void ApplicationServer.close().catch(error => {
|
|
66
|
+
console.error(error);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Run `bun index.ts` or `node --env-file-if-exists=.env index.ts`. Node requires ESM (`"type": "module"` in package.json). When using Bun, load configuration secrets from the application environment or its `.env` file.
|
|
74
|
+
|
|
75
|
+
## TypeScript and lifecycle
|
|
76
|
+
|
|
77
|
+
Add this type-only import to an application-owned `plugin-types.d.ts` for configuration autocomplete:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import type {} from '@beespoke/micro-server-redis/plugin';
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Core validates plugin options before setup and owns cleanup after successful startup or startup failure. Await `ApplicationServer.bootstrap()` before using the configured client, and call `ApplicationServer.close()` during application shutdown. Importing the package does not open a connection. Relative plugin paths resolve from the selected configuration file's directory.
|
|
84
|
+
|
|
85
|
+
The root export can also be used without HTTP core. In that case, explicitly initialize and close the client. The `/plugin` entry is the integration adapter for `@beespoke/micro-server`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Structural subset of the core context; standalone clients need no HTTP dependency. */
|
|
2
|
+
export interface Logger {
|
|
3
|
+
info(...args: unknown[]): void;
|
|
4
|
+
warn(...args: unknown[]): void;
|
|
5
|
+
error(...args: unknown[]): void;
|
|
6
|
+
debug(...args: unknown[]): void;
|
|
7
|
+
}
|
|
8
|
+
export interface PluginContext {
|
|
9
|
+
readonly logger: Logger;
|
|
10
|
+
readonly configDir: string;
|
|
11
|
+
onStart(hook: () => void | Promise<void>): void;
|
|
12
|
+
onClose(hook: () => void | Promise<void>): void;
|
|
13
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export * from './utils/BaseRedis/index.js';
|
|
2
|
+
export { BaseRedisConnector } from './utils/BaseRedis/BaseRedisConnector.js';
|
|
3
|
+
export { parseQueryOptions, parseRediSearchQueryConfig } from './utils/BaseRedis/RedisQueryHelper.js';
|
|
4
|
+
export type { RedisClientOptions, RedisClientType, RediSearchSchema, FtSearchOptions } from 'redis';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
// src/utils/BaseRedis/BaseRedisConnector.ts
|
|
2
|
+
import { createClient } from "redis";
|
|
3
|
+
|
|
4
|
+
class BaseRedisConnector {
|
|
5
|
+
options;
|
|
6
|
+
name = "redis";
|
|
7
|
+
static sharedClient = null;
|
|
8
|
+
static connectPromise = null;
|
|
9
|
+
static closePromise = null;
|
|
10
|
+
logger = console;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
get client() {
|
|
15
|
+
if (!BaseRedisConnector.sharedClient)
|
|
16
|
+
throw new Error("Redis is not connected; await ensureConnected() first");
|
|
17
|
+
return BaseRedisConnector.sharedClient;
|
|
18
|
+
}
|
|
19
|
+
async setup(context) {
|
|
20
|
+
this.logger = context.logger;
|
|
21
|
+
context.onClose(() => this.disconnect());
|
|
22
|
+
await this.ensureConnected();
|
|
23
|
+
}
|
|
24
|
+
connectionOptions() {
|
|
25
|
+
if (this.options)
|
|
26
|
+
return this.options;
|
|
27
|
+
const port = process.env.REDIS_PORT ? Number(process.env.REDIS_PORT) : undefined;
|
|
28
|
+
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
29
|
+
throw new Error("REDIS_PORT must be an integer between 1 and 65535");
|
|
30
|
+
}
|
|
31
|
+
const socket = {
|
|
32
|
+
...process.env.REDIS_HOST ? { host: process.env.REDIS_HOST } : {},
|
|
33
|
+
...port !== undefined ? { port } : {},
|
|
34
|
+
...process.env.REDIS_TLS === "true" ? { tls: true } : {}
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
url: process.env.REDIS_URL,
|
|
38
|
+
socket,
|
|
39
|
+
password: process.env.REDIS_PASS || process.env.REDIS_PASSWORD,
|
|
40
|
+
username: process.env.REDIS_USERNAME
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async ensureConnected() {
|
|
44
|
+
if (BaseRedisConnector.closePromise)
|
|
45
|
+
await BaseRedisConnector.closePromise;
|
|
46
|
+
if (BaseRedisConnector.connectPromise)
|
|
47
|
+
return BaseRedisConnector.connectPromise;
|
|
48
|
+
if (BaseRedisConnector.sharedClient?.isOpen)
|
|
49
|
+
return;
|
|
50
|
+
const client = BaseRedisConnector.sharedClient ?? createClient(this.connectionOptions());
|
|
51
|
+
if (!BaseRedisConnector.sharedClient) {
|
|
52
|
+
BaseRedisConnector.sharedClient = client;
|
|
53
|
+
client.on("error", (error) => this.logger.error(error));
|
|
54
|
+
}
|
|
55
|
+
BaseRedisConnector.connectPromise = (async () => {
|
|
56
|
+
try {
|
|
57
|
+
await client.connect();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
try {
|
|
60
|
+
if (client.isOpen)
|
|
61
|
+
client.destroy();
|
|
62
|
+
} catch (cleanupError) {
|
|
63
|
+
throw new AggregateError([error, cleanupError], "Redis connection and cleanup failed", { cause: error });
|
|
64
|
+
} finally {
|
|
65
|
+
if (BaseRedisConnector.sharedClient === client)
|
|
66
|
+
BaseRedisConnector.sharedClient = null;
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
try {
|
|
72
|
+
await BaseRedisConnector.connectPromise;
|
|
73
|
+
} finally {
|
|
74
|
+
BaseRedisConnector.connectPromise = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async disconnect() {
|
|
78
|
+
if (BaseRedisConnector.closePromise)
|
|
79
|
+
return BaseRedisConnector.closePromise;
|
|
80
|
+
const client = BaseRedisConnector.sharedClient;
|
|
81
|
+
if (!client)
|
|
82
|
+
return;
|
|
83
|
+
BaseRedisConnector.closePromise = (async () => {
|
|
84
|
+
const errors = [];
|
|
85
|
+
try {
|
|
86
|
+
if (client.isOpen) {
|
|
87
|
+
if (client.isReady)
|
|
88
|
+
await client.quit();
|
|
89
|
+
else
|
|
90
|
+
client.destroy();
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
errors.push(error);
|
|
94
|
+
try {
|
|
95
|
+
if (client.isOpen)
|
|
96
|
+
client.destroy();
|
|
97
|
+
} catch (cleanupError) {
|
|
98
|
+
errors.push(cleanupError);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await BaseRedisConnector.connectPromise;
|
|
103
|
+
} catch {}
|
|
104
|
+
if (BaseRedisConnector.sharedClient === client)
|
|
105
|
+
BaseRedisConnector.sharedClient = null;
|
|
106
|
+
BaseRedisConnector.connectPromise = null;
|
|
107
|
+
if (errors.length)
|
|
108
|
+
throw new AggregateError(errors, "Failed to close Redis connection");
|
|
109
|
+
})();
|
|
110
|
+
try {
|
|
111
|
+
await BaseRedisConnector.closePromise;
|
|
112
|
+
} finally {
|
|
113
|
+
BaseRedisConnector.closePromise = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/utils/BaseRedis/BaseRedisCore.ts
|
|
119
|
+
class BaseRedisCore extends BaseRedisConnector {
|
|
120
|
+
constructor(options) {
|
|
121
|
+
super(options);
|
|
122
|
+
}
|
|
123
|
+
ready = async () => {
|
|
124
|
+
await this.ensureConnected();
|
|
125
|
+
return this.client;
|
|
126
|
+
};
|
|
127
|
+
chunk(arr, size) {
|
|
128
|
+
const result = [];
|
|
129
|
+
for (let i = 0;i < arr.length; i += size) {
|
|
130
|
+
result.push(arr.slice(i, i + size));
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
normalizeSearchFields(fields) {
|
|
135
|
+
if (!fields)
|
|
136
|
+
return {};
|
|
137
|
+
const normalized = {};
|
|
138
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
139
|
+
if (value == null)
|
|
140
|
+
continue;
|
|
141
|
+
if (value instanceof Date) {
|
|
142
|
+
normalized[key] = value.getTime().toString();
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(value)) {
|
|
146
|
+
normalized[key] = value.map((item) => `${item}`).join(",");
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (typeof value === "object") {
|
|
150
|
+
normalized[key] = JSON.stringify(value);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
normalized[key] = `${value}`;
|
|
154
|
+
}
|
|
155
|
+
return normalized;
|
|
156
|
+
}
|
|
157
|
+
parseSort(sort) {
|
|
158
|
+
const [field = "createdAt", dir = "desc"] = (sort ?? "").split(":");
|
|
159
|
+
return {
|
|
160
|
+
field,
|
|
161
|
+
direction: dir.toLowerCase() === "asc" ? "ASC" : "DESC"
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
parseHashRow(data) {
|
|
165
|
+
if (!data || data.data == null)
|
|
166
|
+
return null;
|
|
167
|
+
let parsed;
|
|
168
|
+
if (typeof data.data === "string") {
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(data.data);
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
} else if (typeof data.data === "object") {
|
|
175
|
+
parsed = data.data;
|
|
176
|
+
} else {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
let updatedAt;
|
|
180
|
+
if (data.updatedAt != null) {
|
|
181
|
+
const num = Number(data.updatedAt);
|
|
182
|
+
updatedAt = Number.isFinite(num) ? new Date(num) : new Date(`${data.updatedAt}`);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
...parsed,
|
|
186
|
+
updatedAt
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
resolveDocKey(docId, itemPrefix) {
|
|
190
|
+
if (!itemPrefix)
|
|
191
|
+
return docId;
|
|
192
|
+
return docId.includes(":") ? docId : `${itemPrefix}:${docId}`;
|
|
193
|
+
}
|
|
194
|
+
inferPrefixFromKey(key) {
|
|
195
|
+
const idx = key.lastIndexOf(":");
|
|
196
|
+
return idx > 0 ? key.slice(0, idx) : undefined;
|
|
197
|
+
}
|
|
198
|
+
resolveIndexId(id, itemPrefix) {
|
|
199
|
+
if (itemPrefix && id.startsWith(`${itemPrefix}:`)) {
|
|
200
|
+
return id.slice(itemPrefix.length + 1);
|
|
201
|
+
}
|
|
202
|
+
return id;
|
|
203
|
+
}
|
|
204
|
+
buildHashPayload(item, updatedAt, extraFields) {
|
|
205
|
+
const normalized = this.normalizeSearchFields(extraFields);
|
|
206
|
+
delete normalized.data;
|
|
207
|
+
delete normalized.updatedAt;
|
|
208
|
+
return {
|
|
209
|
+
data: JSON.stringify(item),
|
|
210
|
+
updatedAt: updatedAt.toString(),
|
|
211
|
+
...normalized
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// src/utils/BaseRedis/BaseRedisIndexing.ts
|
|
216
|
+
class BaseRedisIndexing extends BaseRedisCore {
|
|
217
|
+
static ensuredSearchIndexes = new WeakMap;
|
|
218
|
+
ensureSearchIndex = async (params) => {
|
|
219
|
+
const client = await this.ready();
|
|
220
|
+
let ensured = BaseRedisIndexing.ensuredSearchIndexes.get(client);
|
|
221
|
+
if (!ensured) {
|
|
222
|
+
ensured = new Set;
|
|
223
|
+
BaseRedisIndexing.ensuredSearchIndexes.set(client, ensured);
|
|
224
|
+
}
|
|
225
|
+
if (ensured.has(params.indexKey))
|
|
226
|
+
return;
|
|
227
|
+
try {
|
|
228
|
+
await client.ft.create(params.indexKey, params.schema, params.options);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
|
231
|
+
if (!message.includes("index already exists")) {
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
ensured.add(params.indexKey);
|
|
236
|
+
};
|
|
237
|
+
syncIndexedCollection = async (params) => {
|
|
238
|
+
const {
|
|
239
|
+
items,
|
|
240
|
+
idSelector,
|
|
241
|
+
scoreSelector,
|
|
242
|
+
indexKey,
|
|
243
|
+
itemPrefix,
|
|
244
|
+
maxItems,
|
|
245
|
+
ttlSeconds,
|
|
246
|
+
batchSize = 500,
|
|
247
|
+
search
|
|
248
|
+
} = params;
|
|
249
|
+
const client = await this.ready();
|
|
250
|
+
if (search) {
|
|
251
|
+
await this.syncFTIndex(search, itemPrefix);
|
|
252
|
+
}
|
|
253
|
+
if (!items.length)
|
|
254
|
+
return;
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
const entries = items.map((item) => {
|
|
257
|
+
const id = idSelector(item);
|
|
258
|
+
const rawScore = scoreSelector(item);
|
|
259
|
+
const score = rawScore instanceof Date ? rawScore.getTime() : Number(rawScore ?? now);
|
|
260
|
+
return { id: `${id}`, score, item };
|
|
261
|
+
});
|
|
262
|
+
const pipeline = client.multi();
|
|
263
|
+
pipeline.zAdd(indexKey, entries.map((e) => ({ value: e.id, score: e.score })));
|
|
264
|
+
if (ttlSeconds) {
|
|
265
|
+
pipeline.expire(indexKey, ttlSeconds);
|
|
266
|
+
}
|
|
267
|
+
for (const { id, item, score } of entries) {
|
|
268
|
+
const key = `${itemPrefix}:${id}`;
|
|
269
|
+
const extraFields = search?.fields ? this.normalizeSearchFields(search.fields(item, score)) : {};
|
|
270
|
+
delete extraFields.data;
|
|
271
|
+
delete extraFields.updatedAt;
|
|
272
|
+
pipeline.hSet(key, {
|
|
273
|
+
data: JSON.stringify(item),
|
|
274
|
+
updatedAt: now.toString(),
|
|
275
|
+
...extraFields
|
|
276
|
+
});
|
|
277
|
+
if (ttlSeconds) {
|
|
278
|
+
pipeline.expire(key, ttlSeconds);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
await pipeline.exec();
|
|
282
|
+
const total = await client.zCard(indexKey);
|
|
283
|
+
if (total > maxItems) {
|
|
284
|
+
const overflow = total - maxItems;
|
|
285
|
+
const script = `
|
|
286
|
+
local overflow = tonumber(ARGV[1])
|
|
287
|
+
if overflow <= 0 then return {} end
|
|
288
|
+
local ids = redis.call('zrange', KEYS[1], 0, overflow - 1)
|
|
289
|
+
if #ids > 0 then
|
|
290
|
+
redis.call('zremrangebyrank', KEYS[1], 0, overflow - 1)
|
|
291
|
+
end
|
|
292
|
+
return ids
|
|
293
|
+
`;
|
|
294
|
+
const evicted = await client.eval(script, {
|
|
295
|
+
keys: [indexKey],
|
|
296
|
+
arguments: [String(overflow)]
|
|
297
|
+
});
|
|
298
|
+
if (Array.isArray(evicted) && evicted.length) {
|
|
299
|
+
for (const chunk of this.chunk(evicted, batchSize)) {
|
|
300
|
+
const delPipe = client.multi();
|
|
301
|
+
for (const id of chunk) {
|
|
302
|
+
delPipe.unlink(`${itemPrefix}:${id}`);
|
|
303
|
+
}
|
|
304
|
+
await delPipe.exec();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
searchIndexedItems = async (params) => {
|
|
310
|
+
const {
|
|
311
|
+
search,
|
|
312
|
+
itemPrefix,
|
|
313
|
+
options = {},
|
|
314
|
+
maxLimit
|
|
315
|
+
} = params;
|
|
316
|
+
const client = await this.ready();
|
|
317
|
+
const paginate = options.paginate ?? true;
|
|
318
|
+
const hardCap = Math.max(1, maxLimit ?? 100);
|
|
319
|
+
const limit = paginate ? Math.min(100, Math.max(1, options.limit ?? 20)) : hardCap;
|
|
320
|
+
const rawPage = Math.max(1, options.page ?? 1);
|
|
321
|
+
const shouldCount = (options.withCount ?? false) || paginate;
|
|
322
|
+
const rawQuery = typeof search.query === "string" ? search.query : options.search ?? "*";
|
|
323
|
+
const query = rawQuery.trim().length ? rawQuery.trim() : "*";
|
|
324
|
+
const sortInfo = this.parseSort(options.sort ?? "createdAt:desc");
|
|
325
|
+
const explicitSort = search.sortBy && search.sortBy.includes(":") ? this.parseSort(search.sortBy) : null;
|
|
326
|
+
const sortByField = explicitSort ? explicitSort.field : search.sortBy;
|
|
327
|
+
const sortDirection = explicitSort ? explicitSort.direction : sortInfo.direction;
|
|
328
|
+
const buildSearchOptions = (from) => {
|
|
329
|
+
const searchOptions = {
|
|
330
|
+
LIMIT: { from, size: limit }
|
|
331
|
+
};
|
|
332
|
+
const returnFields = search.returnFields ?? ["data", "updatedAt"];
|
|
333
|
+
if (returnFields.length > 0) {
|
|
334
|
+
searchOptions.RETURN = returnFields;
|
|
335
|
+
}
|
|
336
|
+
if (sortByField) {
|
|
337
|
+
searchOptions.SORTBY = {
|
|
338
|
+
BY: sortByField,
|
|
339
|
+
DIRECTION: sortDirection
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (search.dialect != null) {
|
|
343
|
+
searchOptions.DIALECT = search.dialect;
|
|
344
|
+
}
|
|
345
|
+
return searchOptions;
|
|
346
|
+
};
|
|
347
|
+
let page = paginate ? rawPage : 1;
|
|
348
|
+
let offset = (page - 1) * limit;
|
|
349
|
+
let result = await client.ft.search(search.indexKey, query, buildSearchOptions(offset));
|
|
350
|
+
const total = result.total ?? 0;
|
|
351
|
+
if (total === 0) {
|
|
352
|
+
return {
|
|
353
|
+
rows: [],
|
|
354
|
+
count: shouldCount ? 0 : undefined,
|
|
355
|
+
page: 1,
|
|
356
|
+
limit,
|
|
357
|
+
hasNextPage: false
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (paginate) {
|
|
361
|
+
const totalPages = Math.max(1, Math.ceil(total / limit));
|
|
362
|
+
const clampedPage = Math.min(page, totalPages);
|
|
363
|
+
if (clampedPage !== page) {
|
|
364
|
+
page = clampedPage;
|
|
365
|
+
offset = (page - 1) * limit;
|
|
366
|
+
result = await client.ft.search(search.indexKey, query, buildSearchOptions(offset));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const rowsById = new Map;
|
|
370
|
+
const missingKeys = [];
|
|
371
|
+
result.documents.forEach((doc) => {
|
|
372
|
+
const row = this.parseHashRow(doc.value);
|
|
373
|
+
if (row) {
|
|
374
|
+
rowsById.set(doc.id, row);
|
|
375
|
+
} else {
|
|
376
|
+
missingKeys.push(doc.id);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
if (missingKeys.length) {
|
|
380
|
+
const pipeline = client.multi();
|
|
381
|
+
for (const docId of missingKeys) {
|
|
382
|
+
pipeline.hGetAll(this.resolveDocKey(docId, itemPrefix));
|
|
383
|
+
}
|
|
384
|
+
const raw = await pipeline.exec();
|
|
385
|
+
raw?.forEach((data, index) => {
|
|
386
|
+
const row = this.parseHashRow(data);
|
|
387
|
+
if (row) {
|
|
388
|
+
rowsById.set(missingKeys[index], row);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const rows = result.documents.map((doc) => rowsById.get(doc.id)).filter(Boolean);
|
|
393
|
+
const hasNextPage = paginate ? offset + limit < total : false;
|
|
394
|
+
return {
|
|
395
|
+
rows,
|
|
396
|
+
count: shouldCount ? total : undefined,
|
|
397
|
+
page,
|
|
398
|
+
limit,
|
|
399
|
+
hasNextPage
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
getIndexedItems = async (params) => {
|
|
403
|
+
const {
|
|
404
|
+
indexKey,
|
|
405
|
+
itemPrefix,
|
|
406
|
+
options = {},
|
|
407
|
+
maxLimit,
|
|
408
|
+
search
|
|
409
|
+
} = params;
|
|
410
|
+
const rawQuery = typeof search?.query === "string" ? search.query : options.search;
|
|
411
|
+
if (search?.indexKey && typeof rawQuery === "string" && rawQuery.trim().length > 0) {
|
|
412
|
+
return this.searchIndexedItems({
|
|
413
|
+
search: {
|
|
414
|
+
...search,
|
|
415
|
+
query: rawQuery
|
|
416
|
+
},
|
|
417
|
+
itemPrefix,
|
|
418
|
+
options,
|
|
419
|
+
maxLimit
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const client = await this.ready();
|
|
423
|
+
const paginate = options.paginate ?? true;
|
|
424
|
+
const hardCap = Math.max(1, maxLimit ?? 100);
|
|
425
|
+
const limit = paginate ? Math.min(100, Math.max(1, options.limit ?? 20)) : hardCap;
|
|
426
|
+
const rawPage = Math.max(1, options.page ?? 1);
|
|
427
|
+
const sort = options.sort ?? "createdAt:desc";
|
|
428
|
+
const withCount = options.withCount ?? false;
|
|
429
|
+
const shouldCount = withCount || paginate;
|
|
430
|
+
let count;
|
|
431
|
+
if (shouldCount) {
|
|
432
|
+
count = await client.zCard(indexKey);
|
|
433
|
+
if (!count) {
|
|
434
|
+
return {
|
|
435
|
+
rows: [],
|
|
436
|
+
count: 0,
|
|
437
|
+
page: 1,
|
|
438
|
+
limit,
|
|
439
|
+
hasNextPage: false
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const totalPages = paginate && count != null ? Math.max(1, Math.ceil(count / limit)) : 1;
|
|
444
|
+
const page = paginate ? count != null ? Math.min(rawPage, totalPages) : rawPage : 1;
|
|
445
|
+
const start = (page - 1) * limit;
|
|
446
|
+
const stop = start + limit - 1;
|
|
447
|
+
const isDesc = sort.endsWith(":desc");
|
|
448
|
+
const ids = await client.zRange(indexKey, start, stop, { REV: isDesc });
|
|
449
|
+
if (!ids.length) {
|
|
450
|
+
return {
|
|
451
|
+
rows: [],
|
|
452
|
+
count,
|
|
453
|
+
page,
|
|
454
|
+
limit,
|
|
455
|
+
hasNextPage: false
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const pipeline = client.multi();
|
|
459
|
+
for (const id of ids) {
|
|
460
|
+
pipeline.hGetAll(`${itemPrefix}:${id}`);
|
|
461
|
+
}
|
|
462
|
+
const raw = await pipeline.exec();
|
|
463
|
+
const rows = [];
|
|
464
|
+
raw?.forEach((data) => {
|
|
465
|
+
const row = this.parseHashRow(data);
|
|
466
|
+
if (row) {
|
|
467
|
+
rows.push(row);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
let hasNextPage = false;
|
|
471
|
+
if (paginate) {
|
|
472
|
+
if (count != null) {
|
|
473
|
+
hasNextPage = start + limit < count;
|
|
474
|
+
} else {
|
|
475
|
+
hasNextPage = ids.length === limit;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
rows,
|
|
480
|
+
count,
|
|
481
|
+
page,
|
|
482
|
+
limit,
|
|
483
|
+
hasNextPage
|
|
484
|
+
};
|
|
485
|
+
};
|
|
486
|
+
async syncFTIndex(config, itemPrefix) {
|
|
487
|
+
const createOptions = {
|
|
488
|
+
...config.options ?? {}
|
|
489
|
+
};
|
|
490
|
+
if (!createOptions.ON) {
|
|
491
|
+
createOptions.ON = "HASH";
|
|
492
|
+
}
|
|
493
|
+
if (!createOptions.PREFIX) {
|
|
494
|
+
createOptions.PREFIX = `${itemPrefix}:`;
|
|
495
|
+
}
|
|
496
|
+
await this.ensureSearchIndex({
|
|
497
|
+
indexKey: config.indexKey,
|
|
498
|
+
schema: config.schema,
|
|
499
|
+
options: createOptions
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// src/utils/BaseRedis/BaseRedisCrud.ts
|
|
504
|
+
class BaseRedisCrud extends BaseRedisIndexing {
|
|
505
|
+
constructor(options) {
|
|
506
|
+
super(options);
|
|
507
|
+
}
|
|
508
|
+
find = async (params) => {
|
|
509
|
+
return this.getIndexedItems(params);
|
|
510
|
+
};
|
|
511
|
+
findById = async (params) => {
|
|
512
|
+
const client = await this.ready();
|
|
513
|
+
const docId = `${params.id}`;
|
|
514
|
+
const key = this.resolveDocKey(docId, params.itemPrefix);
|
|
515
|
+
const data = await client.hGetAll(key);
|
|
516
|
+
if (!data || Object.keys(data).length === 0)
|
|
517
|
+
return null;
|
|
518
|
+
return this.parseHashRow(data);
|
|
519
|
+
};
|
|
520
|
+
insert = async (params) => {
|
|
521
|
+
const {
|
|
522
|
+
id,
|
|
523
|
+
item,
|
|
524
|
+
itemPrefix,
|
|
525
|
+
ttlSeconds,
|
|
526
|
+
indexKey,
|
|
527
|
+
score,
|
|
528
|
+
scoreSelector,
|
|
529
|
+
search
|
|
530
|
+
} = params;
|
|
531
|
+
const client = await this.ready();
|
|
532
|
+
const docId = `${id}`;
|
|
533
|
+
const inferredPrefix = itemPrefix ?? this.inferPrefixFromKey(docId);
|
|
534
|
+
const indexId = this.resolveIndexId(docId, inferredPrefix);
|
|
535
|
+
const now = Date.now();
|
|
536
|
+
if (search) {
|
|
537
|
+
if (!inferredPrefix) {
|
|
538
|
+
throw new Error("itemPrefix is required when using search indexing.");
|
|
539
|
+
}
|
|
540
|
+
await this.syncFTIndex(search, inferredPrefix);
|
|
541
|
+
}
|
|
542
|
+
const rawScore = scoreSelector ? scoreSelector(item) : score;
|
|
543
|
+
const scoreValue = rawScore instanceof Date ? rawScore.getTime() : Number(rawScore ?? now);
|
|
544
|
+
const normalizedScore = Number.isFinite(scoreValue) ? scoreValue : now;
|
|
545
|
+
const extraFields = search?.fields ? search.fields(item, normalizedScore) : undefined;
|
|
546
|
+
const payload = this.buildHashPayload(item, now, extraFields);
|
|
547
|
+
const key = this.resolveDocKey(docId, inferredPrefix);
|
|
548
|
+
const pipeline = client.multi();
|
|
549
|
+
if (indexKey) {
|
|
550
|
+
pipeline.zAdd(indexKey, [{ value: indexId, score: normalizedScore }]);
|
|
551
|
+
if (ttlSeconds) {
|
|
552
|
+
pipeline.expire(indexKey, ttlSeconds);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
pipeline.hSet(key, payload);
|
|
556
|
+
if (ttlSeconds) {
|
|
557
|
+
pipeline.expire(key, ttlSeconds);
|
|
558
|
+
}
|
|
559
|
+
await pipeline.exec();
|
|
560
|
+
return item;
|
|
561
|
+
};
|
|
562
|
+
update = async (params) => {
|
|
563
|
+
const {
|
|
564
|
+
id,
|
|
565
|
+
updates,
|
|
566
|
+
replace = false,
|
|
567
|
+
itemPrefix,
|
|
568
|
+
ttlSeconds,
|
|
569
|
+
indexKey,
|
|
570
|
+
score,
|
|
571
|
+
scoreSelector,
|
|
572
|
+
search
|
|
573
|
+
} = params;
|
|
574
|
+
const current = await this.findById({ id, itemPrefix });
|
|
575
|
+
if (!current)
|
|
576
|
+
return null;
|
|
577
|
+
const patch = typeof updates === "function" ? updates(current) : updates;
|
|
578
|
+
const next = replace ? patch : { ...current, ...patch };
|
|
579
|
+
const client = await this.ready();
|
|
580
|
+
const docId = `${id}`;
|
|
581
|
+
const inferredPrefix = itemPrefix ?? this.inferPrefixFromKey(docId);
|
|
582
|
+
const indexId = this.resolveIndexId(docId, inferredPrefix);
|
|
583
|
+
const now = Date.now();
|
|
584
|
+
if (search) {
|
|
585
|
+
if (!inferredPrefix) {
|
|
586
|
+
throw new Error("itemPrefix is required when using search indexing.");
|
|
587
|
+
}
|
|
588
|
+
await this.syncFTIndex(search, inferredPrefix);
|
|
589
|
+
}
|
|
590
|
+
const rawScore = scoreSelector ? scoreSelector(next) : score;
|
|
591
|
+
const scoreValue = rawScore instanceof Date ? rawScore.getTime() : rawScore != null ? Number(rawScore) : undefined;
|
|
592
|
+
const normalizedScore = scoreValue != null && Number.isFinite(scoreValue) ? scoreValue : now;
|
|
593
|
+
const extraFields = search?.fields ? search.fields(next, normalizedScore) : undefined;
|
|
594
|
+
const payload = this.buildHashPayload(next, now, extraFields);
|
|
595
|
+
const key = this.resolveDocKey(docId, inferredPrefix);
|
|
596
|
+
const pipeline = client.multi();
|
|
597
|
+
pipeline.hSet(key, payload);
|
|
598
|
+
if (ttlSeconds) {
|
|
599
|
+
pipeline.expire(key, ttlSeconds);
|
|
600
|
+
}
|
|
601
|
+
if (indexKey && rawScore != null) {
|
|
602
|
+
pipeline.zAdd(indexKey, [{ value: indexId, score: normalizedScore }]);
|
|
603
|
+
if (ttlSeconds) {
|
|
604
|
+
pipeline.expire(indexKey, ttlSeconds);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
await pipeline.exec();
|
|
608
|
+
return next;
|
|
609
|
+
};
|
|
610
|
+
delete = async (params) => {
|
|
611
|
+
const { id, itemPrefix, indexKey } = params;
|
|
612
|
+
const client = await this.ready();
|
|
613
|
+
const docId = `${id}`;
|
|
614
|
+
const inferredPrefix = itemPrefix ?? this.inferPrefixFromKey(docId);
|
|
615
|
+
const indexId = this.resolveIndexId(docId, inferredPrefix);
|
|
616
|
+
const key = this.resolveDocKey(docId, inferredPrefix);
|
|
617
|
+
const pipeline = client.multi();
|
|
618
|
+
pipeline.unlink(key);
|
|
619
|
+
if (indexKey) {
|
|
620
|
+
pipeline.zRem(indexKey, indexId);
|
|
621
|
+
}
|
|
622
|
+
const result = await pipeline.exec();
|
|
623
|
+
const removed = Array.isArray(result) && typeof result[0] === "number" ? result[0] : 0;
|
|
624
|
+
return removed > 0;
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
// src/utils/BaseRedis/RedisQueryHelper.ts
|
|
628
|
+
var toNumber = (value) => {
|
|
629
|
+
if (typeof value === "string") {
|
|
630
|
+
const n = Number(value);
|
|
631
|
+
return Number.isNaN(n) ? undefined : n;
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
};
|
|
635
|
+
var toBoolean = (value) => {
|
|
636
|
+
if (typeof value === "string") {
|
|
637
|
+
if (value === "true")
|
|
638
|
+
return true;
|
|
639
|
+
if (value === "false")
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
return;
|
|
643
|
+
};
|
|
644
|
+
var toString = (value) => {
|
|
645
|
+
if (typeof value === "string")
|
|
646
|
+
return value;
|
|
647
|
+
return;
|
|
648
|
+
};
|
|
649
|
+
var toStringArray = (value) => {
|
|
650
|
+
if (typeof value === "string") {
|
|
651
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
652
|
+
}
|
|
653
|
+
if (Array.isArray(value)) {
|
|
654
|
+
return value.flatMap((item) => typeof item === "string" ? item : []);
|
|
655
|
+
}
|
|
656
|
+
return;
|
|
657
|
+
};
|
|
658
|
+
function parseQueryOptions(query) {
|
|
659
|
+
const page = toNumber(query.page);
|
|
660
|
+
const limit = toNumber(query.limit);
|
|
661
|
+
const search = toString(query.search);
|
|
662
|
+
return {
|
|
663
|
+
page: page && page > 0 ? page : undefined,
|
|
664
|
+
limit: limit && limit > 0 ? limit : undefined,
|
|
665
|
+
sort: toString(query.sort),
|
|
666
|
+
withCount: toBoolean(query.withCount),
|
|
667
|
+
paginate: toBoolean(query.paginate),
|
|
668
|
+
search
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function parseRediSearchQueryConfig(query, config) {
|
|
672
|
+
const source = query;
|
|
673
|
+
const searchKey = config.searchParam ?? "search";
|
|
674
|
+
const sortByKey = config.sortByParam ?? "sortBy";
|
|
675
|
+
const returnFieldsKey = config.returnFieldsParam ?? "returnFields";
|
|
676
|
+
const dialectKey = config.dialectParam ?? "dialect";
|
|
677
|
+
const search = toString(source[searchKey]);
|
|
678
|
+
const sortBy = toString(source[sortByKey]) ?? config.defaultSortBy;
|
|
679
|
+
const returnFields = toStringArray(source[returnFieldsKey]) ?? config.defaultReturnFields;
|
|
680
|
+
const dialect = toNumber(source[dialectKey]) ?? config.defaultDialect;
|
|
681
|
+
return {
|
|
682
|
+
indexKey: config.indexKey,
|
|
683
|
+
query: search,
|
|
684
|
+
sortBy,
|
|
685
|
+
returnFields,
|
|
686
|
+
dialect
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
export {
|
|
690
|
+
parseRediSearchQueryConfig,
|
|
691
|
+
parseQueryOptions,
|
|
692
|
+
BaseRedisIndexing,
|
|
693
|
+
BaseRedisCrud,
|
|
694
|
+
BaseRedisCore,
|
|
695
|
+
BaseRedisConnector,
|
|
696
|
+
BaseRedisCrud as BaseRedis
|
|
697
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PluginDefinition } from '@beespoke/micro-server';
|
|
2
|
+
import { type RedisClientOptions } from '../index.js';
|
|
3
|
+
export type RedisPluginOptions = RedisClientOptions;
|
|
4
|
+
declare module '@beespoke/micro-server' {
|
|
5
|
+
interface PluginOptionsRegistry {
|
|
6
|
+
'@beespoke/micro-server-redis': RedisPluginOptions;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
declare const definition: PluginDefinition<RedisPluginOptions>;
|
|
10
|
+
export default definition;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/plugin/index.ts
|
|
2
|
+
import { BaseRedisConnector } from "../index.js";
|
|
3
|
+
|
|
4
|
+
class RedisPlugin extends BaseRedisConnector {
|
|
5
|
+
constructor(options) {
|
|
6
|
+
super(options);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function object(value) {
|
|
10
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function validateUrl(value) {
|
|
13
|
+
try {
|
|
14
|
+
if (typeof value !== "string")
|
|
15
|
+
throw new Error;
|
|
16
|
+
const url = new URL(value);
|
|
17
|
+
if (!["redis:", "rediss:"].includes(url.protocol) || !url.hostname)
|
|
18
|
+
throw new Error;
|
|
19
|
+
if (url.pathname && url.pathname !== "/" && !/^\/\d+$/.test(url.pathname))
|
|
20
|
+
throw new Error;
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error("Redis url must be a redis or rediss URL with an optional nonnegative database number");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
var definition = {
|
|
26
|
+
name: "@beespoke/micro-server-redis",
|
|
27
|
+
validate(options) {
|
|
28
|
+
if (!object(options))
|
|
29
|
+
throw new Error("Redis options must be an object");
|
|
30
|
+
const keys = [
|
|
31
|
+
"url",
|
|
32
|
+
"socket",
|
|
33
|
+
"username",
|
|
34
|
+
"password",
|
|
35
|
+
"credentialsProvider",
|
|
36
|
+
"name",
|
|
37
|
+
"database",
|
|
38
|
+
"commandsQueueMaxLength",
|
|
39
|
+
"disableOfflineQueue",
|
|
40
|
+
"readonly",
|
|
41
|
+
"pingInterval",
|
|
42
|
+
"commandOptions",
|
|
43
|
+
"clientSideCache",
|
|
44
|
+
"disableClientInfo",
|
|
45
|
+
"clientInfoTag",
|
|
46
|
+
"emitInvalidate",
|
|
47
|
+
"maintNotifications",
|
|
48
|
+
"maintEndpointType",
|
|
49
|
+
"maintRelaxedCommandTimeout",
|
|
50
|
+
"maintRelaxedSocketTimeout",
|
|
51
|
+
"modules",
|
|
52
|
+
"functions",
|
|
53
|
+
"scripts",
|
|
54
|
+
"RESP",
|
|
55
|
+
"unstableResp3"
|
|
56
|
+
];
|
|
57
|
+
if (Object.keys(options).some((key) => !keys.includes(key)))
|
|
58
|
+
throw new Error("Redis options contain an unknown field");
|
|
59
|
+
if (options.url !== undefined)
|
|
60
|
+
validateUrl(options.url);
|
|
61
|
+
for (const key of ["username", "password", "name", "clientInfoTag"]) {
|
|
62
|
+
if (options[key] !== undefined && typeof options[key] !== "string")
|
|
63
|
+
throw new Error(`Redis ${key} must be a string`);
|
|
64
|
+
}
|
|
65
|
+
for (const key of ["database", "commandsQueueMaxLength", "pingInterval", "maintRelaxedCommandTimeout", "maintRelaxedSocketTimeout"]) {
|
|
66
|
+
if (options[key] !== undefined && (!Number.isSafeInteger(options[key]) || options[key] < 0))
|
|
67
|
+
throw new Error(`Redis ${key} must be a nonnegative integer`);
|
|
68
|
+
}
|
|
69
|
+
for (const key of ["disableOfflineQueue", "readonly", "disableClientInfo", "emitInvalidate", "unstableResp3"]) {
|
|
70
|
+
if (options[key] !== undefined && typeof options[key] !== "boolean")
|
|
71
|
+
throw new Error(`Redis ${key} must be a boolean`);
|
|
72
|
+
}
|
|
73
|
+
if (options.RESP !== undefined && options.RESP !== 2 && options.RESP !== 3)
|
|
74
|
+
throw new Error("Redis RESP must be 2 or 3");
|
|
75
|
+
for (const key of ["credentialsProvider", "commandOptions", "clientSideCache", "modules", "functions", "scripts"]) {
|
|
76
|
+
if (options[key] !== undefined && !object(options[key]))
|
|
77
|
+
throw new Error(`Redis ${key} must be an object`);
|
|
78
|
+
}
|
|
79
|
+
if (options.socket !== undefined) {
|
|
80
|
+
const socket = options.socket;
|
|
81
|
+
if (!object(socket))
|
|
82
|
+
throw new Error("Redis socket must be an object");
|
|
83
|
+
if (socket.port !== undefined && (!Number.isInteger(socket.port) || socket.port < 1 || socket.port > 65535))
|
|
84
|
+
throw new Error("Redis socket.port must be an integer between 1 and 65535");
|
|
85
|
+
for (const key of ["host", "path"]) {
|
|
86
|
+
if (socket[key] !== undefined && (typeof socket[key] !== "string" || !socket[key].trim()))
|
|
87
|
+
throw new Error(`Redis socket.${key} must be a nonempty string`);
|
|
88
|
+
}
|
|
89
|
+
if (socket.tls !== undefined && typeof socket.tls !== "boolean")
|
|
90
|
+
throw new Error("Redis socket.tls must be a boolean");
|
|
91
|
+
for (const key of ["connectTimeout", "socketTimeout"]) {
|
|
92
|
+
if (socket[key] !== undefined && (typeof socket[key] !== "number" || !Number.isFinite(socket[key]) || socket[key] < 0))
|
|
93
|
+
throw new Error(`Redis socket.${key} must be a nonnegative number`);
|
|
94
|
+
}
|
|
95
|
+
if (socket.reconnectStrategy !== undefined && socket.reconnectStrategy !== false && typeof socket.reconnectStrategy !== "function" && (typeof socket.reconnectStrategy !== "number" || !Number.isFinite(socket.reconnectStrategy) || socket.reconnectStrategy < 0))
|
|
96
|
+
throw new Error("Redis socket.reconnectStrategy must be false, a nonnegative number or a function");
|
|
97
|
+
}
|
|
98
|
+
if (!Object.keys(options).length) {
|
|
99
|
+
if (process.env.REDIS_URL)
|
|
100
|
+
validateUrl(process.env.REDIS_URL);
|
|
101
|
+
if (process.env.REDIS_PORT !== undefined) {
|
|
102
|
+
const port = Number(process.env.REDIS_PORT);
|
|
103
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
104
|
+
throw new Error("Redis REDIS_PORT must be an integer between 1 and 65535");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
create(options) {
|
|
109
|
+
return new RedisPlugin(Object.keys(options).length ? options : undefined);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
var plugin_default = definition;
|
|
113
|
+
export {
|
|
114
|
+
plugin_default as default
|
|
115
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RediSearchSchema } from "redis";
|
|
2
|
+
export interface QueryOptions {
|
|
3
|
+
page?: number;
|
|
4
|
+
limit?: number;
|
|
5
|
+
sort?: string;
|
|
6
|
+
withCount?: boolean;
|
|
7
|
+
paginate?: boolean;
|
|
8
|
+
search?: string;
|
|
9
|
+
}
|
|
10
|
+
export type RediSearchFieldValue = string | number | boolean | Date | null | undefined | Array<string | number | boolean>;
|
|
11
|
+
export interface RediSearchCreateOptions {
|
|
12
|
+
ON?: "HASH" | "JSON";
|
|
13
|
+
PREFIX?: string | string[];
|
|
14
|
+
FILTER?: string;
|
|
15
|
+
LANGUAGE?: string;
|
|
16
|
+
LANGUAGE_FIELD?: string;
|
|
17
|
+
SCORE?: number;
|
|
18
|
+
SCORE_FIELD?: string;
|
|
19
|
+
MAXTEXTFIELDS?: boolean;
|
|
20
|
+
TEMPORARY?: number;
|
|
21
|
+
NOOFFSETS?: boolean;
|
|
22
|
+
NOHL?: boolean;
|
|
23
|
+
NOFIELDS?: boolean;
|
|
24
|
+
NOFREQS?: boolean;
|
|
25
|
+
SKIPINITIALSCAN?: boolean;
|
|
26
|
+
STOPWORDS?: string[] | "OFF";
|
|
27
|
+
}
|
|
28
|
+
export interface RediSearchIndexConfig<T = unknown> {
|
|
29
|
+
indexKey: string;
|
|
30
|
+
schema: RediSearchSchema;
|
|
31
|
+
options?: RediSearchCreateOptions;
|
|
32
|
+
fields?: (item: T, score: number) => Record<string, RediSearchFieldValue>;
|
|
33
|
+
}
|
|
34
|
+
export interface RediSearchQueryConfig {
|
|
35
|
+
indexKey: string;
|
|
36
|
+
query?: string;
|
|
37
|
+
sortBy?: string;
|
|
38
|
+
returnFields?: string[];
|
|
39
|
+
dialect?: number;
|
|
40
|
+
}
|
|
41
|
+
export interface RedisResult<T> {
|
|
42
|
+
rows: T[];
|
|
43
|
+
count?: number;
|
|
44
|
+
page: number;
|
|
45
|
+
limit: number;
|
|
46
|
+
hasNextPage: boolean;
|
|
47
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type RedisClientOptions, type RedisClientType } from 'redis';
|
|
2
|
+
import type { PluginContext, Logger } from '../../PluginContext.js';
|
|
3
|
+
/** Shared client facade. Constructors are inert; setup/ensureConnected opens the connection. */
|
|
4
|
+
export declare class BaseRedisConnector {
|
|
5
|
+
private readonly options?;
|
|
6
|
+
readonly name = "redis";
|
|
7
|
+
private static sharedClient;
|
|
8
|
+
private static connectPromise;
|
|
9
|
+
private static closePromise;
|
|
10
|
+
protected logger: Logger;
|
|
11
|
+
protected constructor(options?: RedisClientOptions);
|
|
12
|
+
protected get client(): RedisClientType;
|
|
13
|
+
setup(context: PluginContext): Promise<void>;
|
|
14
|
+
private connectionOptions;
|
|
15
|
+
ensureConnected(): Promise<void>;
|
|
16
|
+
disconnect(): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { RedisClientType, RedisClientOptions } from "redis";
|
|
2
|
+
import { BaseRedisConnector } from "./BaseRedisConnector.js";
|
|
3
|
+
import type { RediSearchFieldValue } from "../../types/RedisTypes.js";
|
|
4
|
+
/**
|
|
5
|
+
* Core Redis utility class providing common helpers for working with Redis,
|
|
6
|
+
* including connection handling, data normalization, key resolution, and hash building.
|
|
7
|
+
* Intended to be extended by specialized Redis managers.
|
|
8
|
+
*/
|
|
9
|
+
export declare class BaseRedisCore extends BaseRedisConnector {
|
|
10
|
+
/** Protected constructor; use by extending classes. */
|
|
11
|
+
protected constructor(options?: RedisClientOptions);
|
|
12
|
+
/**
|
|
13
|
+
* Ensures the Redis client is connected and ready.
|
|
14
|
+
* The connector shares in-flight connections and supports reconnecting after close.
|
|
15
|
+
* @returns {Promise<RedisClientType>} Connected Redis client.
|
|
16
|
+
*/
|
|
17
|
+
protected ready: () => Promise<RedisClientType>;
|
|
18
|
+
/**
|
|
19
|
+
* Splits an array into chunks of a given size.
|
|
20
|
+
* Useful for batching Redis operations.
|
|
21
|
+
* @param arr Array of items to split
|
|
22
|
+
* @param size Maximum size of each chunk
|
|
23
|
+
* @returns Array of chunks
|
|
24
|
+
*/
|
|
25
|
+
protected chunk<T>(arr: T[], size: number): T[][];
|
|
26
|
+
/**
|
|
27
|
+
* Normalizes Redis field values to strings.
|
|
28
|
+
* Converts Dates, Arrays, and Objects; skips null/undefined.
|
|
29
|
+
* @param fields Optional record of fields
|
|
30
|
+
* @returns Normalized record with string values
|
|
31
|
+
*/
|
|
32
|
+
protected normalizeSearchFields(fields?: Record<string, RediSearchFieldValue>): Record<string, string>;
|
|
33
|
+
/**
|
|
34
|
+
* Parses a sort string into a field and direction.
|
|
35
|
+
* @param sort Sort string in the format "field:asc" or "field:desc"
|
|
36
|
+
* @returns Object containing field and direction ("ASC" or "DESC")
|
|
37
|
+
*/
|
|
38
|
+
protected parseSort(sort?: string): {
|
|
39
|
+
field: string;
|
|
40
|
+
direction: "ASC" | "DESC";
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Parses a Redis hash row into a typed object.
|
|
44
|
+
* Converts `data` JSON string to object and parses `updatedAt` to Date.
|
|
45
|
+
* @param data Redis hash row with `data` and `updatedAt` keys
|
|
46
|
+
* @returns Parsed object of type T or null if invalid
|
|
47
|
+
*/
|
|
48
|
+
protected parseHashRow<T>(data: Record<string, any>): T | null;
|
|
49
|
+
/**
|
|
50
|
+
* Resolves a document key by adding an optional prefix.
|
|
51
|
+
* @param docId Document ID
|
|
52
|
+
* @param itemPrefix Optional prefix to namespace keys
|
|
53
|
+
* @returns Full Redis key
|
|
54
|
+
*/
|
|
55
|
+
protected resolveDocKey(docId: string, itemPrefix?: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Infers the prefix from a Redis key.
|
|
58
|
+
* @param key Redis key (e.g., "feed:123")
|
|
59
|
+
* @returns Prefix string or undefined if none found
|
|
60
|
+
*/
|
|
61
|
+
protected inferPrefixFromKey(key: string): string | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Resolves the actual ID by removing the prefix if present.
|
|
64
|
+
* @param id Redis key or ID
|
|
65
|
+
* @param itemPrefix Optional prefix to remove
|
|
66
|
+
* @returns ID without prefix
|
|
67
|
+
*/
|
|
68
|
+
protected resolveIndexId(id: string, itemPrefix?: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* Builds a Redis hash payload for storing an object.
|
|
71
|
+
* Includes `data`, `updatedAt`, and optional extra fields.
|
|
72
|
+
* Reserved keys `data` and `updatedAt` in extraFields are ignored.
|
|
73
|
+
* @param item Object to store
|
|
74
|
+
* @param updatedAt Timestamp in milliseconds
|
|
75
|
+
* @param extraFields Optional extra fields for Redis
|
|
76
|
+
* @returns Redis hash payload with string values
|
|
77
|
+
*/
|
|
78
|
+
protected buildHashPayload<T>(item: T, updatedAt: number, extraFields?: Record<string, RediSearchFieldValue>): Record<string, string>;
|
|
79
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { QueryOptions, RediSearchIndexConfig, RediSearchQueryConfig, RedisResult } from "../../types/RedisTypes.js";
|
|
2
|
+
import { BaseRedisIndexing } from "./BaseRedisIndexing.js";
|
|
3
|
+
import type { RedisClientOptions } from 'redis';
|
|
4
|
+
/**
|
|
5
|
+
* BaseRedisCrud
|
|
6
|
+
*
|
|
7
|
+
* Provides CRUD (Create, Read, Update, Delete) operations on Redis
|
|
8
|
+
* with optional search indexing support.
|
|
9
|
+
* Extends `BaseRedisIndexing` for full-text search and indexing utilities.
|
|
10
|
+
*/
|
|
11
|
+
export declare class BaseRedisCrud extends BaseRedisIndexing {
|
|
12
|
+
/**
|
|
13
|
+
* Protected constructor. Use by extending classes.
|
|
14
|
+
*/
|
|
15
|
+
protected constructor(options?: RedisClientOptions);
|
|
16
|
+
/**
|
|
17
|
+
* Find multiple items in a Redis index with optional search and query options.
|
|
18
|
+
* @template T - Type of the stored objects
|
|
19
|
+
* @param params Index key, item prefix, query options, maxLimit, and search config
|
|
20
|
+
* @returns Promise resolving to RedisResult containing matching items
|
|
21
|
+
*/
|
|
22
|
+
protected find: <T>(params: {
|
|
23
|
+
indexKey: string;
|
|
24
|
+
itemPrefix: string;
|
|
25
|
+
options?: QueryOptions;
|
|
26
|
+
maxLimit?: number;
|
|
27
|
+
search?: RediSearchQueryConfig;
|
|
28
|
+
}) => Promise<RedisResult<T>>;
|
|
29
|
+
/**
|
|
30
|
+
* Find a single item by ID.
|
|
31
|
+
* @template T - Type of the stored object
|
|
32
|
+
* @param params ID and optional itemPrefix
|
|
33
|
+
* @returns Promise resolving to the object or null if not found
|
|
34
|
+
*/
|
|
35
|
+
protected findById: <T>(params: {
|
|
36
|
+
id: string | number;
|
|
37
|
+
itemPrefix?: string;
|
|
38
|
+
}) => Promise<T | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Insert a new item into Redis with optional TTL, indexing, and score.
|
|
41
|
+
* @template T - Type of the stored object
|
|
42
|
+
* @param params Object containing id, item, itemPrefix, TTL, index info, score, and search config
|
|
43
|
+
* @returns Promise resolving to the inserted item
|
|
44
|
+
*/
|
|
45
|
+
protected insert: <T>(params: {
|
|
46
|
+
id: string | number;
|
|
47
|
+
item: T;
|
|
48
|
+
itemPrefix?: string;
|
|
49
|
+
ttlSeconds?: number;
|
|
50
|
+
indexKey?: string;
|
|
51
|
+
score?: number | Date;
|
|
52
|
+
scoreSelector?: (item: T) => number | Date;
|
|
53
|
+
search?: RediSearchIndexConfig<T>;
|
|
54
|
+
}) => Promise<T>;
|
|
55
|
+
/**
|
|
56
|
+
* Update an existing item by ID with partial updates or full replacement.
|
|
57
|
+
* Updates the index score if provided.
|
|
58
|
+
* @template T - Type of the stored object
|
|
59
|
+
* @param params Object containing id, updates (partial or function), replace flag, TTL, index info, score, and search config
|
|
60
|
+
* @returns Promise resolving to updated object or null if item does not exist
|
|
61
|
+
*/
|
|
62
|
+
protected update: <T>(params: {
|
|
63
|
+
id: string | number;
|
|
64
|
+
updates: Partial<T> | ((current: T) => Partial<T> | T);
|
|
65
|
+
replace?: boolean;
|
|
66
|
+
itemPrefix?: string;
|
|
67
|
+
ttlSeconds?: number;
|
|
68
|
+
indexKey?: string;
|
|
69
|
+
score?: number | Date;
|
|
70
|
+
scoreSelector?: (item: T) => number | Date;
|
|
71
|
+
search?: RediSearchIndexConfig<T>;
|
|
72
|
+
}) => Promise<T | null>;
|
|
73
|
+
/**
|
|
74
|
+
* Delete an item by ID.
|
|
75
|
+
* Removes the Redis hash and optionally removes the item from the index.
|
|
76
|
+
* @param params Object containing id, optional itemPrefix, and optional indexKey
|
|
77
|
+
* @returns Promise resolving to true if item was removed, false otherwise
|
|
78
|
+
*/
|
|
79
|
+
protected delete: (params: {
|
|
80
|
+
id: string | number;
|
|
81
|
+
itemPrefix?: string;
|
|
82
|
+
indexKey?: string;
|
|
83
|
+
}) => Promise<boolean>;
|
|
84
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { RediSearchSchema } from "redis";
|
|
2
|
+
import { BaseRedisCore } from "./BaseRedisCore.js";
|
|
3
|
+
import type { QueryOptions, RediSearchCreateOptions, RediSearchIndexConfig, RediSearchQueryConfig, RedisResult } from "../../types/RedisTypes.js";
|
|
4
|
+
/**
|
|
5
|
+
* BaseRedisIndexing
|
|
6
|
+
*
|
|
7
|
+
* Extends BaseRedisCore with Redis full-text search and indexing capabilities.
|
|
8
|
+
* Provides utilities for creating search indexes, syncing collections, and performing
|
|
9
|
+
* paginated and full-text search queries efficiently.
|
|
10
|
+
*/
|
|
11
|
+
export declare class BaseRedisIndexing extends BaseRedisCore {
|
|
12
|
+
/** Set of search indexes that have already been ensured */
|
|
13
|
+
private static readonly ensuredSearchIndexes;
|
|
14
|
+
/**
|
|
15
|
+
* Ensures a RediSearch index exists.
|
|
16
|
+
* Skips creation if the index has already been ensured.
|
|
17
|
+
* @param params Object containing `indexKey`, `schema`, and optional `options`
|
|
18
|
+
*/
|
|
19
|
+
protected ensureSearchIndex: (params: {
|
|
20
|
+
indexKey: string;
|
|
21
|
+
schema: RediSearchSchema;
|
|
22
|
+
options?: RediSearchCreateOptions;
|
|
23
|
+
}) => Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Syncs a collection of items into Redis with bounded memory.
|
|
26
|
+
* Supports indexing, hash storage, TTL, and batch deletion of overflow.
|
|
27
|
+
* @template T - Type of items to sync
|
|
28
|
+
* @param params Object containing items, idSelector, scoreSelector, indexKey, itemPrefix, maxItems, TTL, batchSize, and optional search config
|
|
29
|
+
*/
|
|
30
|
+
protected syncIndexedCollection: <T>(params: {
|
|
31
|
+
items: T[];
|
|
32
|
+
idSelector: (item: T) => string | number;
|
|
33
|
+
scoreSelector: (item: T) => number | Date;
|
|
34
|
+
indexKey: string;
|
|
35
|
+
itemPrefix: string;
|
|
36
|
+
maxItems: number;
|
|
37
|
+
ttlSeconds?: number;
|
|
38
|
+
batchSize?: number;
|
|
39
|
+
search?: RediSearchIndexConfig<T>;
|
|
40
|
+
}) => Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Performs a full-text search query on an indexed collection.
|
|
43
|
+
* Supports pagination, sorting, and missing key recovery.
|
|
44
|
+
* @template T - Type of returned items
|
|
45
|
+
* @param params Object containing search config, optional itemPrefix, query options, and maxLimit
|
|
46
|
+
* @returns Promise resolving to RedisResult with rows, count, page info, and hasNextPage
|
|
47
|
+
*/
|
|
48
|
+
protected searchIndexedItems: <T>(params: {
|
|
49
|
+
search: RediSearchQueryConfig;
|
|
50
|
+
itemPrefix?: string;
|
|
51
|
+
options?: QueryOptions;
|
|
52
|
+
maxLimit?: number;
|
|
53
|
+
}) => Promise<RedisResult<T>>;
|
|
54
|
+
/**
|
|
55
|
+
* Retrieves items directly from a sorted index with strict pagination.
|
|
56
|
+
* No fallback full scan; index-aligned sorting only.
|
|
57
|
+
* @template T - Type of returned items
|
|
58
|
+
* @param params Object containing indexKey, itemPrefix, query options, maxLimit, and optional search config
|
|
59
|
+
* @returns Promise resolving to RedisResult with rows, count, page info, and hasNextPage
|
|
60
|
+
*/
|
|
61
|
+
protected getIndexedItems: <T>(params: {
|
|
62
|
+
indexKey: string;
|
|
63
|
+
itemPrefix: string;
|
|
64
|
+
options?: QueryOptions;
|
|
65
|
+
maxLimit?: number;
|
|
66
|
+
search?: RediSearchQueryConfig;
|
|
67
|
+
}) => Promise<RedisResult<T>>;
|
|
68
|
+
/**
|
|
69
|
+
* Ensures a full-text search index exists for a given config and item prefix.
|
|
70
|
+
* @template T - Type of items in the index
|
|
71
|
+
* @param config RediSearch index configuration
|
|
72
|
+
* @param itemPrefix Prefix used for all keys in the index
|
|
73
|
+
*/
|
|
74
|
+
protected syncFTIndex<T>(config: RediSearchIndexConfig<T>, itemPrefix: string): Promise<void>;
|
|
75
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { QueryOptions, RediSearchQueryConfig } from "../../types/RedisTypes.js";
|
|
2
|
+
import type { ParsedQs } from "qs";
|
|
3
|
+
export declare function parseQueryOptions(query: ParsedQs): QueryOptions;
|
|
4
|
+
export declare function parseRediSearchQueryConfig(query: ParsedQs, config: {
|
|
5
|
+
indexKey: string;
|
|
6
|
+
defaultSortBy?: string;
|
|
7
|
+
defaultReturnFields?: string[];
|
|
8
|
+
defaultDialect?: number;
|
|
9
|
+
searchParam?: string;
|
|
10
|
+
sortByParam?: string;
|
|
11
|
+
returnFieldsParam?: string;
|
|
12
|
+
dialectParam?: string;
|
|
13
|
+
}): RediSearchQueryConfig;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { QueryOptions, RediSearchFieldValue, RediSearchCreateOptions, RediSearchIndexConfig, RediSearchQueryConfig, RedisResult, } from "../../types/RedisTypes.js";
|
|
2
|
+
export { BaseRedisCore } from "./BaseRedisCore.js";
|
|
3
|
+
export { BaseRedisIndexing } from "./BaseRedisIndexing.js";
|
|
4
|
+
export { BaseRedisCrud, BaseRedisCrud as BaseRedis } from "./BaseRedisCrud.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beespoke/micro-server-redis",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.5",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./plugin": {
|
|
14
|
+
"types": "./dist/plugin/index.d.ts",
|
|
15
|
+
"import": "./dist/plugin/index.js",
|
|
16
|
+
"default": "./dist/plugin/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "bun ../../scripts/build-package.ts"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"redis": "^5.11.0",
|
|
27
|
+
"@types/qs": "^6.9.15",
|
|
28
|
+
"@types/node": "^24.0.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@beespoke/micro-server": "^2.0.5"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"@beespoke/micro-server": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|