bunsane 0.1.4 → 0.2.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 (257) hide show
  1. package/.claude/settings.local.json +47 -0
  2. package/.claude/skills/update-memory.md +74 -0
  3. package/.prettierrc +4 -0
  4. package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
  5. package/.serena/memories/architecture.md +154 -0
  6. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
  7. package/.serena/memories/code_style_and_conventions.md +76 -0
  8. package/.serena/memories/project_overview.md +43 -0
  9. package/.serena/memories/schema-dsl-plan.md +107 -0
  10. package/.serena/memories/suggested_commands.md +80 -0
  11. package/.serena/memories/typescript-compilation-status.md +54 -0
  12. package/.serena/project.yml +114 -0
  13. package/TODO.md +1 -7
  14. package/bun.lock +150 -4
  15. package/bunfig.toml +10 -0
  16. package/config/cache.config.ts +77 -0
  17. package/config/upload.config.ts +4 -5
  18. package/core/App.ts +870 -123
  19. package/core/ArcheType.ts +2268 -377
  20. package/core/BatchLoader.ts +181 -71
  21. package/core/Config.ts +153 -0
  22. package/core/Decorators.ts +4 -1
  23. package/core/Entity.ts +621 -92
  24. package/core/EntityHookManager.ts +1 -1
  25. package/core/EntityInterface.ts +3 -1
  26. package/core/EntityManager.ts +1 -13
  27. package/core/ErrorHandler.ts +8 -2
  28. package/core/Logger.ts +9 -0
  29. package/core/Middleware.ts +34 -0
  30. package/core/RequestContext.ts +5 -1
  31. package/core/RequestLoaders.ts +227 -93
  32. package/core/SchedulerManager.ts +193 -52
  33. package/core/cache/CacheAnalytics.ts +399 -0
  34. package/core/cache/CacheFactory.ts +145 -0
  35. package/core/cache/CacheManager.ts +520 -0
  36. package/core/cache/CacheProvider.ts +34 -0
  37. package/core/cache/CacheWarmer.ts +157 -0
  38. package/core/cache/CompressionUtils.ts +110 -0
  39. package/core/cache/MemoryCache.ts +251 -0
  40. package/core/cache/MultiLevelCache.ts +180 -0
  41. package/core/cache/NoOpCache.ts +53 -0
  42. package/core/cache/RedisCache.ts +464 -0
  43. package/core/cache/TTLStrategy.ts +254 -0
  44. package/core/cache/index.ts +6 -0
  45. package/core/components/BaseComponent.ts +120 -0
  46. package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
  47. package/core/components/Decorators.ts +88 -0
  48. package/core/components/Interfaces.ts +7 -0
  49. package/core/components/index.ts +5 -0
  50. package/core/decorators/EntityHooks.ts +0 -3
  51. package/core/decorators/IndexedField.ts +26 -0
  52. package/core/decorators/ScheduledTask.ts +0 -47
  53. package/core/events/EntityLifecycleEvents.ts +1 -1
  54. package/core/health.ts +112 -0
  55. package/core/metadata/definitions/ArcheType.ts +14 -0
  56. package/core/metadata/definitions/Component.ts +9 -0
  57. package/core/metadata/definitions/gqlObject.ts +1 -1
  58. package/core/metadata/index.ts +42 -1
  59. package/core/metadata/metadata-storage.ts +28 -2
  60. package/core/middleware/AccessLog.ts +59 -0
  61. package/core/middleware/RequestId.ts +38 -0
  62. package/core/middleware/SecurityHeaders.ts +62 -0
  63. package/core/middleware/index.ts +3 -0
  64. package/core/scheduler/DistributedLock.ts +266 -0
  65. package/core/scheduler/index.ts +15 -0
  66. package/core/validateEnv.ts +92 -0
  67. package/database/DatabaseHelper.ts +416 -40
  68. package/database/IndexingStrategy.ts +342 -0
  69. package/database/PreparedStatementCache.ts +226 -0
  70. package/database/index.ts +32 -7
  71. package/database/sqlHelpers.ts +14 -2
  72. package/endpoints/archetypes.ts +362 -0
  73. package/endpoints/components.ts +58 -0
  74. package/endpoints/entity.ts +80 -0
  75. package/endpoints/index.ts +27 -0
  76. package/endpoints/query.ts +93 -0
  77. package/endpoints/stats.ts +76 -0
  78. package/endpoints/tables.ts +212 -0
  79. package/endpoints/types.ts +155 -0
  80. package/gql/ArchetypeOperations.ts +32 -86
  81. package/gql/Generator.ts +27 -315
  82. package/gql/GeneratorV2.ts +37 -0
  83. package/gql/builders/InputTypeBuilder.ts +99 -0
  84. package/gql/builders/ResolverBuilder.ts +234 -0
  85. package/gql/builders/TypeDefBuilder.ts +105 -0
  86. package/gql/builders/index.ts +3 -0
  87. package/gql/decorators/Upload.ts +1 -1
  88. package/gql/depthLimit.ts +85 -0
  89. package/gql/graph/GraphNode.ts +224 -0
  90. package/gql/graph/SchemaGraph.ts +278 -0
  91. package/gql/helpers.ts +8 -2
  92. package/gql/index.ts +56 -4
  93. package/gql/middleware.ts +79 -0
  94. package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
  95. package/gql/orchestration/index.ts +1 -0
  96. package/gql/scanner/ServiceScanner.ts +347 -0
  97. package/gql/schema/index.ts +458 -0
  98. package/gql/strategies/TypeGenerationStrategy.ts +329 -0
  99. package/gql/types.ts +1 -0
  100. package/gql/utils/TypeSignature.ts +220 -0
  101. package/gql/utils/index.ts +1 -0
  102. package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
  103. package/gql/visitors/DeduplicationVisitor.ts +82 -0
  104. package/gql/visitors/GraphVisitor.ts +78 -0
  105. package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
  106. package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
  107. package/gql/visitors/TypeCollectorVisitor.ts +79 -0
  108. package/gql/visitors/VisitorComposer.ts +96 -0
  109. package/gql/visitors/index.ts +7 -0
  110. package/package.json +59 -37
  111. package/plugins/index.ts +2 -2
  112. package/query/CTENode.ts +97 -0
  113. package/query/ComponentInclusionNode.ts +689 -0
  114. package/query/FilterBuilder.ts +127 -0
  115. package/query/FilterBuilderRegistry.ts +202 -0
  116. package/query/OrNode.ts +517 -0
  117. package/query/OrQuery.ts +42 -0
  118. package/query/Query.ts +1022 -0
  119. package/query/QueryContext.ts +170 -0
  120. package/query/QueryDAG.ts +122 -0
  121. package/query/QueryNode.ts +65 -0
  122. package/query/SourceNode.ts +53 -0
  123. package/query/builders/FullTextSearchBuilder.ts +236 -0
  124. package/query/index.ts +21 -0
  125. package/scheduler/index.ts +40 -8
  126. package/service/Service.ts +2 -1
  127. package/service/ServiceRegistry.ts +6 -5
  128. package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
  129. package/storage/S3StorageProvider.ts +316 -0
  130. package/{core/storage → storage}/StorageProvider.ts +7 -3
  131. package/studio/bun.lock +482 -0
  132. package/studio/index.html +13 -0
  133. package/studio/package.json +39 -0
  134. package/studio/postcss.config.js +6 -0
  135. package/studio/src/components/DataTable.tsx +211 -0
  136. package/studio/src/components/Layout.tsx +13 -0
  137. package/studio/src/components/PageContainer.tsx +9 -0
  138. package/studio/src/components/PageHeader.tsx +13 -0
  139. package/studio/src/components/SearchBar.tsx +57 -0
  140. package/studio/src/components/Sidebar.tsx +294 -0
  141. package/studio/src/components/ui/button.tsx +56 -0
  142. package/studio/src/components/ui/checkbox.tsx +26 -0
  143. package/studio/src/components/ui/input.tsx +25 -0
  144. package/studio/src/hooks/useDataTable.ts +131 -0
  145. package/studio/src/index.css +36 -0
  146. package/studio/src/lib/api.ts +186 -0
  147. package/studio/src/lib/utils.ts +13 -0
  148. package/studio/src/main.tsx +17 -0
  149. package/studio/src/pages/ArcheType.tsx +239 -0
  150. package/studio/src/pages/Components.tsx +124 -0
  151. package/studio/src/pages/EntityInspector.tsx +302 -0
  152. package/studio/src/pages/QueryRunner.tsx +246 -0
  153. package/studio/src/pages/Table.tsx +94 -0
  154. package/studio/src/pages/Welcome.tsx +241 -0
  155. package/studio/src/routes.tsx +45 -0
  156. package/studio/src/store/archeTypeSettings.ts +30 -0
  157. package/studio/src/store/studio.ts +65 -0
  158. package/studio/src/utils/columnHelpers.tsx +114 -0
  159. package/studio/studio-instructions.md +81 -0
  160. package/studio/tailwind.config.js +77 -0
  161. package/studio/tsconfig.json +24 -0
  162. package/studio/utils.ts +54 -0
  163. package/studio/vite.config.js +19 -0
  164. package/swagger/generator.ts +1 -1
  165. package/tests/e2e/http.test.ts +126 -0
  166. package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
  167. package/tests/fixtures/components/TestOrder.ts +23 -0
  168. package/tests/fixtures/components/TestProduct.ts +23 -0
  169. package/tests/fixtures/components/TestUser.ts +20 -0
  170. package/tests/fixtures/components/index.ts +6 -0
  171. package/tests/graphql/SchemaGeneration.test.ts +90 -0
  172. package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
  173. package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
  174. package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
  175. package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
  176. package/tests/integration/entity/Entity.persistence.test.ts +333 -0
  177. package/tests/integration/query/Query.exec.test.ts +523 -0
  178. package/tests/pglite-setup.ts +61 -0
  179. package/tests/setup.ts +164 -0
  180. package/tests/stress/BenchmarkRunner.ts +203 -0
  181. package/tests/stress/DataSeeder.ts +190 -0
  182. package/tests/stress/StressTestReporter.ts +229 -0
  183. package/tests/stress/cursor-perf-test.ts +171 -0
  184. package/tests/stress/fixtures/StressTestComponents.ts +58 -0
  185. package/tests/stress/index.ts +7 -0
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
  187. package/tests/unit/BatchLoader.test.ts +82 -0
  188. package/tests/unit/archetype/ArcheType.test.ts +107 -0
  189. package/tests/unit/cache/CacheManager.test.ts +347 -0
  190. package/tests/unit/cache/MemoryCache.test.ts +260 -0
  191. package/tests/unit/cache/RedisCache.test.ts +411 -0
  192. package/tests/unit/entity/Entity.components.test.ts +244 -0
  193. package/tests/unit/entity/Entity.test.ts +345 -0
  194. package/tests/unit/gql/depthLimit.test.ts +203 -0
  195. package/tests/unit/gql/operationMiddleware.test.ts +293 -0
  196. package/tests/unit/health/Health.test.ts +129 -0
  197. package/tests/unit/middleware/AccessLog.test.ts +37 -0
  198. package/tests/unit/middleware/Middleware.test.ts +98 -0
  199. package/tests/unit/middleware/RequestId.test.ts +54 -0
  200. package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
  201. package/tests/unit/query/FilterBuilder.test.ts +111 -0
  202. package/tests/unit/query/Query.test.ts +308 -0
  203. package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
  204. package/tests/unit/schema/schema-integration.test.ts +426 -0
  205. package/tests/unit/schema/schema.test.ts +580 -0
  206. package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
  207. package/tests/unit/upload/RestUpload.test.ts +267 -0
  208. package/tests/unit/validateEnv.test.ts +82 -0
  209. package/tests/utils/entity-tracker.ts +57 -0
  210. package/tests/utils/index.ts +13 -0
  211. package/tests/utils/test-context.ts +149 -0
  212. package/tsconfig.json +5 -1
  213. package/types/archetype.types.ts +6 -0
  214. package/types/hooks.types.ts +1 -1
  215. package/types/query.types.ts +110 -0
  216. package/types/scheduler.types.ts +68 -7
  217. package/types/upload.types.ts +1 -0
  218. package/{core → upload}/FileValidator.ts +10 -1
  219. package/upload/RestUpload.ts +130 -0
  220. package/{core/components → upload}/UploadComponent.ts +11 -11
  221. package/{core → upload}/UploadManager.ts +3 -3
  222. package/upload/index.ts +23 -7
  223. package/utils/UploadHelper.ts +27 -6
  224. package/utils/cronParser.ts +16 -6
  225. package/.github/workflows/deploy-docs.yml +0 -57
  226. package/core/Components.ts +0 -202
  227. package/core/EntityCache.ts +0 -15
  228. package/core/Query.ts +0 -880
  229. package/docs/README.md +0 -149
  230. package/docs/_coverpage.md +0 -36
  231. package/docs/_sidebar.md +0 -23
  232. package/docs/api/core.md +0 -568
  233. package/docs/api/hooks.md +0 -554
  234. package/docs/api/index.md +0 -222
  235. package/docs/api/query.md +0 -678
  236. package/docs/api/service.md +0 -744
  237. package/docs/core-concepts/archetypes.md +0 -512
  238. package/docs/core-concepts/components.md +0 -498
  239. package/docs/core-concepts/entity.md +0 -314
  240. package/docs/core-concepts/hooks.md +0 -683
  241. package/docs/core-concepts/query.md +0 -588
  242. package/docs/core-concepts/services.md +0 -647
  243. package/docs/examples/code-examples.md +0 -425
  244. package/docs/getting-started.md +0 -337
  245. package/docs/index.html +0 -97
  246. package/tests/bench/insert.bench.ts +0 -60
  247. package/tests/bench/relations.bench.ts +0 -270
  248. package/tests/bench/sorting.bench.ts +0 -416
  249. package/tests/component-hooks-simple.test.ts +0 -117
  250. package/tests/component-hooks.test.ts +0 -1461
  251. package/tests/component.test.ts +0 -339
  252. package/tests/errorHandling.test.ts +0 -155
  253. package/tests/hooks.test.ts +0 -667
  254. package/tests/query-sorting.test.ts +0 -101
  255. package/tests/query.test.ts +0 -81
  256. package/tests/relations.test.ts +0 -170
  257. package/tests/scheduler.test.ts +0 -724
