@elysiumjs/mnemosyne 0.1.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,180 @@
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
+ /**
69
+ * Gets the current global tenancy configuration.
70
+ *
71
+ * Reads from the 'elysium:mnemosyne' app config key. Returns a default
72
+ * configuration if the app is not yet initialized or no tenancy config is set.
73
+ *
74
+ * @author Axel Nana <axel.nana@workbud.com>
75
+ * @returns The current tenancy configuration.
76
+ */
77
+ export declare const getConfig: () => TenancyConfig;
78
+ /**
79
+ * Registers the tenancy strategy to use for table resolution and isolation.
80
+ * @author Axel Nana <axel.nana@workbud.com>
81
+ * @param strategy The tenancy strategy to register.
82
+ */
83
+ export declare const registerTenancyStrategy: (strategy: TenancyStrategy<unknown, unknown>) => void;
84
+ /**
85
+ * Gets the currently registered tenancy strategy.
86
+ * @author Axel Nana <axel.nana@workbud.com>
87
+ * @returns The current tenancy strategy, or `null` if none is registered.
88
+ */
89
+ export declare const getTenancyStrategy: () => TenancyStrategy<unknown, unknown> | null;
90
+ /**
91
+ * Gets the current tenant from the async context.
92
+ * @author Axel Nana <axel.nana@workbud.com>
93
+ * @returns The current tenant identifier, or `null` if not set.
94
+ */
95
+ export declare const getCurrentTenant: () => string | null;
96
+ /**
97
+ * Runs a callback within the context of a specific tenant.
98
+ * @author Axel Nana <axel.nana@workbud.com>
99
+ * @param tenant The tenant identifier to set.
100
+ * @param callback The function to execute within the tenant context.
101
+ * @returns The return value of the callback.
102
+ */
103
+ export declare const withTenant: <T>(tenant: string, callback: () => T) => T;
104
+ /**
105
+ * Base class for tenant-aware middleware.
106
+ *
107
+ * The middleware extracts the tenant identifier from an HTTP header and
108
+ * sets `ctx.tenant`. For RLS mode, route handlers should use the driver's
109
+ * isolation mechanism to wrap their queries in a tenant-scoped transaction.
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * class OrganizationMiddleware extends TenantMiddleware {
114
+ * protected header = 'x-organization-id';
115
+ * protected allowPublic = false;
116
+ *
117
+ * protected async validateTenant(orgId: string): Promise<boolean> {
118
+ * return Organization.exists(orgId);
119
+ * }
120
+ * }
121
+ * ```
122
+ *
123
+ * @author Axel Nana <axel.nana@workbud.com>
124
+ */
125
+ export declare abstract class TenantMiddleware extends Middleware {
126
+ /**
127
+ * The HTTP header name from which the tenant ID is read.
128
+ */
129
+ protected header: string;
130
+ /**
131
+ * Whether requests without a tenant ID are allowed (defaulting to 'public').
132
+ */
133
+ protected allowPublic: boolean;
134
+ /**
135
+ * The database connection name used for tenant operations.
136
+ */
137
+ protected connectionName: string;
138
+ /**
139
+ * Validates that a tenant identifier is legitimate.
140
+ * Override this to check against your data store.
141
+ * @author Axel Nana <axel.nana@workbud.com>
142
+ * @param _tenant The tenant identifier to validate.
143
+ * @returns `true` if the tenant is valid, `false` otherwise.
144
+ */
145
+ protected validateTenant(_tenant: string): Promise<boolean>;
146
+ /**
147
+ * Hook called after a tenant has been successfully resolved.
148
+ * @author Axel Nana <axel.nana@workbud.com>
149
+ * @param _tenant The resolved tenant identifier.
150
+ * @param _ctx The request context.
151
+ */
152
+ protected onTenantResolved(_tenant: string, _ctx: Context): Promise<void>;
153
+ /**
154
+ * Called when the tenant cannot be determined or is invalid.
155
+ * Throws an appropriate error by default.
156
+ * @author Axel Nana <axel.nana@workbud.com>
157
+ * @param reason Whether the tenant is 'missing' or 'invalid'.
158
+ * @param _ctx The request context.
159
+ */
160
+ protected onTenantError(reason: 'missing' | 'invalid', _ctx: Context): never;
161
+ /**
162
+ * Extracts and validates the tenant from the incoming request.
163
+ * @author Axel Nana <axel.nana@workbud.com>
164
+ * @param ctx The request context.
165
+ */
166
+ onRequest(ctx: Context): Promise<any>;
167
+ }
168
+ /**
169
+ * A simple tenant middleware that allows public access by default.
170
+ * @author Axel Nana <axel.nana@workbud.com>
171
+ */
172
+ export declare class SimpleTenantMiddleware extends TenantMiddleware {
173
+ }
174
+ /**
175
+ * A strict tenant middleware that rejects requests without a tenant ID.
176
+ * @author Axel Nana <axel.nana@workbud.com>
177
+ */
178
+ export declare class StrictTenantMiddleware extends TenantMiddleware {
179
+ protected allowPublic: boolean;
180
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@elysiumjs/mnemosyne",
3
+ "version": "0.1.0",
4
+ "license": "Apache-2.0",
5
+ "description": "Database and caching for Elysium.js applications",
6
+ "private": false,
7
+ "author": "Workbud Technologies Inc.",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/workbud/elysium"
11
+ },
12
+ "homepage": "https://github.com/workbud/elysium",
13
+ "scripts": {
14
+ "format": "prettier --write \"./src/**/*.ts\"",
15
+ "clean": "rimraf dist",
16
+ "build:js": "bun build ./src/index.ts --outdir ./dist --target bun --packages external --root ./src --minify",
17
+ "build:ts": "tsc",
18
+ "build": "bun run clean && bun run build:js && bun run build:ts",
19
+ "typecheck": "tsc --noEmit",
20
+ "prepublishOnly": "bun run build"
21
+ },
22
+ "keywords": [
23
+ "elysium",
24
+ "database",
25
+ "cache",
26
+ "orm"
27
+ ],
28
+ "module": "dist/index.js",
29
+ "type": "module",
30
+ "types": "dist/index.d.ts",
31
+ "sideEffects": false,
32
+ "peerDependencies": {
33
+ "@elysiumjs/core": "^0.7.0"
34
+ },
35
+ "devDependencies": {
36
+ "@elysiumjs/core": "0.7.0"
37
+ },
38
+ "dependencies": {
39
+ "cache-manager": "^7.2.3",
40
+ "cacheable": "^2.0.3",
41
+ "keyv": "^5.0.0",
42
+ "radash": "^12.1.1",
43
+ "reflect-metadata": "^0.2.2"
44
+ }
45
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,218 @@
1
+ // Copyright (c) 2025-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 { KeyvStoreAdapter } from 'keyv';
16
+
17
+ import { createCache } from 'cache-manager';
18
+ import { KeyvCacheableMemory } from 'cacheable';
19
+ import { Keyv } from 'keyv';
20
+ import { alphabetical } from 'radash';
21
+
22
+ import { KeyvRedis } from './keyv-redis';
23
+
24
+ /**
25
+ * Interface for a cache storage.
26
+ * @author Axel Nana <axel.nana@workbud.com>
27
+ */
28
+ export interface CacheInterface {
29
+ /**
30
+ * Retrieves the cached data for a given key.
31
+ * @typeParam T The type of the cached data.
32
+ *
33
+ * @param key The key of the cached data.
34
+ */
35
+ get<T>(key: string): Promise<T | undefined>;
36
+
37
+ /**
38
+ * Retrieves the cached data for a given list of keys.
39
+ * @typeParam T The type of the cached data.
40
+ *
41
+ * @param keys The list of cached data keys.
42
+ */
43
+ mget<T>(keys: string[]): Promise<Array<T | undefined>>;
44
+
45
+ /**
46
+ * Gets the TTL of the cached data with the given key.
47
+ * @param key The key of the cached data.
48
+ * @returns The TTL of the cached data, or `undefined` if the data does not exist.
49
+ */
50
+ ttl(key: string): Promise<number | undefined>;
51
+
52
+ /**
53
+ * Caches data under the given key and an optional TTL.
54
+ * @typeParam T The type of the data to cache.
55
+ *
56
+ * @param key The key of the cached data.
57
+ * @param data The data to cache.
58
+ * @param ttl The optional TTL of the cached data. If not defined, the data is cached forever.
59
+ * @returns The cached data.
60
+ */
61
+ set<T>(key: string, data: T, ttl?: number): Promise<T>;
62
+
63
+ /**
64
+ * Caches data under the given list of keys and an optional TTL.
65
+ * @typeParam T The type of the data to cache.
66
+ *
67
+ * @param data The list of data to cache.
68
+ * @returns The list of cached data.
69
+ */
70
+ mset<T>(
71
+ data: Array<{
72
+ key: string;
73
+ value: T;
74
+ ttl?: number;
75
+ }>
76
+ ): Promise<
77
+ Array<{
78
+ key: string;
79
+ value: T;
80
+ ttl?: number;
81
+ }>
82
+ >;
83
+
84
+ /**
85
+ * Deletes the cached data with the given key.
86
+ * @typeParam T The type of the data to cache.
87
+ *
88
+ * @param key The key of the cached data.
89
+ * @returns `true` if the data was deleted, `false` otherwise.
90
+ */
91
+ del(key: string): Promise<boolean>;
92
+
93
+ /**
94
+ * Deletes the cached data with the given list of keys.
95
+ * @typeParam T The type of the data to cache.
96
+ *
97
+ * @param keys The list of cached data keys.
98
+ * @returns `true` if the data was deleted, `false` otherwise.
99
+ */
100
+ mdel(keys: string[]): Promise<boolean>;
101
+
102
+ /**
103
+ * Deletes all cached data.
104
+ * @returns `true` if the data was deleted, `false` otherwise.
105
+ */
106
+ clear(): Promise<boolean>;
107
+
108
+ /**
109
+ * Creates a sub-cache with the given tags.
110
+ * @param tags The list of tags for the sub-cache.
111
+ */
112
+ tags(...tags: string[]): Omit<CacheInterface, 'tags'>;
113
+ }
114
+
115
+ /**
116
+ * Makes a cache interface wrapping the given store.
117
+ * @author Axel Nana <axel.nana@workburd.com>
118
+ * @param store The cache storage to use in the interface.
119
+ * @returns A new `CacheInterface`.
120
+ */
121
+ const makeCacheInterface = (store: KeyvStoreAdapter): CacheInterface => {
122
+ const base = createCache({
123
+ stores: [
124
+ new Keyv({
125
+ store,
126
+ namespace: 'cache'
127
+ })
128
+ ]
129
+ }) as Omit<CacheInterface, 'tags'>;
130
+
131
+ const tagsCache = new Map<string, Omit<CacheInterface, 'tags'>>();
132
+
133
+ return {
134
+ ...base,
135
+ tags(...tags) {
136
+ const namespace = `cache__${alphabetical(tags, (item) => item).join('__')}`;
137
+ let cache = tagsCache.get(namespace);
138
+
139
+ if (!cache) {
140
+ cache = createCache({
141
+ stores: [
142
+ new Keyv({
143
+ store,
144
+ namespace
145
+ })
146
+ ]
147
+ }) as Omit<CacheInterface, 'tags'>;
148
+
149
+ tagsCache.set(namespace, cache);
150
+ }
151
+
152
+ return cache;
153
+ }
154
+ };
155
+ };
156
+
157
+ export abstract class Cache {
158
+ static #redis: CacheInterface | undefined;
159
+ static #memory: CacheInterface | undefined;
160
+
161
+ /**
162
+ * A weak cache storage for simple non-intrusive caching of data.
163
+ * @author Axel Nana <axel.nana@workbud.com>
164
+ */
165
+ public static readonly weak = Object.seal({
166
+ /**
167
+ * INTERNAL USAGE ONLY.
168
+ * @private
169
+ */
170
+ map: new WeakMap<object, unknown>(),
171
+
172
+ /**
173
+ * Gets the cached data for a given context.
174
+ * @param ctx The cache context.
175
+ * @returns The cached data.
176
+ */
177
+ get<T extends object>(ctx: object): T {
178
+ if (!this.map.has(ctx)) this.map.set(ctx, {});
179
+ return this.map.get(ctx) as T;
180
+ },
181
+
182
+ /**
183
+ * Deletes the cached data for a given context.
184
+ * @param ctx The cache context.
185
+ */
186
+ del(ctx: object): void {
187
+ this.map.delete(ctx);
188
+ }
189
+ });
190
+
191
+ /**
192
+ * Redis-based cache storage.
193
+ * @author Axel Nana <axel.nana@workbud.com>
194
+ */
195
+ 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;
205
+ }
206
+
207
+ /**
208
+ * Memory-based cache storage.
209
+ * @author Axel Nana <axel.nana@workbud.com>
210
+ */
211
+ 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
+ }
218
+ }
@@ -0,0 +1,151 @@
1
+ // Copyright (c) 2025-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 { CacheInterface } from './cache';
16
+ import type { DatabaseCacheConfig, DatabaseDriver } from './interfaces';
17
+
18
+ import { Application, Service } from '@elysiumjs/core';
19
+
20
+ import { Cache } from './cache';
21
+
22
+ /**
23
+ * Creates a cache storage instance tagged for database use.
24
+ * Drivers can use this to build their cache backends.
25
+ * @author Axel Nana <axel.nana@workbud.com>
26
+ * @param config The database cache configuration.
27
+ * @returns A cache interface tagged with 'database'.
28
+ */
29
+ export const createDatabaseCacheStorage = (
30
+ config: DatabaseCacheConfig
31
+ ): Omit<CacheInterface, 'tags'> => {
32
+ return Cache[config.storage ?? 'redis'].tags('database');
33
+ };
34
+
35
+ /**
36
+ * Abstract database manager.
37
+ *
38
+ * Manages named database connections using a driver. Connections are
39
+ * registered in the service container and can be retrieved by name.
40
+ *
41
+ * @author Axel Nana <axel.nana@workbud.com>
42
+ * @template TConnection The connection type managed by the driver.
43
+ * @template TConnectionConfig The configuration used to create a connection.
44
+ */
45
+ export abstract class AbstractDatabase<TConnection, TConnectionConfig> {
46
+ /**
47
+ * The database driver used to create and manage connections.
48
+ */
49
+ protected abstract driver: DatabaseDriver<TConnection, TConnectionConfig>;
50
+
51
+ /**
52
+ * Creates a service name for a database connection.
53
+ * @param name The name of the connection.
54
+ * @returns A service name for the connection.
55
+ */
56
+ private getConnectionName(name: string): string {
57
+ return `db.connection.${name}`;
58
+ }
59
+
60
+ /**
61
+ * Retrieves the connection with the given name.
62
+ *
63
+ * For the default connection, checks the async context for a
64
+ * transaction handle (`db:tx`) before falling back to the service container.
65
+ *
66
+ * @author Axel Nana <axel.nana@workbud.com>
67
+ * @param name The connection name.
68
+ * @returns The connection with the given name.
69
+ */
70
+ public getConnection(name: string = 'default'): TConnection {
71
+ if (name === 'default') {
72
+ const tx = Application.context.getStore()?.get('db:tx') as TConnection | undefined;
73
+ if (tx) return tx;
74
+ }
75
+
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
+ }
106
+
107
+ const connection = this.driver.createConnection(config);
108
+ return Service.instance(serviceName, connection);
109
+ }
110
+
111
+ /**
112
+ * Checks if a connection with the given name exists.
113
+ * @author Axel Nana <axel.nana@workbud.com>
114
+ * @param name The name of the connection to check.
115
+ * @returns `true` if the connection exists, `false` otherwise.
116
+ */
117
+ public connectionExists(name: string): boolean {
118
+ return Service.exists(this.getConnectionName(name));
119
+ }
120
+
121
+ /**
122
+ * Retrieves the default connection.
123
+ * @author Axel Nana <axel.nana@workbud.com>
124
+ * @returns The default connection.
125
+ */
126
+ public getDefaultConnection(): TConnection {
127
+ return this.getConnection('default');
128
+ }
129
+
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
+ /**
143
+ * Returns the raw underlying client for the given connection.
144
+ * @author Axel Nana <axel.nana@workbud.com>
145
+ * @param name The connection name.
146
+ * @returns The raw client instance.
147
+ */
148
+ public getRawClient(name: string = 'default'): unknown {
149
+ return this.driver.getRawClient(this.getConnection(name));
150
+ }
151
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
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 { TenancyConfig } from './tenancy';
16
+
17
+ export type {
18
+ ColumnMetadata,
19
+ DatabaseCacheConfig,
20
+ DatabaseCacheStrategy,
21
+ DatabaseDriver,
22
+ IdType,
23
+ ModelAdapter,
24
+ ModelClass,
25
+ RepositoryClass,
26
+ RepositoryInterface,
27
+ TenancyStrategy
28
+ } from './interfaces';
29
+
30
+ export { AbstractDatabase, createDatabaseCacheStorage } from './database';
31
+ export { createSchemaFromModel, AbstractModel } from './model';
32
+ export { AbstractRepository } from './repository';
33
+
34
+ export {
35
+ getConfig as getTenancyConfig,
36
+ getCurrentTenant,
37
+ withTenant,
38
+ registerTenancyStrategy,
39
+ getTenancyStrategy,
40
+ TenantMiddleware,
41
+ SimpleTenantMiddleware,
42
+ StrictTenantMiddleware
43
+ } from './tenancy';
44
+ export type { TenancyConfig, TenancyMode, ModelTenancyConfig } from './tenancy';
45
+
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
+ }