@beespoke/micro-server-redis 0.0.0-stage → 2.0.3

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 CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # @beespoke/micro-server-redis
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.3",
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.3"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "@beespoke/micro-server": {
35
+ "optional": true
36
+ }
37
+ }
38
+ }