@elysiumjs/mnemosyne-drizzle 0.1.3 → 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/.omc/state/agent-replay-3920b579-6d8e-4f5f-924a-319202936685.jsonl +7 -0
- package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/mission-state.json +93 -0
- package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/subagent-tracking-state.json +61 -0
- package/.turbo/turbo-build.log +3 -0
- package/CHANGELOG.md +40 -0
- package/README.md +51 -0
- package/dist/commands/migration_generate.command.d.ts +11 -0
- package/dist/commands/migration_generate.command.js +1 -0
- package/dist/commands/migration_run.command.d.ts +13 -0
- package/dist/commands/migration_run.command.js +15 -0
- package/dist/database.d.ts +79 -0
- package/dist/database.js +1 -0
- package/dist/drizzle.module.d.ts +40 -0
- package/dist/drizzle.module.js +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +1 -0
- package/dist/migrations.d.ts +33 -0
- package/dist/migrations.js +1 -0
- package/dist/model.d.ts +61 -0
- package/dist/model.js +1 -0
- package/dist/repository.d.ts +35 -0
- package/dist/repository.js +1 -0
- package/dist/table.d.ts +69 -0
- package/dist/table.js +1 -0
- package/dist/tenancy.d.ts +88 -0
- package/dist/tenancy.js +1 -0
- package/package.json +11 -9
- package/scripts/minify.ts +10 -0
- package/src/commands/migration_generate.command.ts +101 -0
- package/src/commands/migration_run.command.ts +160 -0
- package/src/database.ts +37 -7
- package/src/drizzle.module.ts +97 -0
- package/src/index.ts +8 -30
- package/src/migrations.ts +100 -0
- package/src/model.ts +18 -11
- package/src/table.ts +4 -4
- package/src/tenancy.ts +5 -5
- package/tests/drizzle.module.test.ts +201 -0
- package/tests/migrations.test.ts +123 -0
- package/tests/setup.ts +0 -10
- package/tsconfig.json +2 -2
- package/bunfig.toml +0 -11
- package/tests/database.test.ts +0 -198
|
@@ -0,0 +1,97 @@
|
|
|
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 '@elysiumjs/mnemosyne';
|
|
16
|
+
import type { DrizzleConnection, DrizzleConnectionProps } from './database';
|
|
17
|
+
|
|
18
|
+
import { Module, needsDefaultAlias, runAll, Service } from '@elysiumjs/core';
|
|
19
|
+
import {
|
|
20
|
+
AbstractDatabase,
|
|
21
|
+
MnemosyneModule,
|
|
22
|
+
TENANCY_CONFIG_SERVICE,
|
|
23
|
+
TENANCY_STRATEGY_SERVICE
|
|
24
|
+
} from '@elysiumjs/mnemosyne';
|
|
25
|
+
|
|
26
|
+
import { MigrationGenerateCommand } from './commands/migration_generate.command';
|
|
27
|
+
import { MigrationRunCommand } from './commands/migration_run.command';
|
|
28
|
+
import { Database } from './database';
|
|
29
|
+
import { DrizzleRLSTenancy, DrizzleSchemaTenancy } from './tenancy';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Configuration of the {@link DrizzleModule}.
|
|
33
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
34
|
+
*/
|
|
35
|
+
export type DrizzleModuleConfig = {
|
|
36
|
+
/** The name of the connection that `default` resolves to. */
|
|
37
|
+
default: string;
|
|
38
|
+
|
|
39
|
+
/** The connections to declare, by name. */
|
|
40
|
+
connections: Record<string, DrizzleConnectionProps>;
|
|
41
|
+
|
|
42
|
+
/** The tenancy configuration. Defaults to schema-based tenancy. */
|
|
43
|
+
tenancy?: TenancyConfig;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Declares Drizzle connections as global services named `db.connection.{name}`, plus
|
|
48
|
+
* `db.connection.default` aliasing the configured default, and registers the tenancy config
|
|
49
|
+
* and strategy. Connections are created on first use. Also provides the migration commands.
|
|
50
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
51
|
+
*/
|
|
52
|
+
@Module.register<DrizzleModuleConfig>({
|
|
53
|
+
imports: [MnemosyneModule],
|
|
54
|
+
commands: [MigrationGenerateCommand, MigrationRunCommand],
|
|
55
|
+
services: (config) => {
|
|
56
|
+
const alias = needsDefaultAlias('DrizzleModule', config);
|
|
57
|
+
const tenancy = config.tenancy ?? { mode: 'schema' };
|
|
58
|
+
return [
|
|
59
|
+
...Object.entries(config.connections).map(([name, props]) => ({
|
|
60
|
+
name: AbstractDatabase.getConnectionName(name),
|
|
61
|
+
global: true,
|
|
62
|
+
factory: () => Database.createConnection(props)
|
|
63
|
+
})),
|
|
64
|
+
...(alias
|
|
65
|
+
? [
|
|
66
|
+
{
|
|
67
|
+
name: AbstractDatabase.getConnectionName('default'),
|
|
68
|
+
global: true,
|
|
69
|
+
factory: (_config: unknown, get: <T>(name: string) => T) =>
|
|
70
|
+
get<DrizzleConnection>(AbstractDatabase.getConnectionName(config.default))
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
: []),
|
|
74
|
+
{ name: TENANCY_CONFIG_SERVICE, global: true, factory: () => tenancy },
|
|
75
|
+
{
|
|
76
|
+
name: TENANCY_STRATEGY_SERVICE,
|
|
77
|
+
global: true,
|
|
78
|
+
factory: () =>
|
|
79
|
+
tenancy.mode === 'rls' ? new DrizzleRLSTenancy() : new DrizzleSchemaTenancy()
|
|
80
|
+
}
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
export class DrizzleModule extends Module.configurable<DrizzleModuleConfig>({
|
|
85
|
+
defaults: { default: 'default' },
|
|
86
|
+
required: ['connections']
|
|
87
|
+
}) {
|
|
88
|
+
/** Closes the clients of the connections that were created. */
|
|
89
|
+
public override async onDestroy(): Promise<void> {
|
|
90
|
+
const names = Object.keys(this.config.connections).map(AbstractDatabase.getConnectionName);
|
|
91
|
+
await runAll(
|
|
92
|
+
names
|
|
93
|
+
.filter((name) => Service.isConstructed(name))
|
|
94
|
+
.map((name) => () => Service.get<DrizzleConnection>(name)?.$client.close())
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,17 +12,15 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
|
-
import type { MnemosyneConfig } from '@elysiumjs/mnemosyne';
|
|
16
|
-
import type { DrizzleConnectionProps } from './database';
|
|
17
|
-
|
|
18
|
-
import { Application, Event } from '@elysiumjs/core';
|
|
19
|
-
import { registerTenancyStrategy } from '@elysiumjs/mnemosyne';
|
|
20
|
-
|
|
21
|
-
import { Database } from './database';
|
|
22
|
-
import { DrizzleRLSTenancy, DrizzleSchemaTenancy } from './tenancy';
|
|
23
|
-
|
|
24
15
|
// Re-export drizzle-orm/pg-core with enhanced pgTable
|
|
16
|
+
export * from 'drizzle-orm';
|
|
25
17
|
export * from 'drizzle-orm/pg-core';
|
|
18
|
+
export type {
|
|
19
|
+
SelectedFields,
|
|
20
|
+
SelectedFieldsFlat,
|
|
21
|
+
SelectedFieldsOrdered,
|
|
22
|
+
TableConfig
|
|
23
|
+
} from 'drizzle-orm/pg-core';
|
|
26
24
|
export { pgTable, getTableBuilders } from './table';
|
|
27
25
|
|
|
28
26
|
// Re-exports
|
|
@@ -34,6 +32,7 @@ export {
|
|
|
34
32
|
drizzleAdapter,
|
|
35
33
|
type DrizzleModelOptions
|
|
36
34
|
} from './model';
|
|
35
|
+
export { DrizzleModule, type DrizzleModuleConfig } from './drizzle.module';
|
|
37
36
|
export { DrizzleRepository } from './repository';
|
|
38
37
|
export {
|
|
39
38
|
DrizzleSchemaTenancy,
|
|
@@ -46,24 +45,3 @@ export {
|
|
|
46
45
|
getSessionTenant,
|
|
47
46
|
createRLSPolicy
|
|
48
47
|
} from './tenancy';
|
|
49
|
-
|
|
50
|
-
// Auto-initialize on app launch
|
|
51
|
-
Event.once('elysium:app:launched', () => {
|
|
52
|
-
const app = Application.instance;
|
|
53
|
-
const config = app.getConfig<MnemosyneConfig<DrizzleConnectionProps>>('elysium:mnemosyne' as any);
|
|
54
|
-
|
|
55
|
-
if (config?.database) {
|
|
56
|
-
for (const [name, props] of Object.entries(config.database.connections)) {
|
|
57
|
-
Database.registerConnection(name, props);
|
|
58
|
-
}
|
|
59
|
-
if (Database.connectionExists(config.database.default)) {
|
|
60
|
-
Database.setDefaultConnection(config.database.default);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (config?.tenancy) {
|
|
65
|
-
const strategy =
|
|
66
|
-
config.tenancy.mode === 'rls' ? new DrizzleRLSTenancy() : new DrizzleSchemaTenancy();
|
|
67
|
-
registerTenancyStrategy(strategy);
|
|
68
|
-
}
|
|
69
|
-
});
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { basename, dirname } from 'node:path';
|
|
16
|
+
|
|
17
|
+
/** A migration recorded as applied in `elysium_migrations`. */
|
|
18
|
+
export type AppliedMigration = {
|
|
19
|
+
name: string;
|
|
20
|
+
appliedAt: Date;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The migrations to run, and the applied records to rename to their new names. */
|
|
24
|
+
export type MigrationPlan = {
|
|
25
|
+
pending: string[];
|
|
26
|
+
renames: { from: string; to: string }[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const FOLDER_MIGRATION_FILE = 'migration.sql';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Gets the name under which a migration file is tracked. Drizzle Kit 1.0 stores each migration
|
|
33
|
+
* as `<folder>/migration.sql`, in which case the folder name identifies the migration.
|
|
34
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
35
|
+
* @param path The path of the migration file.
|
|
36
|
+
*/
|
|
37
|
+
export const getMigrationName = (path: string): string =>
|
|
38
|
+
basename(path) === FOLDER_MIGRATION_FILE ? basename(dirname(path)) : basename(path);
|
|
39
|
+
|
|
40
|
+
/** Drops the `.sql` extension and the leading index or timestamp: `0001_users.sql` → `users`. */
|
|
41
|
+
const descriptiveName = (name: string): string =>
|
|
42
|
+
name
|
|
43
|
+
.replace(/\.sql$/, '')
|
|
44
|
+
.split('_')
|
|
45
|
+
.slice(1)
|
|
46
|
+
.join('_');
|
|
47
|
+
|
|
48
|
+
/** Reads the UTC `YYYYMMDDHHmmss` timestamp that starts a Drizzle Kit 1.0 folder name. */
|
|
49
|
+
const folderTime = (name: string): number | undefined => {
|
|
50
|
+
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name);
|
|
51
|
+
return match
|
|
52
|
+
? Date.UTC(+match[1]!, +match[2]! - 1, +match[3]!, +match[4]!, +match[5]!, +match[6]!)
|
|
53
|
+
: undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decides which migration files to run.
|
|
58
|
+
*
|
|
59
|
+
* `drizzle-kit up` moves each flat `<prefix>_<name>.sql` file into a
|
|
60
|
+
* `<timestamp>_<name>/migration.sql` folder, where the timestamp is when the migration was
|
|
61
|
+
* generated. An applied record whose flat file is gone is matched, in order, to a folder with
|
|
62
|
+
* the same `<name>` generated before the record was applied; that folder is not run again and
|
|
63
|
+
* the record is renamed. Folders generated later are new migrations and run.
|
|
64
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
65
|
+
* @param files The paths of the migration files.
|
|
66
|
+
* @param applied The applied migrations.
|
|
67
|
+
*/
|
|
68
|
+
export const planMigrations = (files: string[], applied: AppliedMigration[]): MigrationPlan => {
|
|
69
|
+
const appliedNames = new Set(applied.map(({ name }) => name));
|
|
70
|
+
const fileNames = new Set(files.map(getMigrationName));
|
|
71
|
+
|
|
72
|
+
const moved = new Map<string, AppliedMigration[]>();
|
|
73
|
+
for (const record of [...applied].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
74
|
+
if (!record.name.endsWith('.sql') || fileNames.has(record.name)) continue;
|
|
75
|
+
const key = descriptiveName(record.name);
|
|
76
|
+
moved.set(key, [...(moved.get(key) ?? []), record]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const plan: MigrationPlan = { pending: [], renames: [] };
|
|
80
|
+
for (const file of [...files].sort()) {
|
|
81
|
+
const name = getMigrationName(file);
|
|
82
|
+
if (appliedNames.has(name)) continue;
|
|
83
|
+
|
|
84
|
+
const from = basename(file) === FOLDER_MIGRATION_FILE ? takeMoved(moved, name) : undefined;
|
|
85
|
+
if (from) plan.renames.push({ from, to: name });
|
|
86
|
+
else plan.pending.push(file);
|
|
87
|
+
}
|
|
88
|
+
return plan;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Removes and returns the first moved record that the folder `name` can be the new home of. */
|
|
92
|
+
const takeMoved = (moved: Map<string, AppliedMigration[]>, name: string): string | undefined => {
|
|
93
|
+
const records = moved.get(descriptiveName(name));
|
|
94
|
+
const generatedAt = folderTime(name);
|
|
95
|
+
const index =
|
|
96
|
+
records?.findIndex(
|
|
97
|
+
({ appliedAt }) => generatedAt === undefined || generatedAt <= appliedAt.getTime()
|
|
98
|
+
) ?? -1;
|
|
99
|
+
return index < 0 ? undefined : records!.splice(index, 1)[0]!.name;
|
|
100
|
+
};
|
package/src/model.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
15
|
import type { ColumnMetadata, ModelAdapter } from '@elysiumjs/mnemosyne';
|
|
16
|
-
import type { PgColumn, PgTableWithColumns } from 'drizzle-orm/pg-core';
|
|
16
|
+
import type { PgColumn, PgTableWithColumns, TableConfig } from 'drizzle-orm/pg-core';
|
|
17
17
|
|
|
18
18
|
import { AbstractModel, createSchemaFromModel } from '@elysiumjs/mnemosyne';
|
|
19
19
|
import { getTableConfig } from 'drizzle-orm/pg-core';
|
|
@@ -32,23 +32,30 @@ import { wrapTenantSchema } from './tenancy';
|
|
|
32
32
|
*/
|
|
33
33
|
const mapDrizzleDataType = (col: PgColumn): ColumnMetadata['dataType'] => {
|
|
34
34
|
if (col.columnType === 'PgUUID') return 'uuid';
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
if ((col as { dimensions?: number }).dimensions) return 'array';
|
|
36
|
+
|
|
37
|
+
// Drizzle 1.0 column data types are `<kind>` or `<kind> <constraint>` (e.g. `object json`).
|
|
38
|
+
const [kind, constraint] = col.dataType.split(' ');
|
|
39
|
+
switch (kind) {
|
|
38
40
|
case 'number':
|
|
39
41
|
return 'number';
|
|
40
42
|
case 'boolean':
|
|
41
43
|
return 'boolean';
|
|
42
44
|
case 'array':
|
|
43
45
|
return 'array';
|
|
44
|
-
case 'json':
|
|
45
|
-
return 'json';
|
|
46
|
-
case 'date':
|
|
47
|
-
return 'date';
|
|
48
46
|
case 'bigint':
|
|
49
47
|
return 'bigint';
|
|
50
|
-
case '
|
|
51
|
-
|
|
48
|
+
case 'object':
|
|
49
|
+
switch (constraint) {
|
|
50
|
+
case 'json':
|
|
51
|
+
return 'json';
|
|
52
|
+
case 'date':
|
|
53
|
+
return 'date';
|
|
54
|
+
case 'buffer':
|
|
55
|
+
return 'buffer';
|
|
56
|
+
default:
|
|
57
|
+
return 'string';
|
|
58
|
+
}
|
|
52
59
|
default:
|
|
53
60
|
return 'string';
|
|
54
61
|
}
|
|
@@ -145,7 +152,7 @@ export interface DrizzleModelOptions {
|
|
|
145
152
|
* @param options Model configuration options.
|
|
146
153
|
* @returns A model class with Drizzle-backed static metadata.
|
|
147
154
|
*/
|
|
148
|
-
export const DrizzleModel = <TTable extends PgTableWithColumns<
|
|
155
|
+
export const DrizzleModel = <TTable extends PgTableWithColumns<TableConfig>>(
|
|
149
156
|
table: TTable,
|
|
150
157
|
options?: DrizzleModelOptions
|
|
151
158
|
) => {
|
package/src/table.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
|
-
import type {
|
|
15
|
+
import type { AnyPgColumnBuilder, PgTableWithColumns } from 'drizzle-orm/pg-core';
|
|
16
16
|
|
|
17
17
|
import { pgTable as drizzlePgTable } from 'drizzle-orm/pg-core';
|
|
18
18
|
|
|
@@ -27,7 +27,7 @@ import { pgTable as drizzlePgTable } from 'drizzle-orm/pg-core';
|
|
|
27
27
|
* built `PgColumn` instances. This registry preserves the original builder
|
|
28
28
|
* objects so that tenant tables can be re-created in different schemas.
|
|
29
29
|
*/
|
|
30
|
-
const tableRegistry = new WeakMap<PgTableWithColumns<any>, Record<string,
|
|
30
|
+
const tableRegistry = new WeakMap<PgTableWithColumns<any>, Record<string, AnyPgColumnBuilder>>();
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Retrieves the original column builders for a table.
|
|
@@ -36,7 +36,7 @@ const tableRegistry = new WeakMap<PgTableWithColumns<any>, Record<string, PgColu
|
|
|
36
36
|
*/
|
|
37
37
|
export const getTableBuilders = (
|
|
38
38
|
table: PgTableWithColumns<any>
|
|
39
|
-
): Record<string,
|
|
39
|
+
): Record<string, AnyPgColumnBuilder> | undefined => {
|
|
40
40
|
return tableRegistry.get(table);
|
|
41
41
|
};
|
|
42
42
|
|
|
@@ -58,7 +58,7 @@ export const getTableBuilders = (
|
|
|
58
58
|
*/
|
|
59
59
|
export const pgTable = <
|
|
60
60
|
TTableName extends string,
|
|
61
|
-
TColumns extends Record<string,
|
|
61
|
+
TColumns extends Record<string, AnyPgColumnBuilder>
|
|
62
62
|
>(
|
|
63
63
|
tableName: TTableName,
|
|
64
64
|
columns: TColumns,
|
package/src/tenancy.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
15
|
import type { TenancyStrategy } from '@elysiumjs/mnemosyne';
|
|
16
|
-
import type {
|
|
16
|
+
import type { AnyPgColumnBuilder, PgAsyncTransaction, PgSchema, PgTable } from 'drizzle-orm/pg-core';
|
|
17
17
|
import type { DrizzleConnection } from './database';
|
|
18
18
|
|
|
19
19
|
import { getTenancyConfig } from '@elysiumjs/mnemosyne';
|
|
@@ -73,7 +73,7 @@ export const registerTenantSchema = (tenant: string): PgSchema => {
|
|
|
73
73
|
*/
|
|
74
74
|
export const wrapTenantSchema = <
|
|
75
75
|
TTableName extends string,
|
|
76
|
-
TColumnsMap extends Record<string,
|
|
76
|
+
TColumnsMap extends Record<string, AnyPgColumnBuilder>
|
|
77
77
|
>(
|
|
78
78
|
tenant: string,
|
|
79
79
|
tableName: TTableName,
|
|
@@ -109,7 +109,7 @@ export const wrapTenantSchema = <
|
|
|
109
109
|
*/
|
|
110
110
|
export const withRLS = async <T>(
|
|
111
111
|
tenant: string | null,
|
|
112
|
-
callback: (tx:
|
|
112
|
+
callback: (tx: PgAsyncTransaction<any, any>) => Promise<T>,
|
|
113
113
|
connectionName: string = 'default'
|
|
114
114
|
): Promise<T> => {
|
|
115
115
|
const db = Database.getConnection(connectionName);
|
|
@@ -117,7 +117,7 @@ export const withRLS = async <T>(
|
|
|
117
117
|
const varName = config.rls?.sessionVariable ?? 'app.current_tenant';
|
|
118
118
|
const value = tenant ?? 'public';
|
|
119
119
|
|
|
120
|
-
return (db as any).transaction(async (tx:
|
|
120
|
+
return (db as any).transaction(async (tx: PgAsyncTransaction<any, any>) => {
|
|
121
121
|
await tx.execute(sql`SELECT set_config(${varName}, ${value}, true)`);
|
|
122
122
|
return callback(tx);
|
|
123
123
|
});
|
|
@@ -241,7 +241,7 @@ export class DrizzleRLSTenancy implements TenancyStrategy<PgTable, DrizzleConnec
|
|
|
241
241
|
const config = getTenancyConfig();
|
|
242
242
|
const varName = config.rls?.sessionVariable ?? 'app.current_tenant';
|
|
243
243
|
|
|
244
|
-
return (connection as any).transaction(async (tx:
|
|
244
|
+
return (connection as any).transaction(async (tx: PgAsyncTransaction<any, any>) => {
|
|
245
245
|
await tx.execute(sql`SELECT set_config(${varName}, ${tenant}, true)`);
|
|
246
246
|
return callback();
|
|
247
247
|
});
|
|
@@ -0,0 +1,201 @@
|
|
|
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 { Application, Event, Service } from '@elysiumjs/core';
|
|
16
|
+
import { getTenancyConfig, getTenancyStrategy } from '@elysiumjs/mnemosyne';
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
import * as pkg from '../src';
|
|
20
|
+
import { Database, DrizzleDatabaseCache, DrizzleModule, DrizzleRLSTenancy, DrizzleSchemaTenancy } from '../src';
|
|
21
|
+
|
|
22
|
+
const URL = 'postgres://u:p@localhost:5432/db';
|
|
23
|
+
|
|
24
|
+
const stubClient = () => ({
|
|
25
|
+
options: {},
|
|
26
|
+
connect: mock(async () => {}),
|
|
27
|
+
begin: mock(async (_callback: unknown) => {}),
|
|
28
|
+
close: mock(async () => {})
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('DrizzleModule', () => {
|
|
32
|
+
let app: Awaited<ReturnType<typeof Application.testing>> | undefined;
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await app?.shutdown();
|
|
36
|
+
app = undefined;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('registers nothing when the package is imported', async () => {
|
|
40
|
+
const once = spyOn(Event, 'once');
|
|
41
|
+
const on = spyOn(Event, 'on');
|
|
42
|
+
await import('../src/index.ts?fresh-import');
|
|
43
|
+
expect(once).not.toHaveBeenCalled();
|
|
44
|
+
expect(on).not.toHaveBeenCalled();
|
|
45
|
+
once.mockRestore();
|
|
46
|
+
on.mockRestore();
|
|
47
|
+
expect(Service.exists('db.connection.default')).toBe(false);
|
|
48
|
+
expect(Service.exists('mnemosyne.tenancy.config')).toBe(false);
|
|
49
|
+
expect(typeof pkg.DrizzleModule.with).toBe('function');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('provides connections, the default alias and the schema strategy by default', async () => {
|
|
53
|
+
app = await Application.testing({
|
|
54
|
+
modules: [DrizzleModule.with({ default: 'main', connections: { main: { connection: URL } } })],
|
|
55
|
+
boot: 'eager'
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(Database.connectionExists('main')).toBe(true);
|
|
59
|
+
expect(Database.connectionExists('default')).toBe(true);
|
|
60
|
+
const main = Database.getConnection('main');
|
|
61
|
+
expect(main).toBeDefined();
|
|
62
|
+
expect(Database.getConnection()).toBe(main);
|
|
63
|
+
expect(getTenancyConfig()).toEqual({ mode: 'schema' });
|
|
64
|
+
expect(getTenancyStrategy()).toBeInstanceOf(DrizzleSchemaTenancy);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('uses the RLS strategy when tenancy mode is rls', async () => {
|
|
68
|
+
app = await Application.testing({
|
|
69
|
+
modules: [
|
|
70
|
+
DrizzleModule.with({
|
|
71
|
+
connections: { default: { connection: URL } },
|
|
72
|
+
tenancy: { mode: 'rls' }
|
|
73
|
+
})
|
|
74
|
+
],
|
|
75
|
+
boot: 'eager'
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(getTenancyConfig()).toEqual({ mode: 'rls' });
|
|
79
|
+
expect(getTenancyStrategy()).toBeInstanceOf(DrizzleRLSTenancy);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects a default that is not a declared connection', async () => {
|
|
83
|
+
await expect(
|
|
84
|
+
Application.testing({
|
|
85
|
+
modules: [DrizzleModule.with({ default: 'nope', connections: { main: { connection: URL } } })]
|
|
86
|
+
})
|
|
87
|
+
).rejects.toThrow(/nope/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('builds a connection with a cache on a lazy boot', async () => {
|
|
91
|
+
app = await Application.testing({
|
|
92
|
+
modules: [
|
|
93
|
+
DrizzleModule.with({
|
|
94
|
+
connections: { default: { connection: URL, cache: { storage: 'memory' } } }
|
|
95
|
+
})
|
|
96
|
+
],
|
|
97
|
+
boot: 'lazy'
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(await app.get('db.connection.default')).toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('resolves its cache storage on first use', async () => {
|
|
104
|
+
app = await Application.testing({
|
|
105
|
+
modules: [DrizzleModule.with({ connections: { default: { connection: URL } } })],
|
|
106
|
+
boot: 'eager'
|
|
107
|
+
});
|
|
108
|
+
const cache = new DrizzleDatabaseCache('all', 1, 'memory');
|
|
109
|
+
await cache.put('k', [1], ['t'], false);
|
|
110
|
+
expect(await cache.get('k')).toEqual([1]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('closes only the connections that were created', async () => {
|
|
114
|
+
const used = stubClient();
|
|
115
|
+
const unused = stubClient();
|
|
116
|
+
app = await Application.testing({
|
|
117
|
+
modules: [
|
|
118
|
+
DrizzleModule.with({
|
|
119
|
+
default: 'a',
|
|
120
|
+
connections: { a: { client: used as never }, b: { client: unused as never } }
|
|
121
|
+
})
|
|
122
|
+
],
|
|
123
|
+
boot: 'lazy'
|
|
124
|
+
});
|
|
125
|
+
await app.get('db.connection.default');
|
|
126
|
+
|
|
127
|
+
await app.shutdown();
|
|
128
|
+
app = undefined;
|
|
129
|
+
|
|
130
|
+
expect(used.close).toHaveBeenCalledTimes(1);
|
|
131
|
+
expect(unused.close).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('migration commands', () => {
|
|
136
|
+
const originalExit = process.exit;
|
|
137
|
+
const originalStderr = process.stderr.write;
|
|
138
|
+
const originalStdout = process.stdout.write;
|
|
139
|
+
let errors = '';
|
|
140
|
+
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
errors = '';
|
|
143
|
+
process.exit = mock() as never;
|
|
144
|
+
process.stderr.write = mock((message: string) => {
|
|
145
|
+
errors += message;
|
|
146
|
+
return true;
|
|
147
|
+
}) as never;
|
|
148
|
+
process.stdout.write = mock(() => true) as never;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let started: Application | undefined;
|
|
152
|
+
|
|
153
|
+
afterEach(async () => {
|
|
154
|
+
await started?.kernel.shutdown();
|
|
155
|
+
started = undefined;
|
|
156
|
+
Service.clear();
|
|
157
|
+
process.exit = originalExit;
|
|
158
|
+
process.stderr.write = originalStderr;
|
|
159
|
+
process.stdout.write = originalStdout;
|
|
160
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.removeAllListeners(signal);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const makeApp = () => {
|
|
164
|
+
const client = stubClient();
|
|
165
|
+
@Application.register({
|
|
166
|
+
modules: [
|
|
167
|
+
DrizzleModule.with({ connections: { default: { client: client as never } } })
|
|
168
|
+
]
|
|
169
|
+
})
|
|
170
|
+
class TestApp extends Application {}
|
|
171
|
+
const app = new TestApp();
|
|
172
|
+
started = app;
|
|
173
|
+
return { app, client };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
it('migration:run finds db.connection.default', async () => {
|
|
177
|
+
const { app, client } = makeApp();
|
|
178
|
+
await app.run(['migration:run', '--tenant', 'public']);
|
|
179
|
+
|
|
180
|
+
expect(errors).not.toContain('does not exist');
|
|
181
|
+
expect(client.connect).toHaveBeenCalled();
|
|
182
|
+
expect(client.begin).toHaveBeenCalled();
|
|
183
|
+
expect(Database.connectionExists('default')).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('lists the migration commands under NODE_ENV=production', async () => {
|
|
187
|
+
const previous = Bun.env.NODE_ENV;
|
|
188
|
+
Bun.env.NODE_ENV = 'production';
|
|
189
|
+
try {
|
|
190
|
+
const { app } = makeApp();
|
|
191
|
+
await app.run(['list']);
|
|
192
|
+
const names = (app.kernel.graph.collect('commands') as { item: { command: string } }[]).map(
|
|
193
|
+
({ item }) => item.command
|
|
194
|
+
);
|
|
195
|
+
expect(names).toContain('migration:run');
|
|
196
|
+
expect(names).toContain('migration:generate');
|
|
197
|
+
} finally {
|
|
198
|
+
Bun.env.NODE_ENV = previous;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|