Skip to content

Commit 93b999c

Browse files
committed
feat(database): configure arkorm and resora natively
Move Kysely/ArkORM/resora initialization into the framework so scaffolded apps work without an arkormx.config.ts or resora.config.js. @arkstack/database: - DatabaseConfig (multi-connection, Laravel-style) with discrete host/port/user/database/password fields; a url/DATABASE_URL overrides them. createPool/createKysely/createAdapter build the postgres adapter. - bootArkorm() configures ArkORM via Arkorm.configure() from src/config/database.ts (adapter, conventional paths, resora pagination resolver). A user-provided arkormx.config.{ts,js} still takes precedence. Wired through @arkstack/database/setup so both runtime and CLI are covered. defineArkormConfig() remains for explicit configs. resora: - resora() middleware in driver-express and driver-h3 applies config('resources') merged over resora defaults and binds the per-request context, replacing manual Resource.setCtx wiring. - MakeResource applies the resources config so make:resource works without resora.config.js. templates: - Remove arkormx.config.ts and resora.config.js; add src/config/ database.ts and src/config/resources.ts; use the resora() middleware; add DB_* to .env.example. The scaffolder strips these configs for lean projects. tests/setup.ts uses createAdapter from @arkstack/database.
1 parent 9ea94ca commit 93b999c

42 files changed

Lines changed: 684 additions & 237 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

create-arkstack/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
"@inquirer/core": "^10.2.2",
7474
"giget": "^2.0.0",
7575
"inquirer": "^12.9.6",
76-
"dotenv": "^17.4.2",
76+
"dotenv": "catalog:",
7777
"tsdown": "^0.22.0",
7878
"unrun": "^0.3.0"
7979
},
@@ -84,4 +84,4 @@
8484
"typescript": "^6.0.0"
8585
},
8686
"packageManager": "pnpm@10.14.0"
87-
}
87+
}

create-arkstack/src/data.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export const filesToRemove = [
1010
'src/core/database.ts',
1111
'src/config/filesystem.ts',
1212
'src/config/notifications.ts',
13+
'src/config/database.ts',
14+
'src/config/resources.ts',
15+
'src/config/cache.ts',
16+
'src/config/queue.ts',
1317
'prisma',
1418
'prisma.config.ts',
1519
'arkorm.config.ts',

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@arkstack/http": "workspace:^",
3333
"@arkstack/notifications": "workspace:^",
3434
"@arkstack/view": "workspace:^",
35+
"@arkstack/database": "workspace:^",
3536
"@eslint/js": "^10.0.1",
3637
"@eslint/markdown": "^8.0.1",
3738
"@faker-js/faker": "^10.4.0",
@@ -45,7 +46,7 @@
4546
"@vitest/coverage-v8": "^4.1.5",
4647
"arkormx": "^2.9.2",
4748
"clear-router": "^2.8.8",
48-
"dotenv": "^17.3.1",
49+
"dotenv": "catalog:",
4950
"escalade": "^3.2.0",
5051
"eslint": "^10.3.0",
5152
"express": "^5.2.1",
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
1-
import { MakeResource as MakeResourceBase } from 'resora'
1+
import { MakeResource as MakeResourceBase, applyRuntimeConfig, getDefaultConfig } from 'resora'
2+
3+
import { config } from '@arkstack/common'
24

35
export class MakeResource extends MakeResourceBase {
6+
async handle (): Promise<undefined> {
7+
// Apply the application's resora configuration (src/config/resources.ts)
8+
// merged over resora's defaults so generation works without a standalone
9+
// resora.config.js.
10+
try {
11+
applyRuntimeConfig({ ...getDefaultConfig(), ...config('resources', {}) })
12+
} catch {
13+
/** No resources config; fall back to resora defaults. */
14+
}
15+
16+
return super.handle()
17+
}
418
}

packages/database/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@
5757
"@arkormx/plugin-clear-router": "^0.1.46",
5858
"@arkstack/common": "workspace:^",
5959
"@arkstack/contract": "workspace:^",
60-
"arkormx": "^2.9.2"
60+
"dotenv": "catalog:",
61+
"arkormx": "^2.9.2",
62+
"kysely": "^0.28.15",
63+
"pg": "^8.20.0",
64+
"resora": "^1.3.26"
65+
},
66+
"devDependencies": {
67+
"@types/pg": "^8.16.0"
6168
}
6269
}

