bunsane 0.2.10 → 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.
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,
@@ -32,6 +33,8 @@ import {
32
33
  setRemoteManager,
33
34
  } from "./remote";
34
35
  import type { RemoteManagerConfig } from "./remote";
36
+ import type { CacheConfig } from "../config/cache.config";
37
+ import { createRequestContextPlugin } from "./RequestContext";
35
38
 
36
39
  export type CorsConfig = {
37
40
  origin?: string | string[] | ((origin: string) => boolean);
@@ -81,7 +84,17 @@ export default class App {
81
84
  private server: ReturnType<typeof Bun.serve> | null = null;
82
85
  private isShuttingDown = false;
83
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;
84
96
  private graphqlMaxDepth: number = 10;
97
+ private graphqlMaxComplexity: number = 1000;
85
98
  private shutdownGracePeriod = 10_000;
86
99
  private maxRequestBodySize = 50 * 1024 * 1024; // 50MB default
87
100
 
@@ -125,6 +138,9 @@ export default class App {
125
138
  }
126
139
 
127
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
+ }
128
144
  this.config.cors = cors;
129
145
  // Warn about invalid configuration
130
146
  if (cors.credentials && cors.origin === '*') {
@@ -133,19 +149,29 @@ export default class App {
133
149
  }
134
150
 
135
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
+
136
158
  validateEnv();
137
159
  logger.trace(`Initializing App`);
138
160
  ComponentRegistry.init();
139
161
  ServiceRegistry.init();
140
162
 
141
- // 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).
142
167
  try {
143
168
  const { CacheManager } = await import('./cache/CacheManager');
144
169
  const cacheManager = CacheManager.getInstance();
145
- // CacheManager initializes with default config, can be customized later
146
- 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 });
147
173
  } catch (error) {
148
- 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 });
149
175
  }
150
176
 
151
177
  // Plugin initialization
@@ -155,7 +181,12 @@ export default class App {
155
181
  }
156
182
  }
157
183
 
158
- 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) => {
159
190
  const phase = event.detail;
160
191
  logger.info(`Application phase changed to: ${phase}`);
161
192
  // Notify plugins of phase change
@@ -217,23 +248,39 @@ export default class App {
217
248
  if (envDepth) {
218
249
  this.graphqlMaxDepth = parseInt(envDepth, 10);
219
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
+ }
220
258
 
221
259
  const yogaOptions = {
222
260
  cors: this.config.cors,
223
261
  maxDepth: this.graphqlMaxDepth || undefined,
262
+ maxComplexity: this.graphqlMaxComplexity,
224
263
  };
225
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
+
226
273
  if (schema) {
227
274
  this.yoga = createYogaInstance(
228
275
  schema,
229
- this.yogaPlugins,
276
+ effectivePlugins,
230
277
  wrappedContextFactory,
231
278
  yogaOptions
232
279
  );
233
280
  } else {
234
281
  this.yoga = createYogaInstance(
235
282
  undefined,
236
- this.yogaPlugins,
283
+ effectivePlugins,
237
284
  wrappedContextFactory,
238
285
  yogaOptions
239
286
  );
@@ -402,8 +449,21 @@ export default class App {
402
449
  ApplicationPhase.APPLICATION_READY
403
450
  );
404
451
  } catch (error) {
405
- logger.error("Error during SYSTEM_READY phase:");
406
- 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?.();
407
467
  }
408
468
  break;
409
469
  }
@@ -414,7 +474,8 @@ export default class App {
414
474
  break;
415
475
  }
416
476
  }
417
- });
477
+ };
478
+ ApplicationLifecycle.addPhaseListener(this.phaseListener);
418
479
 
419
480
  if (
420
481
  ApplicationLifecycle.getCurrentPhase() ===
@@ -433,17 +494,31 @@ export default class App {
433
494
  }
434
495
  }
435
496
 
436
- waitForAppReady(): Promise<void> {
437
- return new Promise((resolve) => {
438
- const interval = setInterval(() => {
439
- if (
440
- ApplicationLifecycle.getCurrentPhase() >=
441
- ApplicationPhase.APPLICATION_READY
442
- ) {
443
- 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);
444
518
  resolve();
445
519
  }
446
- }, 100);
520
+ };
521
+ ApplicationLifecycle.addPhaseListener(onPhase);
447
522
  });
448
523
  }
449
524
 
@@ -485,8 +560,12 @@ export default class App {
485
560
 
486
561
  const configOrigin = this.config.cors.origin;
487
562
 
488
- // Wildcard allows all
489
- 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 === '*') {
490
569
  // If credentials enabled, cannot use wildcard - return actual origin
491
570
  return this.config.cors.credentials ? requestOrigin : '*';
492
571
  }
