bunsane 0.2.9 → 0.3.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 (56) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/config/cache.config.ts +12 -2
  3. package/core/App.ts +390 -66
  4. package/core/ApplicationLifecycle.ts +68 -4
  5. package/core/Entity.ts +407 -256
  6. package/core/EntityHookManager.ts +88 -21
  7. package/core/EntityManager.ts +12 -3
  8. package/core/Logger.ts +4 -0
  9. package/core/RequestContext.ts +4 -1
  10. package/core/SchedulerManager.ts +92 -9
  11. package/core/cache/CacheFactory.ts +3 -1
  12. package/core/cache/CacheManager.ts +54 -17
  13. package/core/cache/RedisCache.ts +38 -3
  14. package/core/decorators/EntityHooks.ts +24 -12
  15. package/core/middleware/RateLimit.ts +105 -0
  16. package/core/middleware/index.ts +1 -0
  17. package/core/remote/CircuitBreaker.ts +115 -0
  18. package/core/remote/OutboxWorker.ts +183 -0
  19. package/core/remote/RemoteManager.ts +400 -0
  20. package/core/remote/RpcCaller.ts +310 -0
  21. package/core/remote/StreamConsumer.ts +535 -0
  22. package/core/remote/decorators.ts +121 -0
  23. package/core/remote/health.ts +139 -0
  24. package/core/remote/index.ts +37 -0
  25. package/core/remote/metrics.ts +99 -0
  26. package/core/remote/outboxSchema.ts +41 -0
  27. package/core/remote/types.ts +151 -0
  28. package/core/scheduler/DistributedLock.ts +324 -266
  29. package/gql/builders/ResolverBuilder.ts +4 -4
  30. package/gql/complexityLimit.ts +95 -0
  31. package/gql/index.ts +15 -3
  32. package/gql/visitors/ResolverGeneratorVisitor.ts +16 -2
  33. package/package.json +1 -1
  34. package/query/ComponentInclusionNode.ts +13 -6
  35. package/query/OrNode.ts +2 -4
  36. package/query/Query.ts +30 -3
  37. package/query/SqlIdentifier.ts +105 -0
  38. package/query/builders/FullTextSearchBuilder.ts +19 -6
  39. package/service/ServiceRegistry.ts +21 -8
  40. package/storage/LocalStorageProvider.ts +12 -3
  41. package/storage/S3StorageProvider.ts +6 -6
  42. package/tests/e2e/http.test.ts +6 -2
  43. package/tests/helpers/MockRedisClient.ts +113 -0
  44. package/tests/helpers/MockRedisStreamServer.ts +448 -0
  45. package/tests/integration/entity/Entity.saveTimeout.test.ts +110 -0
  46. package/tests/integration/remote/dlq.test.ts +175 -0
  47. package/tests/integration/remote/event-dispatch.test.ts +114 -0
  48. package/tests/integration/remote/outbox.test.ts +130 -0
  49. package/tests/integration/remote/rpc.test.ts +177 -0
  50. package/tests/unit/remote/CircuitBreaker.test.ts +159 -0
  51. package/tests/unit/remote/RemoteError.test.ts +55 -0
  52. package/tests/unit/remote/decorators.test.ts +195 -0
  53. package/tests/unit/remote/metrics.test.ts +115 -0
  54. package/tests/unit/remote/mockRedisStreamServer.test.ts +104 -0
  55. package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
  56. package/upload/FileValidator.ts +9 -6
package/core/App.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import ApplicationLifecycle, {
2
2
  ApplicationPhase,
3
+ type PhaseChangeEvent,
3
4
  } from "./ApplicationLifecycle";