packages/database/src/arkorm.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { Arkorm, DB, Model, defineConfig } from 'arkormx'
2+
3+
import type { ArkormConfig } from 'arkormx'
4+
import { Arkstack } from '@arkstack/contract'
5+
import type { DatabaseConfig } from './types'
6+
import { createAdapter } from './kysely'
7+
import { createArkormCurrentPageResolver } from 'resora'
8+
import { existsSync } from 'node:fs'
9+
import { outputDir } from '@arkstack/common'
10+
import path from 'node:path'
11+
import { resolveConnection } from './config'
12+
13+
/**
14+
* Default ArkORM paths matching the scaffolded application structure. Apps with
15+
* a different layout can override them by adding an `arkormx.config.ts`.
16+
*/
17+
const defaultPaths = (): NonNullable<ArkormConfig['paths']> => {
18+
const dist = path.relative(Arkstack.rootDir(), outputDir())
19+
20+
return {
21+
models: './src/app/models',
22+
factories: './src/database/factories',
23+
seeders: './src/database/seeders',
24+
migrations: './src/database/migrations',
25+
buildOutput: dist,
26+
}
27+
}
28+
29+
/**
30+
* Whether the application provides its own `arkormx.config.{ts,js}`. When it
31+
* does, ArkORM loads it and it takes precedence over the framework defaults.
32+
*/
33+
const hasUserArkormConfig = (): boolean => {
34+
const root = Arkstack.rootDir()
35+
36+
return existsSync(path.join(root, 'arkormx.config.ts'))
37+
|| existsSync(path.join(root, 'arkormx.config.js'))
38+
}
39+
40+
export interface DefineArkormConfigOptions extends Partial<ArkormConfig> {
41+
/**
42+
* The application's database configuration. When provided (and no explicit
43+
* `adapter` is set), the adapter is built from the resolved connection.
44+
*/
45+
database?: DatabaseConfig
46+
/**
47+
* The connection name to bind. Defaults to `database.default`.
48+
*/
49+
connection?: string
50+
}
51+
52+
/**
53+
* Build the ArkORM config object for an application from its database
54+
* configuration. Use this only when authoring an explicit `arkormx.config.ts`;
55+
* by default the framework configures ArkORM for you (see {@link bootArkorm}).
56+
*
57+
* @param options Arkorm config plus the app's database config.
58+
*/
59+
export const defineArkormConfig = (options: DefineArkormConfigOptions = {}) => {
60+
const { database, connection, adapter, pagination, ...rest } = options
61+
62+
let resolvedAdapter = adapter
63+
64+
if (!resolvedAdapter && database) {
65+
const name = connection ?? database.default
66+
const resolved = database.connections[name]
67+
68+
if (!resolved) {
69+
throw new Error(`Database connection "${name}" is not configured.`)
70+
}
71+
72+
resolvedAdapter = createAdapter(resolved)
73+
}
74+
75+
return defineConfig({
76+
...rest,
77+
...(resolvedAdapter ? { adapter: resolvedAdapter } : {}),
78+
pagination: {
79+
resolveCurrentPage: createArkormCurrentPageResolver(),
80+
...(pagination ?? {}),
81+
},
82+
})
83+
}
84+
85+
export interface BootArkormOptions extends Partial<ArkormConfig> {
86+
/** The connection name to bind. Defaults to `database.default`. */
87+
connection?: string
88+
}
89+
90+
/**
91+
* Configure ArkORM natively from `src/config/database.ts`, so applications work
92+
* without an `arkormx.config.ts`.
93+
*
94+
* Builds the adapter from the default (or named) connection, registers the
95+
* conventional paths and the resora pagination resolver, and binds models. A
96+
* user-provided `arkormx.config.{ts,js}` always wins — in that case this is a
97+
* no-op and ArkORM loads the file instead.
98+
*
99+
* @param options Optional overrides merged over the derived config.
100+
* @returns Whether the framework applied its configuration.
101+
*/
102+
export const bootArkorm = (options: BootArkormOptions = {}): boolean => {
103+
if (hasUserArkormConfig()) {
104+
return false
105+
}
106+
107+
const { connection, adapter, paths, pagination, ...rest } = options
108+
const resolvedAdapter = adapter ?? createAdapter(resolveConnection(connection))
109+
110+
Arkorm.configure({
111+
adapter: resolvedAdapter,
112+
paths: { ...defaultPaths(), ...(paths ?? {}) },
113+
pagination: {
114+
resolveCurrentPage: createArkormCurrentPageResolver(),
115+
...(pagination ?? {}),
116+
},
117+
outputExt: 'ts',
118+
...rest,
119+
})
120+
121+
Model.setAdapter(resolvedAdapter)
122+
DB.setAdapter(resolvedAdapter)
123+
124+
return true
125+
}

