bunsane 0.3.0 → 0.3.2

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 (54) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/CHANGELOG.md +104 -0
  3. package/CLAUDE.md +20 -0
  4. package/config/cache.config.ts +35 -1
  5. package/core/App.ts +24 -1060
  6. package/core/ArcheType.ts +78 -2110
  7. package/core/Entity.ts +136 -41
  8. package/core/RequestContext.ts +85 -36
  9. package/core/RequestLoaders.ts +89 -31
  10. package/core/SchedulerManager.ts +13 -13
  11. package/core/app/bootstrap.ts +133 -0
  12. package/core/app/cors.ts +94 -0
  13. package/core/app/graphqlSetup.ts +56 -0
  14. package/core/app/healthEndpoints.ts +31 -0
  15. package/core/app/metricsCollector.ts +27 -0
  16. package/core/app/preparedStatementWarmup.ts +55 -0
  17. package/core/app/processHandlers.ts +43 -0
  18. package/core/app/requestRouter.ts +309 -0
  19. package/core/app/restRegistry.ts +72 -0
  20. package/core/app/shutdown.ts +97 -0
  21. package/core/app/studioRouter.ts +83 -0
  22. package/core/archetype/customTypes.ts +100 -0
  23. package/core/archetype/decorators.ts +171 -0
  24. package/core/archetype/fieldResolvers.ts +621 -0
  25. package/core/archetype/helpers.ts +29 -0
  26. package/core/archetype/relationLoader.ts +118 -0
  27. package/core/archetype/schemaBuilder.ts +141 -0
  28. package/core/archetype/weaver.ts +218 -0
  29. package/core/archetype/zodSchemaBuilder.ts +527 -0
  30. package/core/cache/CacheManager.ts +144 -9
  31. package/core/components/BaseComponent.ts +12 -2
  32. package/core/middleware/AccessLog.ts +8 -1
  33. package/database/PreparedStatementCache.ts +17 -16
  34. package/database/cancellable.ts +22 -0
  35. package/database/instrumentedDb.ts +141 -0
  36. package/docs/RFC_APP_REFACTOR.md +248 -0
  37. package/docs/RFC_REFACTOR_TARGETS.md +251 -0
  38. package/package.json +1 -1
  39. package/query/ComponentInclusionNode.ts +5 -5
  40. package/query/Query.ts +65 -48
  41. package/service/ServiceRegistry.ts +7 -1
  42. package/service/index.ts +4 -2
  43. package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
  44. package/tests/integration/query/Query.abort.test.ts +66 -0
  45. package/tests/unit/cache/CacheManager.test.ts +152 -1
  46. package/tests/unit/database/cancellable.test.ts +81 -0
  47. package/tests/unit/database/instrumentedDb.test.ts +160 -0
  48. package/tests/unit/entity/Entity.components.test.ts +73 -0
  49. package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
  50. package/tests/unit/entity/Entity.reload.test.ts +63 -0
  51. package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
  52. package/tests/unit/query/Query.emptyString.test.ts +69 -0
  53. package/tests/unit/query/Query.test.ts +6 -4
  54. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