4
5
  import {
5
6
  GenerateTableName,
@@ -26,6 +27,14 @@ import studioEndpoint from "../endpoints";
26
27
  import { type Middleware, composeMiddleware } from "./Middleware";
27
28
  import { deepHealthCheck, readinessCheck } from "./health";
28
29
  import { validateEnv } from "./validateEnv";
30
+ import {
31
+ RemoteManager,
32
+ registerRemoteHandlers,
33
+ setRemoteManager,
34
+ } from "./remote";
35
+ import type { RemoteManagerConfig } from "./remote";
36
+ import type { CacheConfig } from "../config/cache.config";
37
+ import { createRequestContextPlugin } from "./RequestContext";
29
38
 
30
39
  export type CorsConfig = {
31
40
  origin?: string | string[] | ((origin: string) => boolean);
@@ -70,10 +79,22 @@ export default class App {
70
79
  private composedHandler: ((req: Request) => Promise<Response>) | null = null;
71
80
 
72
81
  private studioEnabled: boolean = false;
82
+ private remote: RemoteManager | null = null;
83
+ private remoteConfig: Partial<RemoteManagerConfig> | null = null;
73
84
  private server: ReturnType<typeof Bun.serve> | null = null;
74
85
  private isShuttingDown = false;
75
86
  private isReady = false;
87
+ private cacheConfig: Partial<CacheConfig> | null = null;
88
+ private requestContextPluginEnabled = true;
89
+ private phaseListener: ((event: PhaseChangeEvent) => void) | null = null;
90
+ private signalHandlersRegistered = false;
91
+ private processHandlersRegistered = false;
92
+ private sigTermHandler: (() => void) | null = null;
93
+ private sigIntHandler: (() => void) | null = null;
94
+ private unhandledRejectionHandler: ((reason: unknown, promise: Promise<unknown>) => void) | null = null;
95
+ private uncaughtExceptionHandler: ((error: Error) => void) | null = null;
76
96
  private graphqlMaxDepth: number = 10;
97
+ private graphqlMaxComplexity: number = 1000;
77
98
  private shutdownGracePeriod = 10_000;
78
99
  private maxRequestBodySize = 50 * 1024 * 1024; // 50MB default
79
100
 
@@ -117,6 +138,9 @@ export default class App {
117
138
  }
118
139
 
119
140
  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
+ }
120
144
  this.config.cors = cors;
121
145
  // Warn about invalid configuration
122
146
  if (cors.credentials && cors.origin === '*') {
@@ -125,19 +149,29 @@ export default class App {
125
149
  }
126
150
 
127
151
  async init() {
152
+ // Register process-level error handlers FIRST so failures during init
153
+ // (DB prep, component registration, schema build) are observable. If
154
+ // registration happens later (e.g. in start()) any boot-sequence
155
+ // unhandled rejection is silently discarded by the runtime.
156
+ this.registerProcessHandlers();
157
+
128
158
  validateEnv();
129
159
  logger.trace(`Initializing App`);
130
160
  ComponentRegistry.init();
131
161
  ServiceRegistry.init();
132
162
 
133
- // Initialize CacheManager
163
+ // Initialize CacheManager with merged config. MUST await — initialize()
164
+ // is async and sets up pub/sub for cross-instance invalidation. Previously
165
+ // only getInstance() was called, silently skipping pub/sub setup and
166
+ // ignoring any app-supplied config (C04).
134
167
  try {
135
168
  const { CacheManager } = await import('./cache/CacheManager');
136
169
  const cacheManager = CacheManager.getInstance();
137
- // CacheManager initializes with default config, can be customized later
138
- logger.info({ scope: 'cache', component: 'App', msg: 'CacheManager initialized' });
170
+ await cacheManager.initialize(this.cacheConfig ?? {});
171
+ const config = cacheManager.getConfig();
172
+ logger.info({ scope: 'cache', component: 'App', msg: 'CacheManager initialized', provider: config.provider, enabled: config.enabled, strategy: config.strategy });
139
173
  } catch (error) {
140
- logger.warn({ scope: 'cache', component: 'App', msg: 'Failed to initialize CacheManager', error });
174
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Failed to initialize CacheManager', err: error });
141
175
  }
142
176
 
143
177
  // Plugin initialization
@@ -147,7 +181,12 @@ export default class App {
147
181
  }
148
182
  }
149
183
 
150
- ApplicationLifecycle.addPhaseListener(async (event) => {
184
+ // Remove any previous listener so repeated init() calls (tests) don't
185
+ // stack handlers on the lifecycle singleton.
186
+ if (this.phaseListener) {
187
+ ApplicationLifecycle.removePhaseListener(this.phaseListener);
188
+ }
189
+ this.phaseListener = async (event: PhaseChangeEvent) => {
151
190
  const phase = event.detail;
152
191
  logger.info(`Application phase changed to: ${phase}`);
153
192
  // Notify plugins of phase change
@@ -209,23 +248,39 @@ export default class App {
209
248
  if (envDepth) {
210
249
  this.graphqlMaxDepth = parseInt(envDepth, 10);
211
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
+ }
212
258
 
213
259
  const yogaOptions = {
214
260
  cors: this.config.cors,
215
261
  maxDepth: this.graphqlMaxDepth || undefined,
262
+ maxComplexity: this.graphqlMaxComplexity,
216
263
  };
217
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
+
218
273
  if (schema) {
219
274
  this.yoga = createYogaInstance(
220
275
  schema,
221
- this.yogaPlugins,
276
+ effectivePlugins,
222
277
  wrappedContextFactory,
223
278
  yogaOptions
224
279
  );
225
280
  } else {
226
281
  this.yoga = createYogaInstance(
227
282
  undefined,
228
- this.yogaPlugins,
283
+ effectivePlugins,
229
284
  wrappedContextFactory,
230
285
  yogaOptions
231
286
  );
@@ -254,6 +309,40 @@ export default class App {
254
309
  `Registered scheduled tasks for ${services.length} services`
255
310
  );
256
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
+
257
346
  // Collect REST endpoints from all services
258
347
  for (const service of services) {
259
348
  const endpoints = (service.constructor as any)
@@ -360,8 +449,21 @@ export default class App {
360
449
  ApplicationPhase.APPLICATION_READY
361
450
  );
362
451
  } catch (error) {
363
- logger.error("Error during SYSTEM_READY phase:");
364
- logger.error(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?.();
365
467
  }
366
468
  break;
367
469
  }
@@ -372,7 +474,8 @@ export default class App {
372
474
  break;
373
475
  }
374
476
  }
375
- });
477
+ };
478
+ ApplicationLifecycle.addPhaseListener(this.phaseListener);
376
479
 
377
480
  if (
378
481
  ApplicationLifecycle.getCurrentPhase() ===
@@ -391,17 +494,31 @@ export default class App {
391
494
  }
392
495
  }
393
496
 
394
- waitForAppReady(): Promise<void> {
395
- return new Promise((resolve) => {
396
- const interval = setInterval(() => {
397
- if (
398
- ApplicationLifecycle.getCurrentPhase() >=
399
- ApplicationPhase.APPLICATION_READY
400
- ) {
401
- clearInterval(interval);
497
+ /**
498
+ * Resolve once the application has reached APPLICATION_READY. Previously
499
+ * polled every 100ms with no exit condition — a boot failure would keep
500
+ * the interval timer alive forever (H-MEM-1). Now attaches a one-shot
501
+ * phase listener and self-cleans on first match. Bounded by `timeoutMs`
502
+ * so callers cannot hang indefinitely; default matches waitForPhase.
503
+ */
504
+ waitForAppReady(timeoutMs = 60_000): Promise<void> {
505
+ if (ApplicationLifecycle.getCurrentPhase() >= ApplicationPhase.APPLICATION_READY) {
506
+ return Promise.resolve();
507
+ }
508
+ return new Promise((resolve, reject) => {
509
+ const timer = setTimeout(() => {
510
+ ApplicationLifecycle.removePhaseListener(onPhase);
511
+ reject(new Error(`waitForAppReady timed out after ${timeoutMs}ms; current phase=${ApplicationLifecycle.getCurrentPhase()}`));
512
+ }, timeoutMs);
513
+ timer.unref?.();
514
+ const onPhase = (event: PhaseChangeEvent) => {
515
+ if (event.detail === ApplicationPhase.APPLICATION_READY) {
516
+ clearTimeout(timer);
517
+ ApplicationLifecycle.removePhaseListener(onPhase);
402
518
  resolve();
403
519
  }
404
- }, 100);
520
+ };
521
+ ApplicationLifecycle.addPhaseListener(onPhase);
405
522
  });
406
523
  }
407
524
 
@@ -443,8 +560,12 @@ export default class App {
443
560
 
444
561
  const configOrigin = this.config.cors.origin;
445
562
 
446
- // Wildcard allows all
447
- if (configOrigin === '*' || configOrigin === undefined) {
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 === '*') {
448
569
  // If credentials enabled, cannot use wildcard - return actual origin
449
570
  return this.config.cors.credentials ? requestOrigin : '*';
450
571
  }
@@ -476,12 +597,16 @@ export default class App {
476
597
  // If origin not allowed, return empty (no CORS headers)
477
598
  if (requestOrigin && !allowedOrigin) return {};
478
599
 
600
+ // No allowedOrigin means request carried no Origin header. Skip
601
+ // Access-Control-Allow-Origin rather than falling back to '*'.
479
602
  const headers: Record<string, string> = {
480
- 'Access-Control-Allow-Origin': allowedOrigin || '*',
481
603
  'Access-Control-Allow-Methods': this.config.cors.methods?.join(', ') || 'GET, POST, PUT, DELETE, OPTIONS',
482
604
  'Access-Control-Allow-Headers': this.config.cors.allowedHeaders?.join(', ') || 'Content-Type, Authorization',
483
605
  'Vary': 'Origin',
484
606
  };
607
+ if (allowedOrigin) {
608
+ headers['Access-Control-Allow-Origin'] = allowedOrigin;
609
+ }
485
610
 
486
611
  if (this.config.cors.credentials) {
487
612
  headers['Access-Control-Allow-Credentials'] = 'true';
@@ -498,6 +623,27 @@ export default class App {
498
623
  return headers;
499
624
  }
500
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
+
501
647
  private addCorsHeaders(response: Response, req?: Request): Response {
502
648
  const corsHeaders = this.getCorsHeaders(req);
503
649
  if (Object.keys(corsHeaders).length === 0) return response;
@@ -527,18 +673,28 @@ export default class App {
527
673
  });
528
674
  }
529
675
 
530
- // Add request timeout
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).
531
683
  const controller = new AbortController();
532
684
  const timeoutId = setTimeout(() => {
533
- controller.abort();
685
+ controller.abort(new Error(`Request timeout after 30000ms: ${method} ${url.pathname}`));
534
686
  logger.warn(`Request timeout: ${method} ${url.pathname}`);
535
- }, 30000); // 30 second timeout
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 });
536
692
 
537
693
  try {
538
694
  // Health check endpoint
539
695
  if (url.pathname === "/health") {
540
- clearTimeout(timeoutId);
541
696
  const health = await deepHealthCheck();
697
+ clearTimeout(timeoutId);
542
698
  return this.addCorsHeaders(new Response(
543
699
  JSON.stringify(health.result),
544
700
  {
@@ -550,8 +706,8 @@ export default class App {
550
706
 
551
707
  // Metrics endpoint
552
708
  if (url.pathname === "/metrics") {
553
- clearTimeout(timeoutId);
554
709
  const metrics = await this.collectMetrics();
710
+ clearTimeout(timeoutId);
555
711
  return this.addCorsHeaders(new Response(
556
712
  JSON.stringify(metrics),
557
713
  {
@@ -561,10 +717,36 @@ export default class App {
561
717
  ), req);
562
718
  }
563
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
+
564
746
  // Readiness probe
565
747
  if (url.pathname === "/health/ready") {
566
- clearTimeout(timeoutId);
567
748
  const ready = await readinessCheck(this.isReady, this.isShuttingDown);
749
+ clearTimeout(timeoutId);
568
750
  return this.addCorsHeaders(new Response(
569
751
  JSON.stringify(ready.result),
570
752
  {
@@ -911,10 +1093,27 @@ export default class App {
911
1093
  this.name = name;
912
1094
  }
913
1095
 
1096
+ public getName(): string {
1097
+ return this.name;
1098
+ }
1099
+
914
1100
  public setVersion(version: string) {
915
1101
  this.version = version;
916
1102
  }
917
1103
 
1104
+ /**
1105
+ * Enable remote cross-app communication over Redis Streams.
1106
+ * Must be called before `init()` (initialization happens in SYSTEM_READY).
1107
+ * `appName` defaults to the app name.
1108
+ */
1109
+ public enableRemote(config: Partial<RemoteManagerConfig> = {}) {
1110
+ this.remoteConfig = config;
1111
+ }
1112
+
1113
+ public getRemote(): RemoteManager | null {
1114
+ return this.remote;
1115
+ }
1116
+
918
1117
  public subscribeAppReady(callback: () => void) {
919
1118
  this.appReadyCallbacks.push(callback);
920
1119
  }
@@ -935,6 +1134,34 @@ export default class App {
935
1134
  this.graphqlMaxDepth = depth;
936
1135
  }
937
1136
 
1137
+ /**
1138
+ * Set the maximum GraphQL query complexity. 0 disables.
1139
+ * Complexity = sum of per-field costs, multiplied by `first`/`limit`/`take`
1140
+ * arguments when present on each field.
1141
+ */
1142
+ public setGraphQLMaxComplexity(complexity: number) {
1143
+ this.graphqlMaxComplexity = complexity;
1144
+ }
1145
+
1146
+ /**
1147
+ * Supply a cache configuration that will be merged with `defaultCacheConfig`
1148
+ * and passed to `CacheManager.initialize()` during `init()`. Must be called
1149
+ * before `init()`.
1150
+ */
1151
+ public setCacheConfig(config: Partial<CacheConfig>) {
1152
+ this.cacheConfig = config;
1153
+ }
1154
+
1155
+ /**
1156
+ * Disable the auto-applied RequestContext plugin. Only do this if your
1157
+ * app does not use `@BelongsTo` / `@HasMany` relations OR you are
1158
+ * supplying your own DataLoader plugin. Without it, nested relation
1159
+ * resolvers issue one DB query per row (N+1).
1160
+ */
1161
+ public disableRequestContextPlugin() {
1162
+ this.requestContextPluginEnabled = false;
1163
+ }
1164
+
938
1165
  /**
939
1166
  * Set the grace period for draining connections during shutdown (ms).
940
1167
  */
@@ -1014,7 +1241,9 @@ export default class App {
1014
1241
  try {
1015
1242
  const { CacheManager } = await import('./cache/CacheManager');
1016
1243
  cacheStats = await CacheManager.getInstance().getStats();
1017
- } catch {}
1244
+ } catch (err) {
1245
+ logger.warn({ err }, 'metrics: cache stats unavailable');
1246
+ }
1018
1247
 
1019
1248
  return {
1020
1249
  timestamp: new Date().toISOString(),
@@ -1023,6 +1252,7 @@ export default class App {
1023
1252
  cache: cacheStats,
1024
1253
  scheduler: SchedulerManager.getInstance().getMetrics(),
1025
1254
  preparedStatements: preparedStatementCache.getStats(),
1255
+ remote: this.remote ? this.remote.getMetrics() : null,
1026
1256
  };
1027
1257
  }
1028
1258
 
@@ -1066,84 +1296,178 @@ export default class App {
1066
1296
  )}`
1067
1297
  );
1068
1298
 
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
- });
1299
+ // Signal handlers now registered in init() via registerProcessHandlers()
1300
+ // so they cover the boot sequence (before start() runs).
1301
+
1302
+ this.isReady = true;
1303
+ this.appReadyCallbacks.forEach((cb) => cb());
1304
+ }
1075
1305
 
1076
- process.on('SIGINT', async () => {
1306
+ /**
1307
+ * Register process-level signal and error handlers. Called at the top of
1308
+ * `init()` so that failures during boot (DB prep, component registration,
1309
+ * schema build) are logged and don't silently crash the runtime.
1310
+ *
1311
+ * Uses `process.once` for signals so a double SIGTERM can't fire two
1312
+ * concurrent shutdown paths racing each other to `process.exit`. Also
1313
+ * idempotent — safe to call multiple times (e.g. in tests).
1314
+ */
1315
+ 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 = () => {
1077
1324
  logger.info({ scope: 'app', component: 'App', msg: 'Received SIGINT' });
1078
- await this.shutdown();
1079
- process.exit(0);
1080
- });
1325
+ this.shutdown().finally(() => process.exit(0));
1326
+ };
1327
+ process.once('SIGTERM', this.sigTermHandler);
1328
+ process.once('SIGINT', this.sigIntHandler);
1081
1329
 
1082
- // Global error handlers to prevent silent crashes
1083
- process.on('unhandledRejection', (reason, promise) => {
1330
+ // Global error handlers to prevent silent crashes during init AND runtime.
1331
+ this.unhandledRejectionHandler = (reason, promise) => {
1084
1332
  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' });
1333
+ };
1334
+ this.uncaughtExceptionHandler = (error) => {
1335
+ logger.fatal({ scope: 'app', component: 'App', err: error, msg: 'Uncaught exception — shutting down' });
1089
1336
  this.shutdown().finally(() => process.exit(1));
1090
- });
1337
+ };
1338
+ process.on('unhandledRejection', this.unhandledRejectionHandler);
1339
+ process.on('uncaughtException', this.uncaughtExceptionHandler);
1091
1340
 
1092
- this.isReady = true;
1093
- this.appReadyCallbacks.forEach((cb) => cb());
1341
+ this.processHandlersRegistered = true;
1342
+ }
1343
+
1344
+ 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;
1094
1355
  }
1095
1356
 
1096
1357
  /**
1097
- * Gracefully shutdown the application
1358
+ * Gracefully shutdown the application.
1359
+ *
1360
+ * Ordered drain: HTTP → scheduler → remote → cache → database. Each step
1361
+ * awaits completion before the next begins so in-flight work always sees
1362
+ * its dependencies still available. Total budget bounded by
1363
+ * `shutdownGracePeriod`; per-step budgets fall back to reasonable defaults.
1098
1364
  */
1099
1365
  async shutdown(): Promise<void> {
1100
1366
  if (this.isShuttingDown) return;
1101
1367
  this.isShuttingDown = true;
1102
1368
  this.isReady = false;
1103
1369
 
1104
- logger.info({ scope: 'app', component: 'App', msg: 'Shutting down application' });
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));
1105
1374
 
1106
- // Stop HTTP server drain then force-close after grace period
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.
1107
1379
  if (this.server) {
1108
1380
  try {
1109
- logger.info({ scope: 'app', component: 'App', msg: 'Draining connections' });
1381
+ logger.info({ scope: 'app', component: 'App', msg: 'Draining HTTP connections' });
1110
1382
  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?.();
1383
+ await this.waitForHttpDrain(budgetRemaining());
1384
+ try { this.server.stop(true); } catch {}
1116
1385
  logger.info({ scope: 'app', component: 'App', msg: 'HTTP server stopped' });
1117
1386
  } catch (error) {
1118
- logger.warn({ scope: 'app', component: 'App', msg: 'HTTP server stop error', error });
1387
+ logger.warn({ scope: 'app', component: 'App', msg: 'HTTP server stop error', err: error });
1119
1388
  }
1120
1389
  }
1121
1390
 
1122
- // Stop scheduler
1391
+ // 2. Stop scheduler (awaits in-flight tasks internally, see C14).
1123
1392
  try {
1124
- await SchedulerManager.getInstance().stop();
1393
+ await SchedulerManager.getInstance().stop(Math.min(budgetRemaining(), 15_000));
1125
1394
  logger.info({ scope: 'app', component: 'App', msg: 'Scheduler stopped' });
1126
1395
  } catch (error) {
1127
- logger.warn({ scope: 'app', component: 'App', msg: 'Scheduler stop error', 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
+ }
1128
1409
  }
1129
1410
 
1130
- // Shutdown cache
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).
1131
1421
  try {
1132
1422
  const { CacheManager } = await import('./cache/CacheManager');
1133
1423
  await CacheManager.getInstance().shutdown();
1134
1424
  logger.info({ scope: 'cache', component: 'App', msg: 'Cache shutdown completed' });
1135
1425
  } catch (error) {
1136
- logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', error });
1426
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', err: error });
1137
1427
  }
1138
1428
 
1139
- // Close database pool (last step)
1429
+ // 5. Close database pool (last — after all consumers done).
1140
1430
  try {
1141
1431
  db.close();
1142
1432
  logger.info({ scope: 'app', component: 'App', msg: 'Database pool closed' });
1143
1433
  } catch (error) {
1144
- logger.warn({ scope: 'app', component: 'App', msg: 'Database pool close error', error });
1434
+ logger.warn({ scope: 'app', component: 'App', msg: 'Database pool close error', err: error });
1145
1435
  }
1146
1436
 
1147
- logger.info({ scope: 'app', component: 'App', msg: 'Application shutdown completed' });
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
+ }
1148
1472
  }
1149
1473
  }