@strav/database 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/package.json +46 -0
- package/src/database/database.ts +181 -0
- package/src/database/domain/context.ts +84 -0
- package/src/database/domain/index.ts +17 -0
- package/src/database/domain/manager.ts +274 -0
- package/src/database/domain/wrapper.ts +105 -0
- package/src/database/index.ts +32 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +145 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +86 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +1034 -0
- package/src/database/seeder.ts +31 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +1 -0
- package/src/index.ts +5 -0
- package/src/orm/base_model.ts +427 -0
- package/src/orm/decorators.ts +290 -0
- package/src/orm/index.ts +3 -0
- package/src/providers/database_provider.ts +25 -0
- package/src/providers/index.ts +1 -0
- package/src/schema/database_representation.ts +124 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/domain_discovery.ts +83 -0
- package/src/schema/field_builder.ts +160 -0
- package/src/schema/field_definition.ts +69 -0
- package/src/schema/index.ts +22 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +187 -0
- package/src/schema/representation_builder.ts +482 -0
- package/src/schema/type_builder.ts +115 -0
- package/src/schema/types.ts +35 -0
- package/tsconfig.json +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/database",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Database layer for the Strav framework — query builder, ORM, schema builder, and migrations",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"bun",
|
|
9
|
+
"framework",
|
|
10
|
+
"typescript",
|
|
11
|
+
"strav",
|
|
12
|
+
"database",
|
|
13
|
+
"orm"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"src/",
|
|
17
|
+
"package.json",
|
|
18
|
+
"tsconfig.json",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/index.ts",
|
|
23
|
+
"./database": "./src/database/index.ts",
|
|
24
|
+
"./database/*": "./src/database/*.ts",
|
|
25
|
+
"./orm": "./src/orm/index.ts",
|
|
26
|
+
"./orm/*": "./src/orm/*.ts",
|
|
27
|
+
"./schema": "./src/schema/index.ts",
|
|
28
|
+
"./schema/*": "./src/schema/*.ts",
|
|
29
|
+
"./helpers": "./src/helpers/index.ts",
|
|
30
|
+
"./helpers/*": "./src/helpers/*.ts",
|
|
31
|
+
"./providers": "./src/providers/index.ts",
|
|
32
|
+
"./providers/*": "./src/providers/*.ts"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@strav/kernel": "0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@types/luxon": "^3.7.1",
|
|
39
|
+
"luxon": "^3.7.2",
|
|
40
|
+
"reflect-metadata": "^0.2.2"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "bun test tests/",
|
|
44
|
+
"typecheck": "tsc --noEmit"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import Configuration from '@stravigor/kernel/config/configuration'
|
|
3
|
+
import { inject } from '@stravigor/kernel/core/inject'
|
|
4
|
+
import { ConfigurationError } from '@stravigor/kernel/exceptions/errors'
|
|
5
|
+
import { env } from '@stravigor/kernel/helpers/env'
|
|
6
|
+
import { createSchemaAwareSQL } from './domain/wrapper'
|
|
7
|
+
import { getCurrentSchema, hasSchemaContext } from './domain/context'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Database connection wrapper backed by {@link SQL Bun.sql}.
|
|
11
|
+
*
|
|
12
|
+
* Reads connection credentials from the `database.*` configuration keys
|
|
13
|
+
* (loaded from `config/database.ts` / `.env`). Falls back to reading
|
|
14
|
+
* `DB_*` environment variables directly when no config file is present.
|
|
15
|
+
*
|
|
16
|
+
* Register as a singleton in the DI container so a single connection pool
|
|
17
|
+
* is shared across the application.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* container.singleton(Configuration)
|
|
21
|
+
* container.singleton(Database)
|
|
22
|
+
* const db = container.resolve(Database)
|
|
23
|
+
* const rows = await db.sql`SELECT 1 AS result`
|
|
24
|
+
*/
|
|
25
|
+
@inject
|
|
26
|
+
export default class Database {
|
|
27
|
+
private static _connection: SQL | null = null
|
|
28
|
+
private static _schemaConnection: SQL | null = null
|
|
29
|
+
private connection: SQL
|
|
30
|
+
private schemaConnection: SQL
|
|
31
|
+
private multiSchemaEnabled: boolean
|
|
32
|
+
|
|
33
|
+
constructor(protected config: Configuration) {
|
|
34
|
+
// Close any previously orphaned connection (e.g. from hot reload)
|
|
35
|
+
if (Database._connection) {
|
|
36
|
+
Database._connection.close()
|
|
37
|
+
}
|
|
38
|
+
if (Database._schemaConnection) {
|
|
39
|
+
Database._schemaConnection = null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.connection = new SQL({
|
|
43
|
+
hostname: config.get('database.host') ?? env('DB_HOST', '127.0.0.1'),
|
|
44
|
+
port: config.get('database.port') ?? env.int('DB_PORT', 5432),
|
|
45
|
+
username: config.get('database.username') ?? env('DB_USER', 'postgres'),
|
|
46
|
+
password: config.get('database.password') ?? env('DB_PASSWORD', ''),
|
|
47
|
+
database: config.get('database.database') ?? env('DB_DATABASE', 'strav'),
|
|
48
|
+
max: config.get('database.pool') ?? env.int('DB_POOL_MAX', 10),
|
|
49
|
+
idleTimeout: config.get('database.idleTimeout') ?? env.int('DB_IDLE_TIMEOUT', 20),
|
|
50
|
+
})
|
|
51
|
+
Database._connection = this.connection
|
|
52
|
+
|
|
53
|
+
// Check if multi-schema mode is enabled
|
|
54
|
+
this.multiSchemaEnabled = config.get('database.multiSchema.enabled') ?? false
|
|
55
|
+
|
|
56
|
+
// Create schema-aware wrapper if multi-schema is enabled
|
|
57
|
+
if (this.multiSchemaEnabled) {
|
|
58
|
+
this.schemaConnection = createSchemaAwareSQL(this.connection)
|
|
59
|
+
Database._schemaConnection = this.schemaConnection
|
|
60
|
+
} else {
|
|
61
|
+
this.schemaConnection = this.connection
|
|
62
|
+
Database._schemaConnection = this.connection
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The underlying Bun SQL tagged-template client. */
|
|
67
|
+
get sql(): SQL {
|
|
68
|
+
// Return schema-aware connection if in schema context and multi-schema is enabled
|
|
69
|
+
if (this.multiSchemaEnabled && hasSchemaContext()) {
|
|
70
|
+
return this.createSchemaConnection()
|
|
71
|
+
}
|
|
72
|
+
return this.connection
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Create a schema-specific connection with search_path set */
|
|
76
|
+
private createSchemaConnection(): SQL {
|
|
77
|
+
const schema = getCurrentSchema()
|
|
78
|
+
const baseConn = this.connection
|
|
79
|
+
|
|
80
|
+
// Create a proxy that sets search_path before each query
|
|
81
|
+
return new Proxy(baseConn, {
|
|
82
|
+
get(target, prop) {
|
|
83
|
+
const value = Reflect.get(target, prop)
|
|
84
|
+
|
|
85
|
+
// Intercept 'unsafe' method to inject search_path
|
|
86
|
+
if (prop === 'unsafe') {
|
|
87
|
+
return async function(sql: string, params?: any[]) {
|
|
88
|
+
// For queries that should not be prefixed (like SET commands)
|
|
89
|
+
if (sql.trim().toUpperCase().startsWith('SET')) {
|
|
90
|
+
return target.unsafe(sql, params)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Execute with search_path set
|
|
94
|
+
const searchPathSql = `SET search_path TO "${schema}", public`
|
|
95
|
+
await target.unsafe(searchPathSql)
|
|
96
|
+
return target.unsafe(sql, params)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Handle tagged template function
|
|
101
|
+
if (typeof value === 'function') {
|
|
102
|
+
return value.bind(target)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return value
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Handle tagged template literals
|
|
109
|
+
apply(target, thisArg, argArray) {
|
|
110
|
+
return (async () => {
|
|
111
|
+
const schema = getCurrentSchema()
|
|
112
|
+
await target.unsafe(`SET search_path TO "${schema}", public`)
|
|
113
|
+
return Reflect.apply(target as any, thisArg, argArray)
|
|
114
|
+
})()
|
|
115
|
+
},
|
|
116
|
+
}) as SQL
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The global SQL connection, available after DI bootstrap. */
|
|
120
|
+
static get raw(): SQL {
|
|
121
|
+
if (!Database._connection) {
|
|
122
|
+
throw new ConfigurationError(
|
|
123
|
+
'Database not configured. Resolve Database through the container first.'
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Return schema-aware connection if available and in schema context
|
|
128
|
+
if (Database._schemaConnection && hasSchemaContext()) {
|
|
129
|
+
const schema = getCurrentSchema()
|
|
130
|
+
const baseConn = Database._connection
|
|
131
|
+
|
|
132
|
+
// Create inline schema-aware proxy
|
|
133
|
+
return new Proxy(baseConn, {
|
|
134
|
+
get(target, prop) {
|
|
135
|
+
const value = Reflect.get(target, prop)
|
|
136
|
+
|
|
137
|
+
if (prop === 'unsafe') {
|
|
138
|
+
return async function(sql: string, params?: any[]) {
|
|
139
|
+
if (sql.trim().toUpperCase().startsWith('SET')) {
|
|
140
|
+
return target.unsafe(sql, params)
|
|
141
|
+
}
|
|
142
|
+
await target.unsafe(`SET search_path TO "${schema}", public`)
|
|
143
|
+
return target.unsafe(sql, params)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (typeof value === 'function') {
|
|
148
|
+
return value.bind(target)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return value
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
apply(target, thisArg, argArray) {
|
|
155
|
+
return (async () => {
|
|
156
|
+
await target.unsafe(`SET search_path TO "${schema}", public`)
|
|
157
|
+
return Reflect.apply(target as any, thisArg, argArray)
|
|
158
|
+
})()
|
|
159
|
+
},
|
|
160
|
+
}) as SQL
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return Database._connection
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Close the connection pool. */
|
|
167
|
+
async close(): Promise<void> {
|
|
168
|
+
await this.connection.close()
|
|
169
|
+
if (Database._connection === this.connection) {
|
|
170
|
+
Database._connection = null
|
|
171
|
+
}
|
|
172
|
+
if (Database._schemaConnection) {
|
|
173
|
+
Database._schemaConnection = null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Check if multi-schema mode is enabled */
|
|
178
|
+
get isMultiSchema(): boolean {
|
|
179
|
+
return this.multiSchemaEnabled
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema context for multi-domain database operations.
|
|
5
|
+
* Stores the current domain's PostgreSQL schema name.
|
|
6
|
+
*/
|
|
7
|
+
export interface SchemaContext {
|
|
8
|
+
/** The PostgreSQL schema name for the current domain */
|
|
9
|
+
schema: string
|
|
10
|
+
/** Whether to bypass schema isolation (for admin/global operations) */
|
|
11
|
+
bypass?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* AsyncLocalStorage for schema context.
|
|
16
|
+
* Automatically propagates schema context through async operations.
|
|
17
|
+
*/
|
|
18
|
+
export const schemaStorage = new AsyncLocalStorage<SchemaContext>()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Execute a function within a schema context.
|
|
22
|
+
* All database operations within the callback will use the specified schema.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* // In HTTP middleware
|
|
26
|
+
* await withSchema('company_123', async () => {
|
|
27
|
+
* // All queries here automatically use company_123 schema
|
|
28
|
+
* const users = await User.all()
|
|
29
|
+
* const orders = await query(Order).where('status', 'pending').all()
|
|
30
|
+
* })
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* // In background jobs
|
|
34
|
+
* await withSchema(job.domainId, async () => {
|
|
35
|
+
* await processInvoices()
|
|
36
|
+
* })
|
|
37
|
+
*/
|
|
38
|
+
export async function withSchema<T>(
|
|
39
|
+
schema: string,
|
|
40
|
+
callback: () => T | Promise<T>
|
|
41
|
+
): Promise<T> {
|
|
42
|
+
return schemaStorage.run({ schema }, callback)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Execute a function with schema isolation bypassed.
|
|
47
|
+
* Useful for admin operations that need to access all schemas.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* await withoutSchema(async () => {
|
|
51
|
+
* // Query runs without schema restriction
|
|
52
|
+
* await db.sql`SELECT * FROM information_schema.schemata`
|
|
53
|
+
* })
|
|
54
|
+
*/
|
|
55
|
+
export async function withoutSchema<T>(
|
|
56
|
+
callback: () => T | Promise<T>
|
|
57
|
+
): Promise<T> {
|
|
58
|
+
return schemaStorage.run({ schema: 'public', bypass: true }, callback)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get the current schema context.
|
|
63
|
+
* Returns null if not within a schema context.
|
|
64
|
+
*/
|
|
65
|
+
export function getCurrentSchemaContext(): SchemaContext | null {
|
|
66
|
+
return schemaStorage.getStore() ?? null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get the current schema name.
|
|
71
|
+
* Returns 'public' if not within a schema context.
|
|
72
|
+
*/
|
|
73
|
+
export function getCurrentSchema(): string {
|
|
74
|
+
const context = getCurrentSchemaContext()
|
|
75
|
+
return context?.bypass ? 'public' : (context?.schema ?? 'public')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if currently running within a schema context.
|
|
80
|
+
*/
|
|
81
|
+
export function hasSchemaContext(): boolean {
|
|
82
|
+
const context = getCurrentSchemaContext()
|
|
83
|
+
return context !== null && !context.bypass
|
|
84
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type SchemaContext,
|
|
3
|
+
schemaStorage,
|
|
4
|
+
withSchema,
|
|
5
|
+
withoutSchema,
|
|
6
|
+
getCurrentSchemaContext,
|
|
7
|
+
getCurrentSchema,
|
|
8
|
+
hasSchemaContext,
|
|
9
|
+
} from './context'
|
|
10
|
+
|
|
11
|
+
export { SchemaManager } from './manager'
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
createSchemaAwareSQL,
|
|
15
|
+
setSearchPath,
|
|
16
|
+
resetSearchPath,
|
|
17
|
+
} from './wrapper'
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { SQL } from 'bun'
|
|
2
|
+
import Database from '../database'
|
|
3
|
+
import MigrationRunner from '../migration/runner'
|
|
4
|
+
import MigrationTracker from '../migration/tracker'
|
|
5
|
+
import { withSchema, withoutSchema } from './context'
|
|
6
|
+
import { inject } from '@stravigor/kernel/core/inject'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Schema manager for multi-domain database operations.
|
|
10
|
+
* Handles schema creation, deletion, and migrations per domain.
|
|
11
|
+
*/
|
|
12
|
+
@inject
|
|
13
|
+
export class SchemaManager {
|
|
14
|
+
private db: SQL
|
|
15
|
+
private database: Database
|
|
16
|
+
|
|
17
|
+
constructor(database: Database) {
|
|
18
|
+
this.database = database
|
|
19
|
+
this.db = database.sql
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a new schema for a domain.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const manager = container.resolve(SchemaManager)
|
|
27
|
+
* await manager.createSchema('company_123')
|
|
28
|
+
*/
|
|
29
|
+
async createSchema(schema: string): Promise<void> {
|
|
30
|
+
await withoutSchema(async () => {
|
|
31
|
+
// Validate schema name (prevent SQL injection)
|
|
32
|
+
if (!/^[a-z0-9_]+$/.test(schema)) {
|
|
33
|
+
throw new Error(`Invalid schema name: ${schema}. Only lowercase letters, numbers, and underscores are allowed.`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Create the schema
|
|
37
|
+
await this.db.unsafe(`CREATE SCHEMA IF NOT EXISTS "${schema}"`)
|
|
38
|
+
|
|
39
|
+
// Grant permissions to the current user
|
|
40
|
+
const currentUser = await this.getCurrentUser()
|
|
41
|
+
if (currentUser) {
|
|
42
|
+
await this.db.unsafe(`GRANT ALL ON SCHEMA "${schema}" TO "${currentUser}"`)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Delete a domain's schema and all its data.
|
|
49
|
+
* USE WITH CAUTION - This is irreversible!
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* await manager.deleteSchema('company_123')
|
|
53
|
+
*/
|
|
54
|
+
async deleteSchema(schema: string): Promise<void> {
|
|
55
|
+
await withoutSchema(async () => {
|
|
56
|
+
// Validate schema name
|
|
57
|
+
if (!/^[a-z0-9_]+$/.test(schema)) {
|
|
58
|
+
throw new Error(`Invalid schema name: ${schema}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Don't allow deletion of system schemas
|
|
62
|
+
if (['public', 'pg_catalog', 'information_schema'].includes(schema)) {
|
|
63
|
+
throw new Error(`Cannot delete system schema: ${schema}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Drop the schema and all objects within it
|
|
67
|
+
await this.db.unsafe(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if a schema exists.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* const exists = await manager.schemaExists('company_123')
|
|
76
|
+
*/
|
|
77
|
+
async schemaExists(schema: string): Promise<boolean> {
|
|
78
|
+
const result = await withoutSchema(async () => {
|
|
79
|
+
return this.db.unsafe(
|
|
80
|
+
`SELECT EXISTS (
|
|
81
|
+
SELECT 1 FROM information_schema.schemata
|
|
82
|
+
WHERE schema_name = $1
|
|
83
|
+
) as exists`,
|
|
84
|
+
[schema]
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return result[0]?.exists ?? false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* List all domain schemas (excluding system schemas).
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* const schemas = await manager.listSchemas()
|
|
96
|
+
* console.log(schemas) // ['company_123', 'factory_456']
|
|
97
|
+
*/
|
|
98
|
+
async listSchemas(): Promise<string[]> {
|
|
99
|
+
const result = await withoutSchema(async () => {
|
|
100
|
+
return this.db.unsafe(`
|
|
101
|
+
SELECT schema_name
|
|
102
|
+
FROM information_schema.schemata
|
|
103
|
+
WHERE schema_name NOT IN ('public', 'pg_catalog', 'information_schema', 'pg_toast')
|
|
104
|
+
AND schema_name NOT LIKE 'pg_%'
|
|
105
|
+
ORDER BY schema_name
|
|
106
|
+
`)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
return result.map((row: any) => row.schema_name)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run migrations for a specific schema.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* await manager.migrateSchema('company_123')
|
|
117
|
+
*/
|
|
118
|
+
async migrateSchema(schema: string, migrationsPath: string = 'database/migrations/domains'): Promise<void> {
|
|
119
|
+
await withSchema(schema, async () => {
|
|
120
|
+
const tracker = new MigrationTracker(this.database, 'domains')
|
|
121
|
+
const runner = new MigrationRunner(this.database, tracker, migrationsPath, 'domains')
|
|
122
|
+
await runner.run()
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run migrations for all schemas.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* await manager.migrateAllSchemas()
|
|
131
|
+
*/
|
|
132
|
+
async migrateAllSchemas(migrationsPath?: string): Promise<void> {
|
|
133
|
+
const schemas = await this.listSchemas()
|
|
134
|
+
|
|
135
|
+
for (const schema of schemas) {
|
|
136
|
+
console.log(`Migrating schema: ${schema}`)
|
|
137
|
+
await this.migrateSchema(schema, migrationsPath)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Copy schema structure from one schema to another.
|
|
143
|
+
* Useful for creating new domains with the same structure.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* await manager.cloneSchema('public', 'company_123')
|
|
147
|
+
*/
|
|
148
|
+
async cloneSchema(sourceSchema: string, targetSchema: string): Promise<void> {
|
|
149
|
+
await withoutSchema(async () => {
|
|
150
|
+
// Validate schema names
|
|
151
|
+
if (!/^[a-z0-9_]+$/.test(sourceSchema) || !/^[a-z0-9_]+$/.test(targetSchema)) {
|
|
152
|
+
throw new Error('Invalid schema name')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Create target schema
|
|
156
|
+
await this.createSchema(targetSchema)
|
|
157
|
+
|
|
158
|
+
// Get all tables from source schema
|
|
159
|
+
const tables = await this.db.unsafe(`
|
|
160
|
+
SELECT tablename
|
|
161
|
+
FROM pg_tables
|
|
162
|
+
WHERE schemaname = $1
|
|
163
|
+
`, [sourceSchema])
|
|
164
|
+
|
|
165
|
+
// Copy each table structure (without data)
|
|
166
|
+
for (const { tablename } of tables as Array<{ tablename: string }>) {
|
|
167
|
+
await this.db.unsafe(`
|
|
168
|
+
CREATE TABLE "${targetSchema}"."${tablename}"
|
|
169
|
+
(LIKE "${sourceSchema}"."${tablename}" INCLUDING ALL)
|
|
170
|
+
`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Copy sequences
|
|
174
|
+
const sequences = await this.db.unsafe(`
|
|
175
|
+
SELECT sequence_name
|
|
176
|
+
FROM information_schema.sequences
|
|
177
|
+
WHERE sequence_schema = $1
|
|
178
|
+
`, [sourceSchema])
|
|
179
|
+
|
|
180
|
+
for (const { sequence_name } of sequences) {
|
|
181
|
+
const seqDef = await this.db.unsafe(`
|
|
182
|
+
SELECT pg_get_serial_sequence($1, $2) as seq_def
|
|
183
|
+
`, [`${sourceSchema}.${sequence_name}`, 'id'])
|
|
184
|
+
|
|
185
|
+
if (seqDef[0]?.seq_def) {
|
|
186
|
+
await this.db.unsafe(`
|
|
187
|
+
CREATE SEQUENCE "${targetSchema}"."${sequence_name}"
|
|
188
|
+
`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get the current database user.
|
|
196
|
+
*/
|
|
197
|
+
private async getCurrentUser(): Promise<string | null> {
|
|
198
|
+
const result = await this.db.unsafe('SELECT current_user')
|
|
199
|
+
return result[0]?.current_user ?? null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Execute a raw SQL query within a specific schema context.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* const users = await manager.executeSchema('company_123',
|
|
207
|
+
* 'SELECT * FROM users WHERE active = $1', [true]
|
|
208
|
+
* )
|
|
209
|
+
*/
|
|
210
|
+
async executeSchema<T = any>(
|
|
211
|
+
schema: string,
|
|
212
|
+
sql: string,
|
|
213
|
+
params?: any[]
|
|
214
|
+
): Promise<T[]> {
|
|
215
|
+
return withSchema(schema, async () => {
|
|
216
|
+
return this.db.unsafe(sql, params) as Promise<T[]>
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get table statistics for a schema.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* const stats = await manager.getSchemaStats('company_123')
|
|
225
|
+
*/
|
|
226
|
+
async getSchemaStats(schema: string): Promise<{
|
|
227
|
+
tables: number
|
|
228
|
+
totalRows: number
|
|
229
|
+
sizeInBytes: number
|
|
230
|
+
}> {
|
|
231
|
+
const result = await withoutSchema(async () => {
|
|
232
|
+
// Count tables
|
|
233
|
+
const tableCount = await this.db.unsafe(`
|
|
234
|
+
SELECT COUNT(*) as count
|
|
235
|
+
FROM information_schema.tables
|
|
236
|
+
WHERE table_schema = $1
|
|
237
|
+
AND table_type = 'BASE TABLE'
|
|
238
|
+
`, [schema])
|
|
239
|
+
|
|
240
|
+
// Get total row count across all tables
|
|
241
|
+
const tables = await this.db.unsafe(`
|
|
242
|
+
SELECT tablename
|
|
243
|
+
FROM pg_tables
|
|
244
|
+
WHERE schemaname = $1
|
|
245
|
+
`, [schema])
|
|
246
|
+
|
|
247
|
+
let totalRows = 0
|
|
248
|
+
for (const { tablename } of tables as Array<{ tablename: string }>) {
|
|
249
|
+
const countResult = await this.db.unsafe(
|
|
250
|
+
`SELECT COUNT(*) as count FROM "${schema}"."${tablename}"`
|
|
251
|
+
)
|
|
252
|
+
totalRows += Number(countResult[0]?.count ?? 0)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Get schema size
|
|
256
|
+
const sizeResult = await this.db.unsafe(`
|
|
257
|
+
SELECT pg_size_pretty(
|
|
258
|
+
SUM(pg_total_relation_size(quote_ident(schemaname)||'.'||quote_ident(tablename)))
|
|
259
|
+
) as size,
|
|
260
|
+
SUM(pg_total_relation_size(quote_ident(schemaname)||'.'||quote_ident(tablename))) as bytes
|
|
261
|
+
FROM pg_tables
|
|
262
|
+
WHERE schemaname = $1
|
|
263
|
+
`, [schema])
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
tables: Number(tableCount[0]?.count ?? 0),
|
|
267
|
+
totalRows,
|
|
268
|
+
sizeInBytes: Number(sizeResult[0]?.bytes ?? 0),
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
return result
|
|
273
|
+
}
|
|
274
|
+
}
|