@elysiumjs/mnemosyne-drizzle 0.1.4 → 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.
Files changed (56) hide show
  1. package/.omc/state/agent-replay-3920b579-6d8e-4f5f-924a-319202936685.jsonl +7 -0
  2. package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/last-tool-error-state.json +7 -0
  3. package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/mission-state.json +93 -0
  4. package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/pre-tool-advisory-throttle.json +18 -0
  5. package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/subagent-tracking-state.json +61 -0
  6. package/.turbo/turbo-build.log +3 -9
  7. package/CHANGELOG.md +40 -0
  8. package/README.md +51 -0
  9. package/dist/commands/migration_generate.command.d.ts +11 -0
  10. package/dist/commands/migration_generate.command.js +1 -0
  11. package/dist/commands/migration_run.command.d.ts +13 -0
  12. package/dist/commands/migration_run.command.js +15 -0
  13. package/dist/database.d.ts +9 -1
  14. package/dist/database.js +1 -0
  15. package/dist/drizzle.module.d.ts +40 -0
  16. package/dist/drizzle.module.js +1 -0
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.js +1 -2
  19. package/dist/migrations.d.ts +33 -0
  20. package/dist/migrations.js +1 -0
  21. package/dist/model.d.ts +5 -5
  22. package/dist/model.js +1 -0
  23. package/dist/repository.js +1 -0
  24. package/dist/table.d.ts +47 -123
  25. package/dist/table.js +1 -0
  26. package/dist/tenancy.d.ts +3 -3
  27. package/dist/tenancy.js +1 -0
  28. package/package.json +11 -9
  29. package/scripts/minify.ts +10 -0
  30. package/src/commands/migration_generate.command.ts +101 -0
  31. package/src/commands/migration_run.command.ts +160 -0
  32. package/src/database.ts +37 -7
  33. package/src/drizzle.module.ts +97 -0
  34. package/src/index.ts +1 -31
  35. package/src/migrations.ts +100 -0
  36. package/src/model.ts +18 -11
  37. package/src/table.ts +4 -4
  38. package/src/tenancy.ts +5 -5
  39. package/tests/drizzle.module.test.ts +201 -0
  40. package/tests/migrations.test.ts +123 -0
  41. package/tests/setup.ts +0 -10
  42. package/tsconfig.json +2 -2
  43. package/.turbo/turbo-typecheck.log +0 -1
  44. package/coverage/.lcov.info.01ffde0d9f956dce.tmp +0 -14
  45. package/coverage/.lcov.info.2f80a4def3a82099.tmp +0 -343
  46. package/coverage/.lcov.info.560a2daaba0934cd.tmp +0 -366
  47. package/coverage/.lcov.info.7162c9b788dc0bc6.tmp +0 -366
  48. package/coverage/.lcov.info.b57c0b452c3901e5.tmp +0 -17
  49. package/coverage/.lcov.info.c47de8626f331696.tmp +0 -17
  50. package/coverage/.lcov.info.c710a42b25b08349.tmp +0 -343
  51. package/coverage/.lcov.info.dbc8f065467fc8a2.tmp +0 -287
  52. package/coverage/.lcov.info.de180a43301f82c0.tmp +0 -35
  53. package/coverage/.lcov.info.df18163ab929ab1b.tmp +0 -357
  54. package/coverage/.lcov.info.febf711c192f5c0c.tmp +0 -17
  55. package/coverage/lcov.info +0 -357
  56. 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,20 +12,10 @@
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
25
16
  export * from 'drizzle-orm';
26
17
  export * from 'drizzle-orm/pg-core';
27
18
  export type {
28
- ColumnsWithTable,
29
19
  SelectedFields,
30
20
  SelectedFieldsFlat,
31
21
  SelectedFieldsOrdered,
@@ -42,6 +32,7 @@ export {
42
32
  drizzleAdapter,
43
33
  type DrizzleModelOptions
44
34
  } from './model';
35
+ export { DrizzleModule, type DrizzleModuleConfig } from './drizzle.module';
45
36
  export { DrizzleRepository } from './repository';
46
37
  export {
47
38
  DrizzleSchemaTenancy,
@@ -54,24 +45,3 @@ export {
54
45
  getSessionTenant,
55
46
  createRLSPolicy
56
47
  } from './tenancy';
57
-
58
- // Auto-initialize on app launch
59
- Event.once('elysium:app:launched', () => {
60
- const app = Application.instance;
61
- const config = app.getConfig<MnemosyneConfig<DrizzleConnectionProps>>('elysium:mnemosyne' as any);
62
-
63
- if (config?.database) {
64
- for (const [name, props] of Object.entries(config.database.connections)) {
65
- Database.registerConnection(name, props);
66
- }
67
- if (Database.connectionExists(config.database.default)) {
68
- Database.setDefaultConnection(config.database.default);
69
- }
70
- }
71
-
72
- if (config?.tenancy) {
73
- const strategy =
74
- config.tenancy.mode === 'rls' ? new DrizzleRLSTenancy() : new DrizzleSchemaTenancy();
75
- registerTenancyStrategy(strategy);
76
- }
77
- });
@@ -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
- switch (col.dataType) {
36
- case 'string':
37
- return 'string';
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 'buffer':
51
- return 'buffer';
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<any>>(
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 { PgColumnBuilderBase, PgTableWithColumns } from 'drizzle-orm/pg-core';
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, PgColumnBuilderBase>>();
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, PgColumnBuilderBase> | undefined => {
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, PgColumnBuilderBase>
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 { PgColumnBuilderBase, PgSchema, PgTable, PgTransaction } from 'drizzle-orm/pg-core';
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, PgColumnBuilderBase>
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: PgTransaction<any, any, any>) => Promise<T>,
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: PgTransaction<any, any, any>) => {
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: PgTransaction<any, any, any>) => {
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
+ });