package/core/App.ts CHANGED
@@ -1,16 +1,47 @@
1
- import ApplicationLifecycle, {ApplicationPhase} from "core/ApplicationLifecycle";
2
- import { GenerateTableName, HasValidBaseTable, PrepareDatabase, UpdateComponentIndexes } from "database/DatabaseHelper";
3
- import ComponentRegistry from "core/ComponentRegistry";
4
- import { logger as MainLogger } from "core/Logger";
1
+ import ApplicationLifecycle, {
2
+ ApplicationPhase,
3
+ } from "./ApplicationLifecycle";
4
+ import {
5
+ GenerateTableName,
6
+ HasValidBaseTable,
7
+ PrepareDatabase,
8
+ UpdateComponentIndexes,
9
+ EnsureDatabaseMigrations,
10
+ } from "../database/DatabaseHelper";
11
+ import { ComponentRegistry } from "./components";
12
+ import { logger as MainLogger } from "./Logger";
13
+ import { getSerializedMetadataStorage } from "./metadata";
5
14
  const logger = MainLogger.child({ scope: "App" });
6
- import { createYogaInstance } from "gql";
7
- import ServiceRegistry from "service/ServiceRegistry";
8
- import type { Plugin } from "graphql-yoga";
15
+ import { createYogaInstance } from "../gql";
16
+ import ServiceRegistry from "../service/ServiceRegistry";
17
+ import { type Plugin, createPubSub } from "graphql-yoga";
9
18
  import * as path from "path";
