@bunnykit/orm 0.1.5 → 0.1.7
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/README.md +385 -84
- package/dist/bin/bunny.js +133 -19
- package/dist/src/config/BunnyConfig.d.ts +28 -0
- package/dist/src/config/BunnyConfig.js +14 -0
- package/dist/src/connection/Connection.d.ts +20 -1
- package/dist/src/connection/Connection.js +80 -2
- package/dist/src/connection/ConnectionManager.d.ts +40 -0
- package/dist/src/connection/ConnectionManager.js +104 -0
- package/dist/src/connection/TenantContext.d.ts +15 -0
- package/dist/src/connection/TenantContext.js +22 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +3 -0
- package/dist/src/model/BelongsToMany.js +9 -6
- package/dist/src/model/Model.d.ts +5 -0
- package/dist/src/model/Model.js +57 -20
- package/dist/src/model/MorphRelations.js +10 -10
- package/dist/src/query/Builder.d.ts +44 -5
- package/dist/src/query/Builder.js +252 -113
- package/dist/src/query/grammars/Grammar.d.ts +19 -0
- package/dist/src/query/grammars/Grammar.js +47 -0
- package/dist/src/query/grammars/MySqlGrammar.d.ts +13 -0
- package/dist/src/query/grammars/MySqlGrammar.js +59 -0
- package/dist/src/query/grammars/PostgresGrammar.d.ts +13 -0
- package/dist/src/query/grammars/PostgresGrammar.js +62 -0
- package/dist/src/query/grammars/SQLiteGrammar.d.ts +14 -0
- package/dist/src/query/grammars/SQLiteGrammar.js +63 -0
- package/dist/src/schema/Schema.js +44 -26
- package/dist/src/typegen/TypeGenerator.js +4 -2
- package/dist/src/types/index.d.ts +10 -0
- package/package.json +1 -1
package/dist/bin/bunny.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Connection } from "../src/connection/Connection.js";
|
|
3
|
+
import { ConnectionManager } from "../src/connection/ConnectionManager.js";
|
|
4
|
+
import { TenantContext } from "../src/connection/TenantContext.js";
|
|
5
|
+
import { configureBunny } from "../src/config/BunnyConfig.js";
|
|
3
6
|
import { Migrator } from "../src/migration/Migrator.js";
|
|
4
7
|
import { MigrationCreator } from "../src/migration/MigrationCreator.js";
|
|
5
8
|
import { TypeGenerator } from "../src/typegen/TypeGenerator.js";
|
|
@@ -18,6 +21,126 @@ function parseEnvPathSetting(value) {
|
|
|
18
21
|
return undefined;
|
|
19
22
|
return paths.length === 1 ? paths[0] : paths;
|
|
20
23
|
}
|
|
24
|
+
function getDefaultMigrationsPath(config) {
|
|
25
|
+
return config.migrationsPath || config.migrations?.landlord || "./database/migrations";
|
|
26
|
+
}
|
|
27
|
+
function getFirstMigrationPath(path) {
|
|
28
|
+
return normalizePathList(path).filter(Boolean)[0];
|
|
29
|
+
}
|
|
30
|
+
function parseMigrationTarget(args) {
|
|
31
|
+
if (args.includes("--landlord"))
|
|
32
|
+
return { scope: "landlord" };
|
|
33
|
+
if (args.includes("--tenants"))
|
|
34
|
+
return { scope: "tenants" };
|
|
35
|
+
const tenantFlagIndex = args.indexOf("--tenant");
|
|
36
|
+
if (tenantFlagIndex >= 0) {
|
|
37
|
+
const tenantId = args[tenantFlagIndex + 1];
|
|
38
|
+
if (!tenantId) {
|
|
39
|
+
throw new Error("Usage: bun run bunny migrate --tenant <tenantId>");
|
|
40
|
+
}
|
|
41
|
+
return { scope: "tenant", tenantId };
|
|
42
|
+
}
|
|
43
|
+
return { scope: "default" };
|
|
44
|
+
}
|
|
45
|
+
function createTypeGeneratorOptions(config) {
|
|
46
|
+
const modelRoots = normalizePathList(config.modelsPath || config.typeDeclarationModelsDir);
|
|
47
|
+
return {
|
|
48
|
+
declarations: !config.typeStubs,
|
|
49
|
+
stubs: config.typeStubs,
|
|
50
|
+
modelDeclarations: config.typeDeclarations,
|
|
51
|
+
modelDirectory: modelRoots[0],
|
|
52
|
+
modelDirectories: modelRoots.length > 1 ? modelRoots : undefined,
|
|
53
|
+
modelImportPrefix: config.typeDeclarationImportPrefix,
|
|
54
|
+
singularModels: config.typeDeclarationSingularModels,
|
|
55
|
+
declarationDirName: "types",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async function runMigratorCommand(command, migrator, statusLabel) {
|
|
59
|
+
if (command === "migrate") {
|
|
60
|
+
await migrator.run();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (command === "migrate:rollback") {
|
|
64
|
+
await migrator.rollback();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const status = await migrator.status();
|
|
68
|
+
if (statusLabel) {
|
|
69
|
+
console.log(statusLabel);
|
|
70
|
+
}
|
|
71
|
+
console.table(status);
|
|
72
|
+
}
|
|
73
|
+
async function getTenantIds(config) {
|
|
74
|
+
if (!config.tenancy?.listTenants) {
|
|
75
|
+
throw new Error("Tenant migrations require tenancy.listTenants() in bunny.config.ts.");
|
|
76
|
+
}
|
|
77
|
+
const tenantIds = await config.tenancy.listTenants();
|
|
78
|
+
return tenantIds.map((tenantId) => String(tenantId));
|
|
79
|
+
}
|
|
80
|
+
async function runTenantMigrationCommand(command, config, tenantPath, tenantId, typesOutDir) {
|
|
81
|
+
await TenantContext.run(tenantId, async () => {
|
|
82
|
+
const context = TenantContext.current();
|
|
83
|
+
if (!context) {
|
|
84
|
+
throw new Error(`Tenant "${tenantId}" did not resolve to an active context.`);
|
|
85
|
+
}
|
|
86
|
+
console.log(`Tenant: ${tenantId}`);
|
|
87
|
+
const migrator = new Migrator(context.connection, tenantPath, typesOutDir, createTypeGeneratorOptions(config));
|
|
88
|
+
await runMigratorCommand(command, migrator);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async function runConfiguredMigrationCommand(command, config, connection, target) {
|
|
92
|
+
if (!config.migrations) {
|
|
93
|
+
const migrator = new Migrator(connection, getDefaultMigrationsPath(config), config.typesOutDir, createTypeGeneratorOptions(config));
|
|
94
|
+
await runMigratorCommand(command, migrator);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const landlordPath = config.migrations.landlord;
|
|
98
|
+
const tenantPath = config.migrations.tenant;
|
|
99
|
+
const runLandlord = async () => {
|
|
100
|
+
if (!landlordPath)
|
|
101
|
+
return;
|
|
102
|
+
console.log("Landlord migrations");
|
|
103
|
+
const migrator = new Migrator(connection, landlordPath, config.typesOutDir, createTypeGeneratorOptions(config));
|
|
104
|
+
await runMigratorCommand(command, migrator);
|
|
105
|
+
};
|
|
106
|
+
const runAllTenants = async () => {
|
|
107
|
+
if (!tenantPath)
|
|
108
|
+
return;
|
|
109
|
+
if (!config.tenancy?.resolveTenant) {
|
|
110
|
+
throw new Error("Tenant migrations require tenancy.resolveTenant() in bunny.config.ts.");
|
|
111
|
+
}
|
|
112
|
+
ConnectionManager.setTenantResolver(config.tenancy.resolveTenant);
|
|
113
|
+
const tenantIds = await getTenantIds(config);
|
|
114
|
+
for (const tenantId of tenantIds) {
|
|
115
|
+
await runTenantMigrationCommand(command, config, tenantPath, tenantId);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
if (target.scope === "landlord") {
|
|
119
|
+
await runLandlord();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (target.scope === "tenant") {
|
|
123
|
+
if (!tenantPath)
|
|
124
|
+
return;
|
|
125
|
+
if (!config.tenancy?.resolveTenant) {
|
|
126
|
+
throw new Error("Tenant migrations require tenancy.resolveTenant() in bunny.config.ts.");
|
|
127
|
+
}
|
|
128
|
+
ConnectionManager.setTenantResolver(config.tenancy.resolveTenant);
|
|
129
|
+
await runTenantMigrationCommand(command, config, tenantPath, target.tenantId);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (target.scope === "tenants") {
|
|
133
|
+
await runAllTenants();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (command === "migrate:rollback") {
|
|
137
|
+
await runAllTenants();
|
|
138
|
+
await runLandlord();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
await runLandlord();
|
|
142
|
+
await runAllTenants();
|
|
143
|
+
}
|
|
21
144
|
async function createReplBootstrap(config) {
|
|
22
145
|
const tmpRoot = process.env.BUNNY_REPL_TMPDIR || "/private/tmp";
|
|
23
146
|
const dir = join(tmpRoot, "bunny-repl");
|
|
@@ -254,8 +377,8 @@ async function main() {
|
|
|
254
377
|
}
|
|
255
378
|
const config = await loadConfig();
|
|
256
379
|
const creator = new MigrationCreator();
|
|
257
|
-
const migrationRoots = normalizePathList(config.migrationsPath);
|
|
258
|
-
const targetPath = args[2] || migrationRoots[0] || "./database/migrations";
|
|
380
|
+
const migrationRoots = normalizePathList(config.migrationsPath || config.migrations?.landlord);
|
|
381
|
+
const targetPath = args[2] || migrationRoots[0] || getFirstMigrationPath(config.migrations?.landlord) || "./database/migrations";
|
|
259
382
|
const path = await creator.create(name, targetPath);
|
|
260
383
|
console.log(`Created migration: ${path}`);
|
|
261
384
|
return;
|
|
@@ -290,31 +413,22 @@ async function main() {
|
|
|
290
413
|
process.exit(exitCode);
|
|
291
414
|
}
|
|
292
415
|
const config = await loadConfig();
|
|
293
|
-
const connection =
|
|
294
|
-
const modelRoots = normalizePathList(config.modelsPath || config.typeDeclarationModelsDir);
|
|
295
|
-
const migrator = new Migrator(connection, config.migrationsPath, config.typesOutDir, {
|
|
296
|
-
declarations: !config.typeStubs,
|
|
297
|
-
stubs: config.typeStubs,
|
|
298
|
-
modelDeclarations: config.typeDeclarations,
|
|
299
|
-
modelDirectory: modelRoots[0],
|
|
300
|
-
modelDirectories: modelRoots.length > 1 ? modelRoots : undefined,
|
|
301
|
-
modelImportPrefix: config.typeDeclarationImportPrefix,
|
|
302
|
-
singularModels: config.typeDeclarationSingularModels,
|
|
303
|
-
declarationDirName: "types",
|
|
304
|
-
});
|
|
416
|
+
const { connection } = configureBunny(config);
|
|
305
417
|
if (command === "migrate") {
|
|
306
|
-
await
|
|
418
|
+
await runConfiguredMigrationCommand(command, config, connection, parseMigrationTarget(args.slice(1)));
|
|
307
419
|
}
|
|
308
420
|
else if (command === "migrate:rollback") {
|
|
309
|
-
await
|
|
421
|
+
await runConfiguredMigrationCommand(command, config, connection, parseMigrationTarget(args.slice(1)));
|
|
310
422
|
}
|
|
311
423
|
else if (command === "migrate:status") {
|
|
312
|
-
|
|
313
|
-
console.table(status);
|
|
424
|
+
await runConfiguredMigrationCommand(command, config, connection, parseMigrationTarget(args.slice(1)));
|
|
314
425
|
}
|
|
315
426
|
else {
|
|
316
427
|
console.log("Usage:");
|
|
317
|
-
console.log(" bun run bunny migrate Run
|
|
428
|
+
console.log(" bun run bunny migrate Run landlord migrations, then all tenant migrations when configured");
|
|
429
|
+
console.log(" bun run bunny migrate --landlord Run landlord migrations only");
|
|
430
|
+
console.log(" bun run bunny migrate --tenants Run all tenant migrations only");
|
|
431
|
+
console.log(" bun run bunny migrate --tenant <id> Run one tenant's migrations only");
|
|
318
432
|
console.log(" bun run bunny migrate:make <name> [dir] Create a new migration");
|
|
319
433
|
console.log(" bun run bunny migrate:rollback Rollback the last batch");
|
|
320
434
|
console.log(" bun run bunny migrate:status Show migration status");
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Connection } from "../connection/Connection.js";
|
|
2
|
+
import type { TenantResolver } from "../connection/ConnectionManager.js";
|
|
3
|
+
import type { ModelDeclaration } from "../typegen/TypeGenerator.js";
|
|
4
|
+
import type { ConnectionConfig } from "../types/index.js";
|
|
5
|
+
export interface BunnyConfig {
|
|
6
|
+
connection: ConnectionConfig;
|
|
7
|
+
migrationsPath?: string | string[];
|
|
8
|
+
migrations?: {
|
|
9
|
+
landlord?: string | string[];
|
|
10
|
+
tenant?: string | string[];
|
|
11
|
+
};
|
|
12
|
+
tenancy?: {
|
|
13
|
+
resolveTenant?: TenantResolver;
|
|
14
|
+
listTenants?: () => string[] | Promise<string[]>;
|
|
15
|
+
};
|
|
16
|
+
modelsPath?: string | string[];
|
|
17
|
+
typesOutDir?: string;
|
|
18
|
+
typeDeclarations?: Record<string, string | ModelDeclaration>;
|
|
19
|
+
typeDeclarationModelsDir?: string;
|
|
20
|
+
typeDeclarationImportPrefix?: string;
|
|
21
|
+
typeDeclarationSingularModels?: boolean;
|
|
22
|
+
typeStubs?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface ConfiguredBunny {
|
|
25
|
+
config: BunnyConfig;
|
|
26
|
+
connection: Connection;
|
|
27
|
+
}
|
|
28
|
+
export declare function configureBunny(config: BunnyConfig): ConfiguredBunny;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Connection } from "../connection/Connection.js";
|
|
2
|
+
import { ConnectionManager } from "../connection/ConnectionManager.js";
|
|
3
|
+
import { Model } from "../model/Model.js";
|
|
4
|
+
import { Schema } from "../schema/Schema.js";
|
|
5
|
+
export function configureBunny(config) {
|
|
6
|
+
const connection = new Connection(config.connection);
|
|
7
|
+
ConnectionManager.setDefault(connection);
|
|
8
|
+
Model.setConnection(connection);
|
|
9
|
+
Schema.setConnection(connection);
|
|
10
|
+
if (config.tenancy?.resolveTenant) {
|
|
11
|
+
ConnectionManager.setTenantResolver(config.tenancy.resolveTenant);
|
|
12
|
+
}
|
|
13
|
+
return { config, connection };
|
|
14
|
+
}
|
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
import { SQL } from "bun";
|
|
2
2
|
import type { ConnectionConfig } from "../types/index.js";
|
|
3
|
+
import { Grammar } from "../query/grammars/Grammar.js";
|
|
3
4
|
export declare class Connection {
|
|
4
5
|
readonly driver: SQL;
|
|
5
6
|
private driverName;
|
|
6
|
-
|
|
7
|
+
private grammar;
|
|
8
|
+
private config;
|
|
9
|
+
private schema?;
|
|
10
|
+
private ownsDriver;
|
|
11
|
+
constructor(config: ConnectionConfig, options?: {
|
|
12
|
+
driver?: SQL;
|
|
13
|
+
schema?: string;
|
|
14
|
+
ownsDriver?: boolean;
|
|
15
|
+
});
|
|
7
16
|
getDriverName(): "sqlite" | "mysql" | "postgres";
|
|
17
|
+
getGrammar(): Grammar;
|
|
18
|
+
getSchema(): string | undefined;
|
|
19
|
+
withSchema(schema: string): Connection;
|
|
20
|
+
withoutSchema(): Connection;
|
|
21
|
+
qualifyTable(table: string): string;
|
|
22
|
+
private quoteIdentifier;
|
|
8
23
|
query(sqlString: string): Promise<any[]>;
|
|
9
24
|
run(sqlString: string): Promise<any>;
|
|
10
25
|
beginTransaction(): Promise<void>;
|
|
11
26
|
commit(): Promise<void>;
|
|
12
27
|
rollback(): Promise<void>;
|
|
28
|
+
transaction<T>(callback: (connection: Connection) => T | Promise<T>): Promise<T>;
|
|
29
|
+
withTenant<T>(tenantId: string, callback: (connection: Connection) => T | Promise<T>, setting?: string): Promise<T>;
|
|
30
|
+
withSearchPath<T>(schema: string, callback: (connection: Connection) => T | Promise<T>): Promise<T>;
|
|
31
|
+
close(): Promise<void>;
|
|
13
32
|
}
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { SQL } from "bun";
|
|
2
|
+
import { SQLiteGrammar } from "../query/grammars/SQLiteGrammar.js";
|
|
3
|
+
import { MySqlGrammar } from "../query/grammars/MySqlGrammar.js";
|
|
4
|
+
import { PostgresGrammar } from "../query/grammars/PostgresGrammar.js";
|
|
2
5
|
export class Connection {
|
|
3
6
|
driver;
|
|
4
7
|
driverName;
|
|
5
|
-
|
|
8
|
+
grammar;
|
|
9
|
+
config;
|
|
10
|
+
schema;
|
|
11
|
+
ownsDriver;
|
|
12
|
+
constructor(config, options = {}) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.schema = options.schema || ("schema" in config ? config.schema : undefined);
|
|
15
|
+
this.ownsDriver = options.ownsDriver ?? !options.driver;
|
|
6
16
|
let url;
|
|
7
17
|
if ("url" in config && config.url) {
|
|
8
18
|
url = config.url;
|
|
@@ -20,16 +30,51 @@ export class Connection {
|
|
|
20
30
|
else {
|
|
21
31
|
throw new Error("Invalid connection configuration. Provide a url or driver config.");
|
|
22
32
|
}
|
|
23
|
-
this.driver = new SQL(url);
|
|
33
|
+
this.driver = options.driver || new SQL(url);
|
|
24
34
|
this.driverName = url.startsWith("sqlite")
|
|
25
35
|
? "sqlite"
|
|
26
36
|
: url.startsWith("mysql")
|
|
27
37
|
? "mysql"
|
|
28
38
|
: "postgres";
|
|
39
|
+
switch (this.driverName) {
|
|
40
|
+
case "sqlite":
|
|
41
|
+
this.grammar = new SQLiteGrammar();
|
|
42
|
+
break;
|
|
43
|
+
case "mysql":
|
|
44
|
+
this.grammar = new MySqlGrammar();
|
|
45
|
+
break;
|
|
46
|
+
case "postgres":
|
|
47
|
+
this.grammar = new PostgresGrammar();
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
29
50
|
}
|
|
30
51
|
getDriverName() {
|
|
31
52
|
return this.driverName;
|
|
32
53
|
}
|
|
54
|
+
getGrammar() {
|
|
55
|
+
return this.grammar;
|
|
56
|
+
}
|
|
57
|
+
getSchema() {
|
|
58
|
+
return this.schema;
|
|
59
|
+
}
|
|
60
|
+
withSchema(schema) {
|
|
61
|
+
if (this.schema === schema)
|
|
62
|
+
return this;
|
|
63
|
+
return new Connection(this.config, { driver: this.driver, schema, ownsDriver: false });
|
|
64
|
+
}
|
|
65
|
+
withoutSchema() {
|
|
66
|
+
if (!this.schema)
|
|
67
|
+
return this;
|
|
68
|
+
return new Connection(this.config, { driver: this.driver, ownsDriver: false });
|
|
69
|
+
}
|
|
70
|
+
qualifyTable(table) {
|
|
71
|
+
if (!this.schema || this.driverName === "sqlite" || table.includes("."))
|
|
72
|
+
return table;
|
|
73
|
+
return `${this.schema}.${table}`;
|
|
74
|
+
}
|
|
75
|
+
quoteIdentifier(value) {
|
|
76
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
77
|
+
}
|
|
33
78
|
async query(sqlString) {
|
|
34
79
|
// Use unsafe for generated SQL strings
|
|
35
80
|
return (await this.driver.unsafe(sqlString));
|
|
@@ -46,4 +91,37 @@ export class Connection {
|
|
|
46
91
|
async rollback() {
|
|
47
92
|
await this.driver.unsafe("ROLLBACK");
|
|
48
93
|
}
|
|
94
|
+
async transaction(callback) {
|
|
95
|
+
return await this.driver.begin(async (sql) => {
|
|
96
|
+
const connection = new Connection(this.config, {
|
|
97
|
+
driver: sql,
|
|
98
|
+
schema: this.schema,
|
|
99
|
+
ownsDriver: false,
|
|
100
|
+
});
|
|
101
|
+
return await callback(connection);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async withTenant(tenantId, callback, setting = "app.tenant_id") {
|
|
105
|
+
if (this.driverName !== "postgres") {
|
|
106
|
+
return await this.transaction(callback);
|
|
107
|
+
}
|
|
108
|
+
return await this.transaction(async (connection) => {
|
|
109
|
+
await connection.run(`SET LOCAL ${setting} = ${connection.getGrammar().escape(tenantId)}`);
|
|
110
|
+
return await callback(connection);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async withSearchPath(schema, callback) {
|
|
114
|
+
if (this.driverName !== "postgres") {
|
|
115
|
+
throw new Error("search_path schema switching is only supported for PostgreSQL connections.");
|
|
116
|
+
}
|
|
117
|
+
return await this.transaction(async (connection) => {
|
|
118
|
+
await connection.run(`SET LOCAL search_path TO ${connection.quoteIdentifier(schema)}`);
|
|
119
|
+
return await callback(connection.withoutSchema());
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async close() {
|
|
123
|
+
if (this.ownsDriver) {
|
|
124
|
+
await this.driver.close();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
49
127
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Connection } from "./Connection.js";
|
|
2
|
+
import type { ConnectionConfig } from "../types/index.js";
|
|
3
|
+
import type { ActiveTenantContext } from "./TenantContext.js";
|
|
4
|
+
export type TenantResolution = {
|
|
5
|
+
strategy: "database";
|
|
6
|
+
name: string;
|
|
7
|
+
config: ConnectionConfig;
|
|
8
|
+
} | {
|
|
9
|
+
strategy: "schema";
|
|
10
|
+
name: string;
|
|
11
|
+
config?: ConnectionConfig;
|
|
12
|
+
connection?: string | Connection;
|
|
13
|
+
schema: string;
|
|
14
|
+
mode?: "qualify" | "search_path";
|
|
15
|
+
} | {
|
|
16
|
+
strategy: "rls";
|
|
17
|
+
name: string;
|
|
18
|
+
config?: ConnectionConfig;
|
|
19
|
+
connection?: string | Connection;
|
|
20
|
+
tenantId?: string;
|
|
21
|
+
setting?: string;
|
|
22
|
+
};
|
|
23
|
+
export type TenantResolver = (tenantId: string) => TenantResolution | Promise<TenantResolution>;
|
|
24
|
+
export declare class ConnectionManager {
|
|
25
|
+
private static defaultConnection?;
|
|
26
|
+
private static connections;
|
|
27
|
+
private static tenantResolver?;
|
|
28
|
+
private static tenantCache;
|
|
29
|
+
static setDefault(connection: Connection): void;
|
|
30
|
+
static getDefault(): Connection | undefined;
|
|
31
|
+
static add(name: string, connection: Connection | ConnectionConfig): Connection;
|
|
32
|
+
static get(name: string): Connection | undefined;
|
|
33
|
+
static require(name: string): Connection;
|
|
34
|
+
static setTenantResolver(resolver: TenantResolver): void;
|
|
35
|
+
static resolveTenant(tenantId: string): Promise<ActiveTenantContext>;
|
|
36
|
+
static getResolvedTenant(tenantId: string): ActiveTenantContext | undefined;
|
|
37
|
+
static purgeTenant(tenantId: string): void;
|
|
38
|
+
static closeTenant(tenantId: string): Promise<void>;
|
|
39
|
+
static closeAll(): Promise<void>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Connection } from "./Connection.js";
|
|
2
|
+
export class ConnectionManager {
|
|
3
|
+
static defaultConnection;
|
|
4
|
+
static connections = new Map();
|
|
5
|
+
static tenantResolver;
|
|
6
|
+
static tenantCache = new Map();
|
|
7
|
+
static setDefault(connection) {
|
|
8
|
+
this.defaultConnection = connection;
|
|
9
|
+
}
|
|
10
|
+
static getDefault() {
|
|
11
|
+
return this.defaultConnection;
|
|
12
|
+
}
|
|
13
|
+
static add(name, connection) {
|
|
14
|
+
const resolved = connection instanceof Connection ? connection : new Connection(connection);
|
|
15
|
+
this.connections.set(name, resolved);
|
|
16
|
+
return resolved;
|
|
17
|
+
}
|
|
18
|
+
static get(name) {
|
|
19
|
+
return this.connections.get(name);
|
|
20
|
+
}
|
|
21
|
+
static require(name) {
|
|
22
|
+
const connection = this.get(name);
|
|
23
|
+
if (!connection) {
|
|
24
|
+
throw new Error(`No connection registered for "${name}".`);
|
|
25
|
+
}
|
|
26
|
+
return connection;
|
|
27
|
+
}
|
|
28
|
+
static setTenantResolver(resolver) {
|
|
29
|
+
this.tenantResolver = resolver;
|
|
30
|
+
this.tenantCache.clear();
|
|
31
|
+
}
|
|
32
|
+
static async resolveTenant(tenantId) {
|
|
33
|
+
const cached = this.tenantCache.get(tenantId);
|
|
34
|
+
if (cached)
|
|
35
|
+
return cached;
|
|
36
|
+
if (!this.tenantResolver) {
|
|
37
|
+
throw new Error("No tenant resolver configured.");
|
|
38
|
+
}
|
|
39
|
+
const resolution = await this.tenantResolver(tenantId);
|
|
40
|
+
const schema = resolution.strategy === "schema" ? resolution.schema : undefined;
|
|
41
|
+
const schemaMode = resolution.strategy === "schema" ? resolution.mode || "qualify" : undefined;
|
|
42
|
+
let connection = (resolution.strategy === "schema" || resolution.strategy === "rls") && resolution.connection instanceof Connection
|
|
43
|
+
? resolution.connection
|
|
44
|
+
: (resolution.strategy === "schema" || resolution.strategy === "rls") && typeof resolution.connection === "string"
|
|
45
|
+
? this.require(resolution.connection)
|
|
46
|
+
: this.connections.get(resolution.name);
|
|
47
|
+
if (!connection) {
|
|
48
|
+
if ((resolution.strategy === "schema" || resolution.strategy === "rls") && !resolution.config) {
|
|
49
|
+
connection = this.defaultConnection;
|
|
50
|
+
}
|
|
51
|
+
if (!connection && !resolution.config) {
|
|
52
|
+
throw new Error(`No connection config or registered connection found for tenant "${tenantId}".`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!connection) {
|
|
56
|
+
const config = resolution.config;
|
|
57
|
+
if (!config) {
|
|
58
|
+
throw new Error(`No connection config or registered connection found for tenant "${tenantId}".`);
|
|
59
|
+
}
|
|
60
|
+
connection = new Connection(config, { schema });
|
|
61
|
+
this.connections.set(resolution.name, connection);
|
|
62
|
+
}
|
|
63
|
+
else if (schema && schemaMode === "qualify") {
|
|
64
|
+
connection = connection.withSchema(schema);
|
|
65
|
+
}
|
|
66
|
+
const context = {
|
|
67
|
+
tenantId,
|
|
68
|
+
connection,
|
|
69
|
+
connectionName: resolution.name,
|
|
70
|
+
strategy: resolution.strategy,
|
|
71
|
+
schema,
|
|
72
|
+
schemaMode,
|
|
73
|
+
rlsTenantId: resolution.strategy === "rls" ? resolution.tenantId || tenantId : undefined,
|
|
74
|
+
rlsSetting: resolution.strategy === "rls" ? resolution.setting || "app.tenant_id" : undefined,
|
|
75
|
+
};
|
|
76
|
+
this.tenantCache.set(tenantId, context);
|
|
77
|
+
return context;
|
|
78
|
+
}
|
|
79
|
+
static getResolvedTenant(tenantId) {
|
|
80
|
+
return this.tenantCache.get(tenantId);
|
|
81
|
+
}
|
|
82
|
+
static purgeTenant(tenantId) {
|
|
83
|
+
this.tenantCache.delete(tenantId);
|
|
84
|
+
}
|
|
85
|
+
static async closeTenant(tenantId) {
|
|
86
|
+
const context = this.tenantCache.get(tenantId);
|
|
87
|
+
if (!context)
|
|
88
|
+
return;
|
|
89
|
+
this.tenantCache.delete(tenantId);
|
|
90
|
+
const connection = this.connections.get(context.connectionName);
|
|
91
|
+
this.connections.delete(context.connectionName);
|
|
92
|
+
await connection?.close();
|
|
93
|
+
}
|
|
94
|
+
static async closeAll() {
|
|
95
|
+
const connections = new Set(this.connections.values());
|
|
96
|
+
if (this.defaultConnection)
|
|
97
|
+
connections.add(this.defaultConnection);
|
|
98
|
+
this.connections.clear();
|
|
99
|
+
this.tenantCache.clear();
|
|
100
|
+
for (const connection of connections) {
|
|
101
|
+
await connection.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Connection } from "./Connection.js";
|
|
2
|
+
export interface ActiveTenantContext {
|
|
3
|
+
tenantId: string;
|
|
4
|
+
connection: Connection;
|
|
5
|
+
connectionName: string;
|
|
6
|
+
strategy: "database" | "schema" | "rls";
|
|
7
|
+
schema?: string;
|
|
8
|
+
schemaMode?: "qualify" | "search_path";
|
|
9
|
+
rlsTenantId?: string;
|
|
10
|
+
rlsSetting?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare class TenantContext {
|
|
13
|
+
static current(): ActiveTenantContext | undefined;
|
|
14
|
+
static run<T>(tenantId: string, callback: () => T | Promise<T>): Promise<T>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { ConnectionManager } from "./ConnectionManager.js";
|
|
3
|
+
const storage = new AsyncLocalStorage();
|
|
4
|
+
export class TenantContext {
|
|
5
|
+
static current() {
|
|
6
|
+
return storage.getStore();
|
|
7
|
+
}
|
|
8
|
+
static async run(tenantId, callback) {
|
|
9
|
+
const context = await ConnectionManager.resolveTenant(tenantId);
|
|
10
|
+
if (context.strategy === "schema" && context.schema && context.schemaMode === "search_path") {
|
|
11
|
+
return await context.connection.withSearchPath(context.schema, async (connection) => {
|
|
12
|
+
return await storage.run({ ...context, connection }, callback);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
if (context.strategy === "rls") {
|
|
16
|
+
return await context.connection.withTenant(context.rlsTenantId || context.tenantId, async (connection) => {
|
|
17
|
+
return await storage.run({ ...context, connection }, callback);
|
|
18
|
+
}, context.rlsSetting);
|
|
19
|
+
}
|
|
20
|
+
return await storage.run(context, callback);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export { Connection } from "./connection/Connection.js";
|
|
2
|
+
export { ConnectionManager } from "./connection/ConnectionManager.js";
|
|
3
|
+
export type { TenantResolution, TenantResolver } from "./connection/ConnectionManager.js";
|
|
4
|
+
export { TenantContext } from "./connection/TenantContext.js";
|
|
5
|
+
export type { ActiveTenantContext } from "./connection/TenantContext.js";
|
|
6
|
+
export { configureBunny } from "./config/BunnyConfig.js";
|
|
7
|
+
export type { BunnyConfig, ConfiguredBunny } from "./config/BunnyConfig.js";
|
|
2
8
|
export type { ConnectionConfig } from "./types/index.js";
|
|
3
9
|
export { Schema } from "./schema/Schema.js";
|
|
4
10
|
export { Blueprint } from "./schema/Blueprint.js";
|
package/dist/src/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { Connection } from "./connection/Connection.js";
|
|
2
|
+
export { ConnectionManager } from "./connection/ConnectionManager.js";
|
|
3
|
+
export { TenantContext } from "./connection/TenantContext.js";
|
|
4
|
+
export { configureBunny } from "./config/BunnyConfig.js";
|
|
2
5
|
export { Schema } from "./schema/Schema.js";
|
|
3
6
|
export { Blueprint } from "./schema/Blueprint.js";
|
|
4
7
|
export { Grammar } from "./schema/grammars/Grammar.js";
|
|
@@ -21,7 +21,7 @@ export class BelongsToMany {
|
|
|
21
21
|
this.relatedKey = relatedKey || related.primaryKey;
|
|
22
22
|
this.foreignPivotKey = foreignPivotKey || `${snakeCase(parent.constructor.name)}_id`;
|
|
23
23
|
this.relatedPivotKey = relatedPivotKey || `${snakeCase(related.name)}_id`;
|
|
24
|
-
this.builder = related.
|
|
24
|
+
this.builder = related.on(parent.getConnection());
|
|
25
25
|
this.addConstraints();
|
|
26
26
|
}
|
|
27
27
|
addConstraints() {
|
|
@@ -35,7 +35,7 @@ export class BelongsToMany {
|
|
|
35
35
|
}
|
|
36
36
|
addEagerConstraints(models) {
|
|
37
37
|
const keys = models.map((m) => m.getAttribute(this.parentKey));
|
|
38
|
-
this.builder = this.related.
|
|
38
|
+
this.builder = this.related.on(this.parent.getConnection());
|
|
39
39
|
const relatedTable = this.related.getTable();
|
|
40
40
|
this.builder.select(`${relatedTable}.*`, `${this.table}.${this.foreignPivotKey}`);
|
|
41
41
|
this.builder.join(this.table, `${this.table}.${this.relatedPivotKey}`, "=", `${relatedTable}.${this.relatedKey}`);
|
|
@@ -66,7 +66,7 @@ export class BelongsToMany {
|
|
|
66
66
|
}
|
|
67
67
|
newExistenceQuery(parentTable, aggregate, callback) {
|
|
68
68
|
const relatedTable = this.related.getTable();
|
|
69
|
-
const query = this.related.
|
|
69
|
+
const query = this.related.on(this.parent.getConnection()).select(aggregate);
|
|
70
70
|
query.join(this.table, `${this.table}.${this.relatedPivotKey}`, "=", `${relatedTable}.${this.relatedKey}`);
|
|
71
71
|
query.whereColumn(`${this.table}.${this.foreignPivotKey}`, "=", `${parentTable}.${this.parentKey}`);
|
|
72
72
|
if (callback)
|
|
@@ -89,10 +89,12 @@ export class BelongsToMany {
|
|
|
89
89
|
[this.relatedPivotKey]: id,
|
|
90
90
|
...attributes,
|
|
91
91
|
}));
|
|
92
|
-
|
|
92
|
+
const connection = this.parent.getConnection();
|
|
93
|
+
await new Builder(connection, connection.qualifyTable(this.table)).insert(records);
|
|
93
94
|
}
|
|
94
95
|
async detach(ids) {
|
|
95
|
-
const
|
|
96
|
+
const connection = this.parent.getConnection();
|
|
97
|
+
const builder = new Builder(connection, connection.qualifyTable(this.table))
|
|
96
98
|
.where(this.foreignPivotKey, this.parent.getAttribute(this.parentKey));
|
|
97
99
|
if (ids !== undefined) {
|
|
98
100
|
builder.whereIn(this.relatedPivotKey, Array.isArray(ids) ? ids : [ids]);
|
|
@@ -101,7 +103,8 @@ export class BelongsToMany {
|
|
|
101
103
|
}
|
|
102
104
|
async sync(ids, detachMissing = true) {
|
|
103
105
|
const idList = Array.isArray(ids) ? ids : [ids];
|
|
104
|
-
const
|
|
106
|
+
const connection = this.parent.getConnection();
|
|
107
|
+
const current = await new Builder(connection, connection.qualifyTable(this.table))
|
|
105
108
|
.where(this.foreignPivotKey, this.parent.getAttribute(this.parentKey))
|
|
106
109
|
.pluck(this.relatedPivotKey);
|
|
107
110
|
const currentSet = new Set(current);
|
|
@@ -97,10 +97,13 @@ export declare class Model<T extends Record<string, any> = Record<string, any>>
|
|
|
97
97
|
$exists: boolean;
|
|
98
98
|
$relations: Record<string, any>;
|
|
99
99
|
$casts: Record<string, CastDefinition>;
|
|
100
|
+
$connection?: Connection;
|
|
100
101
|
constructor(attributes?: Partial<T>);
|
|
101
102
|
static getTable(): string;
|
|
102
103
|
static getConnection(): Connection;
|
|
103
104
|
static setConnection(connection: Connection): void;
|
|
105
|
+
static on<M extends typeof Model>(this: M, connection: string | Connection): Builder<InstanceType<M>>;
|
|
106
|
+
static forTenant<M extends typeof Model>(this: M, tenantId: string): Builder<InstanceType<M>>;
|
|
104
107
|
static query<M extends typeof Model>(this: M): Builder<InstanceType<M>>;
|
|
105
108
|
static addGlobalScope(name: string, scope: GlobalScope): void;
|
|
106
109
|
static removeGlobalScope(name: string): void;
|
|
@@ -165,6 +168,8 @@ export declare class Model<T extends Record<string, any> = Record<string, any>>
|
|
|
165
168
|
static eagerLoadRelations(models: Model[], relations: string[]): Promise<void>;
|
|
166
169
|
static eagerLoadRelation(models: Model[], relationName: string): Promise<void>;
|
|
167
170
|
fill(attributes: Partial<T>): this;
|
|
171
|
+
setConnection(connection: Connection): this;
|
|
172
|
+
getConnection(): Connection;
|
|
168
173
|
isFillable(key: string): boolean;
|
|
169
174
|
getAttribute<K extends keyof T>(key: K): T[K];
|
|
170
175
|
getAttribute(key: string): any;
|