@elysiumjs/mnemosyne 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
1
+ import type { Context } from '@elysiumjs/core';
2
+ import type { TenancyStrategy } from './interfaces';
3
+ import { Middleware } from '@elysiumjs/core';
4
+ /**
5
+ * The tenancy mode for the application.
6
+ * - `schema`: Each tenant has their own Postgres schema.
7
+ * - `rls`: Row-Level Security with a shared schema and a tenant column.
8
+ * @author Axel Nana <axel.nana@workbud.com>
9
+ */
10
+ export type TenancyMode = 'schema' | 'rls';
11
+ /**
12
+ * Global tenancy configuration for the application.
13
+ * @author Axel Nana <axel.nana@workbud.com>
14
+ */
15
+ export interface TenancyConfig {
16
+ /**
17
+ * The tenancy mode to use.
18
+ */
19
+ mode: TenancyMode;
20
+ /**
21
+ * Configuration specific to RLS mode.
22
+ */
23
+ rls?: {
24
+ /**
25
+ * The PostgreSQL session variable used to store the current tenant.
26
+ * @default 'app.current_tenant'
27
+ */
28
+ sessionVariable?: string;
29
+ /**
30
+ * The default column name used for tenant isolation in RLS policies.
31
+ * @default 'tenant_id'
32
+ */
33
+ defaultColumn?: string;
34
+ };
35
+ /**
36
+ * Configuration specific to schema-based tenancy.
37
+ */
38
+ schema?: {
39
+ /**
40
+ * Whether to automatically create tenant schemas on first use.
41
+ */
42
+ autoCreate?: boolean;
43
+ };
44
+ }
45
+ /**
46
+ * Per-model tenancy configuration, used to control how a specific model
47
+ * interacts with the tenancy system.
48
+ * @author Axel Nana <axel.nana@workbud.com>
49
+ */
50
+ export interface ModelTenancyConfig {
51
+ /**
52
+ * Whether tenancy is enabled for this model.
53
+ */
54
+ enabled: boolean;
55
+ /**
56
+ * Override the global tenancy mode for this model.
57
+ */
58
+ mode?: TenancyMode;
59
+ /**
60
+ * The column name used for RLS tenant isolation in this model.
61
+ */
62
+ column?: string;
63
+ /**
64
+ * Custom RLS policy name for this model.
65
+ */
66
+ policyName?: string;
67
+ }
68
+ /** Name of the global service holding the {@link TenancyConfig}, registered by driver modules. */
69
+ export declare const TENANCY_CONFIG_SERVICE = "mnemosyne.tenancy.config";
70
+ /** Name of the global service holding the {@link TenancyStrategy}, registered by driver modules. */
71
+ export declare const TENANCY_STRATEGY_SERVICE = "mnemosyne.tenancy.strategy";
72
+ /**
73
+ * Gets the current global tenancy configuration.
74
+ *
75
+ * Reads the global service `mnemosyne.tenancy.config`. Returns a default
76
+ * configuration if none is registered.
77
+ *
78
+ * @author Axel Nana <axel.nana@workbud.com>
79
+ * @returns The current tenancy configuration.
80
+ */
81
+ export declare const getTenancyConfig: () => TenancyConfig;
82
+ /**
83
+ * Gets the tenancy strategy registered by the driver module as the global service
84
+ * `mnemosyne.tenancy.strategy`.
85
+ * @author Axel Nana <axel.nana@workbud.com>
86
+ * @returns The current tenancy strategy, or `null` if none is registered.
87
+ */
88
+ export declare const getTenancyStrategy: () => TenancyStrategy<unknown, unknown> | null;
89
+ /**
90
+ * Gets the current tenant from the async context.
91
+ * @author Axel Nana <axel.nana@workbud.com>
92
+ * @returns The current tenant identifier, or `null` if not set.
93
+ */
94
+ export declare const getCurrentTenant: () => string | null;
95
+ /**
96
+ * Runs a callback within the context of a specific tenant.
97
+ * @author Axel Nana <axel.nana@workbud.com>
98
+ * @param tenant The tenant identifier to set.
99
+ * @param callback The function to execute within the tenant context.
100
+ * @returns The return value of the callback.
101
+ */
102
+ export declare const withTenant: <T>(tenant: string, callback: () => T) => T;
103
+ /**
104
+ * Base class for tenant-aware middleware.
105
+ *
106
+ * The middleware extracts the tenant identifier from an HTTP header and
107
+ * sets `ctx.tenant`. For RLS mode, route handlers should use the driver's
108
+ * isolation mechanism to wrap their queries in a tenant-scoped transaction.
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * class OrganizationMiddleware extends TenantMiddleware {
113
+ * protected header = 'x-organization-id';
114
+ * protected allowPublic = false;
115
+ *
116
+ * protected async validateTenant(orgId: string): Promise<boolean> {
117
+ * return Organization.exists(orgId);
118
+ * }
119
+ * }
120
+ * ```
121
+ *
122
+ * @author Axel Nana <axel.nana@workbud.com>
123
+ */
124
+ export declare abstract class TenantMiddleware extends Middleware {
125
+ /**
126
+ * The HTTP header name from which the tenant ID is read.
127
+ */
128
+ protected header: string;
129
+ /**
130
+ * Whether requests without a tenant ID are allowed (defaulting to 'public').
131
+ */
132
+ protected allowPublic: boolean;
133
+ /**
134
+ * The database connection name used for tenant operations.
135
+ */
136
+ protected connectionName: string;
137
+ /**
138
+ * Validates that a tenant identifier is legitimate.
139
+ * Override this to check against your data store.
140
+ * @author Axel Nana <axel.nana@workbud.com>
141
+ * @param _tenant The tenant identifier to validate.
142
+ * @returns `true` if the tenant is valid, `false` otherwise.
143
+ */
144
+ protected validateTenant(_tenant: string): Promise<boolean>;
145
+ /**
146
+ * Hook called after a tenant has been successfully resolved.
147
+ * @author Axel Nana <axel.nana@workbud.com>
148
+ * @param _tenant The resolved tenant identifier.
149
+ * @param _ctx The request context.
150
+ */
151
+ protected onTenantResolved(_tenant: string, _ctx: Context): Promise<void>;
152
+ /**
153
+ * Called when the tenant cannot be determined or is invalid.
154
+ * Throws an appropriate error by default.
155
+ * @author Axel Nana <axel.nana@workbud.com>
156
+ * @param reason Whether the tenant is 'missing' or 'invalid'.
157
+ * @param _ctx The request context.
158
+ */
159
+ protected onTenantError(reason: 'missing' | 'invalid', _ctx: Context): never;
160
+ /**
161
+ * Extracts and validates the tenant from the incoming request.
162
+ * @author Axel Nana <axel.nana@workbud.com>
163
+ * @param ctx The request context.
164
+ */
165
+ onRequest(ctx: Context): Promise<any>;
166
+ }
167
+ /**
168
+ * A simple tenant middleware that allows public access by default.
169
+ * @author Axel Nana <axel.nana@workbud.com>
170
+ */
171
+ export declare class SimpleTenantMiddleware extends TenantMiddleware {
172
+ }
173
+ /**
174
+ * A strict tenant middleware that rejects requests without a tenant ID.
175
+ * @author Axel Nana <axel.nana@workbud.com>
176
+ */
177
+ export declare class StrictTenantMiddleware extends TenantMiddleware {
178
+ protected allowPublic: boolean;
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elysiumjs/mnemosyne",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Database and caching for Elysium.js applications",
6
6
  "private": false,
@@ -13,7 +13,7 @@
13
13
  "scripts": {
14
14
  "format": "prettier --write \"./src/**/*.ts\"",
15
15
  "clean": "rimraf dist",
16
- "build:js": "bun build ./src/index.ts --outdir ./dist --target bun --packages external --root ./src --minify",
16
+ "build:js": "bun build ./src/index.ts --outdir ./dist --target bun --packages external --root ./src --minify-syntax --minify-whitespace",
17
17
  "build:ts": "tsc",
18
18
  "build": "bun run clean && bun run build:js && bun run build:ts",
19
19
  "typecheck": "tsc --noEmit",
@@ -30,16 +30,15 @@
30
30
  "types": "dist/index.d.ts",
31
31
  "sideEffects": false,
32
32
  "peerDependencies": {
33
- "@elysiumjs/core": "workspace:^"
33
+ "@elysiumjs/core": "^0.8.0"
34
34
  },
35
35
  "devDependencies": {
36
- "@elysiumjs/core": "workspace:*"
36
+ "@elysiumjs/core": "0.8.0"
37
37
  },
38
38
  "dependencies": {
39
- "cache-manager": "^7.2.3",
40
- "cacheable": "^2.0.3",
41
- "keyv": "^5.0.0",
42
- "radash": "catalog:",
43
- "reflect-metadata": "catalog:"
39
+ "cache-manager": "^7.2.9",
40
+ "cacheable": "^2.5.0",
41
+ "keyv": "^5.6.0",
42
+ "radash": "^12.1.1"
44
43
  }
45
44
  }
package/src/cache.ts CHANGED
@@ -14,12 +14,16 @@
14
14
 
15
15
  import type { KeyvStoreAdapter } from 'keyv';
16
16
 
17
+ import { Service, ServiceNotFoundError } from '@elysiumjs/core';
17
18
  import { createCache } from 'cache-manager';
18
- import { KeyvCacheableMemory } from 'cacheable';
19
19
  import { Keyv } from 'keyv';
20
20
  import { alphabetical } from 'radash';
21
21
 
22
- import { KeyvRedis } from './keyv-redis';
22
+ /** Name of the global service backing `Cache.redis`. */
23
+ export const REDIS_CACHE_SERVICE = 'mnemosyne.cache.redis';
24
+
25
+ /** Name of the global service backing `Cache.memory`. */
26
+ export const MEMORY_CACHE_SERVICE = 'mnemosyne.cache.memory';
23
27
 
24
28
  /**
25
29
  * Interface for a cache storage.
@@ -118,7 +122,7 @@ export interface CacheInterface {
118
122
  * @param store The cache storage to use in the interface.
119
123
  * @returns A new `CacheInterface`.
120
124
  */
121
- const makeCacheInterface = (store: KeyvStoreAdapter): CacheInterface => {
125
+ export const makeCacheInterface = (store: KeyvStoreAdapter): CacheInterface => {
122
126
  const base = createCache({
123
127
  stores: [
124
128
  new Keyv({
@@ -154,10 +158,17 @@ const makeCacheInterface = (store: KeyvStoreAdapter): CacheInterface => {
154
158
  };
155
159
  };
156
160
 
157
- export abstract class Cache {
158
- static #redis: CacheInterface | undefined;
159
- static #memory: CacheInterface | undefined;
161
+ const getCache = (name: string): CacheInterface => {
162
+ const cache = Service.get<CacheInterface>(name);
163
+ if (!cache) throw new ServiceNotFoundError(name);
164
+ return cache;
165
+ };
160
166
 
167
+ /**
168
+ * Static access to the caches provided by the `MnemosyneModule`.
169
+ * @author Axel Nana <axel.nana@workbud.com>
170
+ */
171
+ export abstract class Cache {
161
172
  /**
162
173
  * A weak cache storage for simple non-intrusive caching of data.
163
174
  * @author Axel Nana <axel.nana@workbud.com>
@@ -191,28 +202,18 @@ export abstract class Cache {
191
202
  /**
192
203
  * Redis-based cache storage.
193
204
  * @author Axel Nana <axel.nana@workbud.com>
205
+ * @throws {ServiceNotFoundError} If the `MnemosyneModule` is not loaded or has no redis cache.
194
206
  */
195
207
  public static get redis(): CacheInterface {
196
- if (!Cache.#redis) {
197
- Cache.#redis = makeCacheInterface(
198
- new KeyvRedis({
199
- connection: 'cache'
200
- })
201
- );
202
- }
203
-
204
- return Cache.#redis;
208
+ return getCache(REDIS_CACHE_SERVICE);
205
209
  }
206
210
 
207
211
  /**
208
212
  * Memory-based cache storage.
209
213
  * @author Axel Nana <axel.nana@workbud.com>
214
+ * @throws {ServiceNotFoundError} If the `MnemosyneModule` is not loaded or has no memory cache.
210
215
  */
211
216
  public static get memory(): CacheInterface {
212
- if (!Cache.#memory) {
213
- Cache.#memory = makeCacheInterface(new KeyvCacheableMemory({ ttl: 60000, lruSize: 5000 }));
214
- }
215
-
216
- return Cache.#memory;
217
+ return getCache(MEMORY_CACHE_SERVICE);
217
218
  }
218
219
  }
package/src/database.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import type { CacheInterface } from './cache';
16
16
  import type { DatabaseCacheConfig, DatabaseDriver } from './interfaces';
17
17
 
18
- import { Application, Service } from '@elysiumjs/core';
18
+ import { Application, Service, ServiceNotFoundError } from '@elysiumjs/core';
19
19
 
20
20
  import { Cache } from './cache';
21
21
 
@@ -35,8 +35,8 @@ export const createDatabaseCacheStorage = (
35
35
  /**
36
36
  * Abstract database manager.
37
37
  *
38
- * Manages named database connections using a driver. Connections are
39
- * registered in the service container and can be retrieved by name.
38
+ * Retrieves named database connections using a driver. Connections are registered as
39
+ * global services `db.connection.{name}` by the driver modules.
40
40
  *
41
41
  * @author Axel Nana <axel.nana@workbud.com>
42
42
  * @template TConnection The connection type managed by the driver.
@@ -49,11 +49,13 @@ export abstract class AbstractDatabase<TConnection, TConnectionConfig> {
49
49
  protected abstract driver: DatabaseDriver<TConnection, TConnectionConfig>;
50
50
 
51
51
  /**
52
- * Creates a service name for a database connection.
52
+ * Creates a service name for a database connection. Driver modules register their
53
+ * connections (and the `default` alias) under this name.
54
+ * @author Axel Nana <axel.nana@workbud.com>
53
55
  * @param name The name of the connection.
54
56
  * @returns A service name for the connection.
55
57
  */
56
- private getConnectionName(name: string): string {
58
+ public static getConnectionName(name: string): string {
57
59
  return `db.connection.${name}`;
58
60
  }
59
61
 
@@ -62,6 +64,7 @@ export abstract class AbstractDatabase<TConnection, TConnectionConfig> {
62
64
  *
63
65
  * For the default connection, checks the async context for a
64
66
  * transaction handle (`db:tx`) before falling back to the service container.
67
+ * @throws {ServiceNotFoundError} If no connection with that name is registered.
65
68
  *
66
69
  * @author Axel Nana <axel.nana@workbud.com>
67
70
  * @param name The connection name.
@@ -73,39 +76,11 @@ export abstract class AbstractDatabase<TConnection, TConnectionConfig> {
73
76
  if (tx) return tx;
74
77
  }
75
78
 
76
- const serviceName = this.getConnectionName(name);
77
- if (!Service.exists(serviceName)) {
78
- // TODO: Use logger service here
79
- console.error(
80
- `No connection with name ${name} found. Please make sure to register the connection before using it.`
81
- );
82
- process.exit(1);
83
- }
84
-
85
- return Service.get<TConnection>(serviceName)!;
86
- }
87
-
88
- /**
89
- * Creates and registers a new connection.
90
- *
91
- * This will make the registered connection available for dependency injection with
92
- * the key `db.connection.{name}`, where `{name}` is replaced with the given name.
93
- *
94
- * @author Axel Nana <axel.nana@workbud.com>
95
- * @param name The connection name.
96
- * @param config The connection configuration.
97
- * @returns The newly created and registered connection.
98
- */
99
- public registerConnection(name: string, config: TConnectionConfig): TConnection {
100
- const serviceName = this.getConnectionName(name);
101
- if (Service.exists(serviceName)) {
102
- // TODO: Use logger service here
103
- console.error(`A connection with the name ${name} has already been registered.`);
104
- process.exit(1);
105
- }
79
+ const serviceName = AbstractDatabase.getConnectionName(name);
80
+ const connection = Service.get<TConnection>(serviceName);
81
+ if (connection === null) throw new ServiceNotFoundError(serviceName);
106
82
 
107
- const connection = this.driver.createConnection(config);
108
- return Service.instance(serviceName, connection);
83
+ return connection;
109
84
  }
110
85
 
111
86
  /**
@@ -115,7 +90,7 @@ export abstract class AbstractDatabase<TConnection, TConnectionConfig> {
115
90
  * @returns `true` if the connection exists, `false` otherwise.
116
91
  */
117
92
  public connectionExists(name: string): boolean {
118
- return Service.exists(this.getConnectionName(name));
93
+ return Service.exists(AbstractDatabase.getConnectionName(name));
119
94
  }
120
95
 
121
96
  /**
@@ -127,18 +102,6 @@ export abstract class AbstractDatabase<TConnection, TConnectionConfig> {
127
102
  return this.getConnection('default');
128
103
  }
129
104
 
130
- /**
131
- * Sets the default connection.
132
- * @author Axel Nana <axel.nana@workbud.com>
133
- * @param name The name of the connection to set as default.
134
- * @returns The default connection.
135
- */
136
- public setDefaultConnection(name: string): TConnection {
137
- const serviceName = this.getConnectionName('default');
138
- Service.remove(serviceName);
139
- return Service.instance(serviceName, this.getConnection(name));
140
- }
141
-
142
105
  /**
143
106
  * Returns the raw underlying client for the given connection.
144
107
  * @author Axel Nana <axel.nana@workbud.com>
package/src/index.ts CHANGED
@@ -12,8 +12,6 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- import type { TenancyConfig } from './tenancy';
16
-
17
15
  export type {
18
16
  ColumnMetadata,
19
17
  DatabaseCacheConfig,
@@ -34,14 +32,6 @@ export * from './repository';
34
32
  export * from './tenancy';
35
33
  export type { TenancyConfig, TenancyMode, ModelTenancyConfig } from './tenancy';
36
34
 
37
- export * from './cache';
35
+ export { Cache, type CacheInterface, MEMORY_CACHE_SERVICE, REDIS_CACHE_SERVICE } from './cache';
36
+ export * from './mnemosyne.module';
38
37
  export * from './keyv-redis';
39
-
40
- // Configuration types
41
- export interface MnemosyneConfig<TConnectionConfig = unknown> {
42
- database?: {
43
- default: string;
44
- connections: Record<string, TConnectionConfig>;
45
- };
46
- tenancy?: TenancyConfig;
47
- }
package/src/keyv-redis.ts CHANGED
@@ -17,7 +17,7 @@ import type { KeyvEntry, StoredData } from 'keyv';
17
17
 
18
18
  import { EventEmitter } from 'node:events';
19
19
 
20
- import { Event, Redis } from '@elysiumjs/core';
20
+ import { Event, Redis, Service } from '@elysiumjs/core';
21
21
 
22
22
  /**
23
23
  * Keyv adapter for redis. This adapter is based on the Bun's Redis client.
@@ -68,10 +68,6 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
68
68
  }) {
69
69
  super();
70
70
 
71
- Event.once('elysium:app:launched', () => {
72
- this.#client = Redis.getConnection(props.connection);
73
- });
74
-
75
71
  this.#connection = props.connection;
76
72
  this.#namespace = props.namespace;
77
73
  this.#keyPrefixSeparator = props.keyPrefixSeparator ?? '::';
@@ -84,8 +80,15 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
84
80
  });
85
81
  }
86
82
 
87
- public get client() {
88
- return this.#client!;
83
+ /**
84
+ * Resolves the Redis client on first use. The async path is always safe, even when the
85
+ * Redis module has not been initialised yet (lazy boot).
86
+ */
87
+ protected async getClient(): Promise<Bun.RedisClient> {
88
+ this.#client ??= await Service.resolve<Bun.RedisClient>(
89
+ Redis.getConnectionName(this.#connection)
90
+ );
91
+ return this.#client;
89
92
  }
90
93
 
91
94
  /**
@@ -118,23 +121,26 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
118
121
  }
119
122
 
120
123
  public async get<Value>(key: string): Promise<StoredData<Value> | undefined> {
124
+ const client = await this.getClient();
121
125
  key = this.createKeyPrefix(key, this.#namespace);
122
- const value = await this.client.get(key);
126
+ const value = await client.get(key);
123
127
 
124
128
  return value === null ? undefined : (value as Value);
125
129
  }
126
130
 
127
131
  public async set(key: string, value: any, ttl?: number) {
132
+ const client = await this.getClient();
128
133
  key = this.createKeyPrefix(key, this.#namespace);
129
134
 
130
135
  if (ttl) {
131
- await this.client.set(key, value, 'PX', ttl);
136
+ await client.set(key, value, 'PX', ttl);
132
137
  } else {
133
- await this.client.set(key, value);
138
+ await client.set(key, value);
134
139
  }
135
140
  }
136
141
 
137
142
  public async setMany(values: Array<KeyvEntry>): Promise<void> {
143
+ const client = await this.getClient();
138
144
  // TODO: Use MULTI when available in Bun
139
145
  const multi = [];
140
146
 
@@ -142,10 +148,10 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
142
148
  const prefixedKey = this.createKeyPrefix(key, this.#namespace);
143
149
  if (ttl) {
144
150
  // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-argument
145
- multi.push(this.client.set(prefixedKey, value, 'PX', ttl));
151
+ multi.push(client.set(prefixedKey, value, 'PX', ttl));
146
152
  } else {
147
153
  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
148
- multi.push(this.client.set(prefixedKey, value));
154
+ multi.push(client.set(prefixedKey, value));
149
155
  }
150
156
  }
151
157
 
@@ -153,19 +159,20 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
153
159
  }
154
160
 
155
161
  public async delete(key: string): Promise<boolean> {
162
+ const client = await this.getClient();
156
163
  key = this.createKeyPrefix(key, this.#namespace);
157
164
  // TODO: Use UNLINK when available in Bun
158
- const deleted = await (this.#useUnlink
159
- ? this.client.send('UNLINK', [key])
160
- : this.client.del(key));
165
+ const deleted = await (this.#useUnlink ? client.send('UNLINK', [key]) : client.del(key));
161
166
 
162
167
  return deleted > 0;
163
168
  }
164
169
 
165
170
  public async clear(): Promise<void> {
166
171
  try {
172
+ const client = await this.getClient();
173
+
167
174
  if (!this.#namespace && this.#noNamespaceAffectsAll) {
168
- await this.client.send('FLUSHDB', []);
175
+ await client.send('FLUSHDB', []);
169
176
  return;
170
177
  }
171
178
 
@@ -175,7 +182,7 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
175
182
  const deletePromises = [];
176
183
 
177
184
  do {
178
- const result = await this.client.send('SCAN', [
185
+ const result = await client.send('SCAN', [
179
186
  cursor,
180
187
  'MATCH',
181
188
  match,
@@ -197,7 +204,7 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
197
204
  keys = keys.filter((key: string) => !key.includes(this.#keyPrefixSeparator));
198
205
  }
199
206
 
200
- deletePromises.push(keys.map((key: string) => this.client.del(key)));
207
+ deletePromises.push(keys.map((key: string) => client.del(key)));
201
208
  } while (cursor !== '0');
202
209
 
203
210
  await Promise.all(deletePromises);
@@ -207,37 +214,42 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
207
214
  }
208
215
 
209
216
  public async has(key: string): Promise<boolean> {
217
+ const client = await this.getClient();
210
218
  key = this.createKeyPrefix(key, this.#namespace);
211
- return this.client.exists(key);
219
+ return client.exists(key);
212
220
  }
213
221
 
214
222
  public async hasMany(keys: string[]): Promise<boolean[]> {
223
+ const client = await this.getClient();
215
224
  const multi = [];
216
225
 
217
226
  for (const key of keys) {
218
227
  const prefixedKey = this.createKeyPrefix(key, this.#namespace);
219
- multi.push(this.client.exists(prefixedKey));
228
+ multi.push(client.exists(prefixedKey));
220
229
  }
221
230
 
222
231
  return Promise.all(multi);
223
232
  }
224
233
 
225
234
  public async getMany<Value>(keys: string[]): Promise<Array<StoredData<Value | undefined>>> {
235
+ const client = await this.getClient();
226
236
  if (keys.length === 0) {
227
237
  return [];
228
238
  }
229
239
 
230
240
  keys = keys.map((key) => this.createKeyPrefix(key, this.#namespace));
231
- const values = await this.client.mget(...keys);
241
+ const values = await client.mget(...keys);
232
242
 
233
243
  return values as Value[];
234
244
  }
235
245
 
236
246
  public async disconnect(): Promise<void> {
237
- this.client.close();
247
+ // The connection is owned by the Redis module; only drop our reference to it.
248
+ this.#client = undefined;
238
249
  }
239
250
 
240
251
  public async deleteMany(keys: string[]): Promise<boolean> {
252
+ const client = await this.getClient();
241
253
  let result = false;
242
254
 
243
255
  // TODO: Use MULTI when available in Bun
@@ -246,9 +258,9 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
246
258
  for (const key of keys) {
247
259
  const prefixedKey = this.createKeyPrefix(key, this.#namespace);
248
260
  if (this.#useUnlink) {
249
- multi.push(this.client.send('UNLINK', [prefixedKey]));
261
+ multi.push(client.send('UNLINK', [prefixedKey]));
250
262
  } else {
251
- multi.push(this.client.del(prefixedKey));
263
+ multi.push(client.del(prefixedKey));
252
264
  }
253
265
  }
254
266
 
@@ -266,11 +278,12 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
266
278
  public async *iterator<Value>(
267
279
  namespace?: string
268
280
  ): AsyncGenerator<Array<string | Awaited<Value> | undefined>, void> {
281
+ const client = await this.getClient();
269
282
  const match = namespace ? `${namespace}${this.#keyPrefixSeparator}*` : '*';
270
283
  let cursor = '0';
271
284
 
272
285
  do {
273
- const result = await this.client.send('SCAN', [cursor, 'MATCH', match, 'TYPE', 'string']);
286
+ const result = await client.send('SCAN', [cursor, 'MATCH', match, 'TYPE', 'string']);
274
287
 
275
288
  // Parse the result
276
289
  let [newCursor, keys] = result;
@@ -281,7 +294,7 @@ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
281
294
  }
282
295
 
283
296
  if (keys.length > 0) {
284
- const values = await this.client.mget(...keys);
297
+ const values = await client.mget(...keys);
285
298
  for (const i of keys.keys()) {
286
299
  const key = this.getKeyWithoutPrefix(keys[i], namespace);
287
300
  const value = values[i];