10
- import { SchedulerManager } from "core/SchedulerManager";
11
- import { registerScheduledTasks } from "core/decorators/ScheduledTask";
12
- import { OpenAPISpecGenerator, type SwaggerEndpointMetadata } from "swagger";
13
- import type BasePlugin from "plugins";
19
+ import { SchedulerManager } from "./SchedulerManager";
20
+ import { registerScheduledTasks } from "../scheduler";
21
+ import { OpenAPISpecGenerator, type SwaggerEndpointMetadata } from "../swagger";
22
+ import type BasePlugin from "../plugins";
23
+ import { preparedStatementCache } from "../database/PreparedStatementCache";
24
+ import db from "../database";
25
+ import studioEndpoint from "../endpoints";
26
+ import { type Middleware, composeMiddleware } from "./Middleware";
27
+ import { deepHealthCheck, readinessCheck } from "./health";
28
+ import { validateEnv } from "./validateEnv";
29
+
30
+ export type CorsConfig = {
31
+ origin?: string | string[] | ((origin: string) => boolean);
32
+ credentials?: boolean;
33
+ allowedHeaders?: string[];
34
+ exposedHeaders?: string[];
35
+ methods?: string[];
36
+ maxAge?: number;
37
+ };
38
+
39
+ export type AppConfig = {
40
+ scheduler: {
41
+ logging: boolean;
42
+ };
43
+ cors?: CorsConfig;
44
+ };
14
45
 
