@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.
Files changed (39) hide show
  1. package/package.json +46 -0
  2. package/src/database/database.ts +181 -0
  3. package/src/database/domain/context.ts +84 -0
  4. package/src/database/domain/index.ts +17 -0
  5. package/src/database/domain/manager.ts +274 -0
  6. package/src/database/domain/wrapper.ts +105 -0
  7. package/src/database/index.ts +32 -0
  8. package/src/database/introspector.ts +446 -0
  9. package/src/database/migration/differ.ts +308 -0
  10. package/src/database/migration/file_generator.ts +125 -0
  11. package/src/database/migration/index.ts +18 -0
  12. package/src/database/migration/runner.ts +145 -0
  13. package/src/database/migration/sql_generator.ts +378 -0
  14. package/src/database/migration/tracker.ts +86 -0
  15. package/src/database/migration/types.ts +189 -0
  16. package/src/database/query_builder.ts +1034 -0
  17. package/src/database/seeder.ts +31 -0
  18. package/src/helpers/identity.ts +12 -0
  19. package/src/helpers/index.ts +1 -0
  20. package/src/index.ts +5 -0
  21. package/src/orm/base_model.ts +427 -0
  22. package/src/orm/decorators.ts +290 -0
  23. package/src/orm/index.ts +3 -0
  24. package/src/providers/database_provider.ts +25 -0
  25. package/src/providers/index.ts +1 -0
  26. package/src/schema/database_representation.ts +124 -0
  27. package/src/schema/define_association.ts +60 -0
  28. package/src/schema/define_schema.ts +46 -0
  29. package/src/schema/domain_discovery.ts +83 -0
  30. package/src/schema/field_builder.ts +160 -0
  31. package/src/schema/field_definition.ts +69 -0
  32. package/src/schema/index.ts +22 -0
  33. package/src/schema/naming.ts +19 -0
  34. package/src/schema/postgres.ts +109 -0
  35. package/src/schema/registry.ts +187 -0
  36. package/src/schema/representation_builder.ts +482 -0
  37. package/src/schema/type_builder.ts +115 -0
  38. package/src/schema/types.ts +35 -0
  39. 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
+ }