package/core/App.ts CHANGED
@@ -1,4 +1,4 @@
1
- import ApplicationLifecycle, {
1
+ import ApplicationLifecycle, {
2
2
  ApplicationPhase,
3
3
  type PhaseChangeEvent,
4
4
  } from "./ApplicationLifecycle";
@@ -11,30 +11,28 @@ import {
11
11
  } from "../database/DatabaseHelper";
12
12
  import { ComponentRegistry } from "./components";
13
13
  import { logger as MainLogger } from "./Logger";
14
- import { getSerializedMetadataStorage } from "./metadata";
15
14
  const logger = MainLogger.child({ scope: "App" });
16
- import { createYogaInstance } from "../gql";
17
15
  import ServiceRegistry from "../service/ServiceRegistry";
18
16
  import { type Plugin, createPubSub } from "graphql-yoga";
19
17
  import * as path from "path";
20
- import { SchedulerManager } from "./SchedulerManager";
21
- import { registerScheduledTasks } from "../scheduler";
22
18
  import { OpenAPISpecGenerator, type SwaggerEndpointMetadata } from "../swagger";
23
19
  import type BasePlugin from "../plugins";
24
20
  import { preparedStatementCache } from "../database/PreparedStatementCache";
25
21
  import db from "../database";
26
- import studioEndpoint from "../endpoints";
27
22
  import { type Middleware, composeMiddleware } from "./Middleware";
28
- import { deepHealthCheck, readinessCheck } from "./health";
29
23
  import { validateEnv } from "./validateEnv";
30
- import {
31
- RemoteManager,
32
- registerRemoteHandlers,
33
- setRemoteManager,
34
- } from "./remote";
35
- import type { RemoteManagerConfig } from "./remote";
24
+ import type { RemoteManager, RemoteManagerConfig } from "./remote";
36
25
  import type { CacheConfig } from "../config/cache.config";
37
- import { createRequestContextPlugin } from "./RequestContext";
26
+ import { assertValidCorsConfig } from "./app/cors";
27
+ import {
28
+ registerProcessHandlers as registerProcessHandlersFn,
29
+ unregisterProcessHandlers as unregisterProcessHandlersFn,
30
+ } from "./app/processHandlers";
31
+ import { runShutdown } from "./app/shutdown";
32
+ import { warmUpPreparedStatementCache as warmUpPreparedStatementCacheFn } from "./app/preparedStatementWarmup";
33
+ import { collectMetrics as collectMetricsFn } from "./app/metricsCollector";
34
+ import { createPhaseListener } from "./app/bootstrap";
35
+ import { handleRequest as handleRequestFn } from "./app/requestRouter";
38
36
 
39
37
  export type CorsConfig = {
40
38
  origin?: string | string[] | ((origin: string) => boolean);
@@ -138,14 +136,8 @@ export default class App {
138
136
  }
139
137
 
140
138
  public setCors(cors: CorsConfig) {
141
- if (cors.origin === undefined) {
142
- throw new Error('[CORS] `origin` is required. Pass an explicit string, array, function, or "*" if you truly want to allow everyone.');
143
- }
139
+ assertValidCorsConfig(cors);
144
140
  this.config.cors = cors;
145
- // Warn about invalid configuration
146
- if (cors.credentials && cors.origin === '*') {
147
- console.warn('[CORS] Warning: credentials=true with origin="*" is invalid per spec. Origin will be reflected from request.');
148
- }
149
141
  }
150
142
 
151
143
  async init() {
@@ -160,7 +152,7 @@ export default class App {
160
152
  ComponentRegistry.init();
161
153
  ServiceRegistry.init();
162
154
 
163
- // Initialize CacheManager with merged config. MUST await initialize()
155
+ // Initialize CacheManager with merged config. MUST await — initialize()
164
156
  // is async and sets up pub/sub for cross-instance invalidation. Previously
165
157
  // only getInstance() was called, silently skipping pub/sub setup and
166
158
  // ignoring any app-supplied config (C04).
@@ -186,295 +178,7 @@ export default class App {
186
178
  if (this.phaseListener) {
187
179
  ApplicationLifecycle.removePhaseListener(this.phaseListener);
188
180
  }
189
- this.phaseListener = async (event: PhaseChangeEvent) => {
190
- const phase = event.detail;
191
- logger.info(`Application phase changed to: ${phase}`);
192
- // Notify plugins of phase change
193
- for (const plugin of this.plugins) {
194
- if (plugin.onPhaseChange) {
195
- await plugin.onPhaseChange(phase, this);
196
- }
197
- }
198
- switch (phase) {
199
- case ApplicationPhase.DATABASE_READY: {
200
- // Warm up prepared statement cache with common query patterns
201
- try {
202
- await this.warmUpPreparedStatementCache();
203
- } catch (error) {
204
- logger.warn(
205
- "Failed to warm up prepared statement cache:",
206
- error as any
207
- );
208
- }
209
- break;
210
- }
211
- case ApplicationPhase.SYSTEM_READY: {
212
- // Perform cache health check
213
- try {
214
- const { CacheManager } = await import('./cache/CacheManager');
215
- const cacheManager = CacheManager.getInstance();
216
- const config = cacheManager.getConfig();
217
-
218
- if (config.enabled) {
219
- const isHealthy = await cacheManager.getProvider().ping();
220
- if (isHealthy) {
221
- logger.info({ scope: 'cache', component: 'App', msg: 'Cache health check passed' });
222
- } else {
223
- logger.warn({ scope: 'cache', component: 'App', msg: 'Cache health check failed' });
224
- }
225
- }
226
- } catch (error) {
227
- logger.warn({ scope: 'cache', component: 'App', msg: 'Cache health check error', error });
228
- }
229
-
230
- try {
231
- const schema = ServiceRegistry.getSchema();
232
-
233
- // Wrap user's context factory to automatically spread Yoga context
234
- const wrappedContextFactory = this.contextFactory
235
- ? async (yogaContext: any) => {
236
- const userContext =
237
- await this.contextFactory!(yogaContext);
238
- // Merge Yoga's context with user's context, preserving Yoga properties
239
- return {
240
- ...yogaContext, // Yoga context (request, params, etc.)
241
- ...userContext, // User's additional context
242
- };
243
- }
244
- : undefined;
245
-
246
- // Read env override for GraphQL depth limit
247
- const envDepth = process.env.GRAPHQL_MAX_DEPTH;
248
- if (envDepth) {
249
- this.graphqlMaxDepth = parseInt(envDepth, 10);
250
- }
251
- const envComplexity = process.env.GRAPHQL_MAX_COMPLEXITY;
252
- if (envComplexity) {
253
- const parsed = parseInt(envComplexity, 10);
254
- if (Number.isFinite(parsed) && parsed >= 0) {
255
- this.graphqlMaxComplexity = parsed;
256
- }
257
- }
258
-
259
- const yogaOptions = {
260
- cors: this.config.cors,
261
- maxDepth: this.graphqlMaxDepth || undefined,
262
- maxComplexity: this.graphqlMaxComplexity,
263
- };
264
-
265
- // Auto-apply RequestContext plugin by default so
266
- // apps using @BelongsTo / @HasMany get DataLoader
267
- // batching without opt-in. Prevents N+1 query
268
- // explosion. Opt out via disableRequestContextPlugin().
269
- const effectivePlugins: Plugin[] = this.requestContextPluginEnabled
270
- ? [createRequestContextPlugin(), ...this.yogaPlugins]
271
- : [...this.yogaPlugins];
272
-
273
- if (schema) {
274
- this.yoga = createYogaInstance(
275
- schema,
276
- effectivePlugins,
277
- wrappedContextFactory,
278
- yogaOptions
279
- );
280
- } else {
281
- this.yoga = createYogaInstance(
282
- undefined,
283
- effectivePlugins,
284
- wrappedContextFactory,
285
- yogaOptions
286
- );
287
- }
288
-
289
- // Get all services for processing
290
- const services = ServiceRegistry.getServices();
291
-
292
- // Initialize Scheduler
293
- const scheduler = SchedulerManager.getInstance();
294
- scheduler.config.enableLogging =
295
- this.config.scheduler.logging;
296
-
297
- // Register scheduled tasks for all services
298
- for (const service of services) {
299
- try {
300
- registerScheduledTasks(service);
301
- } catch (error) {
302
- logger.warn(
303
- `Failed to register scheduled tasks for service ${service.constructor.name}`
304
- );
305
- logger.warn(error);
306
- }
307
- }
308
- logger.info(
309
- `Registered scheduled tasks for ${services.length} services`
310
- );
311
-
312
- // Initialize RemoteManager (opt-in via enableRemote())
313
- if (this.remoteConfig) {
314
- try {
315
- const rmConfig: RemoteManagerConfig = {
316
- appName:
317
- this.remoteConfig.appName ||
318
- this.name,
319
- ...this.remoteConfig,
320
- };
321
- this.remote = new RemoteManager(rmConfig);
322
- setRemoteManager(this.remote);
323
- await this.remote.start();
324
-
325
- for (const service of services) {
326
- try {
327
- registerRemoteHandlers(service);
328
- } catch (error) {
329
- logger.warn(
330
- `Failed to register remote handlers for service ${service.constructor.name}`
331
- );
332
- logger.warn(error);
333
- }
334
- }
335
- logger.info(
336
- `RemoteManager initialized for app "${rmConfig.appName}"`
337
- );
338
- } catch (error) {
339
- logger.error(
340
- "Failed to start RemoteManager:"
341
- );
342
- logger.error(error);
343
- }
344
- }
345
-
346
- // Collect REST endpoints from all services
347
- for (const service of services) {
348
- const endpoints = (service.constructor as any)
349
- .httpEndpoints;
350
- if (endpoints) {
351
- for (const endpoint of endpoints) {
352
- const endpointInfo = {
353
- method: endpoint.method,
354
- path: endpoint.path,
355
- handler: endpoint.handler.bind(service),
356
- service: service,
357
- };
358
- logger.trace(
359
- `Registered REST endpoint: [${endpoint.method}] ${endpoint.path} for service ${service.constructor.name}`
360
- );
361
- this.restEndpoints.push(endpointInfo);
362
- this.restEndpointMap.set(
363
- `${endpoint.method}:${endpoint.path}`,
364
- endpointInfo
365
- );
366
-
367
- // Check if this endpoint has a swagger operation
368
- if (
369
- (endpoint.handler as any)
370
- .swaggerOperation
371
- ) {
372
- // Collect tags from class and method decorators
373
- const classTags =
374
- (service.constructor as any)
375
- .swaggerClassTags || [];
376
- const methodTags =
377
- (service.constructor as any)
378
- .swaggerMethodTags?.[
379
- endpoint.handler.name
380
- ] || [];
381
- const allTags = [
382
- ...classTags,
383
- ...methodTags,
384
- ];
385
-
386
- logger.trace(
387
- `Generating OpenAPI spec for endpoint: [${
388
- endpoint.method
389
- }] ${
390
- endpoint.path
391
- } with tags: ${allTags.join(", ")}`
392
- );
393
-
394
- // Merge tags into the operation
395
- const operation = {
396
- ...(endpoint.handler as any)
397
- .swaggerOperation,
398
- };
399
- if (allTags.length > 0) {
400
- operation.tags = [
401
- ...(operation.tags || []),
402
- ...allTags,
403
- ];
404
- }
405
-
406
- this.openAPISpecGenerator!.addEndpoint({
407
- method: endpoint.method,
408
- path: endpoint.path,
409
- operation,
410
- });
411
- logger.trace(
412
- `Registered OpenAPI spec for endpoint: [${endpoint.method}] ${endpoint.path}`
413
- );
414
- } else {
415
- if (this.enforceDocs) {
416
- logger.warn(
417
- `No swagger operation found for endpoint: [${endpoint.method}] ${endpoint.path} in service ${service.constructor.name}`
418
- );
419
- this.openAPISpecGenerator!.addEndpoint(
420
- {
421
- method: endpoint.method,
422
- path: endpoint.path,
423
- operation: {
424
- summary: `No description for ${endpoint.path}. Don't use this endpoint until it's properly documented!`,
425
- requestBody: {
426
- content: {
427
- "application/json":
428
- {
429
- schema: {},
430
- },
431
- },
432
- },
433
- responses: {
434
- "200": {
435
- description:
436
- "Success",
437
- },
438
- },
439
- },
440
- }
441
- );
442
- }
443
- }
444
- }
445
- }
446
- }
447
-
448
- ApplicationLifecycle.setPhase(
449
- ApplicationPhase.APPLICATION_READY
450
- );
451
- } catch (error) {
452
- // SYSTEM_READY failures must not be swallowed silently.
453
- // Without this, the app stays forever in SYSTEM_READY
454
- // (isReady=false, /health/ready → 503 forever) and k8s
455
- // rollout hangs with no observable cause. Surface the
456
- // failure so the readiness probe reports it and the
457
- // orchestrator can restart.
458
- this.isReady = false;
459
- logger.fatal({ scope: 'app', component: 'App', err: error }, 'Fatal error during SYSTEM_READY phase — marking app unready');
460
- // In production, exit so k8s can restart the pod.
461
- // In tests, rethrow so the test sees the failure.
462
- if (process.env.NODE_ENV === 'test') {
463
- throw error;
464
- }
465
- // Give the logger a chance to flush, then exit.
466
- setTimeout(() => process.exit(1), 100).unref?.();
467
- }
468
- break;
469
- }
470
- case ApplicationPhase.APPLICATION_READY: {
471
- if (process.env.NODE_ENV !== "test") {
472
- this.start();
473
- }
474
- break;
475
- }
476
- }
477
- };
181
+ this.phaseListener = createPhaseListener(this);
478
182
  ApplicationLifecycle.addPhaseListener(this.phaseListener);
479
183
 
480
184
  if (
@@ -496,7 +200,7 @@ export default class App {
496
200
 
497
201
  /**
498
202
  * Resolve once the application has reached APPLICATION_READY. Previously
499
- * polled every 100ms with no exit condition a boot failure would keep
203
+ * polled every 100ms with no exit condition — a boot failure would keep
500
204
  * the interval timer alive forever (H-MEM-1). Now attaches a one-shot
501
205
  * phase listener and self-cleans on first match. Bounded by `timeoutMs`
502
206
  * so callers cannot hang indefinitely; default matches waitForPhase.
@@ -555,538 +259,8 @@ export default class App {
555
259
  this.staticAssets.set(route, resolvedFolder);
556
260
  }
557
261
 
558
- private validateOrigin(requestOrigin: string | null | undefined): string | null {
559
- if (!this.config.cors || !requestOrigin) return null;
560
-
561
- const configOrigin = this.config.cors.origin;
562
-
563
- // origin is required by setCors; undefined here would indicate a
564
- // programming error. Treat as deny rather than silent wildcard.
565
- if (configOrigin === undefined) return null;
566
-
567
- // Explicit wildcard allows all
568
- if (configOrigin === '*') {
569
- // If credentials enabled, cannot use wildcard - return actual origin
570
- return this.config.cors.credentials ? requestOrigin : '*';
571
- }
572
-
573
- // String match
574
- if (typeof configOrigin === 'string') {
575
- return requestOrigin === configOrigin ? configOrigin : null;
576
- }
577
-
578
- // Array - check if origin is in list
579
- if (Array.isArray(configOrigin)) {
580
- return configOrigin.includes(requestOrigin) ? requestOrigin : null;
581
- }
582
-
583
- // Function validator
584
- if (typeof configOrigin === 'function') {
585
- return configOrigin(requestOrigin) ? requestOrigin : null;
586
- }
587
-
588
- return null;
589
- }
590
-
591
- private getCorsHeaders(req?: Request): Record<string, string> {
592
- if (!this.config.cors) return {};
593
-
594
- const requestOrigin = req?.headers.get('Origin');
595
- const allowedOrigin = this.validateOrigin(requestOrigin);
596
-
597
- // If origin not allowed, return empty (no CORS headers)
598
- if (requestOrigin && !allowedOrigin) return {};
599
-
600
- // No allowedOrigin means request carried no Origin header. Skip
601
- // Access-Control-Allow-Origin rather than falling back to '*'.
602
- const headers: Record<string, string> = {
603
- 'Access-Control-Allow-Methods': this.config.cors.methods?.join(', ') || 'GET, POST, PUT, DELETE, OPTIONS',
604
- 'Access-Control-Allow-Headers': this.config.cors.allowedHeaders?.join(', ') || 'Content-Type, Authorization',
605
- 'Vary': 'Origin',
606
- };
607
- if (allowedOrigin) {
608
- headers['Access-Control-Allow-Origin'] = allowedOrigin;
609
- }
610
-
611
- if (this.config.cors.credentials) {
612
- headers['Access-Control-Allow-Credentials'] = 'true';
613
- }
614
-
615
- if (this.config.cors.exposedHeaders?.length) {
616
- headers['Access-Control-Expose-Headers'] = this.config.cors.exposedHeaders.join(', ');
617
- }
618
-
619
- if (this.config.cors.maxAge !== undefined) {
620
- headers['Access-Control-Max-Age'] = String(this.config.cors.maxAge);
621
- }
622
-
623
- return headers;
624
- }
625
-
626
- /**
627
- * Combine multiple AbortSignals into one that aborts when any input
628
- * aborts. Uses `AbortSignal.any` when available (Node 20+/current Bun),
629
- * falls back to a manual combiner for older runtimes.
630
- */
631
- private combineSignals(signals: AbortSignal[]): AbortSignal {
632
- const anyFn = (AbortSignal as any).any;
633
- if (typeof anyFn === 'function') {
634
- return anyFn.call(AbortSignal, signals);
635
- }
636
- const controller = new AbortController();
637
- for (const s of signals) {
638
- if (s.aborted) {
639
- controller.abort((s as any).reason);
640
- return controller.signal;
641
- }
642
- s.addEventListener('abort', () => controller.abort((s as any).reason), { once: true });
643
- }
644
- return controller.signal;
645
- }
646
-
647
- private addCorsHeaders(response: Response, req?: Request): Response {
648
- const corsHeaders = this.getCorsHeaders(req);
649
- if (Object.keys(corsHeaders).length === 0) return response;
650
-
651
- const newHeaders = new Headers(response.headers);
652
- for (const [key, value] of Object.entries(corsHeaders)) {
653
- newHeaders.set(key, value);
654
- }
655
-
656
- return new Response(response.body, {
657
- status: response.status,
658
- statusText: response.statusText,
659
- headers: newHeaders,
660
- });
661
- }
662
-
663
262
  private async handleRequest(req: Request): Promise<Response> {
664
- const url = new URL(req.url);
665
- const method = req.method;
666
- const startTime = Date.now();
667
-
668
- // Handle CORS preflight requests
669
- if (method === 'OPTIONS') {
670
- return new Response(null, {
671
- status: 204,
672
- headers: this.getCorsHeaders(req),
673
- });
674
- }
675
-
676
- // Request timeout — combine the framework wall-clock with the client's
677
- // abort signal. The combined signal is attached to a cloned request
678
- // that is passed to Yoga / REST handlers so downstream work (DB
679
- // queries, resolvers) actually gets cancelled on timeout or client
680
- // disconnect. Previously the signal was created but never propagated,
681
- // so the timer only logged a warning while the request continued
682
- // consuming resources (C05).
683
- const controller = new AbortController();
684
- const timeoutId = setTimeout(() => {
685
- controller.abort(new Error(`Request timeout after 30000ms: ${method} ${url.pathname}`));
686
- logger.warn(`Request timeout: ${method} ${url.pathname}`);
687
- }, 30000);
688
- const combinedSignal = this.combineSignals([req.signal, controller.signal]);
689
- // Rebind the request with the combined signal so handlers (Yoga, REST)
690
- // see it via req.signal and can propagate cancellation.
691
- req = new Request(req, { signal: combinedSignal });
692
-
693
- try {
694
- // Health check endpoint
695
- if (url.pathname === "/health") {
696
- const health = await deepHealthCheck();
697
- clearTimeout(timeoutId);
698
- return this.addCorsHeaders(new Response(
699
- JSON.stringify(health.result),
700
- {
701
- status: health.httpStatus,
702
- headers: { "Content-Type": "application/json" },
703
- }
704
- ), req);
705
- }
706
-
707
- // Metrics endpoint
708
- if (url.pathname === "/metrics") {
709
- const metrics = await this.collectMetrics();
710
- clearTimeout(timeoutId);
711
- return this.addCorsHeaders(new Response(
712
- JSON.stringify(metrics),
713
- {
714
- status: 200,
715
- headers: { "Content-Type": "application/json" },
716
- }
717
- ), req);
718
- }
719
-
720
- // Remote health check
721
- if (url.pathname === "/health/remote") {
722
- if (!this.remote) {
723
- clearTimeout(timeoutId);
724
- return this.addCorsHeaders(new Response(
725
- JSON.stringify({
726
- healthy: false,
727
- error: "Remote subsystem not enabled",
728
- }),
729
- {
730
- status: 503,
731
- headers: { "Content-Type": "application/json" },
732
- }
733
- ), req);
734
- }
735
- const health = await this.remote.health();
736
- clearTimeout(timeoutId);
737
- return this.addCorsHeaders(new Response(
738
- JSON.stringify(health),
739
- {
740
- status: health.healthy ? 200 : 503,
741
- headers: { "Content-Type": "application/json" },
742
- }
743
- ), req);
744
- }
745
-
746
- // Readiness probe
747
- if (url.pathname === "/health/ready") {
748
- const ready = await readinessCheck(this.isReady, this.isShuttingDown);
749
- clearTimeout(timeoutId);
750
- return this.addCorsHeaders(new Response(
751
- JSON.stringify(ready.result),
752
- {
753
- status: ready.httpStatus,
754
- headers: { "Content-Type": "application/json" },
755
- }
756
- ), req);
757
- }
758
-
759
- // OpenAPI spec endpoint
760
- if (url.pathname === "/openapi.json") {
761
- clearTimeout(timeoutId);
762
- return this.addCorsHeaders(new Response(this.openAPISpecGenerator!.toJSON(), {
763
- headers: { "Content-Type": "application/json" },
764
- }), req);
765
- }
766
-
767
- // Swagger UI endpoint
768
- if (url.pathname === "/docs") {
769
- clearTimeout(timeoutId);
770
- const swaggerUIHTML = `
771
- <!DOCTYPE html>
772
- <html>
773
- <head>
774
- <title>${this.name} Documentation</title>
775
- <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css" />
776
- <style>
777
- html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
778
- *, *:before, *:after { box-sizing: inherit; }
779
- body { margin: 0; background: #fafafa; }
780
- </style>
781
- </head>
782
- <body>
783
- <div id="swagger-ui"></div>
784
- <script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-bundle.js"></script>
785
- <script>
786
- window.onload = function() {
787
- const ui = SwaggerUIBundle({
788
- url: '/openapi.json',
789
- dom_id: '#swagger-ui',
790
- deepLinking: true,
791
- presets: [
792
- SwaggerUIBundle.presets.apis,
793
- SwaggerUIBundle.presets.standalone
794
- ],
795
- plugins: [
796
- SwaggerUIBundle.plugins.DownloadUrl
797
- ],
798
- layout: "BaseLayout"
799
- });
800
- };
801
- </script>
802
- </body>
803
- </html>`;
804
- return this.addCorsHeaders(new Response(swaggerUIHTML, {
805
- headers: { "Content-Type": "text/html" },
806
- }), req);
807
- }
808
-
809
- // Studio API endpoints
810
- if (this.studioEnabled && url.pathname.startsWith("/studio/api/")) {
811
- clearTimeout(timeoutId);
812
-
813
- // Studio tables endpoint
814
- if (url.pathname === "/studio/api/tables") {
815
- return this.addCorsHeaders(await studioEndpoint.getTables(), req);
816
- }
817
-
818
- // Studio stats endpoint
819
- if (url.pathname === "/studio/api/stats") {
820
- return this.addCorsHeaders(await studioEndpoint.handleStudioStatsRequest(), req);
821
- }
822
-
823
- // Studio components endpoint
824
- if (url.pathname === "/studio/api/components") {
825
- return this.addCorsHeaders(await studioEndpoint.handleStudioComponentsRequest(), req);
826
- }
827
-
828
- // Studio query endpoint (POST only)
829
- if (url.pathname === "/studio/api/query" && method === "POST") {
830
- const body = await req.json();
831
- return this.addCorsHeaders(await studioEndpoint.handleStudioQueryRequest(body), req);
832
- }
833
-
834
- const studioApiPath = url.pathname.replace("/studio/api/", "");
835
- const pathSegments = studioApiPath.split("/");
836
-
837
- if (pathSegments[0] === "entity" && pathSegments[1]) {
838
- const entityId = pathSegments[1];
839
- return this.addCorsHeaders(
840
- await studioEndpoint.handleEntityInspectorRequest(entityId),
841
- req
842
- );
843
- }
844
-
845
- if (pathSegments[0] === "table" && pathSegments[1]) {
846
- const tableName = pathSegments[1];
847
-
848
- if (method === "DELETE") {
849
- const body = await req.json();
850
- return this.addCorsHeaders(await studioEndpoint.handleStudioTableDeleteRequest(
851
- tableName,
852
- body
853
- ), req);
854
- }
855
-
856
- const limit = url.searchParams.get("limit");
857
- const offset = url.searchParams.get("offset");
858
- const search = url.searchParams.get("search");
859
-
860
- return this.addCorsHeaders(await studioEndpoint.handleStudioTableRequest(tableName, {
861
- limit: limit ? parseInt(limit, 10) : undefined,
862
- offset: offset ? parseInt(offset, 10) : undefined,
863
- search: search ?? undefined,
864
- }), req);
865
- }
866
-
867
- if (pathSegments[0] === "arche-type" && pathSegments[1]) {
868
- const archeTypeName = pathSegments[1];
869
-
870
- if (method === "DELETE") {
871
- const body = await req.json();
872
- return this.addCorsHeaders(await studioEndpoint.handleStudioArcheTypeDeleteRequest(
873
- archeTypeName,
874
- body
875
- ), req);
876
- }
877
-
878
- const limit = url.searchParams.get("limit");
879
- const offset = url.searchParams.get("offset");
880
- const search = url.searchParams.get("search");
881
- const includeDeleted = url.searchParams.get("include_deleted");
882
-
883
- return this.addCorsHeaders(await studioEndpoint.handleStudioArcheTypeRecordsRequest(
884
- archeTypeName,
885
- {
886
- limit: limit ? parseInt(limit, 10) : undefined,
887
- offset: offset ? parseInt(offset, 10) : undefined,
888
- search: search ?? undefined,
889
- include_deleted: includeDeleted === "true",
890
- }
891
- ), req);
892
- }
893
-
894
- return this.addCorsHeaders(new Response(
895
- JSON.stringify({ error: "Studio API endpoint not found" }),
896
- {
897
- status: 404,
898
- headers: { "Content-Type": "application/json" },
899
- }
900
- ), req);
901
- }
902
-
903
- // Studio endpoint - handle both root and all sub-routes
904
- if (
905
- this.studioEnabled &&
906
- (url.pathname === "/studio" ||
907
- url.pathname.startsWith("/studio/"))
908
- ) {
909
- clearTimeout(timeoutId);
910
-
911
- // Skip API routes - they're handled by the API handler above
912
- if (url.pathname.startsWith("/studio/api/")) {
913
- return this.addCorsHeaders(new Response(
914
- JSON.stringify({
915
- error: "Studio API endpoint not found",
916
- }),
917
- {
918
- status: 404,
919
- headers: { "Content-Type": "application/json" },
920
- }
921
- ), req);
922
- }
923
-
924
- // Check if this is a request for static assets (CSS, JS, etc.)
925
- if (url.pathname.startsWith("/studio/assets/")) {
926
- // Let the static assets handler below handle this
927
- // Don't return here, fall through to static assets handler
928
- } else {
929
- // For all other /studio/* routes, serve the React app's index.html
930
- const studioIndexPath = path.join(
931
- import.meta.dirname,
932
- "..",
933
- "studio",
934
- "dist",
935
- "index.html"
936
- );
937
- try {
938
- const studioFile = Bun.file(studioIndexPath);
939
- if (await studioFile.exists()) {
940
- let html = await studioFile.text();
941
- // Inject metadata into the HTML
942
- const metadata = getSerializedMetadataStorage();
943
- const metadataScript = `<script>window.bunsaneMetadata = ${JSON.stringify(
944
- metadata
945
- )};</script>`;
946
- // Insert before the closing </head> tag
947
- html = html.replace(
948
- "</head>",
949
- `${metadataScript}</head>`
950
- );
951
- return this.addCorsHeaders(new Response(html, {
952
- headers: { "Content-Type": "text/html" },
953
- }), req);
954
- } else {
955
- return this.addCorsHeaders(new Response(
956
- "Studio not built. Run `bun run build:studio` to build the studio.",
957
- {
958
- status: 404,
959
- headers: { "Content-Type": "text/plain" },
960
- }
961
- ), req);
962
- }
963
- } catch (error) {
964
- console.log("Error loading studio index.html:", error);
965
- return this.addCorsHeaders(new Response("Studio not available", {
966
- status: 404,
967
- headers: { "Content-Type": "text/plain" },
968
- }), req);
969
- }
970
- }
971
- }
972
- for (const [route, folder] of this.staticAssets) {
973
- if (url.pathname.startsWith(route)) {
974
- const relativePath = url.pathname.slice(route.length);
975
- const filePath = path.join(folder, relativePath);
976
- try {
977
- const file = Bun.file(filePath);
978
- if (await file.exists()) {
979
- clearTimeout(timeoutId);
980
- return this.addCorsHeaders(new Response(file), req);
981
- }
982
- } catch (error) {
983
- logger.error(
984
- `Error serving static file ${filePath}:`,
985
- error as any
986
- );
987
- }
988
- }
989
- }
990
-
991
- // Lookup REST endpoint using map for O(1) performance
992
- const endpointKey = `${method}:${url.pathname}`;
993
- let endpoint = this.restEndpointMap.get(endpointKey);
994
-
995
- // If exact match not found, try pattern matching for parameterized routes
996
- if (!endpoint) {
997
- for (const ep of this.restEndpoints) {
998
- if (ep.method !== method) continue;
999
- // Convert route pattern to regex (e.g., /api/v1/users/:id -> /api/v1/users/[^/]+)
1000
- const pattern = ep.path.replace(/:[^/]+/g, '[^/]+');
1001
- const regex = new RegExp(`^${pattern}$`);
1002
- if (regex.test(url.pathname)) {
1003
- endpoint = ep;
1004
- break;
1005
- }
1006
- }
1007
- }
1008
-
1009
- if (endpoint) {
1010
- try {
1011
- const result = await endpoint.handler(req);
1012
- const duration = Date.now() - startTime;
1013
- logger.trace(
1014
- `REST ${method} ${url.pathname} completed in ${duration}ms`
1015
- );
1016
-
1017
- clearTimeout(timeoutId);
1018
- if (result instanceof Response) {
1019
- return this.addCorsHeaders(result, req);
1020
- } else {
1021
- return this.addCorsHeaders(new Response(JSON.stringify(result), {
1022
- headers: { "Content-Type": "application/json" },
1023
- }), req);
1024
- }
1025
- } catch (error) {
1026
- const duration = Date.now() - startTime;
1027
- logger.error(
1028
- `Error in REST endpoint ${method} ${endpoint.path} after ${duration}ms`,
1029
- error as any
1030
- );
1031
- clearTimeout(timeoutId);
1032
- return this.addCorsHeaders(new Response(
1033
- JSON.stringify({
1034
- error: "Internal server error",
1035
- code: "INTERNAL_ERROR",
1036
- ...(process.env.NODE_ENV === 'development' && {
1037
- message: (error as Error)?.message,
1038
- }),
1039
- }),
1040
- {
1041
- status: 500,
1042
- headers: { "Content-Type": "application/json" },
1043
- }
1044
- ), req);
1045
- }
1046
- }
1047
-
1048
- if (this.yoga) {
1049
- const response = await this.yoga(req);
1050
- const duration = Date.now() - startTime;
1051
- logger.trace(`GraphQL request completed in ${duration}ms`);
1052
- clearTimeout(timeoutId);
1053
- return response;
1054
- }
1055
-
1056
- clearTimeout(timeoutId);
1057
- return this.addCorsHeaders(new Response("Not Found", { status: 404 }), req);
1058
- } catch (error) {
1059
- const duration = Date.now() - startTime;
1060
- logger.error(
1061
- `Request failed after ${duration}ms: ${method} ${url.pathname}`,
1062
- error as any
1063
- );
1064
- clearTimeout(timeoutId);
1065
-
1066
- if ((error as Error).name === "AbortError") {
1067
- return this.addCorsHeaders(new Response(
1068
- JSON.stringify({ error: "Request timeout", code: "TIMEOUT_ERROR" }),
1069
- {
1070
- status: 408,
1071
- headers: { "Content-Type": "application/json" },
1072
- }
1073
- ), req);
1074
- }
1075
-
1076
- return this.addCorsHeaders(new Response(
1077
- JSON.stringify({
1078
- error: "Internal server error",
1079
- code: "INTERNAL_ERROR",
1080
- ...(process.env.NODE_ENV === 'development' && {
1081
- message: (error as Error)?.message,
1082
- }),
1083
- }),
1084
- {
1085
- status: 500,
1086
- headers: { "Content-Type": "application/json" },
1087
- }
1088
- ), req);
1089
- }
263
+ return handleRequestFn(this, req);
1090
264
  }
1091
265
 
1092
266
  public setName(name: string) {
@@ -1177,83 +351,12 @@ export default class App {
1177
351
  this.maxRequestBodySize = bytes;
1178
352
  }
1179
353
 
1180
- /**
1181
- * Warm up the prepared statement cache with common query patterns
1182
- */
1183
354
  private async warmUpPreparedStatementCache(): Promise<void> {
1184
- // Get registered components for generating common queries
1185
- const components = ComponentRegistry.getComponents();
1186
-
1187
- if (components.length === 0) {
1188
- logger.trace(
1189
- "No components registered yet, skipping cache warm-up"
1190
- );
1191
- return;
1192
- }
1193
-
1194
- const commonQueries: Array<{ sql: string; key: string }> = [];
1195
-
1196
- // Generate some common query patterns
1197
- // 1. Simple entity count
1198
- commonQueries.push({
1199
- 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",
1200
- key: "count_all_entities",
1201
- });
1202
-
1203
- // 2. Common component queries (first few components)
1204
- for (let i = 0; i < Math.min(5, components.length); i++) {
1205
- const component = components[i];
1206
- if (component) {
1207
- const { name, ctor } = component;
1208
- const typeId = ComponentRegistry.getComponentId(name);
1209
- if (typeId) {
1210
- commonQueries.push({
1211
- 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`,
1212
- key: `find_${name.toLowerCase()}_sample`,
1213
- });
1214
- }
1215
- }
1216
- }
1217
-
1218
- // 3. Multi-component queries (if we have multiple components)
1219
- if (components.length >= 2) {
1220
- const typeIds = components
1221
- .slice(0, 3)
1222
- .map((component: { name: string; ctor: any }) =>
1223
- ComponentRegistry.getComponentId(component.name)
1224
- )
1225
- .filter((id: string | undefined) => id)
1226
- .join("','");
1227
-
1228
- if (typeIds) {
1229
- commonQueries.push({
1230
- 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`,
1231
- key: "find_multi_component_sample",
1232
- });
1233
- }
1234
- }
1235
-
1236
- await preparedStatementCache.warmUp(commonQueries, db);
355
+ return warmUpPreparedStatementCacheFn(this);
1237
356
  }
1238
357
 
1239
358
  private async collectMetrics() {
1240
- let cacheStats = null;
1241
- try {
1242
- const { CacheManager } = await import('./cache/CacheManager');
1243
- cacheStats = await CacheManager.getInstance().getStats();
1244
- } catch (err) {
1245
- logger.warn({ err }, 'metrics: cache stats unavailable');
1246
- }
1247
-
1248
- return {
1249
- timestamp: new Date().toISOString(),
1250
- uptime: process.uptime(),
1251
- process: process.memoryUsage(),
1252
- cache: cacheStats,
1253
- scheduler: SchedulerManager.getInstance().getMetrics(),
1254
- preparedStatements: preparedStatementCache.getStats(),
1255
- remote: this.remote ? this.remote.getMetrics() : null,
1256
- };
359
+ return collectMetricsFn(this);
1257
360
  }
1258
361
 
1259
362
  async start() {
@@ -1310,164 +413,25 @@ export default class App {
1310
413
  *
1311
414
  * Uses `process.once` for signals so a double SIGTERM can't fire two
1312
415
  * concurrent shutdown paths racing each other to `process.exit`. Also
1313
- * idempotent safe to call multiple times (e.g. in tests).
416
+ * idempotent — safe to call multiple times (e.g. in tests).
1314
417
  */
1315
418
  private registerProcessHandlers(): void {
1316
- if (this.processHandlersRegistered) return;
1317
-
1318
- // Use arrow-bound handlers so `once` works cleanly.
1319
- this.sigTermHandler = () => {
1320
- logger.info({ scope: 'app', component: 'App', msg: 'Received SIGTERM' });
1321
- this.shutdown().finally(() => process.exit(0));
1322
- };
1323
- this.sigIntHandler = () => {
1324
- logger.info({ scope: 'app', component: 'App', msg: 'Received SIGINT' });
1325
- this.shutdown().finally(() => process.exit(0));
1326
- };
1327
- process.once('SIGTERM', this.sigTermHandler);
1328
- process.once('SIGINT', this.sigIntHandler);
1329
-
1330
- // Global error handlers to prevent silent crashes during init AND runtime.
1331
- this.unhandledRejectionHandler = (reason, promise) => {
1332
- logger.error({ scope: 'app', component: 'App', reason, msg: 'Unhandled promise rejection' });
1333
- };
1334
- this.uncaughtExceptionHandler = (error) => {
1335
- logger.fatal({ scope: 'app', component: 'App', err: error, msg: 'Uncaught exception — shutting down' });
1336
- this.shutdown().finally(() => process.exit(1));
1337
- };
1338
- process.on('unhandledRejection', this.unhandledRejectionHandler);
1339
- process.on('uncaughtException', this.uncaughtExceptionHandler);
1340
-
1341
- this.processHandlersRegistered = true;
419
+ registerProcessHandlersFn(this);
1342
420
  }
1343
421
 
1344
422
  private unregisterProcessHandlers(): void {
1345
- if (!this.processHandlersRegistered) return;
1346
- if (this.sigTermHandler) process.removeListener('SIGTERM', this.sigTermHandler);
1347
- if (this.sigIntHandler) process.removeListener('SIGINT', this.sigIntHandler);
1348
- if (this.unhandledRejectionHandler) process.removeListener('unhandledRejection', this.unhandledRejectionHandler);
1349
- if (this.uncaughtExceptionHandler) process.removeListener('uncaughtException', this.uncaughtExceptionHandler);
1350
- this.sigTermHandler = null;
1351
- this.sigIntHandler = null;
1352
- this.unhandledRejectionHandler = null;
1353
- this.uncaughtExceptionHandler = null;
1354
- this.processHandlersRegistered = false;
423
+ unregisterProcessHandlersFn(this);
1355
424
  }
1356
425
 
1357
426
  /**
1358
427
  * Gracefully shutdown the application.
1359
428
  *
1360
- * Ordered drain: HTTP scheduler remote cache database. Each step
429
+ * Ordered drain: HTTP → scheduler → remote → cache → database. Each step
1361
430
  * awaits completion before the next begins so in-flight work always sees
1362
431
  * its dependencies still available. Total budget bounded by
1363
432
  * `shutdownGracePeriod`; per-step budgets fall back to reasonable defaults.
1364
433
  */
1365
434
  async shutdown(): Promise<void> {
1366
- if (this.isShuttingDown) return;
1367
- this.isShuttingDown = true;
1368
- this.isReady = false;
1369
-
1370
- const shutdownStart = Date.now();
1371
- logger.info({ scope: 'app', component: 'App', msg: 'Shutting down application', gracePeriodMs: this.shutdownGracePeriod });
1372
-
1373
- const budgetRemaining = () => Math.max(500, this.shutdownGracePeriod - (Date.now() - shutdownStart));
1374
-
1375
- // 1. Stop HTTP server: stop accepting new connections, wait for in-flight
1376
- // requests to finish. Bun's server.stop(false) initiates graceful
1377
- // drain but does not return a promise — we poll the server's
1378
- // pendingRequests count, then force-close on deadline.
1379
- if (this.server) {
1380
- try {
1381
- logger.info({ scope: 'app', component: 'App', msg: 'Draining HTTP connections' });
1382
- this.server.stop(false);
1383
- await this.waitForHttpDrain(budgetRemaining());
1384
- try { this.server.stop(true); } catch {}
1385
- logger.info({ scope: 'app', component: 'App', msg: 'HTTP server stopped' });
1386
- } catch (error) {
1387
- logger.warn({ scope: 'app', component: 'App', msg: 'HTTP server stop error', err: error });
1388
- }
1389
- }
1390
-
1391
- // 2. Stop scheduler (awaits in-flight tasks internally, see C14).
1392
- try {
1393
- await SchedulerManager.getInstance().stop(Math.min(budgetRemaining(), 15_000));
1394
- logger.info({ scope: 'app', component: 'App', msg: 'Scheduler stopped' });
1395
- } catch (error) {
1396
- logger.warn({ scope: 'app', component: 'App', msg: 'Scheduler stop error', err: error });
1397
- }
1398
-
1399
- // 3. Shutdown RemoteManager (after scheduler, before cache — DB still available).
1400
- if (this.remote) {
1401
- try {
1402
- await this.remote.shutdown();
1403
- setRemoteManager(null);
1404
- this.remote = null;
1405
- logger.info({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown' });
1406
- } catch (error) {
1407
- logger.warn({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown error', err: error });
1408
- }
1409
- }
1410
-
1411
- // 4. Drain any fire-and-forget cache ops triggered by entity.set /
1412
- // entity.remove before we disconnect the cache (H-CACHE-1).
1413
- try {
1414
- const { Entity } = await import('./Entity');
1415
- await Entity.drainPendingCacheOps(Math.min(budgetRemaining(), 5_000));
1416
- } catch (error) {
1417
- logger.warn({ scope: 'cache', component: 'App', msg: 'Entity cache op drain error', err: error });
1418
- }
1419
-
1420
- // 5. Shutdown cache (flush pending writes, unsubscribe pub/sub, disconnect).
1421
- try {
1422
- const { CacheManager } = await import('./cache/CacheManager');
1423
- await CacheManager.getInstance().shutdown();
1424
- logger.info({ scope: 'cache', component: 'App', msg: 'Cache shutdown completed' });
1425
- } catch (error) {
1426
- logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', err: error });
1427
- }
1428
-
1429
- // 5. Close database pool (last — after all consumers done).
1430
- try {
1431
- db.close();
1432
- logger.info({ scope: 'app', component: 'App', msg: 'Database pool closed' });
1433
- } catch (error) {
1434
- logger.warn({ scope: 'app', component: 'App', msg: 'Database pool close error', err: error });
1435
- }
1436
-
1437
- // 6. Dispose lifecycle listeners so a subsequent init() (tests) doesn't
1438
- // stack handlers on the singleton.
1439
- try {
1440
- if (this.phaseListener) {
1441
- ApplicationLifecycle.removePhaseListener(this.phaseListener);
1442
- this.phaseListener = null;
1443
- }
1444
- SchedulerManager.getInstance().disposeLifecycleIntegration();
1445
- } catch { /* ignore */ }
1446
-
1447
- // 7. Unregister process handlers (signals + error handlers) last so
1448
- // shutdown errors still surface via them above.
1449
- this.unregisterProcessHandlers();
1450
-
1451
- logger.info({ scope: 'app', component: 'App', msg: 'Application shutdown completed', durationMs: Date.now() - shutdownStart });
1452
- }
1453
-
1454
- /**
1455
- * Wait for pending HTTP requests to drain, bounded by `timeoutMs`.
1456
- * Bun's `Server` exposes `pendingRequests` for this poll. If the field is
1457
- * unavailable (older Bun), fall back to a fixed sleep.
1458
- */
1459
- private async waitForHttpDrain(timeoutMs: number): Promise<void> {
1460
- if (!this.server) return;
1461
- const deadline = Date.now() + timeoutMs;
1462
- // Poll pending request count. Bun exposes this on the Server object.
1463
- while (Date.now() < deadline) {
1464
- const pending = (this.server as any).pendingRequests ?? 0;
1465
- if (pending === 0) return;
1466
- await new Promise((r) => setTimeout(r, 50));
1467
- }
1468
- const leftover = (this.server as any).pendingRequests ?? -1;
1469
- if (leftover > 0) {
1470
- logger.warn({ scope: 'app', component: 'App', msg: 'HTTP drain timeout, pending requests remaining', pendingRequests: leftover });
1471
- }
435
+ return runShutdown(this);
1472
436
  }
1473
437
  }