15
46
  export default class App {
16
47
  private name: string = "BunSane Application";
@@ -18,8 +49,16 @@ export default class App {
18
49
  private yoga: any;
19
50
  private yogaPlugins: Plugin[] = [];
20
51
  private contextFactory?: (context: any) => any;
21
- private restEndpoints: Array<{ method: string; path: string; handler: Function; service: any }> = [];
22
- private restEndpointMap: Map<string, { method: string; path: string; handler: Function; service: any }> = new Map();
52
+ private restEndpoints: Array<{
53
+ method: string;
54
+ path: string;
55
+ handler: Function;
56
+ service: any;
57
+ }> = [];
58
+ private restEndpointMap: Map<
59
+ string,
60
+ { method: string; path: string; handler: Function; service: any }
61
+ > = new Map();
23
62
  private staticAssets: Map<string, string> = new Map();
24
63
  private openAPISpecGenerator: OpenAPISpecGenerator | null = null;
25
64
  private enforceDocs: boolean = false;
@@ -27,24 +66,83 @@ export default class App {
27
66
  private appReadyCallbacks: Array<() => void> = [];
28
67
 
29
68
  private plugins: BasePlugin[] = [];
69
+ private middlewares: Middleware[] = [];
70
+ private composedHandler: ((req: Request) => Promise<Response>) | null = null;
71
+
72
+ private studioEnabled: boolean = false;
73
+ private server: ReturnType<typeof Bun.serve> | null = null;
74
+ private isShuttingDown = false;
75
+ private isReady = false;
76
+ private graphqlMaxDepth: number = 10;
77
+ private shutdownGracePeriod = 10_000;
78
+ private maxRequestBodySize = 50 * 1024 * 1024; // 50MB default
79
+
80
+ pubSub = createPubSub();
81
+
82
+ public config: AppConfig = {
83
+ scheduler: {
84
+ logging: false,
85
+ },
86
+ };
30
87
 
31
88
  constructor(appName?: string, appVersion?: string) {
32
89
  if (appName) this.name = appName;
33
90
  if (appVersion) this.version = appVersion;
34
91
  this.openAPISpecGenerator = new OpenAPISpecGenerator(
35
92
  this.name,
36
- this.version,
93
+ this.version
94
+ );
95
+
96
+ // Automatically serve the studio if it exists
97
+ const studioPath = path.join(
98
+ import.meta.dirname,
99
+ "..",
100
+ "studio",
101
+ "dist"
37
102
  );
103
+ try {
104
+ const studioDir = Bun.file(studioPath);
105
+ if (studioDir) {
106
+ this.addStaticAssets("/studio", studioPath);
107
+ logger.info("Studio assets loaded from:" + studioPath);
108
+ }
109
+ } catch (error) {
110
+ logger.warn(
111
+ "Studio not found, skipping studio setup:",
112
+ error as any
113
+ );
114
+ }
115
+
38
116
  return this;
39
117
  }
40
118
 
119
+ public setCors(cors: CorsConfig) {
120
+ this.config.cors = cors;
121
+ // Warn about invalid configuration
122
+ if (cors.credentials && cors.origin === '*') {
123
+ console.warn('[CORS] Warning: credentials=true with origin="*" is invalid per spec. Origin will be reflected from request.');
124
+ }
125
+ }
126
+
41
127
  async init() {
128
+ validateEnv();
42
129
  logger.trace(`Initializing App`);
43
130
  ComponentRegistry.init();
44
131
  ServiceRegistry.init();
132
+
133
+ // Initialize CacheManager
134
+ try {
135
+ const { CacheManager } = await import('./cache/CacheManager');
136
+ const cacheManager = CacheManager.getInstance();
137
+ // CacheManager initializes with default config, can be customized later
138
+ logger.info({ scope: 'cache', component: 'App', msg: 'CacheManager initialized' });
139
+ } catch (error) {
140
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Failed to initialize CacheManager', error });
141
+ }
142
+
45
143
  // Plugin initialization
46
- for(const plugin of this.plugins) {
47
- if(plugin.init) {
144
+ for (const plugin of this.plugins) {
145
+ if (plugin.init) {
48
146
  await plugin.init(this);
49
147
  }
50
148
  }
@@ -53,35 +151,84 @@ export default class App {
53
151
  const phase = event.detail;
54
152
  logger.info(`Application phase changed to: ${phase}`);
55
153
  // Notify plugins of phase change
56
- for(const plugin of this.plugins) {
57
- if(plugin.onPhaseChange) {
154
+ for (const plugin of this.plugins) {
155
+ if (plugin.onPhaseChange) {
58
156
  await plugin.onPhaseChange(phase, this);
59
157
  }
60
158
  }
61
- switch(phase) {
159
+ switch (phase) {
62
160
  case ApplicationPhase.DATABASE_READY: {
161
+ // Warm up prepared statement cache with common query patterns
162
+ try {
163
+ await this.warmUpPreparedStatementCache();
164
+ } catch (error) {
165
+ logger.warn(
166
+ "Failed to warm up prepared statement cache:",
167
+ error as any
168
+ );
169
+ }
63
170
  break;
64
171
  }
65
172
  case ApplicationPhase.SYSTEM_READY: {
173
+ // Perform cache health check
66
174
  try {
67
- const schema = ServiceRegistry.getSchema();
175
+ const { CacheManager } = await import('./cache/CacheManager');
176
+ const cacheManager = CacheManager.getInstance();
177
+ const config = cacheManager.getConfig();
68
178
 
69
- // Wrap user's context factory to automatically spread Yoga context
70
- const wrappedContextFactory = this.contextFactory
71
- ? (yogaContext: any) => {
72
- const userContext = this.contextFactory!(yogaContext);
73
- // Merge Yoga's context with user's context, preserving Yoga properties
74
- return {
75
- ...yogaContext, // Yoga context (request, params, etc.)
76
- ...userContext, // User's additional context
77
- };
179
+ if (config.enabled) {
180
+ const isHealthy = await cacheManager.getProvider().ping();
181
+ if (isHealthy) {
182
+ logger.info({ scope: 'cache', component: 'App', msg: 'Cache health check passed' });
183
+ } else {
184
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Cache health check failed' });
78
185
  }
186
+ }
187
+ } catch (error) {
188
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Cache health check error', error });
189
+ }
190
+
191
+ try {
192
+ const schema = ServiceRegistry.getSchema();
193
+
194
+ // Wrap user's context factory to automatically spread Yoga context
195
+ const wrappedContextFactory = this.contextFactory
196
+ ? async (yogaContext: any) => {
197
+ const userContext =
198
+ await this.contextFactory!(yogaContext);
199
+ // Merge Yoga's context with user's context, preserving Yoga properties
200
+ return {
201
+ ...yogaContext, // Yoga context (request, params, etc.)
202
+ ...userContext, // User's additional context
203
+ };
204
+ }
79
205
  : undefined;
80
-
206
+
207
+ // Read env override for GraphQL depth limit
208
+ const envDepth = process.env.GRAPHQL_MAX_DEPTH;
209
+ if (envDepth) {
210
+ this.graphqlMaxDepth = parseInt(envDepth, 10);
211
+ }
212
+
213
+ const yogaOptions = {
214
+ cors: this.config.cors,
215
+ maxDepth: this.graphqlMaxDepth || undefined,
216
+ };
217
+
81
218
  if (schema) {
82
- this.yoga = createYogaInstance(schema, this.yogaPlugins, wrappedContextFactory);
219
+ this.yoga = createYogaInstance(
220
+ schema,
221
+ this.yogaPlugins,
222
+ wrappedContextFactory,
223
+ yogaOptions
224
+ );
83
225
  } else {
84
- this.yoga = createYogaInstance(undefined, this.yogaPlugins, wrappedContextFactory);
226
+ this.yoga = createYogaInstance(
227
+ undefined,
228
+ this.yogaPlugins,
229
+ wrappedContextFactory,
230
+ yogaOptions
231
+ );
85
232
  }
86
233
 
87
234
  // Get all services for processing
@@ -89,74 +236,129 @@ export default class App {
89
236
 
90
237
  // Initialize Scheduler
91
238
  const scheduler = SchedulerManager.getInstance();
239
+ scheduler.config.enableLogging =
240
+ this.config.scheduler.logging;
92
241
 
93
242
  // Register scheduled tasks for all services
94
243
  for (const service of services) {
95
244
  try {
96
245
  registerScheduledTasks(service);
97
246
  } catch (error) {
98
- logger.warn(`Failed to register scheduled tasks for service ${service.constructor.name}`);
247
+ logger.warn(
248
+ `Failed to register scheduled tasks for service ${service.constructor.name}`
249
+ );
99
250
  logger.warn(error);
100
251
  }
101
252
  }
102
- logger.info(`Registered scheduled tasks for ${services.length} services`);
253
+ logger.info(
254
+ `Registered scheduled tasks for ${services.length} services`
255
+ );
103
256
 
104
257
  // Collect REST endpoints from all services
105
258
  for (const service of services) {
106
- const endpoints = (service.constructor as any).httpEndpoints;
259
+ const endpoints = (service.constructor as any)
260
+ .httpEndpoints;
107
261
  if (endpoints) {
108
262
  for (const endpoint of endpoints) {
109
263
  const endpointInfo = {
110
264
  method: endpoint.method,
111
265
  path: endpoint.path,
112
266
  handler: endpoint.handler.bind(service),
113
- service: service
267
+ service: service,
114
268
  };
115
- logger.trace(`Registered REST endpoint: [${endpoint.method}] ${endpoint.path} for service ${service.constructor.name}`);
269
+ logger.trace(
270
+ `Registered REST endpoint: [${endpoint.method}] ${endpoint.path} for service ${service.constructor.name}`
271
+ );
116
272
  this.restEndpoints.push(endpointInfo);
117
- this.restEndpointMap.set(`${endpoint.method}:${endpoint.path}`, endpointInfo);
273
+ this.restEndpointMap.set(
274
+ `${endpoint.method}:${endpoint.path}`,
275
+ endpointInfo
276
+ );
118
277
 
119
278
  // Check if this endpoint has a swagger operation
120
- if ((endpoint.handler as any).swaggerOperation) {
279
+ if (
280
+ (endpoint.handler as any)
281
+ .swaggerOperation
282
+ ) {
121
283
  // Collect tags from class and method decorators
122
- const classTags = (service.constructor as any).swaggerClassTags || [];
123
- const methodTags = (service.constructor as any).swaggerMethodTags?.[endpoint.handler.name] || [];
124
- const allTags = [...classTags, ...methodTags];
284
+ const classTags =
285
+ (service.constructor as any)
286
+ .swaggerClassTags || [];
287
+ const methodTags =
288
+ (service.constructor as any)
289
+ .swaggerMethodTags?.[
290
+ endpoint.handler.name
291
+ ] || [];
292
+ const allTags = [
293
+ ...classTags,
294
+ ...methodTags,
295
+ ];
296
+
297
+ logger.trace(
298
+ `Generating OpenAPI spec for endpoint: [${
299
+ endpoint.method
300
+ }] ${
301
+ endpoint.path
302
+ } with tags: ${allTags.join(", ")}`
303
+ );
125
304
 
126
- logger.trace(`Generating OpenAPI spec for endpoint: [${endpoint.method}] ${endpoint.path} with tags: ${allTags.join(", ")}`);
127
-
128
305
  // Merge tags into the operation
129
- const operation = { ...(endpoint.handler as any).swaggerOperation };
306
+ const operation = {
307
+ ...(endpoint.handler as any)
308
+ .swaggerOperation,
309
+ };
130
310
  if (allTags.length > 0) {
131
- operation.tags = [...(operation.tags || []), ...allTags];
132
- }
133
-
311
+ operation.tags = [
312
+ ...(operation.tags || []),
313
+ ...allTags,
314
+ ];
315
+ }
316
+
134
317
  this.openAPISpecGenerator!.addEndpoint({
135
318
  method: endpoint.method,
136
319
  path: endpoint.path,
137
- operation
320
+ operation,
138
321
  });
139
- logger.trace(`Registered OpenAPI spec for endpoint: [${endpoint.method}] ${endpoint.path}`);
322
+ logger.trace(
323
+ `Registered OpenAPI spec for endpoint: [${endpoint.method}] ${endpoint.path}`
324
+ );
140
325
  } else {
141
- if(this.enforceDocs) {
142
- logger.warn(`No swagger operation found for endpoint: [${endpoint.method}] ${endpoint.path} in service ${service.constructor.name}`);
143
- this.openAPISpecGenerator!.addEndpoint({
144
- method: endpoint.method,
145
- path: endpoint.path,
146
- operation: {
147
- summary: `No description for ${endpoint.path}. Don't use this endpoint until it's properly documented!`,
148
- requestBody: {content: {"application/json": {schema: {}}}},
149
- responses: { "200": { description: "Success" } }
326
+ if (this.enforceDocs) {
327
+ logger.warn(
328
+ `No swagger operation found for endpoint: [${endpoint.method}] ${endpoint.path} in service ${service.constructor.name}`
329
+ );
330
+ this.openAPISpecGenerator!.addEndpoint(
331
+ {
332
+ method: endpoint.method,
333
+ path: endpoint.path,
334
+ operation: {
335
+ summary: `No description for ${endpoint.path}. Don't use this endpoint until it's properly documented!`,
336
+ requestBody: {
337
+ content: {
338
+ "application/json":
339
+ {
340
+ schema: {},
341
+ },
342
+ },
343
+ },
344
+ responses: {
345
+ "200": {
346
+ description:
347
+ "Success",
348
+ },
349
+ },
350
+ },
150
351
  }
151
- });
352
+ );
152
353
  }
153
354
  }
154
355
  }
155
356
  }
156
357
  }
157
358
 
158
-
159
- ApplicationLifecycle.setPhase(ApplicationPhase.APPLICATION_READY);
359
+ ApplicationLifecycle.setPhase(
360
+ ApplicationPhase.APPLICATION_READY
361
+ );
160
362
  } catch (error) {
161
363
  logger.error("Error during SYSTEM_READY phase:");
162
364
  logger.error(error);
@@ -164,7 +366,7 @@ export default class App {
164
366
  break;
165
367
  }
166
368
  case ApplicationPhase.APPLICATION_READY: {
167
- if(process.env.NODE_ENV !== "test") {
369
+ if (process.env.NODE_ENV !== "test") {
168
370
  this.start();
169
371
  }
170
372
  break;
@@ -172,23 +374,30 @@ export default class App {
172
374
  }
173
375
  });
174
376
 
175
- if(ApplicationLifecycle.getCurrentPhase() === ApplicationPhase.DATABASE_INITIALIZING) {
176
- if(!await HasValidBaseTable()) {
377
+ if (
378
+ ApplicationLifecycle.getCurrentPhase() ===
379
+ ApplicationPhase.DATABASE_INITIALIZING
380
+ ) {
381
+ if (!(await HasValidBaseTable())) {
177
382
  await PrepareDatabase();
383
+ } else {
384
+ // Check for missing columns and run migrations
385
+ await EnsureDatabaseMigrations();
178
386
  }
179
387
  logger.trace(`Database prepared...`);
180
388
  ApplicationLifecycle.setPhase(ApplicationPhase.DATABASE_READY);
181
389
  await ComponentRegistry.registerAllComponents();
182
390
  ApplicationLifecycle.setPhase(ApplicationPhase.SYSTEM_REGISTERING);
183
391
  }
184
-
185
-
186
392
  }
187
393
 
188
394
  waitForAppReady(): Promise<void> {
189
- return new Promise(resolve => {
395
+ return new Promise((resolve) => {
190
396
  const interval = setInterval(() => {
191
- if (ApplicationLifecycle.getCurrentPhase() >= ApplicationPhase.COMPONENTS_READY) {
397
+ if (
398
+ ApplicationLifecycle.getCurrentPhase() >=
399
+ ApplicationPhase.APPLICATION_READY
400
+ ) {
192
401
  clearInterval(interval);
193
402
  resolve();
194
403
  }
@@ -213,7 +422,15 @@ export default class App {
213
422
 
214
423
  public addPlugin(plugin: BasePlugin) {
215
424
  this.plugins.push(plugin);
216
- }
425
+ }
426
+
427
+ /**
428
+ * Register an HTTP middleware. Middlewares execute in registration order,
429
+ * wrapping around the core request handler (onion model).
430
+ */
431
+ public use(middleware: Middleware) {
432
+ this.middlewares.push(middleware);
433
+ }
217
434
 
218
435
  public addStaticAssets(route: string, folder: string) {
219
436
  // Resolve the folder path relative to the current working directory
@@ -221,11 +438,95 @@ export default class App {
221
438
  this.staticAssets.set(route, resolvedFolder);
222
439
  }
223
440
 
441
+ private validateOrigin(requestOrigin: string | null | undefined): string | null {
442
+ if (!this.config.cors || !requestOrigin) return null;
443
+
444
+ const configOrigin = this.config.cors.origin;
445
+
446
+ // Wildcard allows all
447
+ if (configOrigin === '*' || configOrigin === undefined) {
448
+ // If credentials enabled, cannot use wildcard - return actual origin
449
+ return this.config.cors.credentials ? requestOrigin : '*';
450
+ }
451
+
452
+ // String match
453
+ if (typeof configOrigin === 'string') {
454
+ return requestOrigin === configOrigin ? configOrigin : null;
455
+ }
456
+
457
+ // Array - check if origin is in list
458
+ if (Array.isArray(configOrigin)) {
459
+ return configOrigin.includes(requestOrigin) ? requestOrigin : null;
460
+ }
461
+
462
+ // Function validator
463
+ if (typeof configOrigin === 'function') {
464
+ return configOrigin(requestOrigin) ? requestOrigin : null;
465
+ }
466
+
467
+ return null;
468
+ }
469
+
470
+ private getCorsHeaders(req?: Request): Record<string, string> {
471
+ if (!this.config.cors) return {};
472
+
473
+ const requestOrigin = req?.headers.get('Origin');
474
+ const allowedOrigin = this.validateOrigin(requestOrigin);
475
+
476
+ // If origin not allowed, return empty (no CORS headers)
477
+ if (requestOrigin && !allowedOrigin) return {};
478
+
479
+ const headers: Record<string, string> = {
480
+ 'Access-Control-Allow-Origin': allowedOrigin || '*',
481
+ 'Access-Control-Allow-Methods': this.config.cors.methods?.join(', ') || 'GET, POST, PUT, DELETE, OPTIONS',
482
+ 'Access-Control-Allow-Headers': this.config.cors.allowedHeaders?.join(', ') || 'Content-Type, Authorization',
483
+ 'Vary': 'Origin',
484
+ };
485
+
486
+ if (this.config.cors.credentials) {
487
+ headers['Access-Control-Allow-Credentials'] = 'true';
488
+ }
489
+
490
+ if (this.config.cors.exposedHeaders?.length) {
491
+ headers['Access-Control-Expose-Headers'] = this.config.cors.exposedHeaders.join(', ');
492
+ }
493
+
494
+ if (this.config.cors.maxAge !== undefined) {
495
+ headers['Access-Control-Max-Age'] = String(this.config.cors.maxAge);
496
+ }
497
+
498
+ return headers;
499
+ }
500
+
501
+ private addCorsHeaders(response: Response, req?: Request): Response {
502
+ const corsHeaders = this.getCorsHeaders(req);
503
+ if (Object.keys(corsHeaders).length === 0) return response;
504
+
505
+ const newHeaders = new Headers(response.headers);
506
+ for (const [key, value] of Object.entries(corsHeaders)) {
507
+ newHeaders.set(key, value);
508
+ }
509
+
510
+ return new Response(response.body, {
511
+ status: response.status,
512
+ statusText: response.statusText,
513
+ headers: newHeaders,
514
+ });
515
+ }
516
+
224
517
  private async handleRequest(req: Request): Promise<Response> {
225
518
  const url = new URL(req.url);
226
519
  const method = req.method;
227
520
  const startTime = Date.now();
228
-
521
+
522
+ // Handle CORS preflight requests
523
+ if (method === 'OPTIONS') {
524
+ return new Response(null, {
525
+ status: 204,
526
+ headers: this.getCorsHeaders(req),
527
+ });
528
+ }
529
+
229
530
  // Add request timeout
230
531
  const controller = new AbortController();
231
532
  const timeoutId = setTimeout(() => {
@@ -235,27 +536,54 @@ export default class App {
235
536
 
236
537
  try {
237
538
  // Health check endpoint
238
- if (url.pathname === '/health') {
539
+ if (url.pathname === "/health") {
239
540
  clearTimeout(timeoutId);
240
- return new Response(JSON.stringify({
241
- status: 'ok',
242
- timestamp: new Date().toISOString(),
243
- uptime: process.uptime()
244
- }), {
245
- headers: { 'Content-Type': 'application/json' }
246
- });
541
+ const health = await deepHealthCheck();
542
+ return this.addCorsHeaders(new Response(
543
+ JSON.stringify(health.result),
544
+ {
545
+ status: health.httpStatus,
546
+ headers: { "Content-Type": "application/json" },
547
+ }
548
+ ), req);
549
+ }
550
+
551
+ // Metrics endpoint
552
+ if (url.pathname === "/metrics") {
553
+ clearTimeout(timeoutId);
554
+ const metrics = await this.collectMetrics();
555
+ return this.addCorsHeaders(new Response(
556
+ JSON.stringify(metrics),
557
+ {
558
+ status: 200,
559
+ headers: { "Content-Type": "application/json" },
560
+ }
561
+ ), req);
562
+ }
563
+
564
+ // Readiness probe
565
+ if (url.pathname === "/health/ready") {
566
+ clearTimeout(timeoutId);
567
+ const ready = await readinessCheck(this.isReady, this.isShuttingDown);
568
+ return this.addCorsHeaders(new Response(
569
+ JSON.stringify(ready.result),
570
+ {
571
+ status: ready.httpStatus,
572
+ headers: { "Content-Type": "application/json" },
573
+ }
574
+ ), req);
247
575
  }
248
576
 
249
577
  // OpenAPI spec endpoint
250
- if (url.pathname === '/openapi.json') {
578
+ if (url.pathname === "/openapi.json") {
251
579
  clearTimeout(timeoutId);
252
- return new Response(this.openAPISpecGenerator!.toJSON(), {
253
- headers: { 'Content-Type': 'application/json' }
254
- });
580
+ return this.addCorsHeaders(new Response(this.openAPISpecGenerator!.toJSON(), {
581
+ headers: { "Content-Type": "application/json" },
582
+ }), req);
255
583
  }
256
584
 
257
585
  // Swagger UI endpoint
258
- if (url.pathname === '/docs') {
586
+ if (url.pathname === "/docs") {
259
587
  clearTimeout(timeoutId);
260
588
  const swaggerUIHTML = `
261
589
  <!DOCTYPE html>
@@ -291,9 +619,173 @@ export default class App {
291
619
  </script>
292
620
  </body>
293
621
  </html>`;
294
- return new Response(swaggerUIHTML, {
295
- headers: { 'Content-Type': 'text/html' }
296
- });
622
+ return this.addCorsHeaders(new Response(swaggerUIHTML, {
623
+ headers: { "Content-Type": "text/html" },
624
+ }), req);
625
+ }
626
+
627
+ // Studio API endpoints
628
+ if (this.studioEnabled && url.pathname.startsWith("/studio/api/")) {
629
+ clearTimeout(timeoutId);
630
+
631
+ // Studio tables endpoint
632
+ if (url.pathname === "/studio/api/tables") {
633
+ return this.addCorsHeaders(await studioEndpoint.getTables(), req);
634
+ }
635
+
636
+ // Studio stats endpoint
637
+ if (url.pathname === "/studio/api/stats") {
638
+ return this.addCorsHeaders(await studioEndpoint.handleStudioStatsRequest(), req);
639
+ }
640
+
641
+ // Studio components endpoint
642
+ if (url.pathname === "/studio/api/components") {
643
+ return this.addCorsHeaders(await studioEndpoint.handleStudioComponentsRequest(), req);
644
+ }
645
+
646
+ // Studio query endpoint (POST only)
647
+ if (url.pathname === "/studio/api/query" && method === "POST") {
648
+ const body = await req.json();
649
+ return this.addCorsHeaders(await studioEndpoint.handleStudioQueryRequest(body), req);
650
+ }
651
+
652
+ const studioApiPath = url.pathname.replace("/studio/api/", "");
653
+ const pathSegments = studioApiPath.split("/");
654
+
655
+ if (pathSegments[0] === "entity" && pathSegments[1]) {
656
+ const entityId = pathSegments[1];
657
+ return this.addCorsHeaders(
658
+ await studioEndpoint.handleEntityInspectorRequest(entityId),
659
+ req
660
+ );
661
+ }
662
+
663
+ if (pathSegments[0] === "table" && pathSegments[1]) {
664
+ const tableName = pathSegments[1];
665
+
666
+ if (method === "DELETE") {
667
+ const body = await req.json();
668
+ return this.addCorsHeaders(await studioEndpoint.handleStudioTableDeleteRequest(
669
+ tableName,
670
+ body
671
+ ), req);
672
+ }
673
+
674
+ const limit = url.searchParams.get("limit");
675
+ const offset = url.searchParams.get("offset");
676
+ const search = url.searchParams.get("search");
677
+
678
+ return this.addCorsHeaders(await studioEndpoint.handleStudioTableRequest(tableName, {
679
+ limit: limit ? parseInt(limit, 10) : undefined,
680
+ offset: offset ? parseInt(offset, 10) : undefined,
681
+ search: search ?? undefined,
682
+ }), req);
683
+ }
684
+
685
+ if (pathSegments[0] === "arche-type" && pathSegments[1]) {
686
+ const archeTypeName = pathSegments[1];
687
+
688
+ if (method === "DELETE") {
689
+ const body = await req.json();
690
+ return this.addCorsHeaders(await studioEndpoint.handleStudioArcheTypeDeleteRequest(
691
+ archeTypeName,
692
+ body
693
+ ), req);
694
+ }
695
+
696
+ const limit = url.searchParams.get("limit");
697
+ const offset = url.searchParams.get("offset");
698
+ const search = url.searchParams.get("search");
699
+ const includeDeleted = url.searchParams.get("include_deleted");
700
+
701
+ return this.addCorsHeaders(await studioEndpoint.handleStudioArcheTypeRecordsRequest(
702
+ archeTypeName,
703
+ {
704
+ limit: limit ? parseInt(limit, 10) : undefined,
705
+ offset: offset ? parseInt(offset, 10) : undefined,
706
+ search: search ?? undefined,
707
+ include_deleted: includeDeleted === "true",
708
+ }
709
+ ), req);
710
+ }
711
+
712
+ return this.addCorsHeaders(new Response(
713
+ JSON.stringify({ error: "Studio API endpoint not found" }),
714
+ {
715
+ status: 404,
716
+ headers: { "Content-Type": "application/json" },
717
+ }
718
+ ), req);
719
+ }
720
+
721
+ // Studio endpoint - handle both root and all sub-routes
722
+ if (
723
+ this.studioEnabled &&
724
+ (url.pathname === "/studio" ||
725
+ url.pathname.startsWith("/studio/"))
726
+ ) {
727
+ clearTimeout(timeoutId);
728
+
729
+ // Skip API routes - they're handled by the API handler above
730
+ if (url.pathname.startsWith("/studio/api/")) {
731
+ return this.addCorsHeaders(new Response(
732
+ JSON.stringify({
733
+ error: "Studio API endpoint not found",
734
+ }),
735
+ {
736
+ status: 404,
737
+ headers: { "Content-Type": "application/json" },
738
+ }
739
+ ), req);
740
+ }
741
+
742
+ // Check if this is a request for static assets (CSS, JS, etc.)
743
+ if (url.pathname.startsWith("/studio/assets/")) {
744
+ // Let the static assets handler below handle this
745
+ // Don't return here, fall through to static assets handler
746
+ } else {
747
+ // For all other /studio/* routes, serve the React app's index.html
748
+ const studioIndexPath = path.join(
749
+ import.meta.dirname,
750
+ "..",
751
+ "studio",
752
+ "dist",
753
+ "index.html"
754
+ );
755
+ try {
756
+ const studioFile = Bun.file(studioIndexPath);
757
+ if (await studioFile.exists()) {
758
+ let html = await studioFile.text();
759
+ // Inject metadata into the HTML
760
+ const metadata = getSerializedMetadataStorage();
761
+ const metadataScript = `<script>window.bunsaneMetadata = ${JSON.stringify(
762
+ metadata
763
+ )};</script>`;
764
+ // Insert before the closing </head> tag
765
+ html = html.replace(
766
+ "</head>",
767
+ `${metadataScript}</head>`
768
+ );
769
+ return this.addCorsHeaders(new Response(html, {
770
+ headers: { "Content-Type": "text/html" },
771
+ }), req);
772
+ } else {
773
+ return this.addCorsHeaders(new Response(
774
+ "Studio not built. Run `bun run build:studio` to build the studio.",
775
+ {
776
+ status: 404,
777
+ headers: { "Content-Type": "text/plain" },
778
+ }
779
+ ), req);
780
+ }
781
+ } catch (error) {
782
+ console.log("Error loading studio index.html:", error);
783
+ return this.addCorsHeaders(new Response("Studio not available", {
784
+ status: 404,
785
+ headers: { "Content-Type": "text/plain" },
786
+ }), req);
787
+ }
788
+ }
297
789
  }
298
790
  for (const [route, folder] of this.staticAssets) {
299
791
  if (url.pathname.startsWith(route)) {
@@ -303,39 +795,71 @@ export default class App {
303
795
  const file = Bun.file(filePath);
304
796
  if (await file.exists()) {
305
797
  clearTimeout(timeoutId);
306
- return new Response(file);
798
+ return this.addCorsHeaders(new Response(file), req);
307
799
  }
308
800
  } catch (error) {
309
- logger.error(`Error serving static file ${filePath}:`, error as any);
801
+ logger.error(
802
+ `Error serving static file ${filePath}:`,
803
+ error as any
804
+ );
310
805
  }
311
806
  }
312
807
  }
313
808
 
314
809
  // Lookup REST endpoint using map for O(1) performance
315
810
  const endpointKey = `${method}:${url.pathname}`;
316
- const endpoint = this.restEndpointMap.get(endpointKey);
811
+ let endpoint = this.restEndpointMap.get(endpointKey);
812
+
813
+ // If exact match not found, try pattern matching for parameterized routes
814
+ if (!endpoint) {
815
+ for (const ep of this.restEndpoints) {
816
+ if (ep.method !== method) continue;
817
+ // Convert route pattern to regex (e.g., /api/v1/users/:id -> /api/v1/users/[^/]+)
818
+ const pattern = ep.path.replace(/:[^/]+/g, '[^/]+');
819
+ const regex = new RegExp(`^${pattern}$`);
820
+ if (regex.test(url.pathname)) {
821
+ endpoint = ep;
822
+ break;
823
+ }
824
+ }
825
+ }
826
+
317
827
  if (endpoint) {
318
828
  try {
319
829
  const result = await endpoint.handler(req);
320
830
  const duration = Date.now() - startTime;
321
- logger.trace(`REST ${method} ${url.pathname} completed in ${duration}ms`);
322
-
831
+ logger.trace(
832
+ `REST ${method} ${url.pathname} completed in ${duration}ms`
833
+ );
834
+
323
835
  clearTimeout(timeoutId);
324
836
  if (result instanceof Response) {
325
- return result;
837
+ return this.addCorsHeaders(result, req);
326
838
  } else {
327
- return new Response(JSON.stringify(result), {
328
- headers: { 'Content-Type': 'application/json' }
329
- });
839
+ return this.addCorsHeaders(new Response(JSON.stringify(result), {
840
+ headers: { "Content-Type": "application/json" },
841
+ }), req);
330
842
  }
331
843
  } catch (error) {
332
844
  const duration = Date.now() - startTime;
333
- logger.error(`Error in REST endpoint ${method} ${endpoint.path} after ${duration}ms`, error as any);
845
+ logger.error(
846
+ `Error in REST endpoint ${method} ${endpoint.path} after ${duration}ms`,
847
+ error as any
848
+ );
334
849
  clearTimeout(timeoutId);
335
- return new Response(JSON.stringify({ error: 'Internal server error' }), {
336
- status: 500,
337
- headers: { 'Content-Type': 'application/json' }
338
- });
850
+ return this.addCorsHeaders(new Response(
851
+ JSON.stringify({
852
+ error: "Internal server error",
853
+ code: "INTERNAL_ERROR",
854
+ ...(process.env.NODE_ENV === 'development' && {
855
+ message: (error as Error)?.message,
856
+ }),
857
+ }),
858
+ {
859
+ status: 500,
860
+ headers: { "Content-Type": "application/json" },
861
+ }
862
+ ), req);
339
863
  }
340
864
  }
341
865
 
@@ -348,23 +872,38 @@ export default class App {
348
872
  }
349
873
 
350
874
  clearTimeout(timeoutId);
351
- return new Response('Not Found', { status: 404 });
875
+ return this.addCorsHeaders(new Response("Not Found", { status: 404 }), req);
352
876
  } catch (error) {
353
877
  const duration = Date.now() - startTime;
354
- logger.error(`Request failed after ${duration}ms: ${method} ${url.pathname}`, error as any);
878
+ logger.error(
879
+ `Request failed after ${duration}ms: ${method} ${url.pathname}`,
880
+ error as any
881
+ );
355
882
  clearTimeout(timeoutId);
356
-
357
- if ((error as Error).name === 'AbortError') {
358
- return new Response(JSON.stringify({ error: 'Request timeout' }), {
359
- status: 408,
360
- headers: { 'Content-Type': 'application/json' }
361
- });
883
+
884
+ if ((error as Error).name === "AbortError") {
885
+ return this.addCorsHeaders(new Response(
886
+ JSON.stringify({ error: "Request timeout", code: "TIMEOUT_ERROR" }),
887
+ {
888
+ status: 408,
889
+ headers: { "Content-Type": "application/json" },
890
+ }
891
+ ), req);
362
892
  }
363
-
364
- return new Response(JSON.stringify({ error: 'Internal server error' }), {
365
- status: 500,
366
- headers: { 'Content-Type': 'application/json' }
367
- });
893
+
894
+ return this.addCorsHeaders(new Response(
895
+ JSON.stringify({
896
+ error: "Internal server error",
897
+ code: "INTERNAL_ERROR",
898
+ ...(process.env.NODE_ENV === 'development' && {
899
+ message: (error as Error)?.message,
900
+ }),
901
+ }),
902
+ {
903
+ status: 500,
904
+ headers: { "Content-Type": "application/json" },
905
+ }
906
+ ), req);
368
907
  }
369
908
  }
370
909
 
@@ -384,19 +923,227 @@ export default class App {
384
923
  this.enforceDocs = value;
385
924
  }
386
925
 
926
+ public enableStudio() {
927
+ this.studioEnabled = true;
928
+ logger.info("Studio API enabled");
929
+ }
930
+
931
+ /**
932
+ * Set the maximum allowed GraphQL query depth. 0 disables the limit.
933
+ */
934
+ public setGraphQLMaxDepth(depth: number) {
935
+ this.graphqlMaxDepth = depth;
936
+ }
937
+
938
+ /**
939
+ * Set the grace period for draining connections during shutdown (ms).
940
+ */
941
+ public setShutdownGracePeriod(ms: number) {
942
+ this.shutdownGracePeriod = ms;
943
+ }
944
+
945
+ /**
946
+ * Set the maximum request body size in bytes (default: 50MB).
947
+ * Rejects oversized requests at the HTTP layer before buffering.
948
+ */
949
+ public setMaxRequestBodySize(bytes: number) {
950
+ this.maxRequestBodySize = bytes;
951
+ }
952
+
953
+ /**
954
+ * Warm up the prepared statement cache with common query patterns
955
+ */
956
+ private async warmUpPreparedStatementCache(): Promise<void> {
957
+ // Get registered components for generating common queries
958
+ const components = ComponentRegistry.getComponents();
959
+
960
+ if (components.length === 0) {
961
+ logger.trace(
962
+ "No components registered yet, skipping cache warm-up"
963
+ );
964
+ return;
965
+ }
966
+
967
+ const commonQueries: Array<{ sql: string; key: string }> = [];
968
+
969
+ // Generate some common query patterns
970
+ // 1. Simple entity count
971
+ commonQueries.push({
972
+ sql: "SELECT COUNT(*) as count FROM (SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.deleted_at IS NULL) AS subquery",
973
+ key: "count_all_entities",
974
+ });
975
+
976
+ // 2. Common component queries (first few components)
977
+ for (let i = 0; i < Math.min(5, components.length); i++) {
978
+ const component = components[i];
979
+ if (component) {
980
+ const { name, ctor } = component;
981
+ const typeId = ComponentRegistry.getComponentId(name);
982
+ if (typeId) {
983
+ commonQueries.push({
984
+ sql: `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id = '${typeId}' AND ec.deleted_at IS NULL LIMIT 10`,
985
+ key: `find_${name.toLowerCase()}_sample`,
986
+ });
987
+ }
988
+ }
989
+ }
990
+
991
+ // 3. Multi-component queries (if we have multiple components)
992
+ if (components.length >= 2) {
993
+ const typeIds = components
994
+ .slice(0, 3)
995
+ .map((component: { name: string; ctor: any }) =>
996
+ ComponentRegistry.getComponentId(component.name)
997
+ )
998
+ .filter((id: string | undefined) => id)
999
+ .join("','");
1000
+
1001
+ if (typeIds) {
1002
+ commonQueries.push({
1003
+ sql: `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id IN ('${typeIds}') AND ec.deleted_at IS NULL LIMIT 10`,
1004
+ key: "find_multi_component_sample",
1005
+ });
1006
+ }
1007
+ }
1008
+
1009
+ await preparedStatementCache.warmUp(commonQueries, db);
1010
+ }
1011
+
1012
+ private async collectMetrics() {
1013
+ let cacheStats = null;
1014
+ try {
1015
+ const { CacheManager } = await import('./cache/CacheManager');
1016
+ cacheStats = await CacheManager.getInstance().getStats();
1017
+ } catch {}
1018
+
1019
+ return {
1020
+ timestamp: new Date().toISOString(),
1021
+ uptime: process.uptime(),
1022
+ process: process.memoryUsage(),
1023
+ cache: cacheStats,
1024
+ scheduler: SchedulerManager.getInstance().getMetrics(),
1025
+ preparedStatements: preparedStatementCache.getStats(),
1026
+ };
1027
+ }
1028
+
387
1029
  async start() {
388
1030
  logger.info("Application Started");
389
- const port = parseInt(process.env.PORT || "3000");
390
- const server = Bun.serve({
1031
+ const port = parseInt(process.env.APP_PORT || "3000");
1032
+
1033
+ // Read env overrides
1034
+ const envGracePeriod = process.env.SHUTDOWN_GRACE_PERIOD_MS;
1035
+ if (envGracePeriod) {
1036
+ this.shutdownGracePeriod = parseInt(envGracePeriod, 10);
1037
+ }
1038
+ const envBodySize = process.env.MAX_REQUEST_BODY_SIZE;
1039
+ if (envBodySize) {
1040
+ this.maxRequestBodySize = parseInt(envBodySize, 10);
1041
+ }
1042
+
1043
+ // Compose middleware chain around the core request handler
1044
+ this.composedHandler = composeMiddleware(
1045
+ this.middlewares,
1046
+ this.handleRequest.bind(this),
1047
+ );
1048
+
1049
+ this.server = Bun.serve({
1050
+ idleTimeout: 0, // Disable idle timeout because we have subscriptions
391
1051
  port: port,
392
- fetch: this.handleRequest.bind(this),
1052
+ maxRequestBodySize: this.maxRequestBodySize,
1053
+ fetch: this.composedHandler,
393
1054
  });
394
-
1055
+
395
1056
  // Update the OpenAPI spec with the actual server URL
396
- this.openAPISpecGenerator!.addServer(`http://localhost:${port}`, "Development server");
397
-
398
- logger.info(`Server is running on ${new URL(this.yoga?.graphqlEndpoint || '/graphql', `http://${server.hostname}:${server.port}`)}`)
1057
+ this.openAPISpecGenerator!.addServer(
1058
+ `http://localhost:${port}`,
1059
+ "Development server"
1060
+ );
1061
+
1062
+ logger.info(
1063
+ `Server is running on ${new URL(
1064
+ this.yoga?.graphqlEndpoint || "/graphql",
1065
+ `http://${this.server.hostname}:${this.server.port}`
1066
+ )}`
1067
+ );
1068
+
1069
+ // Register signal handlers for graceful shutdown
1070
+ process.on('SIGTERM', async () => {
1071
+ logger.info({ scope: 'app', component: 'App', msg: 'Received SIGTERM' });
1072
+ await this.shutdown();
1073
+ process.exit(0);
1074
+ });
1075
+
1076
+ process.on('SIGINT', async () => {
1077
+ logger.info({ scope: 'app', component: 'App', msg: 'Received SIGINT' });
1078
+ await this.shutdown();
1079
+ process.exit(0);
1080
+ });
1081
+
1082
+ // Global error handlers to prevent silent crashes
1083
+ process.on('unhandledRejection', (reason, promise) => {
1084
+ logger.error({ scope: 'app', component: 'App', reason, msg: 'Unhandled promise rejection' });
1085
+ });
1086
+
1087
+ process.on('uncaughtException', (error) => {
1088
+ logger.fatal({ scope: 'app', component: 'App', error, msg: 'Uncaught exception — shutting down' });
1089
+ this.shutdown().finally(() => process.exit(1));
1090
+ });
1091
+
1092
+ this.isReady = true;
1093
+ this.appReadyCallbacks.forEach((cb) => cb());
1094
+ }
1095
+
1096
+ /**
1097
+ * Gracefully shutdown the application
1098
+ */
1099
+ async shutdown(): Promise<void> {
1100
+ if (this.isShuttingDown) return;
1101
+ this.isShuttingDown = true;
1102
+ this.isReady = false;
1103
+
1104
+ logger.info({ scope: 'app', component: 'App', msg: 'Shutting down application' });
1105
+
1106
+ // Stop HTTP server — drain then force-close after grace period
1107
+ if (this.server) {
1108
+ try {
1109
+ logger.info({ scope: 'app', component: 'App', msg: 'Draining connections' });
1110
+ this.server.stop(false);
1111
+ const forceTimer = setTimeout(() => {
1112
+ logger.warn({ scope: 'app', component: 'App', msg: 'Grace period expired, forcing connection close' });
1113
+ try { this.server?.stop(true); } catch {}
1114
+ }, this.shutdownGracePeriod);
1115
+ forceTimer.unref?.();
1116
+ logger.info({ scope: 'app', component: 'App', msg: 'HTTP server stopped' });
1117
+ } catch (error) {
1118
+ logger.warn({ scope: 'app', component: 'App', msg: 'HTTP server stop error', error });
1119
+ }
1120
+ }
1121
+
1122
+ // Stop scheduler
1123
+ try {
1124
+ await SchedulerManager.getInstance().stop();
1125
+ logger.info({ scope: 'app', component: 'App', msg: 'Scheduler stopped' });
1126
+ } catch (error) {
1127
+ logger.warn({ scope: 'app', component: 'App', msg: 'Scheduler stop error', error });
1128
+ }
1129
+
1130
+ // Shutdown cache
1131
+ try {
1132
+ const { CacheManager } = await import('./cache/CacheManager');
1133
+ await CacheManager.getInstance().shutdown();
1134
+ logger.info({ scope: 'cache', component: 'App', msg: 'Cache shutdown completed' });
1135
+ } catch (error) {
1136
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', error });
1137
+ }
1138
+
1139
+ // Close database pool (last step)
1140
+ try {
1141
+ db.close();
1142
+ logger.info({ scope: 'app', component: 'App', msg: 'Database pool closed' });
1143
+ } catch (error) {
1144
+ logger.warn({ scope: 'app', component: 'App', msg: 'Database pool close error', error });
1145
+ }
399
1146
 
400
- this.appReadyCallbacks.forEach(cb => cb());
1147
+ logger.info({ scope: 'app', component: 'App', msg: 'Application shutdown completed' });
401
1148
  }
402
- }
1149
+ }