packages/database/src/config.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { ConnectionConfig, DatabaseConfig } from './types'
2+
import { DotPath, DotPathValue, config } from '@arkstack/common'
3+
4+
/**
5+
* Read a value from the `database` configuration namespace with a fallback.
6+
*
7+
* Never throws when the config file is missing; returns the default instead.
8+
*
9+
* @param key Dot path within the database config.
10+
* @param defaultValue Value returned when the key is not set.
11+
*/
12+
export const configure = <T extends DotPath<DatabaseConfig>> (
13+
key: T,
14+
defaultValue: unknown,
15+
): DotPathValue<DatabaseConfig, T> => {
16+
try {
17+
return config(`database.${key}`, defaultValue) as never
18+
} catch {
19+
return defaultValue as never
20+
}
21+
}
22+
23+
/**
24+
* Resolve a configured {@link ConnectionConfig} by name, or the default
25+
* connection when none is given.
26+
*
27+
* @param name The connection name. Defaults to `database.default`.
28+
* @throws when the named connection is not configured.
29+
*/
30+
export const resolveConnection = (name?: string): ConnectionConfig => {
31+
const connection = name ?? (configure('default', 'pgsql'))
32+
const connections = configure('connections', {})
33+
const resolved = connections[connection]
34+
35+
if (!resolved) {
36+
throw new Error(`Database connection "${connection}" is not configured.`)
37+
}
38+
39+
return resolved
40+
}

packages/database/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@ export * from './extensions/Migration'
44
export * from './extensions/Model'
55
export * from './extensions/SchemaBuilder'
66
export * from './extensions/Seeder'
7-
export * from './ValidatorDBDriver'
7+
export * from './ValidatorDBDriver'
8+
export * from './types'
9+
export * from './config'
10+
export * from './kysely'
11+
export * from './arkorm'

packages/database/src/kysely.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Pool, type PoolConfig } from 'pg'
2+
3+
import { Kysely, PostgresDialect } from 'kysely'
4+
5+
import type { ConnectionConfig } from './types'
6+
import { createKyselyAdapter } from 'arkormx'
7+
8+
/**
9+
* Build a pg {@link Pool} from a {@link ConnectionConfig}. A `url` (or
10+
* `DATABASE_URL`) wins over the discrete `host`/`port`/`user`/... fields.
11+
*
12+
* @param connection
13+
* @returns
14+
*/
15+
export const createPool = (connection: ConnectionConfig): Pool => {
16+
const poolConfig: PoolConfig = connection.url
17+
? { connectionString: connection.url }
18+
: {
19+
host: connection.host,
20+
port: connection.port,
21+
user: connection.user,
22+
database: connection.database,
23+
password: connection.password,
24+
}
25+
26+
if (connection.ssl !== undefined) {
27+
poolConfig.ssl = connection.ssl as PoolConfig['ssl']
28+
}
29+
30+
if (connection.pool?.max !== undefined) {
31+
poolConfig.max = connection.pool.max
32+
}
33+
34+
if (connection.pool?.idleTimeoutMillis !== undefined) {
35+
poolConfig.idleTimeoutMillis = connection.pool.idleTimeoutMillis
36+
}
37+
38+
if (connection.pool?.connectionTimeoutMillis !== undefined) {
39+
poolConfig.connectionTimeoutMillis = connection.pool.connectionTimeoutMillis
40+
}
41+
42+
return new Pool(poolConfig)
43+
}
44+
45+
/**
46+
* Build a Kysely instance for the given connection using the Postgres dialect.
47+
*
48+
* @param connection
49+
* @returns
50+
*/
51+
export const createKysely = <DB = Record<string, never>> (connection: ConnectionConfig): Kysely<DB> => {
52+
return new Kysely<DB>({
53+
dialect: new PostgresDialect({ pool: createPool(connection) }),
54+
})
55+
}
56+
57+
/**
58+
* Build an ArkORM Kysely adapter for the given connection. This is what binds
59+
* models to the database.
60+
*
61+
* @param connection
62+
* @returns
63+
*/
64+
export const createAdapter = (connection: ConnectionConfig) => {
65+
return createKyselyAdapter(createKysely(connection))
66+
}

packages/database/src/setup.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
1+
import 'dotenv/config'
2+
13
import { CoreRouter } from 'clear-router/core'
24
import { Validator } from 'kanun'
35
import { ValidatorDBDriver } from './ValidatorDBDriver'
6+
import { bootArkorm } from './arkorm'
47
import { clearRouterPlugin } from '@arkormx/plugin-clear-router'
58

69
CoreRouter.use(clearRouterPlugin)
7-
Validator.useDatabase(new ValidatorDBDriver())
10+
Validator.useDatabase(new ValidatorDBDriver())
11+
12+
/**
13+
* Configure ArkORM from `src/config/database.ts` so the application works
14+
* without an `arkormx.config.ts`. A user-provided config file still takes
15+
* precedence, and an unconfigured database (e.g. some CLI contexts) is ignored.
16+
*/
17+
try {
18+
bootArkorm()
19+
} catch {
20+
/** Database not configured in this context. */
21+
}

0 commit comments

Comments
 (0)