@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,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
116
  export const getTenancyConfig = (): 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;
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
  })
@@ -12,16 +12,15 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- import { afterEach, beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test';
16
-
17
- import { Application, Service } from '@elysiumjs/core';
15
+ import { Application, Service, ServiceNotFoundError } from '@elysiumjs/core';
16
+ import { afterEach, describe, expect, it, jest, mock, spyOn } from 'bun:test';
18
17
 
19
18
  import { AbstractDatabase } from '../src/database';
20
19
 
21
20
  const mockDriver = {
22
21
  createConnection: mock((config: any) => ({ mockConnection: true, config })),
23
22
  withTransaction: mock((conn: any, cb: any) => cb(conn)),
24
- getRawClient: mock((conn: any) => ({ rawClient: true })),
23
+ getRawClient: mock((_conn: any) => ({ rawClient: true }))
25
24
  };
26
25
 
27
26
  class TestDatabase extends AbstractDatabase<any, any> {
@@ -29,220 +28,85 @@ class TestDatabase extends AbstractDatabase<any, any> {
29
28
  }
30
29
 
31
30
  describe('AbstractDatabase', () => {
32
- // Mock console.error and process.exit to prevent actual console output and process exit
33
- const originalConsoleError = console.error;
34
- const originalProcessExit = process.exit;
35
-
36
- beforeEach(() => {
37
- console.error = mock();
38
- process.exit = mock() as any;
39
- });
40
-
41
31
  afterEach(() => {
42
- console.error = originalConsoleError;
43
- process.exit = originalProcessExit;
32
+ jest.restoreAllMocks();
44
33
  jest.clearAllMocks();
45
34
  });
46
35
 
47
- describe('registerConnection', () => {
48
- it('should register a new connection', () => {
49
- const db = new TestDatabase();
50
-
51
- // Mock Service.exists to return false (connection doesn't exist)
52
- const existsSpy = spyOn(Service, 'exists').mockReturnValueOnce(false);
53
- const instanceSpy = spyOn(Service, 'instance');
54
-
55
- // Call the function
56
- const result = db.registerConnection('test', { connection: 'sqlite:test.db' });
57
-
58
- // Check if Service.exists was called with the correct connection name
59
- expect(existsSpy).toHaveBeenCalledWith('db.connection.test');
60
-
61
- // Check if the driver's createConnection was called with the config
62
- expect(mockDriver.createConnection).toHaveBeenCalledWith({ connection: 'sqlite:test.db' });
63
-
64
- // Check if Service.instance was called with the correct parameters
65
- expect(instanceSpy).toHaveBeenCalledWith('db.connection.test', {
66
- mockConnection: true,
67
- config: { connection: 'sqlite:test.db' }
68
- });
69
- });
70
-
71
- it('should throw an error if the connection already exists', () => {
72
- const db = new TestDatabase();
73
-
74
- // Mock Service.exists to return true (connection exists)
75
- const existsSpy = spyOn(Service, 'exists').mockReturnValueOnce(true);
76
-
77
- // Call the function
78
- db.registerConnection('test', { connection: 'sqlite:test.db' });
79
-
80
- // Check if console.error and process.exit were called
81
- expect(console.error).toHaveBeenCalledWith(
82
- expect.stringContaining('A connection with the name test has already been registered')
83
- );
84
- expect(process.exit).toHaveBeenCalledWith(1);
85
- });
86
- });
87
-
88
36
  describe('getConnection', () => {
89
37
  it('should retrieve an existing connection', () => {
90
- const db = new TestDatabase();
91
-
92
- // Mock Service.exists to return true (connection exists)
93
- const existsSpy = spyOn(Service, 'exists').mockReturnValueOnce(true);
94
-
95
- // Mock Service.get to return a mock connection
96
38
  const mockConnection = { mockConnection: true };
97
39
  const getSpy = spyOn(Service, 'get').mockReturnValue(mockConnection);
98
40
 
99
- // Call the function
100
- const result = db.getConnection('test');
101
-
102
- // Check if Service.exists was called with the correct connection name
103
- expect(existsSpy).toHaveBeenCalledWith('db.connection.test');
41
+ const result = new TestDatabase().getConnection('test');
104
42
 
105
- // Check if Service.get was called with the correct connection name
106
43
  expect(getSpy).toHaveBeenCalledWith('db.connection.test');
107
-
108
- // Check if the result is correct
109
- expect(result as any).toEqual(mockConnection);
44
+ expect(result).toBe(mockConnection);
110
45
  });
111
46
 
112
- it('should throw an error if the connection does not exist', () => {
113
- const db = new TestDatabase();
47
+ it('should throw ServiceNotFoundError if the connection does not exist', () => {
48
+ spyOn(Service, 'get').mockReturnValue(null);
114
49
 
115
- // Mock Service.exists to return false (connection doesn't exist)
116
- const existsSpy = spyOn(Service, 'exists').mockReturnValueOnce(false);
50
+ expect(() => new TestDatabase().getConnection('test')).toThrow(ServiceNotFoundError);
51
+ });
117
52
 
118
- // Call the function
119
- db.getConnection('test');
53
+ it('should return the transaction handle for the default connection', () => {
54
+ const tx = { tx: true };
55
+ const originalDescriptor = Object.getOwnPropertyDescriptor(Application, 'context');
56
+ Object.defineProperty(Application, 'context', {
57
+ get: () => ({ getStore: () => new Map([['db:tx', tx]]) }),
58
+ configurable: true
59
+ });
120
60
 
121
- // Check if console.error and process.exit were called
122
- expect(console.error).toHaveBeenCalledWith(
123
- expect.stringContaining('No connection with name test found')
124
- );
125
- expect(process.exit).toHaveBeenCalledWith(1);
61
+ try {
62
+ expect(new TestDatabase().getConnection()).toBe(tx);
63
+ } finally {
64
+ if (originalDescriptor) Object.defineProperty(Application, 'context', originalDescriptor);
65
+ }
126
66
  });
127
67
  });
128
68
 
129
69
  describe('connectionExists', () => {
130
- it('should return true if the connection exists', () => {
131
- const db = new TestDatabase();
132
-
133
- // Mock Service.exists to return true
134
- const existsSpy = spyOn(Service, 'exists').mockReturnValueOnce(true);
135
-
136
- // Call the function
137
- const result = db.connectionExists('test');
138
-
139
- // Check if Service.exists was called with the correct connection name
140
- expect(existsSpy).toHaveBeenCalledWith('db.connection.test');
141
-
142
- // Check if the result is correct
143
- expect(result).toBe(true);
144
- });
145
-
146
- it('should return false if the connection does not exist', () => {
70
+ it('should delegate to Service.exists', () => {
71
+ const existsSpy = spyOn(Service, 'exists')
72
+ .mockReturnValueOnce(true)
73
+ .mockReturnValueOnce(false);
147
74
  const db = new TestDatabase();
148
75
 
149
- // Mock Service.exists to return false
150
- const existsSpy = spyOn(Service, 'exists').mockReturnValueOnce(false);
151
-
152
- // Call the function
153
- const result = db.connectionExists('test');
154
-
155
- // Check if Service.exists was called with the correct connection name
76
+ expect(db.connectionExists('test')).toBe(true);
77
+ expect(db.connectionExists('test')).toBe(false);
156
78
  expect(existsSpy).toHaveBeenCalledWith('db.connection.test');
157
-
158
- // Check if the result is correct
159
- expect(result).toBe(false);
160
79
  });
161
80
  });
162
81
 
163
82
  describe('getDefaultConnection', () => {
164
83
  it('should retrieve the default connection', () => {
165
- const db = new TestDatabase();
166
-
167
- // Save original descriptor and mock Application.context
168
- const originalDescriptor = Object.getOwnPropertyDescriptor(Application, 'context');
169
- Object.defineProperty(Application, 'context', {
170
- get: () => ({ getStore: () => null }),
171
- configurable: true,
172
- });
173
-
174
- // Mock Service.exists to return true and Service.get to return a mock connection
175
- const mockConnection = { mockConnection: true };
176
- spyOn(Service, 'exists').mockReturnValueOnce(true);
177
- spyOn(Service, 'get').mockReturnValue(mockConnection);
178
-
179
- // Call the function
180
- const result = db.getDefaultConnection();
181
-
182
- // Check if the result is correct
183
- expect(result as any).toEqual(mockConnection);
184
-
185
- // Restore original descriptor
186
- if (originalDescriptor) {
187
- Object.defineProperty(Application, 'context', originalDescriptor);
188
- }
189
- });
190
- });
191
-
192
- describe('setDefaultConnection', () => {
193
- it('should set the default connection', () => {
194
- const db = new TestDatabase();
195
-
196
- // Save original descriptor and mock Application.context
197
84
  const originalDescriptor = Object.getOwnPropertyDescriptor(Application, 'context');
198
85
  Object.defineProperty(Application, 'context', {
199
86
  get: () => ({ getStore: () => null }),
200
- configurable: true,
87
+ configurable: true
201
88
  });
202
89
 
203
- const instanceSpy = spyOn(Service, 'instance');
204
- const removeSpy = spyOn(Service, 'remove');
205
-
206
- // Mock getConnection to return a mock connection
207
- const mockConnection = { mockConnection: true };
208
- spyOn(Service, 'exists').mockReturnValue(true);
209
- spyOn(Service, 'get').mockReturnValue(mockConnection);
210
-
211
- // Call the function
212
- const result = db.setDefaultConnection('test');
213
-
214
- // Check if Service.remove was called with the correct connection name
215
- expect(removeSpy).toHaveBeenCalledWith('db.connection.default');
216
-
217
- // Check if Service.instance was called with the correct parameters
218
- expect(instanceSpy).toHaveBeenCalledWith('db.connection.default', mockConnection);
90
+ try {
91
+ const mockConnection = { mockConnection: true };
92
+ const getSpy = spyOn(Service, 'get').mockReturnValue(mockConnection);
219
93
 
220
- // Check if the result is correct
221
- expect(result as any).toEqual(mockConnection);
222
-
223
- // Restore original descriptor
224
- if (originalDescriptor) {
225
- Object.defineProperty(Application, 'context', originalDescriptor);
94
+ expect(new TestDatabase().getDefaultConnection()).toBe(mockConnection);
95
+ expect(getSpy).toHaveBeenCalledWith('db.connection.default');
96
+ } finally {
97
+ if (originalDescriptor) Object.defineProperty(Application, 'context', originalDescriptor);
226
98
  }
227
99
  });
228
100
  });
229
101
 
230
102
  describe('getRawClient', () => {
231
103
  it('should return the raw client from the driver', () => {
232
- const db = new TestDatabase();
233
-
234
- // Mock getConnection to return a mock connection
235
104
  const mockConnection = { mockConnection: true };
236
- spyOn(Service, 'exists').mockReturnValue(true);
237
105
  spyOn(Service, 'get').mockReturnValue(mockConnection);
238
106
 
239
- // Call the function
240
- const result = db.getRawClient('test');
107
+ const result = new TestDatabase().getRawClient('test');
241
108
 
242
- // Check if the driver's getRawClient was called with the connection
243
109
  expect(mockDriver.getRawClient).toHaveBeenCalledWith(mockConnection);
244
-
245
- // Check if the result is correct
246
110
  expect(result).toEqual({ rawClient: true });
247
111
  });
248
112
  });