@elysiumjs/mnemosyne-drizzle 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.
package/src/table.ts ADDED
@@ -0,0 +1,70 @@
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 { PgColumnBuilderBase, PgTableWithColumns } from 'drizzle-orm/pg-core';
16
+
17
+ import { pgTable as drizzlePgTable } from 'drizzle-orm/pg-core';
18
+
19
+ // ============================================================================
20
+ // Column Builder Registry
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Maps tables to their original column builder definitions.
25
+ *
26
+ * When `pgTable()` is called, column builders are consumed and turned into
27
+ * built `PgColumn` instances. This registry preserves the original builder
28
+ * objects so that tenant tables can be re-created in different schemas.
29
+ */
30
+ const tableRegistry = new WeakMap<PgTableWithColumns<any>, Record<string, PgColumnBuilderBase>>();
31
+
32
+ /**
33
+ * Retrieves the original column builders for a table.
34
+ * @param table The table created with enhanced pgTable.
35
+ * @returns The original column builder definitions, or `undefined`.
36
+ */
37
+ export const getTableBuilders = (
38
+ table: PgTableWithColumns<any>
39
+ ): Record<string, PgColumnBuilderBase> | undefined => {
40
+ return tableRegistry.get(table);
41
+ };
42
+
43
+ // ============================================================================
44
+ // Enhanced pgTable Function
45
+ // ============================================================================
46
+
47
+ /**
48
+ * Creates a PostgreSQL table with automatic column builder registration.
49
+ *
50
+ * This is an enhanced version of Drizzle's `pgTable` that automatically
51
+ * registers column builders for tenancy support. It has the exact same
52
+ * signature and behavior as the original.
53
+ *
54
+ * @param tableName The name of the database table.
55
+ * @param columns The column definitions.
56
+ * @param extraConfig Optional extra Drizzle table configuration.
57
+ * @returns A Drizzle PostgreSQL table with registered column builders.
58
+ */
59
+ export const pgTable = <
60
+ TTableName extends string,
61
+ TColumns extends Record<string, PgColumnBuilderBase>
62
+ >(
63
+ tableName: TTableName,
64
+ columns: TColumns,
65
+ extraConfig?: (table: any) => any
66
+ ) => {
67
+ const table = drizzlePgTable(tableName, columns, extraConfig);
68
+ tableRegistry.set(table, columns);
69
+ return table;
70
+ };
package/src/tenancy.ts ADDED
@@ -0,0 +1,249 @@
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 { TenancyStrategy } from '@elysiumjs/mnemosyne';
16
+ import type { PgColumnBuilderBase, PgSchema, PgTable, PgTransaction } from 'drizzle-orm/pg-core';
17
+ import type { DrizzleConnection } from './database';
18
+
19
+ import { getTenancyConfig } from '@elysiumjs/mnemosyne';
20
+ import { sql } from 'drizzle-orm';
21
+ import { getTableConfig, pgPolicy, pgSchema, pgTable } from 'drizzle-orm/pg-core';
22
+
23
+ import { Database } from './database';
24
+ import { getTableBuilders } from './table';
25
+
26
+ // ============================================================================
27
+ // Internal State
28
+ // ============================================================================
29
+
30
+ const tenantSchemas: Map<string, PgTable> = new Map();
31
+ const schemaRegistry: Map<string, PgSchema> = new Map();
32
+
33
+ // ============================================================================
34
+ // Schema-based Tenancy Helpers
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Returns the Drizzle `PgSchema` for the given tenant, creating it if needed.
39
+ * @author Axel Nana <axel.nana@workbud.com>
40
+ * @param tenant The tenant identifier.
41
+ * @returns The tenant's PostgreSQL schema.
42
+ */
43
+ export const getTenantSchema = (tenant: string): PgSchema => {
44
+ return schemaRegistry.get(tenant) ?? registerTenantSchema(tenant);
45
+ };
46
+
47
+ /**
48
+ * Registers a new tenant schema in the registry.
49
+ * @author Axel Nana <axel.nana@workbud.com>
50
+ * @param tenant The tenant identifier.
51
+ * @returns The newly registered (or existing) `PgSchema`.
52
+ */
53
+ export const registerTenantSchema = (tenant: string): PgSchema => {
54
+ if (schemaRegistry.has(tenant)) {
55
+ return schemaRegistry.get(tenant)!;
56
+ }
57
+ const tenantSchema = pgSchema(tenant);
58
+ schemaRegistry.set(tenant, tenantSchema);
59
+ return tenantSchema;
60
+ };
61
+
62
+ /**
63
+ * Wraps a table definition in a tenant-specific schema.
64
+ *
65
+ * For the `'public'` tenant, returns a standard `pgTable`.
66
+ * For other tenants, creates a schema-qualified table and caches it.
67
+ *
68
+ * @author Axel Nana <axel.nana@workbud.com>
69
+ * @param tenant The tenant identifier.
70
+ * @param tableName The table name.
71
+ * @param columns The column definitions.
72
+ * @returns A tenant-scoped `PgTable`.
73
+ */
74
+ export const wrapTenantSchema = <
75
+ TTableName extends string,
76
+ TColumnsMap extends Record<string, PgColumnBuilderBase>
77
+ >(
78
+ tenant: string,
79
+ tableName: TTableName,
80
+ columns: TColumnsMap
81
+ ): PgTable => {
82
+ if (tenant === 'public') {
83
+ return pgTable(tableName, columns);
84
+ }
85
+
86
+ const fullTableName = `${tenant}.${tableName}`;
87
+ if (tenantSchemas.has(fullTableName)) {
88
+ return tenantSchemas.get(fullTableName)!;
89
+ }
90
+
91
+ const tenantSchema = getTenantSchema(tenant);
92
+ const schemaTable = tenantSchema.table(tableName, columns);
93
+ tenantSchemas.set(fullTableName, schemaTable);
94
+
95
+ return schemaTable;
96
+ };
97
+
98
+ // ============================================================================
99
+ // RLS-based Tenancy Helpers
100
+ // ============================================================================
101
+
102
+ /**
103
+ * Executes a callback within an RLS-scoped transaction for the given tenant.
104
+ * @author Axel Nana <axel.nana@workbud.com>
105
+ * @param tenant The tenant identifier (or `null` for 'public').
106
+ * @param callback The function to execute within the transaction.
107
+ * @param connectionName The database connection name.
108
+ * @returns The return value of the callback.
109
+ */
110
+ export const withRLS = async <T>(
111
+ tenant: string | null,
112
+ callback: (tx: PgTransaction<any, any, any>) => Promise<T>,
113
+ connectionName: string = 'default'
114
+ ): Promise<T> => {
115
+ const db = Database.getConnection(connectionName);
116
+ const config = getTenancyConfig();
117
+ const varName = config.rls?.sessionVariable ?? 'app.current_tenant';
118
+ const value = tenant ?? 'public';
119
+
120
+ return (db as any).transaction(async (tx: PgTransaction<any, any, any>) => {
121
+ await tx.execute(sql`SELECT set_config(${varName}, ${value}, true)`);
122
+ return callback(tx);
123
+ });
124
+ };
125
+
126
+ /**
127
+ * Sets the current tenant on a persistent database connection (session-level).
128
+ * @author Axel Nana <axel.nana@workbud.com>
129
+ * @param tenant The tenant identifier (or `null` for 'public').
130
+ * @param connectionName The database connection name.
131
+ */
132
+ export const setConnectionTenant = async (
133
+ tenant: string | null,
134
+ connectionName: string = 'default'
135
+ ): Promise<void> => {
136
+ const db = Database.getConnection(connectionName);
137
+ const config = getTenancyConfig();
138
+ const varName = config.rls?.sessionVariable ?? 'app.current_tenant';
139
+ const value = tenant ?? 'public';
140
+
141
+ await (db as any).execute(sql`SELECT set_config(${varName}, ${value}, false)`);
142
+ };
143
+
144
+ /**
145
+ * Reads the current tenant from the PostgreSQL session variable.
146
+ * @author Axel Nana <axel.nana@workbud.com>
147
+ * @param connectionName The database connection name.
148
+ * @returns The current tenant identifier, or `null`.
149
+ */
150
+ export const getSessionTenant = async (
151
+ connectionName: string = 'default'
152
+ ): Promise<string | null> => {
153
+ const db = Database.getConnection(connectionName);
154
+ const config = getTenancyConfig();
155
+ const varName = config.rls?.sessionVariable ?? 'app.current_tenant';
156
+
157
+ const result = await (db as any).execute(sql`SELECT current_setting(${varName}, true) as tenant`);
158
+ return (result as any)?.rows?.[0]?.tenant ?? null;
159
+ };
160
+
161
+ /**
162
+ * Creates an RLS policy for tenant isolation on a table.
163
+ * @author Axel Nana <axel.nana@workbud.com>
164
+ * @param tableName The table name.
165
+ * @param columnName The tenant column name (defaults to config or 'tenant_id').
166
+ * @param policyName The policy name (defaults to `{tableName}_tenant_isolation`).
167
+ * @returns A Drizzle `PgPolicy` instance.
168
+ */
169
+ export const createRLSPolicy = (tableName: string, columnName?: string, policyName?: string) => {
170
+ const config = getTenancyConfig();
171
+ const varName = config.rls?.sessionVariable ?? 'app.current_tenant';
172
+ const column = columnName ?? config.rls?.defaultColumn ?? 'tenant_id';
173
+ const name = policyName ?? `${tableName}_tenant_isolation`;
174
+
175
+ return pgPolicy(name, {
176
+ for: 'all',
177
+ to: 'public',
178
+ using: sql`${sql.identifier(column)} = current_setting(${varName}, true)::text`,
179
+ withCheck: sql`${sql.identifier(column)} = current_setting(${varName}, true)::text`
180
+ });
181
+ };
182
+
183
+ // ============================================================================
184
+ // TenancyStrategy Implementations
185
+ // ============================================================================
186
+
187
+ /**
188
+ * Schema-based tenancy strategy for Drizzle.
189
+ *
190
+ * Each tenant gets its own PostgreSQL schema. Table resolution maps
191
+ * the base table to a schema-qualified equivalent.
192
+ *
193
+ * @author Axel Nana <axel.nana@workbud.com>
194
+ */
195
+ export class DrizzleSchemaTenancy implements TenancyStrategy<PgTable, DrizzleConnection> {
196
+ readonly mode = 'schema';
197
+
198
+ resolveTable(table: PgTable, tenant: string): PgTable {
199
+ const builders = getTableBuilders(table as any);
200
+ if (!builders) {
201
+ throw new Error(
202
+ `No column builders registered for table. ` +
203
+ `Use pgTable() from '@elysiumjs/mnemosyne-drizzle' for tenant support.`
204
+ );
205
+ }
206
+ const config = getTableConfig(table as any);
207
+ return wrapTenantSchema(tenant, config.name, builders) as PgTable;
208
+ }
209
+
210
+ async withIsolation<T>(
211
+ _connection: DrizzleConnection,
212
+ _tenant: string,
213
+ callback: () => Promise<T>
214
+ ): Promise<T> {
215
+ // Schema-based isolation doesn't need transaction wrapping
216
+ return callback();
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Row-Level Security tenancy strategy for Drizzle.
222
+ *
223
+ * All tenants share the same schema. Isolation is achieved via
224
+ * PostgreSQL RLS policies and a session variable set in each transaction.
225
+ *
226
+ * @author Axel Nana <axel.nana@workbud.com>
227
+ */
228
+ export class DrizzleRLSTenancy implements TenancyStrategy<PgTable, DrizzleConnection> {
229
+ readonly mode = 'rls';
230
+
231
+ resolveTable(table: PgTable, _tenant: string): PgTable {
232
+ // RLS doesn't modify the table - filtering is handled by policies
233
+ return table;
234
+ }
235
+
236
+ async withIsolation<T>(
237
+ connection: DrizzleConnection,
238
+ tenant: string,
239
+ callback: () => Promise<T>
240
+ ): Promise<T> {
241
+ const config = getTenancyConfig();
242
+ const varName = config.rls?.sessionVariable ?? 'app.current_tenant';
243
+
244
+ return (connection as any).transaction(async (tx: PgTransaction<any, any, any>) => {
245
+ await tx.execute(sql`SELECT set_config(${varName}, ${tenant}, true)`);
246
+ return callback();
247
+ });
248
+ }
249
+ }
@@ -0,0 +1,198 @@
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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test';
16
+
17
+ import { Application, Service } from '@elysiumjs/core';
18
+
19
+ import { Database } from '../src/database';
20
+
21
+ // Mock drizzle function
22
+ mock.module('drizzle-orm/bun-sql', () => ({
23
+ drizzle: mock(() => ({ mockDrizzleInstance: true }))
24
+ }));
25
+
26
+ describe('DrizzleDatabase', () => {
27
+ // Mock console.error and process.exit to prevent actual console output and process exit
28
+ const originalConsoleError = console.error;
29
+ const originalProcessExit = process.exit;
30
+
31
+ beforeAll(() => {
32
+ // Mock Application.context so that getConnection('default') works
33
+ const mockContext = {
34
+ getStore: mock(() => new Map())
35
+ };
36
+ Object.defineProperty(Application, 'context', {
37
+ get: () => mockContext,
38
+ configurable: true
39
+ });
40
+ });
41
+
42
+ beforeEach(() => {
43
+ console.error = mock();
44
+ process.exit = mock() as any;
45
+ });
46
+
47
+ afterEach(() => {
48
+ console.error = originalConsoleError;
49
+ process.exit = originalProcessExit;
50
+ jest.clearAllMocks();
51
+ });
52
+
53
+ afterAll(() => {
54
+ mock.restore();
55
+ });
56
+
57
+ describe('registerConnection', () => {
58
+ it('should register a new connection', () => {
59
+ // Mock Service.exists to return false (connection doesn't exist)
60
+ const existsSpy = spyOn(Service, 'exists').mockReturnValueOnce(false);
61
+ const instanceSpy = spyOn(Service, 'instance');
62
+
63
+ // Call the function
64
+ const result = Database.registerConnection('test', { connection: 'sqlite:test.db' });
65
+
66
+ // Check if Service.exists was called with the correct connection name
67
+ expect(existsSpy).toHaveBeenCalledWith('db.connection.test');
68
+
69
+ // Check if Service.instance was called with the correct parameters
70
+ expect(instanceSpy).toHaveBeenCalledWith('db.connection.test', {
71
+ mockDrizzleInstance: true
72
+ });
73
+
74
+ // Check if the result is correct
75
+ expect(result as any).toEqual({ mockDrizzleInstance: true });
76
+ });
77
+
78
+ it('should exit if the connection already exists', () => {
79
+ // Mock Service.exists to return true (connection exists)
80
+ spyOn(Service, 'exists').mockReturnValueOnce(true);
81
+
82
+ // Call the function
83
+ Database.registerConnection('test', { connection: 'sqlite:test.db' });
84
+
85
+ // Check if console.error and process.exit were called
86
+ expect(console.error).toHaveBeenCalledWith(
87
+ expect.stringContaining('A connection with the name test has already been registered')
88
+ );
89
+ expect(process.exit).toHaveBeenCalledWith(1);
90
+ });
91
+ });
92
+
93
+ describe('getConnection', () => {
94
+ it('should retrieve an existing connection', () => {
95
+ // Mock Service.exists to return true (connection exists)
96
+ const existsSpy = spyOn(Service, 'exists').mockReturnValueOnce(true);
97
+
98
+ // Mock Service.get to return a mock connection
99
+ const mockConnection = { mockConnection: true };
100
+ const getSpy = spyOn(Service, 'get').mockReturnValue(mockConnection);
101
+
102
+ // Call the function
103
+ const result = Database.getConnection('test');
104
+
105
+ // Check if Service.exists was called with the correct connection name
106
+ expect(existsSpy).toHaveBeenCalledWith('db.connection.test');
107
+
108
+ // Check if Service.get was called with the correct connection name
109
+ expect(getSpy).toHaveBeenCalledWith('db.connection.test');
110
+
111
+ // Check if the result is correct
112
+ expect(result as any).toEqual(mockConnection);
113
+ });
114
+
115
+ it('should exit if the connection does not exist', () => {
116
+ // Mock Service.exists to return false (connection doesn't exist)
117
+ spyOn(Service, 'exists').mockReturnValueOnce(false);
118
+
119
+ // Call the function
120
+ Database.getConnection('test');
121
+
122
+ // Check if console.error and process.exit were called
123
+ expect(console.error).toHaveBeenCalledWith(
124
+ expect.stringContaining('No connection with name test found')
125
+ );
126
+ expect(process.exit).toHaveBeenCalledWith(1);
127
+ });
128
+ });
129
+
130
+ describe('connectionExists', () => {
131
+ it('should return true if the connection exists', () => {
132
+ // Mock Service.exists to return true
133
+ const existsSpy = spyOn(Service, 'exists').mockReturnValueOnce(true);
134
+
135
+ // Call the function
136
+ const result = Database.connectionExists('test');
137
+
138
+ // Check if Service.exists was called with the correct connection name
139
+ expect(existsSpy).toHaveBeenCalledWith('db.connection.test');
140
+
141
+ // Check if the result is correct
142
+ expect(result).toBe(true);
143
+ });
144
+
145
+ it('should return false if the connection does not exist', () => {
146
+ // Mock Service.exists to return false
147
+ const existsSpy = spyOn(Service, 'exists').mockReturnValueOnce(false);
148
+
149
+ // Call the function
150
+ const result = Database.connectionExists('test');
151
+
152
+ // Check if Service.exists was called with the correct connection name
153
+ expect(existsSpy).toHaveBeenCalledWith('db.connection.test');
154
+
155
+ // Check if the result is correct
156
+ expect(result).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe('getDefaultConnection', () => {
161
+ it('should retrieve the default connection', () => {
162
+ // Mock Service to return the mock connection
163
+ const mockConnection = { mockConnection: true };
164
+ spyOn(Service, 'exists').mockReturnValueOnce(true);
165
+ spyOn(Service, 'get').mockReturnValue(mockConnection);
166
+
167
+ // Call the function
168
+ const result = Database.getDefaultConnection();
169
+
170
+ // Check if the result is correct
171
+ expect(result as any).toEqual(mockConnection);
172
+ });
173
+ });
174
+
175
+ describe('setDefaultConnection', () => {
176
+ it('should set the default connection', () => {
177
+ const instanceSpy = spyOn(Service, 'instance');
178
+ const removeSpy = spyOn(Service, 'remove');
179
+ spyOn(Service, 'exists').mockReturnValueOnce(true);
180
+
181
+ // Mock Service.get to return a mock connection
182
+ const mockConnection = { mockConnection: true };
183
+ spyOn(Service, 'get').mockReturnValue(mockConnection);
184
+
185
+ // Call the function
186
+ const result = Database.setDefaultConnection('test');
187
+
188
+ // Check if Service.remove was called with the correct connection name
189
+ expect(removeSpy).toHaveBeenCalledWith('db.connection.default');
190
+
191
+ // Check if Service.instance was called with the correct parameters
192
+ expect(instanceSpy).toHaveBeenCalledWith('db.connection.default', mockConnection);
193
+
194
+ // Check if the result is correct
195
+ expect(result as any).toEqual(mockConnection);
196
+ });
197
+ });
198
+ });