@@ -518,12 +597,16 @@ export default class App {
518
597
  // If origin not allowed, return empty (no CORS headers)
519
598
  if (requestOrigin && !allowedOrigin) return {};
520
599
 
600
+ // No allowedOrigin means request carried no Origin header. Skip
601
+ // Access-Control-Allow-Origin rather than falling back to '*'.
521
602
  const headers: Record<string, string> = {
522
- 'Access-Control-Allow-Origin': allowedOrigin || '*',
523
603
  'Access-Control-Allow-Methods': this.config.cors.methods?.join(', ') || 'GET, POST, PUT, DELETE, OPTIONS',
524
604
  'Access-Control-Allow-Headers': this.config.cors.allowedHeaders?.join(', ') || 'Content-Type, Authorization',
525
605
  'Vary': 'Origin',
526
606
  };
607
+ if (allowedOrigin) {
608
+ headers['Access-Control-Allow-Origin'] = allowedOrigin;
609
+ }
527
610
 
528
611
  if (this.config.cors.credentials) {
529
612
  headers['Access-Control-Allow-Credentials'] = 'true';
@@ -540,6 +623,27 @@ export default class App {
540
623
  return headers;
541
624
  }
542
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
+
543
647
  private addCorsHeaders(response: Response, req?: Request): Response {
544
648
  const corsHeaders = this.getCorsHeaders(req);
545
649
  if (Object.keys(corsHeaders).length === 0) return response;
@@ -569,18 +673,28 @@ export default class App {
569
673
  });
570
674
  }
571
675
 
572
- // 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).
573
683
  const controller = new AbortController();
574
684
  const timeoutId = setTimeout(() => {
575
- controller.abort();
685
+ controller.abort(new Error(`Request timeout after 30000ms: ${method} ${url.pathname}`));
576
686
  logger.warn(`Request timeout: ${method} ${url.pathname}`);
577
- }, 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 });
578
692
 
