@elysiumjs/mnemosyne 0.1.0 → 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.
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,
@@ -27,31 +25,13 @@ export type {
27
25
  TenancyStrategy
28
26
  } from './interfaces';
29
27
 
30
- export { AbstractDatabase, createDatabaseCacheStorage } from './database';
31
- export { createSchemaFromModel, AbstractModel } from './model';
32
- export { AbstractRepository } from './repository';
28
+ export * from './database';
29
+ export * from './model';
30
+ export * from './repository';
33
31
 
34
- export {
35
- getConfig as getTenancyConfig,
36
- getCurrentTenant,
37
- withTenant,
38
- registerTenancyStrategy,
39
- getTenancyStrategy,
40
- TenantMiddleware,
41
- SimpleTenantMiddleware,
42
- StrictTenantMiddleware
43
- } from './tenancy';
32
+ export * from './tenancy';
44
33
  export type { TenancyConfig, TenancyMode, ModelTenancyConfig } from './tenancy';
45
34
 
46
- export { Cache } from './cache';
47
- export type { CacheInterface } from './cache';
48
- export { KeyvRedis } from './keyv-redis';
49
-
50
- // Configuration types
51
- export interface MnemosyneConfig<TConnectionConfig = unknown> {
52
- database?: {
53
- default: string;
54
- connections: Record<string, TConnectionConfig>;
55
- };
56
- tenancy?: TenancyConfig;
57
- }
35
+ export { Cache, type CacheInterface, MEMORY_CACHE_SERVICE, REDIS_CACHE_SERVICE } from './cache';
36
+ export * from './mnemosyne.module';
37
+ export * from './keyv-redis';
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];
@@ -0,0 +1,83 @@
1
+ // Copyright (c) 2026-present Workbud Technologies Inc. All rights reserved.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import type { ServiceFactoryDefinition } from '@elysiumjs/core';
16
+
17
+ import { Module } from '@elysiumjs/core';
18
+ import { KeyvCacheableMemory } from 'cacheable';
19
+
20
+ import { makeCacheInterface, MEMORY_CACHE_SERVICE, REDIS_CACHE_SERVICE } from './cache';
21
+ import { KeyvRedis } from './keyv-redis';
22
+
23
+ /**
24
+ * Configuration of the {@link MnemosyneModule}.
25
+ * @author Axel Nana <axel.nana@workbud.com>
26
+ */
27
+ export type MnemosyneModuleConfig = {
28
+ cache: {
29
+ /** The Redis-backed cache. Set to `false` to not provide `Cache.redis`. */
30
+ redis?:
31
+ | {
32
+ /** The name of the Redis connection to use. */
33
+ connection: string;
34
+ }
35
+ | false;
36
+
37
+ /** The memory cache. Set to `false` to not provide `Cache.memory`. */
38
+ memory?:
39
+ | {
40
+ /** The default TTL of entries, in milliseconds. */
41
+ ttl: number;
42
+
43
+ /** The maximum number of entries kept. */
44
+ lruSize: number;
45
+ }
46
+ | false;
47
+ };
48
+ };
49
+
50
+ /**
51
+ * Provides the caches as the global services `mnemosyne.cache.redis` and
52
+ * `mnemosyne.cache.memory`. Tenancy config and strategy, and database connections, are
53
+ * registered by the database driver modules.
54
+ * @author Axel Nana <axel.nana@workbud.com>
55
+ */
56
+ @Module.register<MnemosyneModuleConfig>({
57
+ services: (config) => {
58
+ const services: ServiceFactoryDefinition[] = [];
59
+ const { redis, memory } = config.cache;
60
+
61
+ if (redis) {
62
+ services.push({
63
+ name: REDIS_CACHE_SERVICE,
64
+ global: true,
65
+ factory: () => makeCacheInterface(new KeyvRedis({ connection: redis.connection }))
66
+ });
67
+ }
68
+
69
+ if (memory) {
70
+ services.push({
71
+ name: MEMORY_CACHE_SERVICE,
72
+ global: true,
73
+ factory: () =>
74
+ makeCacheInterface(new KeyvCacheableMemory({ ttl: memory.ttl, lruSize: memory.lruSize }))
75
+ });
76
+ }
77
+
78
+ return services;
79
+ }
80
+ })
81
+ export class MnemosyneModule extends Module.configurable<MnemosyneModuleConfig>({
82
+ defaults: { cache: { redis: { connection: 'cache' }, memory: { ttl: 60000, lruSize: 5000 } } }
83
+ }) {}
package/src/tenancy.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import type { Context } from '@elysiumjs/core';
16
16
  import type { TenancyStrategy } from './interfaces';
