@stravigor/core 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/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -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 +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import EnvLoader from './loaders/env_loader'
|
|
4
|
+
import TypescriptLoader from './loaders/typescript_loader'
|
|
5
|
+
import type { ConfigData, ConfigurationLoader } from './types'
|
|
6
|
+
|
|
7
|
+
export default class Configuration {
|
|
8
|
+
private data: ConfigData = {}
|
|
9
|
+
private configPath: string
|
|
10
|
+
private environment?: string
|
|
11
|
+
private loaders: ConfigurationLoader[] = [new EnvLoader(), new TypescriptLoader()]
|
|
12
|
+
|
|
13
|
+
constructor(configPath: string = './config', environment?: string) {
|
|
14
|
+
this.configPath = configPath
|
|
15
|
+
this.environment = environment
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scan the config directory and load every supported file.
|
|
20
|
+
* Each file is stored under a key derived from its name
|
|
21
|
+
* (e.g. `database.ts` → `"database"`).
|
|
22
|
+
*/
|
|
23
|
+
async load(): Promise<void> {
|
|
24
|
+
let files: string[]
|
|
25
|
+
try {
|
|
26
|
+
files = readdirSync(this.configPath)
|
|
27
|
+
} catch {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const filePath = join(this.configPath, file)
|
|
33
|
+
const loader = this.loaders.find(l => l.supports(filePath))
|
|
34
|
+
if (!loader) continue
|
|
35
|
+
|
|
36
|
+
const config = await loader.load(filePath, this.environment)
|
|
37
|
+
if (config === null) continue
|
|
38
|
+
|
|
39
|
+
let key = basename(file, '.' + file.split('.').pop())
|
|
40
|
+
// Handle dotfiles like .env where basename strips the entire name
|
|
41
|
+
if (!key) key = file.replace(/^\./, '')
|
|
42
|
+
this.data[key] = config
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Retrieve a config value using dot notation.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* config.get('database.host') // value of data.database.host
|
|
51
|
+
* config.get('database.port', 3306) // with fallback
|
|
52
|
+
*/
|
|
53
|
+
get(key: string, defaultValue?: any): any {
|
|
54
|
+
const parts = key.split('.')
|
|
55
|
+
let current: any = this.data
|
|
56
|
+
|
|
57
|
+
for (const part of parts) {
|
|
58
|
+
if (current === undefined || current === null || typeof current !== 'object') {
|
|
59
|
+
return defaultValue
|
|
60
|
+
}
|
|
61
|
+
current = current[part]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return current !== undefined ? current : defaultValue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check whether a key exists (even if its value is `undefined`). */
|
|
68
|
+
has(key: string): boolean {
|
|
69
|
+
const parts = key.split('.')
|
|
70
|
+
let current: any = this.data
|
|
71
|
+
|
|
72
|
+
for (const part of parts) {
|
|
73
|
+
if (current === undefined || current === null || typeof current !== 'object') {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
if (!(part in current)) {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
current = current[part]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Set a config value using dot notation. */
|
|
86
|
+
set(key: string, value: any): void {
|
|
87
|
+
const parts = key.split('.')
|
|
88
|
+
const last = parts.pop()!
|
|
89
|
+
let current: any = this.data
|
|
90
|
+
|
|
91
|
+
for (const part of parts) {
|
|
92
|
+
if (current[part] === undefined || typeof current[part] !== 'object') {
|
|
93
|
+
current[part] = {}
|
|
94
|
+
}
|
|
95
|
+
current = current[part]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
current[last] = value
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Return all loaded config data. */
|
|
102
|
+
all(): ConfigData {
|
|
103
|
+
return this.data
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ConfigData, ConfigurationLoader } from '../types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Abstract base class for configuration loaders.
|
|
5
|
+
*
|
|
6
|
+
* Subclasses declare the file extensions they handle via {@link extensions} and
|
|
7
|
+
* implement {@link load} to parse a specific file format. Shared helpers for
|
|
8
|
+
* extension matching and environment-based config extraction are provided here.
|
|
9
|
+
*/
|
|
10
|
+
export abstract class BaseConfigurationLoader implements ConfigurationLoader {
|
|
11
|
+
/** File extensions this loader can handle (without the leading dot). */
|
|
12
|
+
protected extensions: string[] = []
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load and parse a configuration file.
|
|
16
|
+
*
|
|
17
|
+
* @param filePath - Absolute path to the configuration file.
|
|
18
|
+
* @param environment - Optional environment key (e.g. `"production"`) used
|
|
19
|
+
* to select a nested section from the loaded config.
|
|
20
|
+
* @returns The parsed configuration data, or `null` if the file does not exist.
|
|
21
|
+
*/
|
|
22
|
+
abstract load(filePath: string, environment?: string): Promise<ConfigData>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check whether this loader supports the given file path based on its
|
|
26
|
+
* extension.
|
|
27
|
+
*
|
|
28
|
+
* @param filePath - Path to test.
|
|
29
|
+
* @returns `true` if the file's extension is in {@link extensions}.
|
|
30
|
+
*/
|
|
31
|
+
supports(filePath: string): boolean {
|
|
32
|
+
const ext = this.getFileExtension(filePath)
|
|
33
|
+
return this.extensions.includes(ext)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extract the lowercase file extension from a path.
|
|
38
|
+
*
|
|
39
|
+
* @param filePath - The file path to inspect.
|
|
40
|
+
* @returns The extension without a leading dot, or an empty string if none.
|
|
41
|
+
*/
|
|
42
|
+
protected getFileExtension(filePath: string): string {
|
|
43
|
+
return filePath.split('.').pop()?.toLowerCase() || ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return the environment-specific subset of a configuration object.
|
|
48
|
+
*
|
|
49
|
+
* If `environment` is provided and the config object contains a matching
|
|
50
|
+
* top-level key, that nested value is returned. Otherwise the full config
|
|
51
|
+
* is returned unchanged.
|
|
52
|
+
*
|
|
53
|
+
* @param config - The full configuration object.
|
|
54
|
+
* @param environment - Optional environment key to look up.
|
|
55
|
+
* @returns The environment subset, or the original config if no match.
|
|
56
|
+
*/
|
|
57
|
+
protected extractEnvironmentConfig(config: any, environment?: string): any {
|
|
58
|
+
if (!environment || typeof config !== 'object' || config === null) {
|
|
59
|
+
return config
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check if config has environment-specific sections
|
|
63
|
+
if (config[environment]) {
|
|
64
|
+
return config[environment]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return config
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { BaseConfigurationLoader } from './base_loader'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration loader for `.env` files.
|
|
6
|
+
*
|
|
7
|
+
* Loads the base `.env` file first, then merges values from an
|
|
8
|
+
* environment-specific file (e.g. `.env.test`, `.env.production`) on top,
|
|
9
|
+
* so environment-specific values override the base ones.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // .env
|
|
13
|
+
* DB_HOST=localhost
|
|
14
|
+
* DB_PORT=5432
|
|
15
|
+
*
|
|
16
|
+
* // .env.production
|
|
17
|
+
* DB_HOST=db.example.com
|
|
18
|
+
*
|
|
19
|
+
* // Result when environment is "production":
|
|
20
|
+
* // { DB_HOST: "db.example.com", DB_PORT: "5432" }
|
|
21
|
+
*/
|
|
22
|
+
export default class EnvLoader extends BaseConfigurationLoader {
|
|
23
|
+
protected override extensions = ['env']
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load a `.env` file and optionally merge an environment-specific override
|
|
27
|
+
* file on top.
|
|
28
|
+
*
|
|
29
|
+
* Given a `filePath` of `/app/config/.env`, this will:
|
|
30
|
+
* 1. Parse `/app/config/.env` (if it exists)
|
|
31
|
+
* 2. Parse `/app/config/.env.{environment}` (if `environment` is provided and the file exists)
|
|
32
|
+
* 3. Return the merged result, with environment-specific values taking precedence.
|
|
33
|
+
*
|
|
34
|
+
* @param filePath - Absolute path to the base `.env` file.
|
|
35
|
+
* @param environment - Optional environment name (e.g. `"test"`, `"production"`).
|
|
36
|
+
* @returns The merged key-value pairs, or `null` if the base file does not exist.
|
|
37
|
+
* @throws If a file exists but cannot be read or parsed.
|
|
38
|
+
*/
|
|
39
|
+
async load(filePath: string, environment?: string): Promise<any> {
|
|
40
|
+
const baseConfig = await this.parseEnvFile(filePath)
|
|
41
|
+
|
|
42
|
+
if (baseConfig === null) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!environment) {
|
|
47
|
+
return baseConfig
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dir = dirname(filePath)
|
|
51
|
+
const envFilePath = join(dir, `.env.${environment}`)
|
|
52
|
+
const envConfig = await this.parseEnvFile(envFilePath)
|
|
53
|
+
|
|
54
|
+
if (envConfig === null) {
|
|
55
|
+
return baseConfig
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { ...baseConfig, ...envConfig }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse a single `.env` file into a key-value object.
|
|
63
|
+
*
|
|
64
|
+
* Supports:
|
|
65
|
+
* - `KEY=value` pairs
|
|
66
|
+
* - Quoted values (single and double quotes are stripped)
|
|
67
|
+
* - Comments (lines starting with `#`)
|
|
68
|
+
* - Blank lines (ignored)
|
|
69
|
+
*
|
|
70
|
+
* @param filePath - Absolute path to the `.env` file.
|
|
71
|
+
* @returns The parsed key-value pairs, or `null` if the file does not exist.
|
|
72
|
+
*/
|
|
73
|
+
private async parseEnvFile(filePath: string): Promise<Record<string, string> | null> {
|
|
74
|
+
const file = Bun.file(filePath)
|
|
75
|
+
|
|
76
|
+
if (!(await file.exists())) {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const content = await file.text()
|
|
81
|
+
const result: Record<string, string> = {}
|
|
82
|
+
|
|
83
|
+
for (const line of content.split('\n')) {
|
|
84
|
+
const trimmed = line.trim()
|
|
85
|
+
|
|
86
|
+
// Skip empty lines and comments
|
|
87
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const separatorIndex = trimmed.indexOf('=')
|
|
92
|
+
if (separatorIndex === -1) {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const key = trimmed.slice(0, separatorIndex).trim()
|
|
97
|
+
let value = trimmed.slice(separatorIndex + 1).trim()
|
|
98
|
+
|
|
99
|
+
// Strip surrounding quotes
|
|
100
|
+
if (
|
|
101
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
102
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
103
|
+
) {
|
|
104
|
+
value = value.slice(1, -1)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
result[key] = value
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return result
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { BaseConfigurationLoader } from './base_loader'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration loader for TypeScript and JavaScript files.
|
|
7
|
+
*
|
|
8
|
+
* Dynamically imports `.ts` and `.js` configuration files using Bun's native
|
|
9
|
+
* TypeScript support, and optionally extracts an environment-specific section
|
|
10
|
+
* from the exported config object.
|
|
11
|
+
*
|
|
12
|
+
* Config files should default-export a configuration object. When an
|
|
13
|
+
* `environment` is provided, the loader looks for a matching top-level key
|
|
14
|
+
* and returns that subset instead of the full object.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // config/database.ts
|
|
18
|
+
* export default {
|
|
19
|
+
* development: { host: 'localhost', port: 5432 },
|
|
20
|
+
* production: { host: 'db.example.com', port: 5432 },
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
export default class TypescriptLoader extends BaseConfigurationLoader {
|
|
24
|
+
protected override extensions = ['ts', 'js']
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load and evaluate a TypeScript or JavaScript configuration file.
|
|
28
|
+
*
|
|
29
|
+
* @param filePath - Absolute path to the config file.
|
|
30
|
+
* @param environment - Optional environment key (e.g. `"production"`) used
|
|
31
|
+
* to extract a nested section from the config object.
|
|
32
|
+
* @returns The resolved configuration object, the environment-specific
|
|
33
|
+
* subset if found, or `null` if the file does not exist.
|
|
34
|
+
* @throws If the file exists but cannot be imported or evaluated.
|
|
35
|
+
*/
|
|
36
|
+
async load(filePath: string, environment?: string): Promise<any> {
|
|
37
|
+
if (!existsSync(filePath)) {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// Convert to file URL for dynamic import
|
|
43
|
+
const fileUrl = pathToFileURL(filePath).href
|
|
44
|
+
const module = await import(fileUrl + '?t=' + Date.now())
|
|
45
|
+
|
|
46
|
+
let config = module.default || module
|
|
47
|
+
|
|
48
|
+
// Extract environment-specific config
|
|
49
|
+
config = this.extractEnvironmentConfig(config, environment)
|
|
50
|
+
|
|
51
|
+
return config
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error(`Failed to load TypeScript/JavaScript config from ${filePath}: ${error}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
import { INJECTABLE } from './inject.ts'
|
|
3
|
+
|
|
4
|
+
/** Constructor type for injectable classes. */
|
|
5
|
+
type Constructor<T = any> = new (...args: any[]) => T
|
|
6
|
+
|
|
7
|
+
/** A factory function that receives the container and returns a service instance. */
|
|
8
|
+
type Factory<T = any> = (container: Container) => T
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A lightweight dependency injection container.
|
|
12
|
+
*
|
|
13
|
+
* Services are registered as factory functions or `@inject`-decorated classes
|
|
14
|
+
* and resolved by string name or class constructor.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const app = new Container()
|
|
18
|
+
* .singleton(Database)
|
|
19
|
+
* .singleton(UserService) // @inject decorated
|
|
20
|
+
* .singleton('logger', () => new Logger())
|
|
21
|
+
*
|
|
22
|
+
* const svc = app.resolve(UserService) // Database auto-injected
|
|
23
|
+
*/
|
|
24
|
+
export default class Container {
|
|
25
|
+
private factories = new Map<any, { factory: Factory; singleton: boolean }>()
|
|
26
|
+
private instances = new Map<any, any>()
|
|
27
|
+
|
|
28
|
+
/** Create a factory from a class constructor, resolving `design:paramtypes` metadata. */
|
|
29
|
+
private classToFactory<T>(Cls: Constructor<T>): Factory<T> {
|
|
30
|
+
const paramTypes: Constructor[] = Reflect.getMetadata('design:paramtypes', Cls) ?? []
|
|
31
|
+
return (c: Container) => new Cls(...paramTypes.map(dep => c.resolve(dep)))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wrap an `@inject`-decorated class in a factory, or return a plain factory as-is. */
|
|
35
|
+
private toFactory<T>(factoryOrClass: Factory<T> | Constructor<T>): Factory<T> {
|
|
36
|
+
if (INJECTABLE in factoryOrClass) {
|
|
37
|
+
return this.classToFactory(factoryOrClass as Constructor<T>)
|
|
38
|
+
}
|
|
39
|
+
return factoryOrClass as Factory<T>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Register an `@inject` class that creates a new instance on every {@link resolve} call. */
|
|
43
|
+
register<T>(ctor: Constructor<T>): this
|
|
44
|
+
/** Register a factory under a class constructor key. Creates a new instance on every {@link resolve} call. */
|
|
45
|
+
register<T>(ctor: Constructor<T>, factory: Factory<T>): this
|
|
46
|
+
/** Register a factory or `@inject` class under a string name. Creates a new instance on every {@link resolve} call. */
|
|
47
|
+
register<T>(name: string, factory: Factory<T> | Constructor<T>): this
|
|
48
|
+
register<T>(nameOrCtor: string | Constructor<T>, factory?: Factory<T> | Constructor<T>): this {
|
|
49
|
+
if (typeof nameOrCtor === 'function') {
|
|
50
|
+
const resolved = factory
|
|
51
|
+
? this.toFactory(factory)
|
|
52
|
+
: this.classToFactory(nameOrCtor)
|
|
53
|
+
this.factories.set(nameOrCtor, { factory: resolved, singleton: false })
|
|
54
|
+
} else {
|
|
55
|
+
this.factories.set(nameOrCtor, { factory: this.toFactory(factory!), singleton: false })
|
|
56
|
+
}
|
|
57
|
+
return this
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Register an `@inject` class as a singleton by its constructor. */
|
|
61
|
+
singleton<T>(ctor: Constructor<T>): this
|
|
62
|
+
/** Register a factory under a class constructor key (resolved by constructor). */
|
|
63
|
+
singleton<T>(ctor: Constructor<T>, factory: Factory<T>): this
|
|
64
|
+
/** Register a factory or `@inject` class as a singleton under a string name. */
|
|
65
|
+
singleton<T>(name: string, factory: Factory<T> | Constructor<T>): this
|
|
66
|
+
singleton<T>(nameOrCtor: string | Constructor<T>, factory?: Factory<T> | Constructor<T>): this {
|
|
67
|
+
if (typeof nameOrCtor === 'function') {
|
|
68
|
+
const resolved = factory
|
|
69
|
+
? this.toFactory(factory)
|
|
70
|
+
: this.classToFactory(nameOrCtor)
|
|
71
|
+
this.factories.set(nameOrCtor, { factory: resolved, singleton: true })
|
|
72
|
+
} else {
|
|
73
|
+
this.factories.set(nameOrCtor, { factory: this.toFactory(factory!), singleton: true })
|
|
74
|
+
}
|
|
75
|
+
return this
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve a service by its class constructor. */
|
|
79
|
+
resolve<T>(ctor: Constructor<T>): T
|
|
80
|
+
/** Resolve a service by its string name. */
|
|
81
|
+
resolve<T>(name: string): T
|
|
82
|
+
resolve<T>(key: string | Constructor<T>): T {
|
|
83
|
+
const entry = this.factories.get(key)
|
|
84
|
+
if (!entry) {
|
|
85
|
+
const label = typeof key === 'string' ? `"${key}"` : key.name
|
|
86
|
+
throw new Error(`Service ${label} is not registered`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (entry.singleton) {
|
|
90
|
+
if (!this.instances.has(key)) {
|
|
91
|
+
this.instances.set(key, entry.factory(this))
|
|
92
|
+
}
|
|
93
|
+
return this.instances.get(key)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return entry.factory(this)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Check whether a service has been registered under the given name or constructor. */
|
|
100
|
+
has(key: string | Constructor): boolean {
|
|
101
|
+
return this.factories.has(key)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Instantiate a class with automatic dependency injection.
|
|
106
|
+
*
|
|
107
|
+
* Unlike {@link resolve}, this does not require prior registration.
|
|
108
|
+
* Constructor dependencies are resolved recursively: registered services
|
|
109
|
+
* are pulled from the container, unregistered `@inject` classes are
|
|
110
|
+
* instantiated via `make()` as well.
|
|
111
|
+
*/
|
|
112
|
+
make<T>(ctor: Constructor<T>): T {
|
|
113
|
+
const paramTypes: Constructor[] = Reflect.getMetadata('design:paramtypes', ctor) ?? []
|
|
114
|
+
const deps = paramTypes.map(dep => (this.has(dep) ? this.resolve(dep) : this.make(dep)))
|
|
115
|
+
return new ctor(...deps)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
|
|
3
|
+
/** Symbol used to mark a class as injectable. */
|
|
4
|
+
export const INJECTABLE = Symbol('inject:injectable')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Class decorator that marks a class as injectable.
|
|
8
|
+
*
|
|
9
|
+
* With `emitDecoratorMetadata` enabled, TypeScript automatically emits
|
|
10
|
+
* constructor parameter type metadata (`design:paramtypes`). The container
|
|
11
|
+
* reads this metadata to auto-resolve dependencies by class reference.
|
|
12
|
+
*
|
|
13
|
+
* Works as both `@inject` and `@inject()`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* @inject
|
|
17
|
+
* class UserService {
|
|
18
|
+
* constructor(protected db: Database, protected logger: Logger) {}
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* container.singleton(Database)
|
|
22
|
+
* container.singleton(Logger)
|
|
23
|
+
* container.singleton(UserService)
|
|
24
|
+
* container.resolve(UserService) // db and logger auto-injected by type
|
|
25
|
+
*/
|
|
26
|
+
export function inject<T extends Function>(target: T): void
|
|
27
|
+
export function inject(): <T extends Function>(target: T) => void
|
|
28
|
+
export function inject(target?: any) {
|
|
29
|
+
const mark = (cls: any) => {
|
|
30
|
+
Object.defineProperty(cls, INJECTABLE, { value: true, enumerable: false })
|
|
31
|
+
}
|
|
32
|
+
if (typeof target === 'function') {
|
|
33
|
+
mark(target)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
return (cls: any) => {
|
|
37
|
+
mark(cls)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import Configuration from '../config/configuration.ts'
|
|
3
|
+
import { inject } from '../core/inject.ts'
|
|
4
|
+
import { ConfigurationError } from '../exceptions/errors.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Database connection wrapper backed by {@link SQL Bun.sql}.
|
|
8
|
+
*
|
|
9
|
+
* Reads connection credentials from the `database.*` configuration keys
|
|
10
|
+
* (loaded from `config/database.ts` / `.env`).
|
|
11
|
+
*
|
|
12
|
+
* Register as a singleton in the DI container so a single connection pool
|
|
13
|
+
* is shared across the application.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* container.singleton(Configuration)
|
|
17
|
+
* container.singleton(Database)
|
|
18
|
+
* const db = container.resolve(Database)
|
|
19
|
+
* const rows = await db.sql`SELECT 1 AS result`
|
|
20
|
+
*/
|
|
21
|
+
@inject
|
|
22
|
+
export default class Database {
|
|
23
|
+
private static _connection: SQL | null = null
|
|
24
|
+
private connection: SQL
|
|
25
|
+
|
|
26
|
+
constructor(protected config: Configuration) {
|
|
27
|
+
this.connection = new SQL({
|
|
28
|
+
hostname: config.get('database.host', '127.0.0.1'),
|
|
29
|
+
port: config.get('database.port', 5432),
|
|
30
|
+
username: config.get('database.username', 'postgres'),
|
|
31
|
+
password: config.get('database.password', ''),
|
|
32
|
+
database: config.get('database.database', 'strav'),
|
|
33
|
+
})
|
|
34
|
+
Database._connection = this.connection
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The underlying Bun SQL tagged-template client. */
|
|
38
|
+
get sql(): SQL {
|
|
39
|
+
return this.connection
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The global SQL connection, available after DI bootstrap. */
|
|
43
|
+
static get raw(): SQL {
|
|
44
|
+
if (!Database._connection) {
|
|
45
|
+
throw new ConfigurationError('Database not configured. Resolve Database through the container first.')
|
|
46
|
+
}
|
|
47
|
+
return Database._connection
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Close the connection pool. */
|
|
51
|
+
async close(): Promise<void> {
|
|
52
|
+
await this.connection.close()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SQL } from 'bun'
|
|
2
|
+
import Database from './database.ts'
|
|
3
|
+
|
|
4
|
+
export { default as Database } from './database.ts'
|
|
5
|
+
export { default as DatabaseIntrospector } from './introspector.ts'
|
|
6
|
+
export { default as QueryBuilder, query } from './query_builder.ts'
|
|
7
|
+
export type { PaginationResult, PaginationMeta } from './query_builder.ts'
|
|
8
|
+
export * from './migration/index.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pre-configured SQL tagged-template client.
|
|
12
|
+
*
|
|
13
|
+
* A transparent proxy to the Database singleton's underlying Bun SQL connection.
|
|
14
|
+
* Available after the Database is resolved through the DI container.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* import { sql } from '@stravigor/core/database'
|
|
18
|
+
* const rows = await sql`SELECT * FROM "user" WHERE "id" = ${id}`
|
|
19
|
+
* await sql.begin(async (tx) => { ... })
|
|
20
|
+
*/
|
|
21
|
+
export const sql: SQL = new Proxy((() => {}) as unknown as SQL, {
|
|
22
|
+
apply(_target, _thisArg, args) {
|
|
23
|
+
return (Database.raw as any)(...args)
|
|
24
|
+
},
|
|
25
|
+
get(_target, prop) {
|
|
26
|
+
const real = Database.raw
|
|
27
|
+
const val = (real as any)[prop]
|
|
28
|
+
return typeof val === 'function' ? val.bind(real) : val
|
|
29
|
+
},
|
|
30
|
+
})
|