@getvision/server 0.0.0-develop-20251031183955

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.
@@ -0,0 +1,685 @@
1
+ import { Hono } from 'hono'
2
+ import type { Env, Schema, Input, MiddlewareHandler } from 'hono'
3
+ import { VisionCore } from '@getvision/core'
4
+ import type { RouteMetadata } from '@getvision/core'
5
+ import { AsyncLocalStorage } from 'async_hooks'
6
+ import { existsSync } from 'fs'
7
+ import { spawn, type ChildProcess } from 'child_process'
8
+ import { ServiceBuilder } from './service'
9
+ import type { VisionContext } from './types'
10
+ import { EventBus } from './event-bus'
11
+ import { eventRegistry } from './event-registry'
12
+
13
+ const visionContext = new AsyncLocalStorage<VisionContext>()
14
+
15
+ /**
16
+ * Vision Server configuration
17
+ */
18
+ export interface VisionConfig {
19
+ service: {
20
+ name: string
21
+ version?: string
22
+ description?: string
23
+ integrations?: Record<string, string>
24
+ drizzle?: {
25
+ autoStart?: boolean
26
+ port?: number
27
+ }
28
+ }
29
+ vision?: {
30
+ enabled?: boolean
31
+ port?: number
32
+ maxTraces?: number
33
+ maxLogs?: number
34
+ logging?: boolean
35
+ }
36
+ routes?: {
37
+ autodiscover?: boolean
38
+ dirs?: string[]
39
+ }
40
+ pubsub?: {
41
+ redis?: {
42
+ host?: string
43
+ port?: number
44
+ password?: string
45
+ }
46
+ devMode?: boolean // Use in-memory event bus (no Redis required)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Vision - Meta-framework built on Hono with observability
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const app = new Vision({
56
+ * service: {
57
+ * name: 'My API',
58
+ * version: '1.0.0'
59
+ * },
60
+ * pubsub: {
61
+ * schemas: {
62
+ * 'user/created': {
63
+ * data: z.object({ userId: z.string() })
64
+ * }
65
+ * }
66
+ * }
67
+ * })
68
+ *
69
+ * const userService = app.service('users')
70
+ * .endpoint('GET', '/users/:id', schema, handler)
71
+ * .on('user/created', handler)
72
+ *
73
+ * app.start(3000)
74
+ * ```
75
+ */
76
+ export class Vision<
77
+ E extends Env = Env,
78
+ S extends Schema = {},
79
+ BasePath extends string = '/'
80
+ > extends Hono<E, S, BasePath> {
81
+ private visionCore: VisionCore
82
+ private eventBus: EventBus
83
+ private config: VisionConfig
84
+ private serviceBuilders: ServiceBuilder<any>[] = []
85
+ private fileBasedRoutes: RouteMetadata[] = []
86
+
87
+ constructor(config?: VisionConfig) {
88
+ super()
89
+
90
+ const defaultConfig: VisionConfig = {
91
+ service: {
92
+ name: 'Vision SubApp',
93
+ },
94
+ vision: {
95
+ enabled: false,
96
+ port: 9500,
97
+ },
98
+ pubsub: {
99
+ devMode: true,
100
+ },
101
+ routes: {
102
+ autodiscover: true,
103
+ dirs: ['app/routes'],
104
+ },
105
+ }
106
+
107
+ // Merge shallowly (good enough for our config structure)
108
+ this.config = {
109
+ ...defaultConfig,
110
+ ...(config || {}),
111
+ service: { ...defaultConfig.service, ...(config?.service || {}) },
112
+ vision: { ...defaultConfig.vision, ...(config?.vision || {}) },
113
+ pubsub: { ...defaultConfig.pubsub, ...(config?.pubsub || {}) },
114
+ routes: { ...defaultConfig.routes, ...(config?.routes || {}) },
115
+ }
116
+
117
+ // Initialize Vision Core
118
+ const visionEnabled = this.config.vision?.enabled !== false
119
+ const visionPort = this.config.vision?.port ?? 9500
120
+
121
+ if (visionEnabled) {
122
+ this.visionCore = new VisionCore({
123
+ port: visionPort,
124
+ maxTraces: this.config.vision?.maxTraces ?? 1000,
125
+ maxLogs: this.config.vision?.maxLogs ?? 10000,
126
+ })
127
+
128
+ // Detect and optionally start Drizzle Studio
129
+ const drizzleInfo = detectDrizzle()
130
+ let drizzleStudioUrl: string | undefined
131
+
132
+ if (drizzleInfo.detected) {
133
+ console.log(`🗄️ Drizzle detected (${drizzleInfo.configPath})`)
134
+
135
+ if (this.config.service.drizzle?.autoStart) {
136
+ const drizzlePort = this.config.service.drizzle.port || 4983
137
+ const started = startDrizzleStudio(drizzlePort)
138
+ if (started) {
139
+ drizzleStudioUrl = 'https://local.drizzle.studio'
140
+ }
141
+ } else {
142
+ console.log('💡 Tip: Enable Drizzle Studio auto-start with drizzle: { autoStart: true }')
143
+ drizzleStudioUrl = 'https://local.drizzle.studio'
144
+ }
145
+ }
146
+
147
+ // Clean integrations (remove undefined values)
148
+ const cleanIntegrations = Object.fromEntries(
149
+ Object.entries(this.config.service.integrations || {}).filter(([_, v]) => v !== undefined)
150
+ )
151
+
152
+ // Set app status
153
+ this.visionCore.setAppStatus({
154
+ name: this.config.service.name,
155
+ version: this.config.service.version ?? '0.0.0',
156
+ description: this.config.service.description,
157
+ running: true,
158
+ pid: process.pid,
159
+ metadata: {
160
+ framework: 'vision-server',
161
+ integrations: Object.keys(cleanIntegrations).length > 0 ? cleanIntegrations : undefined,
162
+ drizzle: drizzleInfo.detected
163
+ ? {
164
+ detected: true,
165
+ configPath: drizzleInfo.configPath,
166
+ studioUrl: drizzleStudioUrl,
167
+ autoStarted: this.config.service.drizzle?.autoStart || false,
168
+ }
169
+ : {
170
+ detected: false,
171
+ configPath: undefined,
172
+ studioUrl: undefined,
173
+ autoStarted: false,
174
+ },
175
+ },
176
+ })
177
+ } else {
178
+ // Create dummy Vision Core that does nothing
179
+ this.visionCore = null as any
180
+ }
181
+
182
+ // Initialize EventBus
183
+ this.eventBus = new EventBus({
184
+ redis: this.config.pubsub?.redis,
185
+ devMode: this.config.pubsub?.devMode,
186
+ })
187
+
188
+ // Register JSON-RPC methods for events/cron
189
+ if (visionEnabled) {
190
+ this.registerEventMethods()
191
+ }
192
+
193
+ // Install Vision middleware automatically
194
+ if (visionEnabled) {
195
+ this.installVisionMiddleware()
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Register JSON-RPC methods for events and cron jobs
201
+ */
202
+ private registerEventMethods() {
203
+ const server = this.visionCore.getServer()
204
+
205
+ // List all events
206
+ server.registerMethod('events/list', async () => {
207
+ const events = eventRegistry.getAllEvents()
208
+ return events.map(event => ({
209
+ name: event.name,
210
+ description: event.description,
211
+ icon: event.icon,
212
+ tags: event.tags,
213
+ handlers: event.handlers.length,
214
+ lastTriggered: event.lastTriggered,
215
+ totalCount: event.totalCount,
216
+ failedCount: event.failedCount,
217
+ }))
218
+ })
219
+
220
+ // List all cron jobs
221
+ server.registerMethod('cron/list', async () => {
222
+ const crons = eventRegistry.getAllCrons()
223
+ return crons.map(cron => ({
224
+ name: cron.name,
225
+ schedule: cron.schedule,
226
+ description: cron.description,
227
+ icon: cron.icon,
228
+ tags: cron.tags,
229
+ lastRun: cron.lastRun,
230
+ nextRun: cron.nextRun,
231
+ totalRuns: cron.totalRuns,
232
+ failedRuns: cron.failedRuns,
233
+ }))
234
+ })
235
+ }
236
+
237
+ /**
238
+ * Install Vision tracing middleware
239
+ */
240
+ private installVisionMiddleware() {
241
+ const logging = this.config.vision?.logging !== false
242
+
243
+ this.use('*', async (c, next) => {
244
+ // Skip OPTIONS requests
245
+ if (c.req.method === 'OPTIONS') {
246
+ return next()
247
+ }
248
+
249
+ const startTime = Date.now()
250
+
251
+ // Create trace
252
+ const trace = this.visionCore.createTrace(c.req.method, c.req.path)
253
+
254
+ // Run request in AsyncLocalStorage context
255
+ return visionContext.run(
256
+ {
257
+ vision: this.visionCore,
258
+ traceId: trace.id,
259
+ rootSpanId: ''
260
+ },
261
+ async () => {
262
+ // Start main span
263
+ const tracer = this.visionCore.getTracer()
264
+ const rootSpan = tracer.startSpan('http.request', trace.id)
265
+
266
+ // Update context with rootSpanId
267
+ const ctx = visionContext.getStore()
268
+ if (ctx) {
269
+ ctx.rootSpanId = rootSpan.id
270
+ }
271
+
272
+ // Provide c.span globally for all downstream handlers (Vision/Hono sub-apps)
273
+ if (!(c as any).span) {
274
+ ;(c as any).span = <T>(
275
+ name: string,
276
+ attributes: Record<string, any> = {},
277
+ fn?: () => T
278
+ ): T => {
279
+ const current = visionContext.getStore()
280
+ const currentTraceId = current?.traceId || trace.id
281
+ const currentRootSpanId = current?.rootSpanId || rootSpan.id
282
+ const s = tracer.startSpan(name, currentTraceId, currentRootSpanId)
283
+ for (const [k, v] of Object.entries(attributes)) tracer.setAttribute(s.id, k, v)
284
+ try {
285
+ const result = fn ? fn() : (undefined as any)
286
+ const completed = tracer.endSpan(s.id)
287
+ if (completed) this.visionCore.getTraceStore().addSpan(currentTraceId, completed)
288
+ return result
289
+ } catch (err) {
290
+ tracer.setAttribute(s.id, 'error', true)
291
+ tracer.setAttribute(s.id, 'error.message', err instanceof Error ? err.message : String(err))
292
+ const completed = tracer.endSpan(s.id)
293
+ if (completed) this.visionCore.getTraceStore().addSpan(currentTraceId, completed)
294
+ throw err
295
+ }
296
+ }
297
+ }
298
+
299
+ // Add request attributes
300
+ tracer.setAttribute(rootSpan.id, 'http.method', c.req.method)
301
+ tracer.setAttribute(rootSpan.id, 'http.path', c.req.path)
302
+ tracer.setAttribute(rootSpan.id, 'http.url', c.req.url)
303
+
304
+ // Add query params if any
305
+ const url = new URL(c.req.url)
306
+ if (url.search) {
307
+ tracer.setAttribute(rootSpan.id, 'http.query', url.search)
308
+ }
309
+
310
+ // Capture request metadata
311
+ try {
312
+ const rawReq = c.req.raw
313
+ const headers: Record<string, string> = {}
314
+ rawReq.headers.forEach((v, k) => { headers[k] = v })
315
+
316
+ const urlObj = new URL(c.req.url)
317
+ const query: Record<string, string> = {}
318
+ urlObj.searchParams.forEach((v, k) => { query[k] = v })
319
+
320
+ let body: unknown = undefined
321
+ const ct = headers['content-type'] || headers['Content-Type']
322
+ if (ct && ct.includes('application/json')) {
323
+ try {
324
+ body = await rawReq.clone().json()
325
+ } catch {}
326
+ }
327
+
328
+ const sessionId = headers['x-vision-session']
329
+ if (sessionId) {
330
+ tracer.setAttribute(rootSpan.id, 'session.id', sessionId)
331
+ trace.metadata = { ...(trace.metadata || {}), sessionId }
332
+ }
333
+
334
+ const requestMeta = {
335
+ method: c.req.method,
336
+ url: urlObj.pathname + (urlObj.search || ''),
337
+ headers,
338
+ query: Object.keys(query).length ? query : undefined,
339
+ body,
340
+ }
341
+ tracer.setAttribute(rootSpan.id, 'http.request', requestMeta)
342
+ trace.metadata = { ...(trace.metadata || {}), request: requestMeta }
343
+
344
+ // Emit start log
345
+ if (logging) {
346
+ const parts = [
347
+ `method=${c.req.method}`,
348
+ `path=${c.req.path}`,
349
+ ]
350
+ if (sessionId) parts.push(`sessionId=${sessionId}`)
351
+ parts.push(`traceId=${trace.id}`)
352
+ console.info(`INF starting request ${parts.join(' ')}`)
353
+ }
354
+
355
+ // Execute request
356
+ await next()
357
+
358
+ // Add response attributes
359
+ tracer.setAttribute(rootSpan.id, 'http.status_code', c.res.status)
360
+ const resHeaders: Record<string, string> = {}
361
+ c.res.headers?.forEach((v, k) => { resHeaders[k] = v as unknown as string })
362
+
363
+ let respBody: unknown = undefined
364
+ const resCt = c.res.headers?.get('content-type') || ''
365
+ try {
366
+ const clone = c.res.clone()
367
+ if (resCt.includes('application/json')) {
368
+ const txt = await clone.text()
369
+ if (txt && txt.length <= 65536) {
370
+ try { respBody = JSON.parse(txt) } catch { respBody = txt }
371
+ }
372
+ }
373
+ } catch {}
374
+
375
+ const responseMeta = {
376
+ status: c.res.status,
377
+ headers: Object.keys(resHeaders).length ? resHeaders : undefined,
378
+ body: respBody,
379
+ }
380
+ tracer.setAttribute(rootSpan.id, 'http.response', responseMeta)
381
+ trace.metadata = { ...(trace.metadata || {}), response: responseMeta }
382
+
383
+ } catch (error) {
384
+ // Track error
385
+ tracer.addEvent(rootSpan.id, 'error', {
386
+ message: error instanceof Error ? error.message : 'Unknown error',
387
+ stack: error instanceof Error ? error.stack : undefined,
388
+ })
389
+
390
+ tracer.setAttribute(rootSpan.id, 'error', true)
391
+ throw error
392
+
393
+ } finally {
394
+ // End span and add it to trace
395
+ const completedSpan = tracer.endSpan(rootSpan.id)
396
+ if (completedSpan) {
397
+ this.visionCore.getTraceStore().addSpan(trace.id, completedSpan)
398
+ }
399
+
400
+ // Complete trace
401
+ const duration = Date.now() - startTime
402
+ this.visionCore.completeTrace(trace.id, c.res.status, duration)
403
+
404
+ // Add trace ID to response headers
405
+ c.header('X-Vision-Trace-Id', trace.id)
406
+
407
+ // Emit completion log
408
+ if (logging) {
409
+ console.info(
410
+ `INF request completed code=${c.res.status} duration=${duration}ms method=${c.req.method} path=${c.req.path} traceId=${trace.id}`
411
+ )
412
+ }
413
+ }
414
+ }
415
+ )
416
+ })
417
+ }
418
+
419
+
420
+ /**
421
+ * Create a new service with builder pattern
422
+ *
423
+ * @example
424
+ * ```ts
425
+ * const userService = app.service('users')
426
+ * .endpoint('GET', '/users/:id', schema, handler)
427
+ * .on('user/created', handler)
428
+ * ```
429
+ */
430
+ service<TEvents extends Record<string, any> = {}>(name: string) {
431
+ const builder = new ServiceBuilder<TEvents>(name, this.eventBus, this.visionCore)
432
+
433
+ // Зберігаємо builder для реєстрації в start()
434
+ this.serviceBuilders.push(builder)
435
+
436
+ return builder
437
+ }
438
+
439
+ /**
440
+ * Get services and routes metadata without registering to this VisionCore
441
+ */
442
+ public getServiceSummaries(): Array<{ name: string; routes: RouteMetadata[] }> {
443
+ const summaries: Array<{ name: string; routes: RouteMetadata[] }> = []
444
+ for (const builder of this.serviceBuilders) {
445
+ const name = (builder as any).getDisplayName?.() ?? 'Service'
446
+ const rawRoutes = (builder as any).getRoutesMetadata?.()
447
+ if (!rawRoutes || !Array.isArray(rawRoutes)) continue
448
+ const routes: RouteMetadata[] = rawRoutes.map((r: any) => ({
449
+ method: r.method,
450
+ path: r.path,
451
+ handler: name,
452
+ requestBody: r.requestBody,
453
+ responseBody: r.responseBody,
454
+ }))
455
+ summaries.push({ name, routes })
456
+ }
457
+ return summaries
458
+ }
459
+
460
+ /**
461
+ * Build all service builders
462
+ */
463
+ public buildAllServices() {
464
+ const allServices: Array<{ name: string; routes: RouteMetadata[] }> = []
465
+
466
+ // Build all services (this populates allServices via builder.build)
467
+ for (const builder of this.serviceBuilders) {
468
+ builder.build(this as any, allServices)
469
+ }
470
+
471
+ // Group file-based routes by path prefix (e.g., /products, /analytics)
472
+ if (this.fileBasedRoutes.length > 0) {
473
+ const groupedRoutes = new Map<string, RouteMetadata[]>()
474
+
475
+ for (const route of this.fileBasedRoutes) {
476
+ // Extract first path segment as service name
477
+ const segments = route.path.split('/').filter(s => s && !s.startsWith(':'))
478
+ const serviceName = segments[0] || 'root'
479
+
480
+ if (!groupedRoutes.has(serviceName)) {
481
+ groupedRoutes.set(serviceName, [])
482
+ }
483
+ groupedRoutes.get(serviceName)!.push(route)
484
+ }
485
+
486
+ // Add each group as a service
487
+ for (const [name, routes] of groupedRoutes.entries()) {
488
+ const capitalizedName = name.charAt(0).toUpperCase() + name.slice(1)
489
+ allServices.push({
490
+ name: capitalizedName,
491
+ routes
492
+ })
493
+ }
494
+ }
495
+
496
+ // Don't register to VisionCore here - let start() handle it after sub-apps are loaded
497
+ // Just return allServices so they can be registered later
498
+ return allServices
499
+ }
500
+
501
+ /**
502
+ * Get Vision Core instance
503
+ */
504
+ getVision(): VisionCore {
505
+ return this.visionCore
506
+ }
507
+
508
+ /**
509
+ * Get EventBus instance
510
+ */
511
+ getEventBus(): EventBus {
512
+ return this.eventBus
513
+ }
514
+
515
+
516
+ /**
517
+ * Autoload Vision/Hono sub-apps from configured directories
518
+ */
519
+ private async autoloadRoutes(): Promise<Array<{ name: string; routes: any[] }>> {
520
+ const enabled = this.config.routes?.autodiscover !== false
521
+ const dirs = this.config.routes?.dirs ?? ['app/routes']
522
+ if (!enabled) return []
523
+
524
+ const existing: string[] = []
525
+ for (const d of dirs) {
526
+ try { if (existsSync(d)) existing.push(d) } catch {}
527
+ }
528
+ if (existing.length === 0) return []
529
+
530
+ const { loadSubApps } = await import('./router')
531
+ let allSubAppSummaries: Array<{ name: string; routes: any[] }> = []
532
+ for (const d of existing) {
533
+ try {
534
+ const summaries = await loadSubApps(this as any, d)
535
+ allSubAppSummaries = allSubAppSummaries.concat(summaries)
536
+ } catch (e) {
537
+ console.error(`❌ Failed to load sub-apps from ${d}:`, (e as any)?.message || e)
538
+ if (e instanceof Error && e.stack) {
539
+ console.error('Stack:', e.stack)
540
+ }
541
+ }
542
+ }
543
+ return allSubAppSummaries
544
+ }
545
+
546
+ /**
547
+ * Start the server (convenience method)
548
+ */
549
+ async start(port: number = 3000, options?: { hostname?: string }) {
550
+ // Build all services WITHOUT registering to VisionCore yet
551
+ const rootSummaries = this.buildAllServices()
552
+ // Autoload file-based Vision/Hono sub-apps if enabled (returns merged sub-app summaries)
553
+ const subAppSummaries = await this.autoloadRoutes()
554
+
555
+ // Merge root and sub-app services by name
556
+ const allServices = new Map<string, { name: string; routes: any[] }>()
557
+
558
+ // Add root services first
559
+ for (const summary of rootSummaries || []) {
560
+ allServices.set(summary.name, { name: summary.name, routes: [...summary.routes] })
561
+ }
562
+
563
+ // Merge sub-app services (combine routes if service name already exists)
564
+ for (const summary of subAppSummaries || []) {
565
+ if (allServices.has(summary.name)) {
566
+ const existing = allServices.get(summary.name)!
567
+ existing.routes.push(...summary.routes)
568
+ } else {
569
+ allServices.set(summary.name, { name: summary.name, routes: [...summary.routes] })
570
+ }
571
+ }
572
+
573
+ // Register all services in one call
574
+ if (this.visionCore && allServices.size > 0) {
575
+ const servicesToRegister = Array.from(allServices.values())
576
+ this.visionCore.registerServices(servicesToRegister)
577
+ const flatRoutes = servicesToRegister.flatMap(s => s.routes)
578
+ this.visionCore.registerRoutes(flatRoutes)
579
+ console.log(`✅ Registered ${servicesToRegister.length} total services (${flatRoutes.length} routes)`)
580
+ }
581
+
582
+ console.log(`🚀 Starting ${this.config.service.name}...`)
583
+ console.log(`📡 API Server: http://localhost:${port}`)
584
+
585
+ // Setup cleanup on exit
586
+ const cleanup = async () => {
587
+ console.log('🛑 Shutting down...')
588
+ await this.eventBus.close()
589
+ }
590
+
591
+ process.on('SIGINT', cleanup)
592
+ process.on('SIGTERM', cleanup)
593
+
594
+ // For Node.js
595
+ if (typeof process !== 'undefined' && process.versions && process.versions.node) {
596
+ const { serve } = await import('@hono/node-server')
597
+ serve({
598
+ fetch: this.fetch.bind(this),
599
+ port,
600
+ hostname: options?.hostname
601
+ })
602
+ } else {
603
+ // For other runtimes, just return the app
604
+ console.log('⚠️ Use your runtime\'s serve function')
605
+ return this
606
+ }
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Get Vision context (internal use)
612
+ */
613
+ export function getVisionContext(): VisionContext | undefined {
614
+ return visionContext.getStore()
615
+ }
616
+
617
+ // ============================================================================
618
+ // Drizzle Studio Integration
619
+ // ============================================================================
620
+
621
+ let drizzleStudioProcess: ChildProcess | null = null
622
+
623
+ /**
624
+ * Detect Drizzle configuration
625
+ */
626
+ function detectDrizzle(): { detected: boolean; configPath?: string } {
627
+ const possiblePaths = [
628
+ 'drizzle.config.ts',
629
+ 'drizzle.config.js',
630
+ 'drizzle.config.mjs',
631
+ ]
632
+
633
+ for (const path of possiblePaths) {
634
+ if (existsSync(path)) {
635
+ return { detected: true, configPath: path }
636
+ }
637
+ }
638
+ return { detected: false }
639
+ }
640
+
641
+ /**
642
+ * Start Drizzle Studio
643
+ */
644
+ function startDrizzleStudio(port: number): boolean {
645
+ if (drizzleStudioProcess) {
646
+ console.log('⚠️ Drizzle Studio is already running')
647
+ return false
648
+ }
649
+
650
+ console.log(`🗄️ Starting Drizzle Studio on port ${port}...`)
651
+
652
+ try {
653
+ drizzleStudioProcess = spawn('npx', ['drizzle-kit', 'studio', '--port', String(port), '--host', '0.0.0.0'], {
654
+ stdio: 'inherit',
655
+ detached: false,
656
+ })
657
+
658
+ drizzleStudioProcess.on('error', (error) => {
659
+ console.error('❌ Failed to start Drizzle Studio:', error.message)
660
+ })
661
+
662
+ drizzleStudioProcess.on('exit', (code) => {
663
+ if (code !== 0 && code !== null) {
664
+ console.error(`❌ Drizzle Studio exited with code ${code}`)
665
+ }
666
+ })
667
+
668
+ console.log(`✅ Drizzle Studio: https://local.drizzle.studio`)
669
+ return true
670
+ } catch (error) {
671
+ console.error('❌ Failed to start Drizzle Studio:', error)
672
+ return false
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Stop Drizzle Studio
678
+ */
679
+ function stopDrizzleStudio(): void {
680
+ if (drizzleStudioProcess) {
681
+ drizzleStudioProcess.kill()
682
+ drizzleStudioProcess = null
683
+ console.log('🛑 Drizzle Studio stopped')
684
+ }
685
+ }