17
17
 
18
- import { Application, Middleware } from '@elysiumjs/core';
18
+ import { Application, Middleware, Service } from '@elysiumjs/core';
19
19
 
20
20
  // ============================================================================
21
21
  // Types
@@ -95,53 +95,36 @@ export interface ModelTenancyConfig {
95
95
  }
96
96
 
97
97
  // ============================================================================
98
- // Internal State
98
+ // Configuration
99
99
  // ============================================================================
100
100
 
101
- let _strategy: TenancyStrategy<unknown, unknown> | null = null;
101
+ /** Name of the global service holding the {@link TenancyConfig}, registered by driver modules. */
102
+ export const TENANCY_CONFIG_SERVICE = 'mnemosyne.tenancy.config';
102
103
 
103
- // ============================================================================
104
- // Configuration
105
- // ============================================================================
104
+ /** Name of the global service holding the {@link TenancyStrategy}, registered by driver modules. */
105
+ export const TENANCY_STRATEGY_SERVICE = 'mnemosyne.tenancy.strategy';
106
106
 
107
107
  /**
108
108
  * Gets the current global tenancy configuration.
109
109
  *
110
- * Reads from the 'elysium:mnemosyne' app config key. Returns a default
111
- * configuration if the app is not yet initialized or no tenancy config is set.
110
+ * Reads the global service `mnemosyne.tenancy.config`. Returns a default
111
+ * configuration if none is registered.
112
112
  *
113
113
  * @author Axel Nana <axel.nana@workbud.com>
114
114
  * @returns The current tenancy configuration.
115
115
  */