579
693
  try {
580
694
  // Health check endpoint
581
695
  if (url.pathname === "/health") {
582
- clearTimeout(timeoutId);
583
696
  const health = await deepHealthCheck();
697
+ clearTimeout(timeoutId);
584
698
  return this.addCorsHeaders(new Response(
585
699
  JSON.stringify(health.result),
586
700
  {
@@ -592,8 +706,8 @@ export default class App {
592
706
 
593
707
  // Metrics endpoint
594
708
  if (url.pathname === "/metrics") {
595
- clearTimeout(timeoutId);
596
709
  const metrics = await this.collectMetrics();
710
+ clearTimeout(timeoutId);
597
711
  return this.addCorsHeaders(new Response(
598
712
  JSON.stringify(metrics),
599
713
  {
@@ -605,8 +719,8 @@ export default class App {
605
719
 
606
720
  // Remote health check
607
721
  if (url.pathname === "/health/remote") {
608
- clearTimeout(timeoutId);
609
722
  if (!this.remote) {
723
+ clearTimeout(timeoutId);
610
724
  return this.addCorsHeaders(new Response(
611
725
  JSON.stringify({
612
726
  healthy: false,
@@ -619,6 +733,7 @@ export default class App {
619
733
  ), req);
620
734
  }
621
735
  const health = await this.remote.health();
736
+ clearTimeout(timeoutId);
622
737
  return this.addCorsHeaders(new Response(
623
738
  JSON.stringify(health),
624
739
  {
@@ -630,8 +745,8 @@ export default class App {
630
745
 
631
746
  // Readiness probe
632
747
  if (url.pathname === "/health/ready") {
633
- clearTimeout(timeoutId);
634
748
  const ready = await readinessCheck(this.isReady, this.isShuttingDown);
749
+ clearTimeout(timeoutId);
635
750
  return this.addCorsHeaders(new Response(
636
751
  JSON.stringify(ready.result),
637
752
  {
@@ -1019,6 +1134,34 @@ export default class App {
1019
1134
  this.graphqlMaxDepth = depth;
1020
1135
  }
1021
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
+
1022
1165
  /**
1023
1166
  * Set the grace period for draining connections during shutdown (ms).
1024
1167
  */
@@ -1098,7 +1241,9 @@ export default class App {
1098
1241
  try {
1099
1242
  const { CacheManager } = await import('./cache/CacheManager');
1100
1243
  cacheStats = await CacheManager.getInstance().getStats();
1101
- } catch {}
1244
+ } catch (err) {
1245
+ logger.warn({ err }, 'metrics: cache stats unavailable');
1246
+ }
1102
1247
 
1103
1248
  return {
1104
1249
  timestamp: new Date().toISOString(),
@@ -1151,68 +1296,107 @@ export default class App {
1151
1296
  )}`
1152
1297
  );
1153
1298
 
1154
- // Register signal handlers for graceful shutdown
1155
- process.on('SIGTERM', async () => {
1156
- logger.info({ scope: 'app', component: 'App', msg: 'Received SIGTERM' });
1157
- await this.shutdown();
1158
- process.exit(0);
1159
- });
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
+ }
1305
+
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;
1160
1317
 
1161
- process.on('SIGINT', async () => {
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 = () => {
1162
1324
  logger.info({ scope: 'app', component: 'App', msg: 'Received SIGINT' });
1163
- await this.shutdown();
1164
- process.exit(0);
1165
- });
1325
+ this.shutdown().finally(() => process.exit(0));
1326
+ };
1327
+ process.once('SIGTERM', this.sigTermHandler);
1328
+ process.once('SIGINT', this.sigIntHandler);
1166
1329
 
1167
- // Global error handlers to prevent silent crashes
1168
- process.on('unhandledRejection', (reason, promise) => {
1330
+ // Global error handlers to prevent silent crashes during init AND runtime.
1331
+ this.unhandledRejectionHandler = (reason, promise) => {
1169
1332
  logger.error({ scope: 'app', component: 'App', reason, msg: 'Unhandled promise rejection' });
1170
- });
1171
-
1172
- process.on('uncaughtException', (error) => {
1173
- 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' });
1174
1336
  this.shutdown().finally(() => process.exit(1));
1175
- });
1337
+ };
1338
+ process.on('unhandledRejection', this.unhandledRejectionHandler);
1339
+ process.on('uncaughtException', this.uncaughtExceptionHandler);
1176
1340
 
1177
- this.isReady = true;
1178
- 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;
1179
1355
  }
1180
1356
 
1181
1357
  /**
1182
- * 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.
1183
1364
  */
1184
1365
  async shutdown(): Promise<void> {
1185
1366
  if (this.isShuttingDown) return;
1186
1367
  this.isShuttingDown = true;
1187
1368
  this.isReady = false;
1188
1369
 
1189
- 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 });
1190
1372
 
1191
- // Stop HTTP server drain then force-close after grace period
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.
1192
1379
  if (this.server) {
1193
1380
  try {
1194
- logger.info({ scope: 'app', component: 'App', msg: 'Draining connections' });
1381
+ logger.info({ scope: 'app', component: 'App', msg: 'Draining HTTP connections' });
1195
1382
  this.server.stop(false);
1196
- const forceTimer = setTimeout(() => {
1197
- logger.warn({ scope: 'app', component: 'App', msg: 'Grace period expired, forcing connection close' });
1198
- try { this.server?.stop(true); } catch {}
1199
- }, this.shutdownGracePeriod);
1200
- forceTimer.unref?.();
1383
+ await this.waitForHttpDrain(budgetRemaining());
1384
+ try { this.server.stop(true); } catch {}
1201
1385
  logger.info({ scope: 'app', component: 'App', msg: 'HTTP server stopped' });
1202
1386
  } catch (error) {
1203
- 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 });
1204
1388
  }
1205
1389
  }
1206
1390
 
1207
- // Stop scheduler
1391
+ // 2. Stop scheduler (awaits in-flight tasks internally, see C14).
1208
1392
  try {
1209
- await SchedulerManager.getInstance().stop();
1393
+ await SchedulerManager.getInstance().stop(Math.min(budgetRemaining(), 15_000));
1210
1394
  logger.info({ scope: 'app', component: 'App', msg: 'Scheduler stopped' });
1211
1395
  } catch (error) {
1212
- logger.warn({ scope: 'app', component: 'App', msg: 'Scheduler stop error', error });
1396
+ logger.warn({ scope: 'app', component: 'App', msg: 'Scheduler stop error', err: error });
1213
1397
  }
1214
1398
 
1215
- // Shutdown RemoteManager (after scheduler, before cache — DB still available)
1399
+ // 3. Shutdown RemoteManager (after scheduler, before cache — DB still available).
1216
1400
  if (this.remote) {
1217
1401
  try {
1218
1402
  await this.remote.shutdown();
@@ -1220,27 +1404,70 @@ export default class App {
1220
1404
  this.remote = null;
1221
1405
  logger.info({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown' });
1222
1406
  } catch (error) {
1223
- logger.warn({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown error', error });
1407
+ logger.warn({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown error', err: error });
1224
1408
  }
1225
1409
  }
1226
1410
 
1227
- // 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).
1228
1421
  try {
1229
1422
  const { CacheManager } = await import('./cache/CacheManager');
1230
1423
  await CacheManager.getInstance().shutdown();
1231
1424
  logger.info({ scope: 'cache', component: 'App', msg: 'Cache shutdown completed' });
1232
1425
  } catch (error) {
1233
- logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', error });
1426
+ logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', err: error });
1234
1427
  }
1235
1428
 
1236
- // Close database pool (last step)
1429
+ // 5. Close database pool (last — after all consumers done).
1237
1430
  try {
1238
1431
  db.close();
1239
1432
  logger.info({ scope: 'app', component: 'App', msg: 'Database pool closed' });
1240
1433
  } catch (error) {
1241
- 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 });
1242
1435
  }
1243
1436
 
1244
- 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
+ }
1245
1472
  }
1246
1473
  }