@igstack/app-catalog-backend-core 0.0.1

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1934 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +2539 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +84 -0
  7. package/prisma/migrations/20250526183023_init/migration.sql +71 -0
  8. package/prisma/migrations/migration_lock.toml +3 -0
  9. package/prisma/schema.prisma +149 -0
  10. package/src/__tests__/dummy.test.ts +7 -0
  11. package/src/db/client.ts +42 -0
  12. package/src/db/index.ts +21 -0
  13. package/src/db/syncAppCatalog.ts +312 -0
  14. package/src/db/tableSyncMagazine.ts +32 -0
  15. package/src/db/tableSyncPrismaAdapter.ts +203 -0
  16. package/src/index.ts +126 -0
  17. package/src/middleware/backendResolver.ts +42 -0
  18. package/src/middleware/createEhMiddleware.ts +171 -0
  19. package/src/middleware/database.ts +62 -0
  20. package/src/middleware/featureRegistry.ts +173 -0
  21. package/src/middleware/index.ts +43 -0
  22. package/src/middleware/types.ts +202 -0
  23. package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
  24. package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
  25. package/src/modules/appCatalog/service.ts +130 -0
  26. package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +187 -0
  27. package/src/modules/appCatalogAdmin/catalogBackupController.ts +213 -0
  28. package/src/modules/approvalMethod/approvalMethodRouter.ts +169 -0
  29. package/src/modules/approvalMethod/slugUtils.ts +17 -0
  30. package/src/modules/approvalMethod/syncApprovalMethods.ts +38 -0
  31. package/src/modules/assets/assetRestController.ts +271 -0
  32. package/src/modules/assets/assetUtils.ts +114 -0
  33. package/src/modules/assets/screenshotRestController.ts +195 -0
  34. package/src/modules/assets/screenshotRouter.ts +112 -0
  35. package/src/modules/assets/syncAssets.ts +277 -0
  36. package/src/modules/assets/upsertAsset.ts +46 -0
  37. package/src/modules/auth/auth.ts +51 -0
  38. package/src/modules/auth/authProviders.ts +40 -0
  39. package/src/modules/auth/authRouter.ts +75 -0
  40. package/src/modules/auth/authorizationUtils.ts +132 -0
  41. package/src/modules/auth/devMockUserUtils.ts +49 -0
  42. package/src/modules/auth/registerAuthRoutes.ts +33 -0
  43. package/src/modules/icons/iconRestController.ts +171 -0
  44. package/src/modules/icons/iconRouter.ts +180 -0
  45. package/src/modules/icons/iconService.ts +73 -0
  46. package/src/modules/icons/iconUtils.ts +46 -0
  47. package/src/prisma-json-types.d.ts +34 -0
  48. package/src/server/controller.ts +47 -0
  49. package/src/server/ehStaticControllerContract.ts +19 -0
  50. package/src/server/ehTrpcContext.ts +26 -0
  51. package/src/server/trpcSetup.ts +89 -0
  52. package/src/types/backend/api.ts +73 -0
  53. package/src/types/backend/common.ts +10 -0
  54. package/src/types/backend/companySpecificBackend.ts +5 -0
  55. package/src/types/backend/dataSources.ts +25 -0
  56. package/src/types/backend/deployments.ts +40 -0
  57. package/src/types/common/app/appTypes.ts +13 -0
  58. package/src/types/common/app/ui/appUiTypes.ts +12 -0
  59. package/src/types/common/appCatalogTypes.ts +65 -0
  60. package/src/types/common/approvalMethodTypes.ts +149 -0
  61. package/src/types/common/env/envTypes.ts +7 -0
  62. package/src/types/common/resourceTypes.ts +8 -0
  63. package/src/types/common/sharedTypes.ts +5 -0
  64. package/src/types/index.ts +21 -0