116
- export const getConfig = (): TenancyConfig => {
117
- const app = Application.instance;
118
- if (!app) {
119
- return { mode: 'schema' };
120
- }
121
- const config = app.getConfig<{ tenancy?: TenancyConfig }>('elysium:mnemosyne' as any);
122
- return config?.tenancy ?? { mode: 'schema' };
123
- };
124
-
125
- // ============================================================================
126
- // Strategy Registry
127
- // ============================================================================
128
-
129
- /**
130
- * Registers the tenancy strategy to use for table resolution and isolation.
131
- * @author Axel Nana <axel.nana@workbud.com>
132
- * @param strategy The tenancy strategy to register.
133
- */
134
- export const registerTenancyStrategy = (strategy: TenancyStrategy<unknown, unknown>): void => {
135
- _strategy = strategy;
116
+ export const getTenancyConfig = (): TenancyConfig => {
117
+ return Service.get<TenancyConfig>(TENANCY_CONFIG_SERVICE) ?? { mode: 'schema' };
136
118
  };
137
119
 
138
120
  /**
139
- * Gets the currently registered tenancy strategy.
121
+ * Gets the tenancy strategy registered by the driver module as the global service
122
+ * `mnemosyne.tenancy.strategy`.
140
123
  * @author Axel Nana <axel.nana@workbud.com>
141
124
  * @returns The current tenancy strategy, or `null` if none is registered.
142
125
  */
143
126
  export const getTenancyStrategy = (): TenancyStrategy<unknown, unknown> | null => {
144
- return _strategy;
127
+ return Service.get<TenancyStrategy<unknown, unknown>>(TENANCY_STRATEGY_SERVICE);
145
128
  };
146
129
 
147
130
  // ============================================================================
@@ -14,21 +14,63 @@
14
14
 
15
15
  import type { Mock } from 'bun:test';
16
16
 
17
+ import { Application, RedisModule } from '@elysiumjs/core';
17
18
  import { afterAll, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
18
19
  import { createCache } from 'cache-manager';
19
20
  import * as KV from 'keyv';
20
21
 
21
- import { Event, Redis } from '@elysiumjs/core';
22
-
23
- import { Cache } from '../src/cache';
22
+ import { Cache, MnemosyneModule } from '../src';
24
23
 
25
24
  // Mock dependencies
26
25
  mock.module('cache-manager', () => ({
27
26
  createCache: mock(createCache)
28
27
  }));
29
28
 
30
- Redis.registerConnection('cache', {
31
- url: process.env.REDIS_TEST_URL!
29
+ // A minimal in-memory stand-in for Bun.RedisClient
30
+ const data = new Map<string, string>();
31
+ const originalRedisClient = Bun.RedisClient;
32
+ const RedisClientFake = mock(function () {
33
+ return {
34
+ close: mock(),
35
+ get: async (k: string) => data.get(k) ?? null,
36
+ set: async (k: string, v: string) => {
37
+ data.set(k, v);
38
+ return 'OK';
39
+ },
40
+ del: async (k: string) => Number(data.delete(k)),
41
+ exists: async (k: string) => data.has(k),
42
+ mget: async (...keys: string[]) => keys.map((k) => data.get(k) ?? null),
43
+ send: async (cmd: string, args: string[]) => {
44
+ if (cmd === 'UNLINK') return Number(data.delete(args[0]));
45
+ if (cmd === 'SCAN') return ['0', [...data.keys()]];
46
+ if (cmd === 'FLUSHDB') return data.clear();
47
+ return null;
48
+ }
49
+ };
50
+ }) as unknown as typeof Bun.RedisClient;
51
+
52
+ let app: Awaited<ReturnType<typeof Application.testing>>;
53
+
54
+ beforeAll(async () => {
55
+ Bun.RedisClient = RedisClientFake;
56
+ app = await Application.testing({
57
+ modules: [
58
+ RedisModule.with({
59
+ default: 'cache',
60
+ connections: { cache: { url: 'redis://cache:6379' } }
61
+ }),
62
+ MnemosyneModule
63
+ ]
64
+ });
65
+ await app.get('mnemosyne.cache.redis');
66
+ await app.get('mnemosyne.cache.memory');
67
+ });
68
+
69
+ afterAll(async () => {
70
+ // Some tests spy on Keyv without restoring it
71
+ mock.restore();
72
+ await app.shutdown();
73
+ Bun.RedisClient = originalRedisClient;
32
74
  });
33
75
 
34
76
  // Test the weak cache
@@ -82,9 +124,8 @@ describe('Cache.weak', () => {
82
124
 
83
125
  // Test the Redis cache
84
126
  describe('Cache.redis', () => {
85
- beforeAll(() => {
86
- Cache.redis.clear();
87
- Event.emit('elysium:app:launched', null, null);
127
+ beforeAll(async () => {
128
+ await Cache.redis.clear();
88
129
  });
89
130
 
90
131
  // Reset mocks before each test
@@ -109,7 +150,7 @@ describe('Cache.redis', () => {
109
150
  expect(getSpy).toHaveBeenCalledWith('test-key');
110
151
 
111
152
  // Check if the result is correct
112
- expect(result).toBeNull();
153
+ expect(result).toBeUndefined();
113
154
  });
114
155
 
115
156
  it('should call set method with the correct parameters', async () => {
@@ -443,7 +484,7 @@ describe('Cache namespacing and tagging', () => {
443
484
  Cache.memory.tags('tag1', 'tag2', 'tag3');
444
485
 
445
486
  // Check if Keyv was instantiated with the correct namespace
446
- expect(keyvSpy).toHaveBeenLastCalledWith(
487
+ expect(keyvSpy).toHaveBeenCalledWith(
447
488
  expect.objectContaining({
448
489
  namespace: 'cache__tag1__tag2__tag3'
449
490
  })