@objectstack/core 17.0.0-rc.3 → 17.0.0-rc.5

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/dist/index.cjs CHANGED
@@ -35,7 +35,6 @@ __export(index_exports, {
35
35
  ANONYMOUS_DENY_MESSAGE: () => ANONYMOUS_DENY_MESSAGE,
36
36
  ANONYMOUS_DENY_STATUS: () => ANONYMOUS_DENY_STATUS,
37
37
  API_KEY_PREFIX: () => API_KEY_PREFIX,
38
- ApiRegistry: () => ApiRegistry,
39
38
  CORE_FALLBACK_FACTORIES: () => CORE_FALLBACK_FACTORIES,
40
39
  DependencyResolver: () => DependencyResolver,
41
40
  HotReloadManager: () => HotReloadManager,
@@ -71,7 +70,6 @@ __export(index_exports, {
71
70
  calendarPartsInTz: () => calendarPartsInTz,
72
71
  calendarPartsInTzOrUtc: () => calendarPartsInTzOrUtc,
73
72
  counterSignPayload: () => counterSignPayload,
74
- createApiRegistryPlugin: () => createApiRegistryPlugin,
75
73
  createLogger: () => createLogger,
76
74
  createMemoryCache: () => createMemoryCache,
77
75
  createMemoryI18n: () => createMemoryI18n,
@@ -402,7 +400,21 @@ var ObjectKernelBase = class {
402
400
  }
403
401
  }
404
402
  /**
405
- * Trigger a hook with all registered handlers
403
+ * Trigger a hook with all registered handlers, ISOLATING failures: a
404
+ * handler that throws is logged and the remaining handlers still run.
405
+ *
406
+ * Use this for hooks where one subscriber's failure must not deny the
407
+ * others their turn — notification-style hooks, and `kernel:shutdown`,
408
+ * where the handlers still queued behind the failing one are the cleanup
409
+ * that flushes buffers and releases resources (#5257).
410
+ *
411
+ * It is the WRONG dispatcher for anything on the BOOT path. Every hook
412
+ * dispatched before "✅ Bootstrap complete" is a precondition of that
413
+ * claim, so swallowing a throw there does not rescue the boot — it only
414
+ * hides the failure behind a process that reports success. Those hooks
415
+ * (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) use
416
+ * {@link triggerHookOrThrow} (#5170, #5257).
417
+ *
406
418
  * @param name - Hook name
407
419
  * @param args - Arguments to pass to handlers
408
420
  */
@@ -420,6 +432,54 @@ var ObjectKernelBase = class {
420
432
  }
421
433
  }
422
434
  }
435
+ /**
436
+ * Trigger a hook with all registered handlers, PROPAGATING the first
437
+ * failure: the remaining handlers do not run and the original error
438
+ * reaches the caller unwrapped.
439
+ *
440
+ * This is the dispatch semantics `ObjectKernel` has always had for every
441
+ * lifecycle hook (its `context.trigger` is a bare awaited loop that never
442
+ * catches). `LiteKernel` used the isolating {@link triggerHook} for all of
443
+ * them, so one hook name meant two opposite things depending on which
444
+ * kernel booted the same plugin code (#5170).
445
+ *
446
+ * `LiteKernel` now uses this dispatcher for all three BOOT-path hooks:
447
+ *
448
+ * - `kernel:ready` (#5170) — the only correct moment for a plugin to
449
+ * assert that the preconditions it declared were actually met (the
450
+ * registries are still filling during `init()`), so "declared but not
451
+ * deliverable ⇒ refuse to boot" gates live there. On LiteKernel, which
452
+ * is what vitest/serverless/edge run, they were downgraded to an error
453
+ * log while the process carried on serving traffic without the
454
+ * guarantee it claimed.
455
+ * - `kernel:bootstrapped` and `kernel:listening` (#5257) — the same
456
+ * argument one hook later. `kernel:listening` is where HTTP server
457
+ * plugins open their socket, so a swallowed failure there produced the
458
+ * worst shape available: a live process printing "✅ Bootstrap complete"
459
+ * with nothing listening. `kernel:bootstrapped` carries reconcile and
460
+ * audit passes whose silent failure is a quieter version of the same
461
+ * lie.
462
+ *
463
+ * Deliberately NOT applied to `kernel:shutdown`, which keeps
464
+ * {@link triggerHook}: on the teardown path a failing handler must not
465
+ * block the cleanup queued behind it. That is a per-hook judgement
466
+ * recorded at the dispatch site in `lite-kernel.ts`, not an inherited
467
+ * default — and it is the reason this dispatcher is chosen per hook rather
468
+ * than swapped in wholesale.
469
+ *
470
+ * @param name - Hook name
471
+ * @param args - Arguments to pass to handlers
472
+ */
473
+ async triggerHookOrThrow(name, ...args) {
474
+ const handlers = this.hooks.get(name) || [];
475
+ this.logger.debug(`Triggering hook: ${name}`, {
476
+ hook: name,
477
+ handlerCount: handlers.length
478
+ });
479
+ for (const handler of handlers) {
480
+ await handler(...args);
481
+ }
482
+ }
423
483
  /**
424
484
  * Get current kernel state
425
485
  */
@@ -452,6 +512,73 @@ var LEVEL_COLORS = {
452
512
  silent: ""
453
513
  };
454
514
  var RESET = "\x1B[0m";
515
+ function tokenizeFieldName(name) {
516
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/([a-zA-Z])([0-9])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word.toLowerCase());
517
+ }
518
+ function singularizeWord(word) {
519
+ if (/(?:ss|us|is)$/.test(word)) return word;
520
+ if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2);
521
+ if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1);
522
+ return word;
523
+ }
524
+ var CONCATENATED_SECRET_QUALIFIERS = /* @__PURE__ */ new Set([
525
+ "access",
526
+ "account",
527
+ "admin",
528
+ "api",
529
+ "app",
530
+ "auth",
531
+ "bearer",
532
+ "client",
533
+ "csrf",
534
+ "db",
535
+ "database",
536
+ "encryption",
537
+ "id",
538
+ "jwt",
539
+ "master",
540
+ "oauth",
541
+ "private",
542
+ "public",
543
+ "refresh",
544
+ "root",
545
+ "secret",
546
+ "service",
547
+ "session",
548
+ "shared",
549
+ "sign",
550
+ "signing",
551
+ "ssh",
552
+ "token",
553
+ "user",
554
+ "webhook",
555
+ "xsrf"
556
+ ]);
557
+ function isQualifiedConcatenation(word, redactWord) {
558
+ for (const base of [word, singularizeWord(word)]) {
559
+ if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;
560
+ if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;
561
+ }
562
+ return false;
563
+ }
564
+ function containsWordRun(words, run) {
565
+ for (let i = 0; i + run.length <= words.length; i++) {
566
+ if (run.every((word, offset) => words[i + offset] === word)) return true;
567
+ }
568
+ return false;
569
+ }
570
+ function fieldWordsMatchPattern(nameWords, patternWords) {
571
+ if (patternWords.length === 0 || nameWords.length === 0) return false;
572
+ if (patternWords.length > 1) {
573
+ const glued = patternWords.join("");
574
+ return containsWordRun(nameWords, patternWords) || nameWords.some((word) => word === glued || singularizeWord(word) === glued);
575
+ }
576
+ const redactWord = patternWords[0];
577
+ const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;
578
+ return nameWords.some(
579
+ (word) => word === redactWord || isCompound && singularizeWord(word) === redactWord || isQualifiedConcatenation(word, redactWord)
580
+ );
581
+ }
455
582
  function colorEnabled(stream) {
456
583
  if (typeof process !== "undefined") {
457
584
  const noColor = process.env?.NO_COLOR;
@@ -490,6 +617,7 @@ var ObjectLogger = class _ObjectLogger {
490
617
  rotation: config.rotation ?? { maxSize: "10m", maxFiles: 5 }
491
618
  };
492
619
  this.bindings = bindings;
620
+ this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);
493
621
  if (this.config.file && typeof process !== "undefined") {
494
622
  this.openFileStream(this.config.file);
495
623
  }
@@ -535,12 +663,25 @@ var ObjectLogger = class _ObjectLogger {
535
663
  isEnabled(level) {
536
664
  return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];
537
665
  }
666
+ /**
667
+ * Whether a meta field name names one of the configured secrets.
668
+ *
669
+ * Until #5573 this was `lower.includes(pattern)`, which redacted every
670
+ * field whose name merely *contained* a redact word — `keys`, `keyword`,
671
+ * `tokens`, `monkey`, `secretary` — and replaced its value with
672
+ * `***REDACTED***`, so the reader lost the fact AND was told a secret had
673
+ * been withheld. Matching is now on word boundaries: `key` matches
674
+ * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.
675
+ */
676
+ isRedactedFieldName(key) {
677
+ const nameWords = tokenizeFieldName(key);
678
+ return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));
679
+ }
538
680
  redactSensitive(obj) {
539
681
  if (!obj || typeof obj !== "object") return obj;
540
682
  const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
541
683
  for (const key in redacted) {
542
- const lower = key.toLowerCase();
543
- if (this.config.redact.some((p) => lower.includes(p.toLowerCase()))) {
684
+ if (this.isRedactedFieldName(key)) {
544
685
  redacted[key] = "***REDACTED***";
545
686
  } else if (typeof redacted[key] === "object" && redacted[key] !== null) {
546
687
  redacted[key] = this.redactSensitive(redacted[key]);
@@ -603,18 +744,44 @@ var ObjectLogger = class _ObjectLogger {
603
744
  this.write("warn", message, meta);
604
745
  }
605
746
  error(message, errorOrMeta, meta) {
606
- if (errorOrMeta instanceof Error) {
607
- this.write("error", message, meta, errorOrMeta);
608
- } else {
609
- this.write("error", message, errorOrMeta);
610
- }
747
+ this.writeErrorLike("error", message, errorOrMeta, meta);
611
748
  }
612
749
  fatal(message, errorOrMeta, meta) {
750
+ this.writeErrorLike("fatal", message, errorOrMeta, meta);
751
+ }
752
+ /**
753
+ * `error`/`fatal` dispatch — the two levels whose contract has an `Error`
754
+ * slot in front of `meta`.
755
+ *
756
+ * The `Logger` contract declares `error(message, error?: Error, meta?)`, and
757
+ * `ObjectLogger` additionally tolerates a **meta object** in the `error`
758
+ * slot because many in-repo call sites write `logger.error(msg, { … })`.
759
+ * That tolerance is fine; dropping a parameter the contract *declares* is
760
+ * not, and that is what the previous dispatch did:
761
+ *
762
+ * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);
763
+ * else this.write(level, message, errorOrMeta);
764
+ *
765
+ * With `error === undefined` the `else` branch passed `undefined` as the
766
+ * meta and **never read the third argument**, so every contract-shaped
767
+ * `logger.error(msg, undefined, { … })` call rendered a bare message with
768
+ * its diagnostics silently gone — ~15 such call sites across `metadata`,
769
+ * `metadata-protocol`, `client` and `core/security`, plus the connector
770
+ * reconcile seam that found this (#5575). The contract's two sibling
771
+ * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)
772
+ * both honour the slot, so the contract was right and this class was the
773
+ * outlier — declared ≠ enforced, Prime Directive #10.
774
+ *
775
+ * All three shapes are now honoured. When both slots carry meta, `meta`
776
+ * (the later, more specific argument) wins on a key collision.
777
+ */
778
+ writeErrorLike(level, message, errorOrMeta, meta) {
613
779
  if (errorOrMeta instanceof Error) {
614
- this.write("fatal", message, meta, errorOrMeta);
615
- } else {
616
- this.write("fatal", message, errorOrMeta);
780
+ this.write(level, message, meta, errorOrMeta);
781
+ return;
617
782
  }
783
+ const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : errorOrMeta ?? meta;
784
+ this.write(level, message, merged);
618
785
  }
619
786
  log(message, ...args) {
620
787
  this.info(message, args.length > 0 ? { args } : void 0);
@@ -1873,11 +2040,12 @@ var ObjectKernel = class {
1873
2040
  }
1874
2041
  this.state = "stopping";
1875
2042
  this.logger.info("Graceful shutdown started");
2043
+ const shutdownTimeoutError = new Error("Shutdown timeout exceeded");
1876
2044
  try {
1877
2045
  const shutdownPromise = this.performShutdown();
1878
2046
  const timeoutPromise = new Promise((_, reject) => {
1879
2047
  const t = setTimeout(() => {
1880
- reject(new Error("Shutdown timeout exceeded"));
2048
+ reject(shutdownTimeoutError);
1881
2049
  }, this.config.shutdownTimeout);
1882
2050
  if (t.unref) t.unref();
1883
2051
  });
@@ -1885,10 +2053,17 @@ var ObjectKernel = class {
1885
2053
  this.state = "stopped";
1886
2054
  this.logger.info("\u2705 Graceful shutdown complete");
1887
2055
  } catch (error) {
1888
- this.logger.error("Shutdown timed out \u2014 forcing exit", error);
1889
2056
  this.state = "stopped";
1890
- await this.logger.destroy();
1891
- process.exit(1);
2057
+ if (error === shutdownTimeoutError) {
2058
+ this.logger.error("Shutdown timed out \u2014 forcing exit", error);
2059
+ await this.logger.destroy();
2060
+ process.exit(1);
2061
+ } else {
2062
+ this.logger.error(
2063
+ "Shutdown finished with an unexpected teardown error \u2014 the kernel is stopped and the process is NOT being exited; some cleanup may not have run",
2064
+ error
2065
+ );
2066
+ }
1892
2067
  } finally {
1893
2068
  await this.logger.destroy();
1894
2069
  }
@@ -2078,8 +2253,48 @@ var ObjectKernel = class {
2078
2253
  }
2079
2254
  this.startedPlugins.clear();
2080
2255
  }
2256
+ /**
2257
+ * Dispatch `kernel:shutdown`, ISOLATING failures: a handler that throws is
2258
+ * logged and the remaining handlers still run (#5274).
2259
+ *
2260
+ * This is a per-hook judgement, deliberately NOT the bare awaited loop
2261
+ * `context.trigger` runs for every other hook — the boot-path hooks
2262
+ * (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) keep
2263
+ * propagating, because everything dispatched before "✅ Bootstrap complete"
2264
+ * is a precondition of that claim and swallowing a throw there only hides
2265
+ * the failure behind a process reporting success (#5170, #5257).
2266
+ *
2267
+ * On the teardown path there is no "refuse to proceed" left to buy. What is
2268
+ * queued behind a failing shutdown handler is the rest of the cleanup —
2269
+ * every other subscriber, then each plugin's `destroy()` in reverse order —
2270
+ * which is what flushes buffers, closes connections and releases locks. So
2271
+ * one bad handler must not amplify into leaked resources and unflushed
2272
+ * writes. Same reasoning, same wording, same `Hook handler failed:
2273
+ * kernel:shutdown` log line as `LiteKernel`'s dispatch site, which reaches
2274
+ * the shared isolating dispatcher `ObjectKernelBase.triggerHook` (#5257).
2275
+ *
2276
+ * `ObjectKernel` cannot call that dispatcher: it does not extend
2277
+ * `ObjectKernelBase` (only `LiteKernel` does) and owns its own `hooks` map,
2278
+ * so the semantics are mirrored here rather than shared. One hook name
2279
+ * meaning two opposite things across the two kernels is exactly the bug
2280
+ * #5170/#5257 closed, so the pin for this one lives on both sides too.
2281
+ */
2282
+ async triggerShutdownHookIsolating() {
2283
+ const handlers = this.hooks.get("kernel:shutdown") || [];
2284
+ this.logger.debug("Triggering hook: kernel:shutdown", {
2285
+ hook: "kernel:shutdown",
2286
+ handlerCount: handlers.length
2287
+ });
2288
+ for (const handler of handlers) {
2289
+ try {
2290
+ await handler();
2291
+ } catch (error) {
2292
+ this.logger.error("Hook handler failed: kernel:shutdown", error);
2293
+ }
2294
+ }
2295
+ }
2081
2296
  async performShutdown() {
2082
- await this.context.trigger("kernel:shutdown");
2297
+ await this.triggerShutdownHookIsolating();
2083
2298
  const orderedPlugins = Array.from(this.plugins.values()).reverse();
2084
2299
  for (const plugin of orderedPlugins) {
2085
2300
  if (plugin.destroy) {
@@ -2181,9 +2396,14 @@ var LiteKernel = class extends ObjectKernelBase {
2181
2396
  for (const plugin of orderedPlugins) {
2182
2397
  await this.runPluginStart(plugin);
2183
2398
  }
2184
- await this.triggerHook("kernel:ready");
2185
- await this.triggerHook("kernel:bootstrapped");
2186
- await this.triggerHook("kernel:listening");
2399
+ try {
2400
+ await this.triggerHookOrThrow("kernel:ready");
2401
+ await this.triggerHookOrThrow("kernel:bootstrapped");
2402
+ await this.triggerHookOrThrow("kernel:listening");
2403
+ } catch (error) {
2404
+ this.state = "stopped";
2405
+ throw error;
2406
+ }
2187
2407
  this.logger.info("\u2705 Bootstrap complete", {
2188
2408
  pluginCount: this.plugins.size
2189
2409
  });
@@ -2231,569 +2451,6 @@ var LiteKernel = class extends ObjectKernelBase {
2231
2451
  }
2232
2452
  };
2233
2453
 
2234
- // src/api-registry.ts
2235
- var import_api = require("@objectstack/spec/api");
2236
- var ApiRegistry = class {
2237
- constructor(logger, conflictResolution = "error", version = "1.0.0") {
2238
- this.apis = /* @__PURE__ */ new Map();
2239
- this.endpoints = /* @__PURE__ */ new Map();
2240
- this.routes = /* @__PURE__ */ new Map();
2241
- // Performance optimization: Auxiliary indices for O(1) lookups
2242
- this.apisByType = /* @__PURE__ */ new Map();
2243
- this.apisByTag = /* @__PURE__ */ new Map();
2244
- this.apisByStatus = /* @__PURE__ */ new Map();
2245
- this.logger = logger;
2246
- this.conflictResolution = conflictResolution;
2247
- this.version = version;
2248
- this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2249
- }
2250
- /**
2251
- * Register an API with its endpoints
2252
- *
2253
- * @param api - API registry entry
2254
- * @throws Error if API already registered or route conflicts detected
2255
- */
2256
- registerApi(api) {
2257
- if (this.apis.has(api.id)) {
2258
- throw new Error(`[ApiRegistry] API '${api.id}' already registered`);
2259
- }
2260
- const fullApi = import_api.ApiRegistryEntrySchema.parse(api);
2261
- for (const endpoint of fullApi.endpoints) {
2262
- this.validateEndpoint(endpoint, fullApi.id);
2263
- }
2264
- this.apis.set(fullApi.id, fullApi);
2265
- for (const endpoint of fullApi.endpoints) {
2266
- this.registerEndpoint(fullApi.id, endpoint);
2267
- }
2268
- this.updateIndices(fullApi);
2269
- this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2270
- this.logger.info(`API registered: ${fullApi.id}`, {
2271
- api: fullApi.id,
2272
- type: fullApi.type,
2273
- endpointCount: fullApi.endpoints.length
2274
- });
2275
- }
2276
- /**
2277
- * Unregister an API and all its endpoints
2278
- *
2279
- * @param apiId - API identifier
2280
- */
2281
- unregisterApi(apiId) {
2282
- const api = this.apis.get(apiId);
2283
- if (!api) {
2284
- throw new Error(`[ApiRegistry] API '${apiId}' not found`);
2285
- }
2286
- for (const endpoint of api.endpoints) {
2287
- this.unregisterEndpoint(apiId, endpoint.id);
2288
- }
2289
- this.removeFromIndices(api);
2290
- this.apis.delete(apiId);
2291
- this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2292
- this.logger.info(`API unregistered: ${apiId}`);
2293
- }
2294
- /**
2295
- * Register a single endpoint
2296
- *
2297
- * @param apiId - API identifier
2298
- * @param endpoint - Endpoint registration
2299
- * @throws Error if route conflict detected
2300
- */
2301
- registerEndpoint(apiId, endpoint) {
2302
- const endpointKey = `${apiId}:${endpoint.id}`;
2303
- if (this.endpoints.has(endpointKey)) {
2304
- throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' already registered for API '${apiId}'`);
2305
- }
2306
- this.endpoints.set(endpointKey, { api: apiId, endpoint });
2307
- if (endpoint.path) {
2308
- this.registerRoute(apiId, endpoint);
2309
- }
2310
- }
2311
- /**
2312
- * Unregister a single endpoint
2313
- *
2314
- * @param apiId - API identifier
2315
- * @param endpointId - Endpoint identifier
2316
- */
2317
- unregisterEndpoint(apiId, endpointId) {
2318
- const endpointKey = `${apiId}:${endpointId}`;
2319
- const entry = this.endpoints.get(endpointKey);
2320
- if (!entry) {
2321
- return;
2322
- }
2323
- if (entry.endpoint.path) {
2324
- const routeKey = this.getRouteKey(entry.endpoint);
2325
- this.routes.delete(routeKey);
2326
- }
2327
- this.endpoints.delete(endpointKey);
2328
- }
2329
- /**
2330
- * Register a route with conflict detection
2331
- *
2332
- * @param apiId - API identifier
2333
- * @param endpoint - Endpoint registration
2334
- * @throws Error if route conflict detected (based on strategy)
2335
- */
2336
- registerRoute(apiId, endpoint) {
2337
- const routeKey = this.getRouteKey(endpoint);
2338
- const priority = endpoint.priority ?? 100;
2339
- const existingRoute = this.routes.get(routeKey);
2340
- if (existingRoute) {
2341
- this.handleRouteConflict(routeKey, apiId, endpoint, existingRoute, priority);
2342
- return;
2343
- }
2344
- this.routes.set(routeKey, {
2345
- api: apiId,
2346
- endpointId: endpoint.id,
2347
- priority
2348
- });
2349
- }
2350
- /**
2351
- * Handle route conflict based on resolution strategy
2352
- *
2353
- * @param routeKey - Route key
2354
- * @param apiId - New API identifier
2355
- * @param endpoint - New endpoint
2356
- * @param existingRoute - Existing route registration
2357
- * @param newPriority - New endpoint priority
2358
- * @throws Error if strategy is 'error'
2359
- */
2360
- handleRouteConflict(routeKey, apiId, endpoint, existingRoute, newPriority) {
2361
- const strategy = this.conflictResolution;
2362
- switch (strategy) {
2363
- case "error":
2364
- throw new Error(
2365
- `[ApiRegistry] Route conflict detected: '${routeKey}' is already registered by API '${existingRoute.api}' endpoint '${existingRoute.endpointId}'`
2366
- );
2367
- case "priority":
2368
- if (newPriority > existingRoute.priority) {
2369
- this.logger.warn(
2370
- `Route conflict: replacing '${routeKey}' (priority ${existingRoute.priority} -> ${newPriority})`,
2371
- {
2372
- oldApi: existingRoute.api,
2373
- oldEndpoint: existingRoute.endpointId,
2374
- newApi: apiId,
2375
- newEndpoint: endpoint.id
2376
- }
2377
- );
2378
- this.routes.set(routeKey, {
2379
- api: apiId,
2380
- endpointId: endpoint.id,
2381
- priority: newPriority
2382
- });
2383
- } else {
2384
- this.logger.warn(
2385
- `Route conflict: keeping existing '${routeKey}' (priority ${existingRoute.priority} >= ${newPriority})`,
2386
- {
2387
- existingApi: existingRoute.api,
2388
- existingEndpoint: existingRoute.endpointId,
2389
- newApi: apiId,
2390
- newEndpoint: endpoint.id
2391
- }
2392
- );
2393
- }
2394
- break;
2395
- case "first-wins":
2396
- this.logger.warn(
2397
- `Route conflict: keeping first registered '${routeKey}'`,
2398
- {
2399
- existingApi: existingRoute.api,
2400
- newApi: apiId
2401
- }
2402
- );
2403
- break;
2404
- case "last-wins":
2405
- this.logger.warn(
2406
- `Route conflict: replacing with last registered '${routeKey}'`,
2407
- {
2408
- oldApi: existingRoute.api,
2409
- newApi: apiId
2410
- }
2411
- );
2412
- this.routes.set(routeKey, {
2413
- api: apiId,
2414
- endpointId: endpoint.id,
2415
- priority: newPriority
2416
- });
2417
- break;
2418
- default:
2419
- throw new Error(`[ApiRegistry] Unknown conflict resolution strategy: ${strategy}`);
2420
- }
2421
- }
2422
- /**
2423
- * Generate a unique route key for conflict detection
2424
- *
2425
- * NOTE: This implementation uses exact string matching for route conflict detection.
2426
- * It works well for static paths but has limitations with parameterized routes.
2427
- * For example, `/api/users/:id` and `/api/users/:userId` will NOT be detected as conflicts
2428
- * even though they are semantically identical parameterized patterns. Similarly,
2429
- * `/api/:resource/list` and `/api/:entity/list` would also not be detected as conflicting.
2430
- *
2431
- * For more advanced conflict detection (e.g., path-to-regexp pattern matching),
2432
- * consider integrating with your routing library's conflict detection mechanism.
2433
- *
2434
- * @param endpoint - Endpoint registration
2435
- * @returns Route key (e.g., "GET:/api/v1/customers/:id")
2436
- */
2437
- getRouteKey(endpoint) {
2438
- const method = endpoint.method || "ANY";
2439
- return `${method}:${endpoint.path}`;
2440
- }
2441
- /**
2442
- * Validate endpoint registration
2443
- *
2444
- * @param endpoint - Endpoint to validate
2445
- * @param apiId - API identifier (for error messages)
2446
- * @throws Error if endpoint is invalid
2447
- */
2448
- validateEndpoint(endpoint, apiId) {
2449
- if (!endpoint.id) {
2450
- throw new Error(`[ApiRegistry] Endpoint in API '${apiId}' missing 'id' field`);
2451
- }
2452
- if (!endpoint.path) {
2453
- throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' in API '${apiId}' missing 'path' field`);
2454
- }
2455
- }
2456
- /**
2457
- * Get an API by ID
2458
- *
2459
- * @param apiId - API identifier
2460
- * @returns API registry entry or undefined
2461
- */
2462
- getApi(apiId) {
2463
- return this.apis.get(apiId);
2464
- }
2465
- /**
2466
- * Get all registered APIs
2467
- *
2468
- * @returns Array of all APIs
2469
- */
2470
- getAllApis() {
2471
- return Array.from(this.apis.values());
2472
- }
2473
- /**
2474
- * Find APIs matching query criteria
2475
- *
2476
- * Performance optimized with auxiliary indices for O(1) lookups on type, tags, and status.
2477
- *
2478
- * @param query - Discovery query parameters
2479
- * @returns Matching APIs
2480
- */
2481
- findApis(query) {
2482
- let resultIds;
2483
- if (query.type) {
2484
- const typeIds = this.apisByType.get(query.type);
2485
- if (!typeIds || typeIds.size === 0) {
2486
- return { apis: [], total: 0, filters: query };
2487
- }
2488
- resultIds = new Set(typeIds);
2489
- }
2490
- if (query.status) {
2491
- const statusIds = this.apisByStatus.get(query.status);
2492
- if (!statusIds || statusIds.size === 0) {
2493
- return { apis: [], total: 0, filters: query };
2494
- }
2495
- if (resultIds) {
2496
- resultIds = new Set([...resultIds].filter((id) => statusIds.has(id)));
2497
- } else {
2498
- resultIds = new Set(statusIds);
2499
- }
2500
- if (resultIds.size === 0) {
2501
- return { apis: [], total: 0, filters: query };
2502
- }
2503
- }
2504
- if (query.tags && query.tags.length > 0) {
2505
- const tagMatches = /* @__PURE__ */ new Set();
2506
- for (const tag of query.tags) {
2507
- const tagIds = this.apisByTag.get(tag);
2508
- if (tagIds) {
2509
- tagIds.forEach((id) => tagMatches.add(id));
2510
- }
2511
- }
2512
- if (tagMatches.size === 0) {
2513
- return { apis: [], total: 0, filters: query };
2514
- }
2515
- if (resultIds) {
2516
- resultIds = new Set([...resultIds].filter((id) => tagMatches.has(id)));
2517
- } else {
2518
- resultIds = tagMatches;
2519
- }
2520
- if (resultIds.size === 0) {
2521
- return { apis: [], total: 0, filters: query };
2522
- }
2523
- }
2524
- let results;
2525
- if (resultIds) {
2526
- results = Array.from(resultIds).map((id) => this.apis.get(id)).filter((api) => api !== void 0);
2527
- } else {
2528
- results = Array.from(this.apis.values());
2529
- }
2530
- if (query.pluginSource) {
2531
- results = results.filter(
2532
- (api) => api.metadata?.pluginSource === query.pluginSource
2533
- );
2534
- }
2535
- if (query.version) {
2536
- results = results.filter((api) => api.version === query.version);
2537
- }
2538
- if (query.search) {
2539
- const searchLower = query.search.toLowerCase();
2540
- results = results.filter(
2541
- (api) => api.name.toLowerCase().includes(searchLower) || api.description && api.description.toLowerCase().includes(searchLower)
2542
- );
2543
- }
2544
- return {
2545
- apis: results,
2546
- total: results.length,
2547
- filters: query
2548
- };
2549
- }
2550
- /**
2551
- * Get endpoint by API ID and endpoint ID
2552
- *
2553
- * @param apiId - API identifier
2554
- * @param endpointId - Endpoint identifier
2555
- * @returns Endpoint registration or undefined
2556
- */
2557
- getEndpoint(apiId, endpointId) {
2558
- const key = `${apiId}:${endpointId}`;
2559
- return this.endpoints.get(key)?.endpoint;
2560
- }
2561
- /**
2562
- * Find endpoint by route (method + path)
2563
- *
2564
- * @param method - HTTP method
2565
- * @param path - URL path
2566
- * @returns Endpoint registration or undefined
2567
- */
2568
- findEndpointByRoute(method, path) {
2569
- const routeKey = `${method}:${path}`;
2570
- const route = this.routes.get(routeKey);
2571
- if (!route) {
2572
- return void 0;
2573
- }
2574
- const api = this.apis.get(route.api);
2575
- const endpoint = this.getEndpoint(route.api, route.endpointId);
2576
- if (!api || !endpoint) {
2577
- return void 0;
2578
- }
2579
- return { api, endpoint };
2580
- }
2581
- /**
2582
- * Get complete registry snapshot
2583
- *
2584
- * @returns Current registry state
2585
- */
2586
- getRegistry() {
2587
- const apis = Array.from(this.apis.values());
2588
- const byType = {};
2589
- for (const api of apis) {
2590
- if (!byType[api.type]) {
2591
- byType[api.type] = [];
2592
- }
2593
- byType[api.type].push(api);
2594
- }
2595
- const byStatus = {};
2596
- for (const api of apis) {
2597
- const status = api.metadata?.status || "active";
2598
- if (!byStatus[status]) {
2599
- byStatus[status] = [];
2600
- }
2601
- byStatus[status].push(api);
2602
- }
2603
- const totalEndpoints = apis.reduce(
2604
- (sum, api) => sum + api.endpoints.length,
2605
- 0
2606
- );
2607
- return {
2608
- version: this.version,
2609
- conflictResolution: this.conflictResolution,
2610
- apis,
2611
- totalApis: apis.length,
2612
- totalEndpoints,
2613
- byType,
2614
- byStatus,
2615
- updatedAt: this.updatedAt
2616
- };
2617
- }
2618
- /**
2619
- * Clear all registered APIs
2620
- *
2621
- * **⚠️ SAFETY WARNING:**
2622
- * This method clears all registered APIs and should be used with caution.
2623
- *
2624
- * **Usage Restrictions:**
2625
- * - In production environments (NODE_ENV=production), a `force: true` parameter is required
2626
- * - Primarily intended for testing and development hot-reload scenarios
2627
- *
2628
- * @param options - Clear options
2629
- * @param options.force - Force clear in production environment (default: false)
2630
- * @throws Error if called in production without force flag
2631
- *
2632
- * @example Safe usage in tests
2633
- * ```typescript
2634
- * beforeEach(() => {
2635
- * registry.clear(); // OK in test environment
2636
- * });
2637
- * ```
2638
- *
2639
- * @example Usage in production (requires explicit force)
2640
- * ```typescript
2641
- * // In production, explicit force is required
2642
- * registry.clear({ force: true });
2643
- * ```
2644
- */
2645
- clear(options = {}) {
2646
- const isProduction = this.isProductionEnvironment();
2647
- if (isProduction && !options.force) {
2648
- throw new Error(
2649
- "[ApiRegistry] Cannot clear registry in production environment without force flag. Use clear({ force: true }) if you really want to clear the registry."
2650
- );
2651
- }
2652
- this.apis.clear();
2653
- this.endpoints.clear();
2654
- this.routes.clear();
2655
- this.apisByType.clear();
2656
- this.apisByTag.clear();
2657
- this.apisByStatus.clear();
2658
- this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2659
- if (isProduction) {
2660
- this.logger.warn("API registry forcefully cleared in production", { force: options.force });
2661
- } else {
2662
- this.logger.info("API registry cleared");
2663
- }
2664
- }
2665
- /**
2666
- * Get registry statistics
2667
- *
2668
- * @returns Registry statistics
2669
- */
2670
- getStats() {
2671
- const apis = Array.from(this.apis.values());
2672
- const apisByType = {};
2673
- for (const api of apis) {
2674
- apisByType[api.type] = (apisByType[api.type] || 0) + 1;
2675
- }
2676
- const endpointsByApi = {};
2677
- for (const api of apis) {
2678
- endpointsByApi[api.id] = api.endpoints.length;
2679
- }
2680
- return {
2681
- totalApis: this.apis.size,
2682
- totalEndpoints: this.endpoints.size,
2683
- totalRoutes: this.routes.size,
2684
- apisByType,
2685
- endpointsByApi
2686
- };
2687
- }
2688
- /**
2689
- * Update auxiliary indices when an API is registered
2690
- *
2691
- * @param api - API entry to index
2692
- * @private
2693
- * @internal
2694
- */
2695
- updateIndices(api) {
2696
- this.ensureIndexSet(this.apisByType, api.type).add(api.id);
2697
- const status = api.metadata?.status || "active";
2698
- this.ensureIndexSet(this.apisByStatus, status).add(api.id);
2699
- const tags = api.metadata?.tags || [];
2700
- for (const tag of tags) {
2701
- this.ensureIndexSet(this.apisByTag, tag).add(api.id);
2702
- }
2703
- }
2704
- /**
2705
- * Remove API from auxiliary indices when unregistered
2706
- *
2707
- * @param api - API entry to remove from indices
2708
- * @private
2709
- * @internal
2710
- */
2711
- removeFromIndices(api) {
2712
- this.removeFromIndexSet(this.apisByType, api.type, api.id);
2713
- const status = api.metadata?.status || "active";
2714
- this.removeFromIndexSet(this.apisByStatus, status, api.id);
2715
- const tags = api.metadata?.tags || [];
2716
- for (const tag of tags) {
2717
- this.removeFromIndexSet(this.apisByTag, tag, api.id);
2718
- }
2719
- }
2720
- /**
2721
- * Helper to ensure an index set exists and return it
2722
- *
2723
- * @param map - Index map
2724
- * @param key - Index key
2725
- * @returns The Set for this key (created if needed)
2726
- * @private
2727
- * @internal
2728
- */
2729
- ensureIndexSet(map, key) {
2730
- let set = map.get(key);
2731
- if (!set) {
2732
- set = /* @__PURE__ */ new Set();
2733
- map.set(key, set);
2734
- }
2735
- return set;
2736
- }
2737
- /**
2738
- * Helper to remove an ID from an index set and clean up empty sets
2739
- *
2740
- * @param map - Index map
2741
- * @param key - Index key
2742
- * @param id - API ID to remove
2743
- * @private
2744
- * @internal
2745
- */
2746
- removeFromIndexSet(map, key, id) {
2747
- const set = map.get(key);
2748
- if (set) {
2749
- set.delete(id);
2750
- if (set.size === 0) {
2751
- map.delete(key);
2752
- }
2753
- }
2754
- }
2755
- /**
2756
- * Check if running in production environment
2757
- *
2758
- * @returns true if NODE_ENV is 'production'
2759
- * @private
2760
- * @internal
2761
- */
2762
- isProductionEnvironment() {
2763
- return getEnv("NODE_ENV") === "production";
2764
- }
2765
- };
2766
-
2767
- // src/api-registry-plugin.ts
2768
- function createApiRegistryPlugin(config = {}) {
2769
- const {
2770
- conflictResolution = "error",
2771
- version = "1.0.0"
2772
- } = config;
2773
- return {
2774
- name: "com.objectstack.core.api-registry",
2775
- /**
2776
- * Services init() registers on every path (ADR-0116, #4131) — lets the
2777
- * kernel name this plugin when a consumer requires one before it inits.
2778
- */
2779
- providesServices: ["api-registry"],
2780
- type: "standard",
2781
- version: "1.0.0",
2782
- init: async (ctx) => {
2783
- const registry = new ApiRegistry(
2784
- ctx.logger,
2785
- conflictResolution,
2786
- version
2787
- );
2788
- ctx.registerService("api-registry", registry);
2789
- ctx.logger.info("API Registry plugin initialized", {
2790
- conflictResolution,
2791
- version
2792
- });
2793
- }
2794
- };
2795
- }
2796
-
2797
2454
  // src/qa/index.ts
2798
2455
  var qa_exports = {};
2799
2456
  __export(qa_exports, {
@@ -5762,10 +5419,11 @@ var PluginHealthMonitor = class {
5762
5419
  const checks = [];
5763
5420
  try {
5764
5421
  if (config.checkMethod && typeof plugin[config.checkMethod] === "function") {
5765
- const checkResult = await Promise.race([
5422
+ const checkResult = await this.raceCheckTimeout(
5766
5423
  plugin[config.checkMethod](),
5767
- this.timeout(config.timeout, `Health check timeout after ${config.timeout}ms`)
5768
- ]);
5424
+ config.timeout,
5425
+ `Health check timeout after ${config.timeout}ms`
5426
+ );
5769
5427
  if (checkResult === false || checkResult && checkResult.status === "unhealthy") {
5770
5428
  status = "unhealthy";
5771
5429
  message = checkResult?.message || "Custom health check failed";
@@ -5921,12 +5579,39 @@ var PluginHealthMonitor = class {
5921
5579
  this.logger.info("Health monitor shutdown complete");
5922
5580
  }
5923
5581
  /**
5924
- * Timeout helper
5582
+ * Race a plugin's custom health check against its timeout guard, and
5583
+ * reclaim the guard the moment the race settles (#4875).
5584
+ *
5585
+ * Same shape, same reasoning as `ObjectKernel.raceStartupTimeout()` (#4813,
5586
+ * PR #4874): the guard used to be armed and then abandoned — when the check
5587
+ * won the race, its `setTimeout` stayed ref'd in the event loop for the full
5588
+ * `config.timeout`. Health checks are *periodic*, so unlike the kernel's
5589
+ * one-shot startup guards the orphans here accumulate: one per plugin per
5590
+ * round, each pinning the loop for `config.timeout`.
5591
+ *
5592
+ * Clearing on settle rather than `unref()`-ing at arm time is deliberate.
5593
+ * An unref'd guard also stops pinning the loop, but it stops being a guard
5594
+ * as well: if the check never settles and nothing else keeps the loop alive,
5595
+ * Node exits before the timer can fire and the timeout is never reported.
5596
+ * The guard has to stay ref'd exactly as long as the race is undecided,
5597
+ * which is what `clearTimeout` in a `finally` expresses.
5598
+ *
5599
+ * `check` is widened to `T | PromiseLike<T>` because `checkMethod` is called
5600
+ * dynamically off the plugin and may be synchronous; such a check wins the
5601
+ * race immediately and the guard is reclaimed on the same turn.
5925
5602
  */
5926
- timeout(ms, message) {
5927
- return new Promise((_, reject) => {
5928
- setTimeout(() => reject(new Error(message)), ms);
5603
+ async raceCheckTimeout(check, ms, message) {
5604
+ let guard;
5605
+ const timeoutPromise = new Promise((_, reject) => {
5606
+ guard = setTimeout(() => {
5607
+ reject(new Error(message));
5608
+ }, ms);
5929
5609
  });
5610
+ try {
5611
+ return await Promise.race([check, timeoutPromise]);
5612
+ } finally {
5613
+ clearTimeout(guard);
5614
+ }
5930
5615
  }
5931
5616
  };
5932
5617
 
@@ -6118,11 +5803,11 @@ var HotReloadManager = class {
6118
5803
  }
6119
5804
  if (plugin.destroy) {
6120
5805
  this.logger.debug("Destroying plugin", { plugin: pluginName });
6121
- const shutdownPromise = plugin.destroy();
6122
- const timeoutPromise = new Promise((_, reject) => {
6123
- setTimeout(() => reject(new Error("Shutdown timeout")), config.shutdownTimeout);
6124
- });
6125
- await Promise.race([shutdownPromise, timeoutPromise]);
5806
+ await this.raceShutdownTimeout(
5807
+ plugin.destroy(),
5808
+ config.shutdownTimeout,
5809
+ "Shutdown timeout"
5810
+ );
6126
5811
  this.logger.debug("Plugin destroyed successfully", { plugin: pluginName });
6127
5812
  }
6128
5813
  this.logger.debug("Plugin module would be reloaded here", { plugin: pluginName });
@@ -6149,6 +5834,41 @@ var HotReloadManager = class {
6149
5834
  return false;
6150
5835
  }
6151
5836
  }
5837
+ /**
5838
+ * Race a plugin's `destroy()` against its shutdown-timeout guard, and
5839
+ * reclaim the guard the moment the race settles (#4952).
5840
+ *
5841
+ * The guard used to be armed and then abandoned — byte-for-byte the leak
5842
+ * #4813 fixed in the kernel's startup guards (PR #4874) and #4875 fixed in
5843
+ * the periodic health checks (PR #4950): when `destroy()` won the race, its
5844
+ * `setTimeout` stayed ref'd in the event loop for the full
5845
+ * `shutdownTimeout`, so a hot reload that finished in milliseconds still
5846
+ * pinned the loop for the whole budget — once per reload, per plugin.
5847
+ *
5848
+ * Clearing on settle rather than `unref()`-ing at arm time is deliberate.
5849
+ * An unref'd guard also stops pinning the loop, but it stops being a guard
5850
+ * as well: if `destroy()` never settles and nothing else keeps the loop
5851
+ * alive, Node exits before the timer can fire and the timeout is never
5852
+ * reported. The guard has to stay ref'd exactly as long as the race is
5853
+ * undecided, which is what `clearTimeout` in a `finally` expresses.
5854
+ *
5855
+ * `shutdown` is widened to `T | PromiseLike<T>` because the Plugin contract
5856
+ * permits a synchronous `destroy()` (`Promise<void> | void`); such a hook
5857
+ * wins the race immediately and the guard is reclaimed on the same turn.
5858
+ */
5859
+ async raceShutdownTimeout(shutdown, timeout, message) {
5860
+ let guard;
5861
+ const timeoutPromise = new Promise((_, reject) => {
5862
+ guard = setTimeout(() => {
5863
+ reject(new Error(message));
5864
+ }, timeout);
5865
+ });
5866
+ try {
5867
+ return await Promise.race([shutdown, timeoutPromise]);
5868
+ } finally {
5869
+ clearTimeout(guard);
5870
+ }
5871
+ }
6152
5872
  /**
6153
5873
  * Schedule a reload with debouncing
6154
5874
  */
@@ -6608,7 +6328,6 @@ var NamespaceResolver = class {
6608
6328
  ANONYMOUS_DENY_MESSAGE,
6609
6329
  ANONYMOUS_DENY_STATUS,
6610
6330
  API_KEY_PREFIX,
6611
- ApiRegistry,
6612
6331
  CORE_FALLBACK_FACTORIES,
6613
6332
  DependencyResolver,
6614
6333
  HotReloadManager,
@@ -6644,7 +6363,6 @@ var NamespaceResolver = class {
6644
6363
  calendarPartsInTz,
6645
6364
  calendarPartsInTzOrUtc,
6646
6365
  counterSignPayload,
6647
- createApiRegistryPlugin,
6648
6366
  createLogger,
6649
6367
  createMemoryCache,
6650
6368
  createMemoryI18n,