@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.
- package/.turbo/turbo-build.log +8 -0
- package/CHANGELOG.md +34 -0
- package/README.md +19 -16
- package/dist/cache.d.ts +130 -0
- package/dist/database.d.ts +66 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +2 -0
- package/dist/interfaces.d.ts +350 -0
- package/dist/keyv-redis.d.ts +88 -0
- package/dist/mnemosyne.module.d.ts +44 -0
- package/dist/model.d.ts +68 -0
- package/dist/repository.d.ts +96 -0
- package/dist/tenancy.d.ts +179 -0
- package/package.json +8 -9
- package/src/cache.ts +21 -20
- package/src/database.ts +13 -50
- package/src/index.ts +2 -12
- package/src/keyv-redis.ts +39 -26
- package/src/mnemosyne.module.ts +83 -0
- package/src/tenancy.ts +12 -29
- package/tests/cache.test.ts +51 -10
- package/tests/database.test.ts +37 -173
- package/tests/mnemosyne.module.test.ts +214 -0
- package/tests/setup.ts +0 -13
- package/bunfig.toml +0 -19
|
@@ -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.
|
|
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": "
|
|
33
|
+
"@elysiumjs/core": "^0.8.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@elysiumjs/core": "
|
|
36
|
+
"@elysiumjs/core": "0.8.0"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"cache-manager": "^7.2.
|
|
40
|
-
"cacheable": "^2.0
|
|
41
|
-
"keyv": "^5.
|
|
42
|
-
"radash": "
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
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 =
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
136
|
+
await client.set(key, value, 'PX', ttl);
|
|
132
137
|
} else {
|
|
133
|
-
await
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
261
|
+
multi.push(client.send('UNLINK', [prefixedKey]));
|
|
250
262
|
} else {
|
|
251
|
-
multi.push(
|
|
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
|
|
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
|
|
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];
|