@@ -0,0 +1,203 @@
1
+ import { tableSync } from '@igstack/app-catalog-table-sync'
2
+ import type { Prisma, PrismaClient } from '@prisma/client'
3
+ import type * as runtime from '@prisma/client/runtime/library'
4
+ import { mapValues, omit, pick } from 'radashi'
5
+
6
+ export type ScalarKeys<TPrismaModelName extends Prisma.ModelName> =
7
+ keyof Prisma.TypeMap['model'][TPrismaModelName]['payload']['scalars']
8
+ export type ObjectKeys<TPrismaModelName extends Prisma.ModelName> =
9
+ keyof Prisma.TypeMap['model'][TPrismaModelName]['payload']['objects']
10
+
11
+ export type ScalarFilter<TPrismaModelName extends Prisma.ModelName> = Partial<
12
+ Prisma.TypeMap['model'][TPrismaModelName]['payload']['scalars']
13
+ >
14
+
15
+ export type GetOperationFns<TModel extends Prisma.ModelName> = {
16
+ [TOperation in keyof Prisma.TypeMap['model']['DbAppForCatalog']['operations']]: (
17
+ args: Prisma.TypeMap['model'][TModel]['operations'][TOperation]['args'],
18
+ ) => Promise<
19
+ Prisma.TypeMap['model'][TModel]['operations'][TOperation]['result']
20
+ >
21
+ }
22
+
23
+ export interface TableSyncParamsPrisma<
24
+ TPrismaClient extends PrismaClient,
25
+ TPrismaModelName extends Prisma.ModelName,
26
+ TUniqColumns extends ReadonlyArray<ScalarKeys<TPrismaModelName>>,
27
+ TRelationColumns extends ReadonlyArray<ObjectKeys<TPrismaModelName>>,
28
+ > {
29
+ id?: ScalarKeys<TPrismaModelName>
30
+ prisma: TPrismaClient
31
+ prismaModelName: TPrismaModelName
32
+ uniqColumns: TUniqColumns
33
+ relationColumns?: TRelationColumns
34
+ where?: ScalarFilter<TPrismaModelName>
35
+ upsertOnly?: boolean
36
+ }
37
+
38
+ function getPrismaModelOperations<
39
+ TPrismaClient extends Omit<PrismaClient, runtime.ITXClientDenyList>,
40
+ TPrismaModelName extends Prisma.ModelName,
41
+ >(prisma: TPrismaClient, prismaModelName: TPrismaModelName) {
42
+ const key = (prismaModelName.slice(0, 1).toLowerCase() +
43
+ prismaModelName.slice(1)) as keyof TPrismaClient
44
+ return prisma[key] as GetOperationFns<TPrismaModelName>
45
+ }
46
+
47
+ export type MakeTFromPrismaModel<TPrismaModelName extends Prisma.ModelName> =
48
+ NonNullable<
49
+ Prisma.TypeMap['model'][TPrismaModelName]['operations']['findUnique']['result']
50
+ >
51
+
52
+ export function tableSyncPrisma<
53
+ TPrismaClient extends PrismaClient,
54
+ TPrismaModelName extends Prisma.ModelName,
55
+ TUniqColumns extends ReadonlyArray<ScalarKeys<TPrismaModelName>>,
56
+ TRelationColumns extends ReadonlyArray<ObjectKeys<TPrismaModelName>>,
57
+ TId extends ScalarKeys<TPrismaModelName> = ScalarKeys<TPrismaModelName>,
58
+ >(
59
+ params: TableSyncParamsPrisma<
60
+ TPrismaClient,
61
+ TPrismaModelName,
62
+ TUniqColumns,
63
+ TRelationColumns
64
+ >,
65
+ ) {
66
+ const {
67
+ prisma,
68
+ prismaModelName,
69
+ uniqColumns,
70
+ where: whereGlobal,
71
+ upsertOnly,
72
+ } = params
73
+ const prismOperations = getPrismaModelOperations(prisma, prismaModelName)
74
+
75
+ const idColumn = (params.id ?? 'id') as TId
76
+ // @ts-ignore maybe someday later (never)
77
+ return tableSync<MakeTFromPrismaModel<TPrismaModelName>, TUniqColumns, TId>({
78
+ id: idColumn,
79
+ uniqColumns,
80
+ readAll: async () => {
81
+ const findManyArgs = whereGlobal
82
+ ? {
83
+ where: whereGlobal,
84
+ }
85
+ : {}
86
+ return (await prismOperations.findMany(findManyArgs)) as Array<
87
+ MakeTFromPrismaModel<TPrismaModelName>
88
+ >
89
+ },
90
+ writeAll: async (createData, update, deleteIds) => {
91
+ const prismaUniqKey = params.uniqColumns.join('_')
92
+ const relationColumnList =
93
+ params.relationColumns ?? ([] as Array<ObjectKeys<TPrismaModelName>>)
94
+
95
+ return prisma.$transaction(async (tx) => {
96
+ const txOps = getPrismaModelOperations(tx, prismaModelName)
97
+ for (const { data, where } of update) {
98
+ const uniqKeyWhere =
99
+ Object.keys(where).length > 1
100
+ ? {
101
+ [prismaUniqKey]: where,
102
+ }
103
+ : where
104
+
105
+ const dataScalar = omit(data, relationColumnList)
106
+ const dataRelations = mapValues(
107
+ pick(data, relationColumnList),
108
+ (value) => {
109
+ return {
110
+ set: value,
111
+ }
112
+ },
113
+ )
114
+
115
+ // @ts-expect-error This is too difficult for me to come up with right types
116
+ await txOps.update({
117
+ data: { ...dataScalar, ...dataRelations },
118
+ where: { ...uniqKeyWhere },
119
+ })
120
+ }
121
+
122
+ if (upsertOnly !== true) {
123
+ await txOps.deleteMany({
124
+ where: {
125
+ [idColumn]: {
126
+ in: deleteIds,
127
+ },
128
+ },
129
+ })
130
+ }
131
+
132
+ // Validate uniqueness of uniqColumns before creating records
133
+ const createDataMapped = createData.map((data) => {
134
+ // @ts-expect-error This is too difficult for me to come up with right types
135
+ const dataScalar = omit(data, relationColumnList)
136
+
137
+ // @ts-expect-error This is too difficult for me to come up with right types
138
+ const onlyRelationColumns = pick(data, relationColumnList)
139
+ const dataRelations = mapValues(onlyRelationColumns, (value) => {
140
+ return {
141
+ connect: value,
142
+ }
143
+ })
144
+
145
+ return { ...dataScalar, ...dataRelations }
146
+ })
147
+
148
+ // Check for duplicates in the data to be created
149
+ if (createDataMapped.length > 0) {
150
+ const uniqKeysInCreate = new Set<string>()
151
+ const duplicateKeys: Array<string> = []
152
+
153
+ for (const data of createDataMapped) {
154
+ const keyParts = params.uniqColumns.map((col) => {
155
+ const value = data[col as keyof typeof data]
156
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive: unique columns may be nullable in schemas
157
+ return value === null || value === undefined
158
+ ? 'null'
159
+ : String(value)
160
+ })
161
+ const key = keyParts.join(':')
162
+
163
+ if (uniqKeysInCreate.has(key)) {
164
+ duplicateKeys.push(key)
165
+ } else {
166
+ uniqKeysInCreate.add(key)
167
+ }
168
+ }
169
+
170
+ if (duplicateKeys.length > 0) {
171
+ const uniqColumnsStr = params.uniqColumns.join(', ')
172
+ throw new Error(
173
+ `Duplicate unique key values found in data to be created. ` +
174
+ `Model: ${prismaModelName}, Unique columns: [${uniqColumnsStr}], ` +
175
+ `Duplicate keys: [${duplicateKeys.join(', ')}]`,
176
+ )
177
+ }
178
+ }
179
+
180
+ const results: Array<MakeTFromPrismaModel<TPrismaModelName>> = []
181
+
182
+ if (relationColumnList.length === 0) {
183
+ // @ts-expect-error This is too difficult for me to come up with right types
184
+ const batchResult = await txOps.createManyAndReturn({
185
+ data: createDataMapped,
186
+ })
187
+
188
+ results.push(...batchResult)
189
+ } else {
190
+ for (const dataMappedElement of createDataMapped) {
191
+ // @ts-expect-error too difficult for me
192
+ const newVar = await txOps.create({
193
+ data: dataMappedElement,
194
+ })
195
+ results.push(newVar as MakeTFromPrismaModel<TPrismaModelName>)
196
+ }
197
+ }
198
+
199
+ return results
200
+ })
201
+ },
202
+ })
203
+ }
package/src/index.ts ADDED
@@ -0,0 +1,126 @@
1
+ // common
2
+
3
+ export { createTrpcRouter } from './server/controller'
4
+ export type { TRPCRouter } from './server/controller'
5
+ export { createEhTrpcContext } from './server/ehTrpcContext'
6
+ export type {
7
+ EhTrpcContext,
8
+ EhTrpcContextOptions,
9
+ } from './server/ehTrpcContext'
10
+
11
+ export { staticControllerContract } from './server/ehStaticControllerContract'
12
+ export type { EhStaticControllerContract } from './server/ehStaticControllerContract'
13
+
14
+ // ui-only
15
+
16
+ // backend-only
17
+
18
+ export type { AppForCatalog } from './types/common/appCatalogTypes'
19
+ export * from './types/index'
20
+
21
+ // Auth
22
+ export {
23
+ createAuth,
24
+ type AuthConfig,
25
+ type BetterAuth,
26
+ } from './modules/auth/auth'
27
+
28
+ export { registerAuthRoutes } from './modules/auth/registerAuthRoutes'
29
+
30
+ export { createAuthRouter, type AuthRouter } from './modules/auth/authRouter'
31
+
32
+ export {
33
+ getUserGroups,
34
+ isMemberOfAnyGroup,
35
+ isMemberOfAllGroups,
36
+ isAdmin,
37
+ requireAdmin,
38
+ requireGroups,
39
+ type UserWithGroups,
40
+ } from './modules/auth/authorizationUtils'
41
+
42
+ // Admin
43
+ export {
44
+ createAdminChatHandler,
45
+ tool,
46
+ type AdminChatHandlerOptions,
47
+ } from './modules/admin/chat/createAdminChatHandler'
48
+
49
+ export {
50
+ createDatabaseTools,
51
+ createPrismaDatabaseClient,
52
+ DEFAULT_ADMIN_SYSTEM_PROMPT,
53
+ type DatabaseClient,
54
+ } from './modules/admin/chat/createDatabaseTools'
55
+
56
+ // Icon management
57
+ export {
58
+ registerIconRestController,
59
+ type IconRestControllerConfig,
60
+ } from './modules/icons/iconRestController'
61
+
62
+ export {
63
+ getAssetByName,
64
+ upsertIcon,
65
+ upsertIcons,
66
+ type UpsertIconInput,
67
+ } from './modules/icons/iconService'
68
+
69
+ // Asset management (universal for icons, screenshots, etc.)
70
+ export {
71
+ registerAssetRestController,
72
+ type AssetRestControllerConfig,
73
+ } from './modules/assets/assetRestController'
74
+
75
+ export {
76
+ registerScreenshotRestController,
77
+ type ScreenshotRestControllerConfig,
78
+ } from './modules/assets/screenshotRestController'
79
+
80
+ export { createScreenshotRouter } from './modules/assets/screenshotRouter'
81
+
82
+ export { syncAssets, type SyncAssetsConfig } from './modules/assets/syncAssets'
83
+
84
+ // App Catalog Admin
85
+ export { createAppCatalogAdminRouter } from './modules/appCatalogAdmin/appCatalogAdminRouter'
86
+
87
+ // Approval Methods
88
+ export { createApprovalMethodRouter } from './modules/approvalMethod/approvalMethodRouter'
89
+ export {
90
+ syncApprovalMethods,
91
+ type ApprovalMethodSyncInput,
92
+ } from './modules/approvalMethod/syncApprovalMethods'
93
+
94
+ // Database utilities
95
+ export {
96
+ connectDb,
97
+ disconnectDb,
98
+ getDbClient,
99
+ setDbClient,
100
+ syncAppCatalog,
101
+ TABLE_SYNC_MAGAZINE,
102
+ tableSyncPrisma,
103
+ type MakeTFromPrismaModel,
104
+ type ObjectKeys,
105
+ type ScalarFilter,
106
+ type ScalarKeys,
107
+ type SyncAppCatalogResult,
108
+ type TableSyncMagazine,
109
+ type TableSyncMagazineModelNameKey,
110
+ type TableSyncParamsPrisma,
111
+ } from './db'
112
+
113
+ // Middleware (batteries-included backend setup)
114
+ export {
115
+ createEhMiddleware,
116
+ EhDatabaseManager,
117
+ type EhDatabaseConfig,
118
+ type EhAuthConfig,
119
+ type EhAdminChatConfig,
120
+ type EhFeatureToggles,
121
+ type EhBackendProvider,
122
+ type EhLifecycleHooks,
123
+ type EhMiddlewareOptions,
124
+ type EhMiddlewareResult,
125
+ type MiddlewareContext,
126
+ } from './middleware'
@@ -0,0 +1,42 @@
1
+ import type { AppCatalogCompanySpecificBackend } from '../types/backend/companySpecificBackend'
2
+ import type { EhBackendProvider } from './types'
3
+
4
+ /**
5
+ * Type guard to check if an object implements AppCatalogCompanySpecificBackend.
6
+ */
7
+ function isBackendInstance(obj: unknown): obj is AppCatalogCompanySpecificBackend {
8
+ return (
9
+ typeof obj === 'object' &&
10
+ obj !== null &&
11
+ (typeof (obj as AppCatalogCompanySpecificBackend).getApps === 'function' ||
12
+ typeof (obj as AppCatalogCompanySpecificBackend).getApps === 'undefined')
13
+ )
14
+ }
15
+
16
+ /**
17
+ * Normalizes different backend provider types into a consistent async factory function.
18
+ * Supports:
19
+ * - Direct object implementing AppCatalogCompanySpecificBackend
20
+ * - Sync factory function that returns the backend
21
+ * - Async factory function that returns the backend
22
+ */
23
+ export function createBackendResolver(
24
+ provider: EhBackendProvider,
25
+ ): () => Promise<AppCatalogCompanySpecificBackend> {
26
+ // If it's already an object with the required methods, wrap it
27
+ if (isBackendInstance(provider)) {
28
+ return async () => provider
29
+ }
30
+
31
+ // If it's a function, call it and handle both sync and async results
32
+ if (typeof provider === 'function') {
33
+ return async () => {
34
+ const result = provider()
35
+ return result instanceof Promise ? result : result
36
+ }
37
+ }
38
+
39
+ throw new Error(
40
+ 'Invalid backend provider: must be an object implementing AppCatalogCompanySpecificBackend or a factory function',
41
+ )
42
+ }
@@ -0,0 +1,171 @@
1
+ import express, { Router } from 'express'
2
+ import * as trpcExpress from '@trpc/server/adapters/express'
3
+ import type {
4
+ EhMiddlewareOptions,
5
+ EhMiddlewareResult,
6
+ MiddlewareContext,
7
+ } from './types'
8
+ import { EhDatabaseManager } from './database'
9
+ import { createBackendResolver } from './backendResolver'
10
+ import { registerFeatures } from './featureRegistry'
11
+ import { createTrpcRouter } from '../server/controller'
12
+ import { createEhTrpcContext } from '../server/ehTrpcContext'
13
+ import { createAuth } from '../modules/auth/auth'
14
+ import { createMockUserFromDevConfig } from '../modules/auth/devMockUserUtils'
15
+
16
+ export async function createEhMiddleware(
17
+ options: EhMiddlewareOptions,
18
+ ): Promise<EhMiddlewareResult> {
19
+ // Normalize options with defaults
20
+ const basePath = options.basePath ?? '/api'
21
+ const normalizedOptions = { ...options, basePath }
22
+
23
+ // Initialize database manager
24
+ const dbManager = new EhDatabaseManager(options.database)
25
+ // Initialize the client (which also sets the global singleton)
26
+ dbManager.getClient()
27
+
28
+ // Create auth instance
29
+ const auth = createAuth({
30
+ appName: options.auth.appName,
31
+ baseURL: options.auth.baseURL,
32
+ secret: options.auth.secret,
33
+ providers: options.auth.providers,
34
+ plugins: options.auth.plugins,
35
+ sessionExpiresIn: options.auth.sessionExpiresIn,
36
+ sessionUpdateAge: options.auth.sessionUpdateAge,
37
+ })
38
+
39
+ // Create tRPC router
40
+ const trpcRouter = createTrpcRouter(auth)
41
+
42
+ // Normalize backend provider to async factory function
43
+ const resolveBackend = createBackendResolver(options.backend)
44
+
45
+ // Get admin groups from config with default
46
+ const adminGroups = options.auth.adminGroups ?? ['env_hopper_ui_super_admins']
47
+
48
+ // Create tRPC context factory
49
+ const createContext = async ({
50
+ req,
51
+ }: trpcExpress.CreateExpressContextOptions) => {
52
+ const companySpecificBackend = await resolveBackend()
53
+
54
+ let user = null
55
+ let userGroups: Array<string> = []
56
+
57
+ // Check if dev mock user is configured
58
+ if (options.auth.devMockUser) {
59
+ user = createMockUserFromDevConfig(options.auth.devMockUser)
60
+ userGroups = options.auth.devMockUser.groups
61
+ } else {
62
+ // Extract user from session
63
+ try {
64
+ const session = await auth.api.getSession({
65
+ headers: req.headers as HeadersInit,
66
+ })
67
+ user = session?.user ?? null
68
+
69
+ // If user is authenticated and Okta is configured, decode groups from access token
70
+ if (user && options.auth.oktaGroupsClaim) {
71
+ try {
72
+ // Get the current access token (auto-refreshes if expired)
73
+ // Note: better-auth requires providerId, but we use 'okta' as default
74
+ const tokenResult = await auth.api.getAccessToken({
75
+ body: {
76
+ providerId: 'okta',
77
+ },
78
+ headers: req.headers as HeadersInit,
79
+ })
80
+
81
+ if (tokenResult.accessToken) {
82
+ // Decode JWT to extract groups claim
83
+ const parts = tokenResult.accessToken.split('.')
84
+ if (parts.length === 3 && parts[1]) {
85
+ const payload = JSON.parse(
86
+ Buffer.from(parts[1], 'base64').toString(),
87
+ )
88
+ const groups = payload[options.auth.oktaGroupsClaim]
89
+ userGroups = Array.isArray(groups) ? groups : []
90
+ }
91
+ }
92
+ } catch (error) {
93
+ console.error('[tRPC Context] Failed to get access token:', error)
94
+ }
95
+ }
96
+ } catch (error) {
97
+ console.error('[tRPC Context] Failed to get session:', error)
98
+ }
99
+ }
100
+
101
+ // Attach groups to user object for authorization checks
102
+ const userWithGroups = user ? { ...user, groups: userGroups } : null
103
+
104
+ return createEhTrpcContext({
105
+ companySpecificBackend,
106
+ user: userWithGroups,
107
+ adminGroups,
108
+ })
109
+ }
110
+
111
+ // Create Express router
112
+ const router = Router()
113
+ router.use(express.json())
114
+
115
+ // Build middleware context for feature registration
116
+ const middlewareContext: MiddlewareContext = {
117
+ auth,
118
+ trpcRouter,
119
+ createContext: async () => {
120
+ const companySpecificBackend = await resolveBackend()
121
+ return createEhTrpcContext({
122
+ companySpecificBackend,
123
+ adminGroups,
124
+ })
125
+ },
126
+ authConfig: options.auth,
127
+ }
128
+
129
+ // Register tRPC middleware (if enabled)
130
+ if (normalizedOptions.features?.trpc !== false) {
131
+ router.use(
132
+ `${basePath}/trpc`,
133
+ trpcExpress.createExpressMiddleware({
134
+ router: trpcRouter,
135
+ createContext,
136
+ }),
137
+ )
138
+ }
139
+
140
+ // Register all enabled features
141
+ registerFeatures(router, normalizedOptions, middlewareContext)
142
+
143
+ // Call onRoutesRegistered hook if provided
144
+ if (options.hooks?.onRoutesRegistered) {
145
+ await options.hooks.onRoutesRegistered(router)
146
+ }
147
+
148
+ return {
149
+ router,
150
+ auth,
151
+ trpcRouter,
152
+
153
+ async connect(): Promise<void> {
154
+ await dbManager.connect()
155
+ if (options.hooks?.onDatabaseConnected) {
156
+ await options.hooks.onDatabaseConnected()
157
+ }
158
+ },
159
+
160
+ async disconnect(): Promise<void> {
161
+ if (options.hooks?.onDatabaseDisconnecting) {
162
+ await options.hooks.onDatabaseDisconnecting()
163
+ }
164
+ await dbManager.disconnect()
165
+ },
166
+
167
+ addRoutes(callback: (router: Router) => void): void {
168
+ callback(router)
169
+ },
170
+ }
171
+ }
@@ -0,0 +1,62 @@
1
+ import { PrismaClient } from '@prisma/client'
2
+ import type { EhDatabaseConfig } from './types'
3
+ import { setDbClient } from '../db/client'
4
+
5
+ /**
6
+ * Formats a database connection URL from structured config.
7
+ */
8
+ function formatConnectionUrl(config: EhDatabaseConfig): string {
9
+ if ('url' in config) {
10
+ return config.url
11
+ }
12
+
13
+ const { host, port, database, username, password, schema = 'public' } = config
14
+ return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}?schema=${schema}`
15
+ }
16
+
17
+ /**
18
+ * Internal database manager used by the middleware.
19
+ * Handles connection URL formatting and lifecycle.
20
+ */
21
+ export class EhDatabaseManager {
22
+ private client: PrismaClient | null = null
23
+ private config: EhDatabaseConfig
24
+
25
+ constructor(config: EhDatabaseConfig) {
26
+ this.config = config
27
+ }
28
+
29
+ /**
30
+ * Get or create the Prisma client instance.
31
+ * Uses lazy initialization for flexibility.
32
+ */
33
+ getClient(): PrismaClient {
34
+ if (!this.client) {
35
+ const datasourceUrl = formatConnectionUrl(this.config)
36
+
37
+ this.client = new PrismaClient({
38
+ datasourceUrl,
39
+ log:
40
+ process.env.NODE_ENV === 'development'
41
+ ? ['warn', 'error']
42
+ : ['warn', 'error'],
43
+ })
44
+
45
+ // Bridge with existing backend-core getDbClient() usage
46
+ setDbClient(this.client)
47
+ }
48
+ return this.client
49
+ }
50
+
51
+ async connect(): Promise<void> {
52
+ const client = this.getClient()
53
+ await client.$connect()
54
+ }
55
+
56
+ async disconnect(): Promise<void> {
57
+ if (this.client) {
58
+ await this.client.$disconnect()
59
+ this.client = null
60
+ }
61
+ }
62
+ }