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

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.js CHANGED
@@ -280,7 +280,21 @@ var ObjectKernelBase = class {
280
280
  }
281
281
  }
282
282
  /**
283
- * Trigger a hook with all registered handlers
283
+ * Trigger a hook with all registered handlers, ISOLATING failures: a
284
+ * handler that throws is logged and the remaining handlers still run.
285
+ *
286
+ * Use this for hooks where one subscriber's failure must not deny the
287
+ * others their turn — notification-style hooks, and `kernel:shutdown`,
288
+ * where the handlers still queued behind the failing one are the cleanup
289
+ * that flushes buffers and releases resources (#5257).
290
+ *
291
+ * It is the WRONG dispatcher for anything on the BOOT path. Every hook
292
+ * dispatched before "✅ Bootstrap complete" is a precondition of that
293
+ * claim, so swallowing a throw there does not rescue the boot — it only
294
+ * hides the failure behind a process that reports success. Those hooks
295
+ * (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) use
296
+ * {@link triggerHookOrThrow} (#5170, #5257).
297
+ *
284
298
  * @param name - Hook name
285
299
  * @param args - Arguments to pass to handlers
286
300
  */
@@ -298,6 +312,54 @@ var ObjectKernelBase = class {
298
312
  }
299
313
  }
300
314
  }
315
+ /**
316
+ * Trigger a hook with all registered handlers, PROPAGATING the first
317
+ * failure: the remaining handlers do not run and the original error
318
+ * reaches the caller unwrapped.
319
+ *
320
+ * This is the dispatch semantics `ObjectKernel` has always had for every
321
+ * lifecycle hook (its `context.trigger` is a bare awaited loop that never
322
+ * catches). `LiteKernel` used the isolating {@link triggerHook} for all of
323
+ * them, so one hook name meant two opposite things depending on which
324
+ * kernel booted the same plugin code (#5170).
325
+ *
326
+ * `LiteKernel` now uses this dispatcher for all three BOOT-path hooks:
327
+ *
328
+ * - `kernel:ready` (#5170) — the only correct moment for a plugin to
329
+ * assert that the preconditions it declared were actually met (the
330
+ * registries are still filling during `init()`), so "declared but not
331
+ * deliverable ⇒ refuse to boot" gates live there. On LiteKernel, which
332
+ * is what vitest/serverless/edge run, they were downgraded to an error
333
+ * log while the process carried on serving traffic without the
334
+ * guarantee it claimed.
335
+ * - `kernel:bootstrapped` and `kernel:listening` (#5257) — the same
336
+ * argument one hook later. `kernel:listening` is where HTTP server
337
+ * plugins open their socket, so a swallowed failure there produced the
338
+ * worst shape available: a live process printing "✅ Bootstrap complete"
339
+ * with nothing listening. `kernel:bootstrapped` carries reconcile and
340
+ * audit passes whose silent failure is a quieter version of the same
341
+ * lie.
342
+ *
343
+ * Deliberately NOT applied to `kernel:shutdown`, which keeps
344
+ * {@link triggerHook}: on the teardown path a failing handler must not
345
+ * block the cleanup queued behind it. That is a per-hook judgement
346
+ * recorded at the dispatch site in `lite-kernel.ts`, not an inherited
347
+ * default — and it is the reason this dispatcher is chosen per hook rather
348
+ * than swapped in wholesale.
349
+ *
350
+ * @param name - Hook name
351
+ * @param args - Arguments to pass to handlers
352
+ */
353
+ async triggerHookOrThrow(name, ...args) {
354
+ const handlers = this.hooks.get(name) || [];
355
+ this.logger.debug(`Triggering hook: ${name}`, {
356
+ hook: name,
357
+ handlerCount: handlers.length
358
+ });
359
+ for (const handler of handlers) {
360
+ await handler(...args);
361
+ }
362
+ }
301
363
  /**
302
364
  * Get current kernel state
303
365
  */
@@ -330,6 +392,73 @@ var LEVEL_COLORS = {
330
392
  silent: ""
331
393
  };
332
394
  var RESET = "\x1B[0m";
395
+ function tokenizeFieldName(name) {
396
+ 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());
397
+ }
398
+ function singularizeWord(word) {
399
+ if (/(?:ss|us|is)$/.test(word)) return word;
400
+ if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2);
401
+ if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1);
402
+ return word;
403
+ }
404
+ var CONCATENATED_SECRET_QUALIFIERS = /* @__PURE__ */ new Set([
405
+ "access",
406
+ "account",
407
+ "admin",
408
+ "api",
409
+ "app",
410
+ "auth",
411
+ "bearer",
412
+ "client",
413
+ "csrf",
414
+ "db",
415
+ "database",
416
+ "encryption",
417
+ "id",
418
+ "jwt",
419
+ "master",
420
+ "oauth",
421
+ "private",
422
+ "public",
423
+ "refresh",
424
+ "root",
425
+ "secret",
426
+ "service",
427
+ "session",
428
+ "shared",
429
+ "sign",
430
+ "signing",
431
+ "ssh",
432
+ "token",
433
+ "user",
434
+ "webhook",
435
+ "xsrf"
436
+ ]);
437
+ function isQualifiedConcatenation(word, redactWord) {
438
+ for (const base of [word, singularizeWord(word)]) {
439
+ if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;
440
+ if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;
441
+ }
442
+ return false;
443
+ }
444
+ function containsWordRun(words, run) {
445
+ for (let i = 0; i + run.length <= words.length; i++) {
446
+ if (run.every((word, offset) => words[i + offset] === word)) return true;
447
+ }
448
+ return false;
449
+ }
450
+ function fieldWordsMatchPattern(nameWords, patternWords) {
451
+ if (patternWords.length === 0 || nameWords.length === 0) return false;
452
+ if (patternWords.length > 1) {
453
+ const glued = patternWords.join("");
454
+ return containsWordRun(nameWords, patternWords) || nameWords.some((word) => word === glued || singularizeWord(word) === glued);
455
+ }
456
+ const redactWord = patternWords[0];
457
+ const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;
458
+ return nameWords.some(
459
+ (word) => word === redactWord || isCompound && singularizeWord(word) === redactWord || isQualifiedConcatenation(word, redactWord)
460
+ );
461
+ }
333
462
  function colorEnabled(stream) {
334
463
  if (typeof process !== "undefined") {
335
464
  const noColor = process.env?.NO_COLOR;
@@ -368,6 +497,7 @@ var ObjectLogger = class _ObjectLogger {
368
497
  rotation: config.rotation ?? { maxSize: "10m", maxFiles: 5 }
369
498
  };
370
499
  this.bindings = bindings;
500
+ this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);
371
501
  if (this.config.file && typeof process !== "undefined") {
372
502
  this.openFileStream(this.config.file);
373
503
  }
@@ -413,12 +543,25 @@ var ObjectLogger = class _ObjectLogger {
413
543
  isEnabled(level) {
414
544
  return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];
415
545
  }
546
+ /**
547
+ * Whether a meta field name names one of the configured secrets.
548
+ *
549
+ * Until #5573 this was `lower.includes(pattern)`, which redacted every
550
+ * field whose name merely *contained* a redact word — `keys`, `keyword`,
551
+ * `tokens`, `monkey`, `secretary` — and replaced its value with
552
+ * `***REDACTED***`, so the reader lost the fact AND was told a secret had
553
+ * been withheld. Matching is now on word boundaries: `key` matches
554
+ * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.
555
+ */
556
+ isRedactedFieldName(key) {
557
+ const nameWords = tokenizeFieldName(key);
558
+ return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));
559
+ }
416
560
  redactSensitive(obj) {
417
561
  if (!obj || typeof obj !== "object") return obj;
418
562
  const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
419
563
  for (const key in redacted) {
420
- const lower = key.toLowerCase();
421
- if (this.config.redact.some((p) => lower.includes(p.toLowerCase()))) {
564
+ if (this.isRedactedFieldName(key)) {
422
565
  redacted[key] = "***REDACTED***";
423
566
  } else if (typeof redacted[key] === "object" && redacted[key] !== null) {
424
567
  redacted[key] = this.redactSensitive(redacted[key]);
@@ -481,18 +624,44 @@ var ObjectLogger = class _ObjectLogger {
481
624
  this.write("warn", message, meta);
482
625
  }
483
626
  error(message, errorOrMeta, meta) {
484
- if (errorOrMeta instanceof Error) {
485
- this.write("error", message, meta, errorOrMeta);
486
- } else {
487
- this.write("error", message, errorOrMeta);
488
- }
627
+ this.writeErrorLike("error", message, errorOrMeta, meta);
489
628
  }
490
629
  fatal(message, errorOrMeta, meta) {
630
+ this.writeErrorLike("fatal", message, errorOrMeta, meta);
631
+ }
632
+ /**
633
+ * `error`/`fatal` dispatch — the two levels whose contract has an `Error`
634
+ * slot in front of `meta`.
635
+ *
636
+ * The `Logger` contract declares `error(message, error?: Error, meta?)`, and
637
+ * `ObjectLogger` additionally tolerates a **meta object** in the `error`
638
+ * slot because many in-repo call sites write `logger.error(msg, { … })`.
639
+ * That tolerance is fine; dropping a parameter the contract *declares* is
640
+ * not, and that is what the previous dispatch did:
641
+ *
642
+ * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);
643
+ * else this.write(level, message, errorOrMeta);
644
+ *
645
+ * With `error === undefined` the `else` branch passed `undefined` as the
646
+ * meta and **never read the third argument**, so every contract-shaped
647
+ * `logger.error(msg, undefined, { … })` call rendered a bare message with
648
+ * its diagnostics silently gone — ~15 such call sites across `metadata`,
649
+ * `metadata-protocol`, `client` and `core/security`, plus the connector
650
+ * reconcile seam that found this (#5575). The contract's two sibling
651
+ * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)
652
+ * both honour the slot, so the contract was right and this class was the
653
+ * outlier — declared ≠ enforced, Prime Directive #10.
654
+ *
655
+ * All three shapes are now honoured. When both slots carry meta, `meta`
656
+ * (the later, more specific argument) wins on a key collision.
657
+ */
658
+ writeErrorLike(level, message, errorOrMeta, meta) {
491
659
  if (errorOrMeta instanceof Error) {
492
- this.write("fatal", message, meta, errorOrMeta);
493
- } else {
494
- this.write("fatal", message, errorOrMeta);
660
+ this.write(level, message, meta, errorOrMeta);
661
+ return;
495
662
  }
663
+ const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : errorOrMeta ?? meta;
664
+ this.write(level, message, merged);
496
665
  }
497
666
  log(message, ...args) {
498
667
  this.info(message, args.length > 0 ? { args } : void 0);
@@ -1757,11 +1926,12 @@ var ObjectKernel = class {
1757
1926
  }
1758
1927
  this.state = "stopping";
1759
1928
  this.logger.info("Graceful shutdown started");
1929
+ const shutdownTimeoutError = new Error("Shutdown timeout exceeded");
1760
1930
  try {
1761
1931
  const shutdownPromise = this.performShutdown();
1762
1932
  const timeoutPromise = new Promise((_, reject) => {
1763
1933
  const t = setTimeout(() => {
1764
- reject(new Error("Shutdown timeout exceeded"));
1934
+ reject(shutdownTimeoutError);
1765
1935
  }, this.config.shutdownTimeout);
1766
1936
  if (t.unref) t.unref();
1767
1937
  });
@@ -1769,10 +1939,17 @@ var ObjectKernel = class {
1769
1939
  this.state = "stopped";
1770
1940
  this.logger.info("\u2705 Graceful shutdown complete");
1771
1941
  } catch (error) {
1772
- this.logger.error("Shutdown timed out \u2014 forcing exit", error);
1773
1942
  this.state = "stopped";
1774
- await this.logger.destroy();
1775
- process.exit(1);
1943
+ if (error === shutdownTimeoutError) {
1944
+ this.logger.error("Shutdown timed out \u2014 forcing exit", error);
1945
+ await this.logger.destroy();
1946
+ process.exit(1);
1947
+ } else {
1948
+ this.logger.error(
1949
+ "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",
1950
+ error
1951
+ );
1952
+ }
1776
1953
  } finally {
1777
1954
  await this.logger.destroy();
1778
1955
  }
@@ -1962,8 +2139,48 @@ var ObjectKernel = class {
1962
2139
  }
1963
2140
  this.startedPlugins.clear();
1964
2141
  }
2142
+ /**
2143
+ * Dispatch `kernel:shutdown`, ISOLATING failures: a handler that throws is
2144
+ * logged and the remaining handlers still run (#5274).
2145
+ *
2146
+ * This is a per-hook judgement, deliberately NOT the bare awaited loop
2147
+ * `context.trigger` runs for every other hook — the boot-path hooks
2148
+ * (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) keep
2149
+ * propagating, because everything dispatched before "✅ Bootstrap complete"
2150
+ * is a precondition of that claim and swallowing a throw there only hides
2151
+ * the failure behind a process reporting success (#5170, #5257).
2152
+ *
2153
+ * On the teardown path there is no "refuse to proceed" left to buy. What is
2154
+ * queued behind a failing shutdown handler is the rest of the cleanup —
2155
+ * every other subscriber, then each plugin's `destroy()` in reverse order —
2156
+ * which is what flushes buffers, closes connections and releases locks. So
2157
+ * one bad handler must not amplify into leaked resources and unflushed
2158
+ * writes. Same reasoning, same wording, same `Hook handler failed:
2159
+ * kernel:shutdown` log line as `LiteKernel`'s dispatch site, which reaches
2160
+ * the shared isolating dispatcher `ObjectKernelBase.triggerHook` (#5257).
2161
+ *
2162
+ * `ObjectKernel` cannot call that dispatcher: it does not extend
2163
+ * `ObjectKernelBase` (only `LiteKernel` does) and owns its own `hooks` map,
2164
+ * so the semantics are mirrored here rather than shared. One hook name
2165
+ * meaning two opposite things across the two kernels is exactly the bug
2166
+ * #5170/#5257 closed, so the pin for this one lives on both sides too.
2167
+ */
2168
+ async triggerShutdownHookIsolating() {
2169
+ const handlers = this.hooks.get("kernel:shutdown") || [];
2170
+ this.logger.debug("Triggering hook: kernel:shutdown", {
2171
+ hook: "kernel:shutdown",
2172
+ handlerCount: handlers.length
2173
+ });
2174
+ for (const handler of handlers) {
2175
+ try {
2176
+ await handler();
2177
+ } catch (error) {
2178
+ this.logger.error("Hook handler failed: kernel:shutdown", error);
2179
+ }
2180
+ }
2181
+ }
1965
2182
  async performShutdown() {
1966
- await this.context.trigger("kernel:shutdown");
2183
+ await this.triggerShutdownHookIsolating();
1967
2184
  const orderedPlugins = Array.from(this.plugins.values()).reverse();
1968
2185
  for (const plugin of orderedPlugins) {
1969
2186
  if (plugin.destroy) {
@@ -2065,9 +2282,14 @@ var LiteKernel = class extends ObjectKernelBase {
2065
2282
  for (const plugin of orderedPlugins) {
2066
2283
  await this.runPluginStart(plugin);
2067
2284
  }
2068
- await this.triggerHook("kernel:ready");
2069
- await this.triggerHook("kernel:bootstrapped");
2070
- await this.triggerHook("kernel:listening");
2285
+ try {
2286
+ await this.triggerHookOrThrow("kernel:ready");
2287
+ await this.triggerHookOrThrow("kernel:bootstrapped");
2288
+ await this.triggerHookOrThrow("kernel:listening");
2289
+ } catch (error) {
2290
+ this.state = "stopped";
2291
+ throw error;
2292
+ }
2071
2293
  this.logger.info("\u2705 Bootstrap complete", {
2072
2294
  pluginCount: this.plugins.size
2073
2295
  });
@@ -2115,569 +2337,6 @@ var LiteKernel = class extends ObjectKernelBase {
2115
2337
  }
2116
2338
  };
2117
2339
 
2118
- // src/api-registry.ts
2119
- import { ApiRegistryEntrySchema } from "@objectstack/spec/api";
2120
- var ApiRegistry = class {
2121
- constructor(logger, conflictResolution = "error", version = "1.0.0") {
2122
- this.apis = /* @__PURE__ */ new Map();
2123
- this.endpoints = /* @__PURE__ */ new Map();
2124
- this.routes = /* @__PURE__ */ new Map();
2125
- // Performance optimization: Auxiliary indices for O(1) lookups
2126
- this.apisByType = /* @__PURE__ */ new Map();
2127
- this.apisByTag = /* @__PURE__ */ new Map();
2128
- this.apisByStatus = /* @__PURE__ */ new Map();
2129
- this.logger = logger;
2130
- this.conflictResolution = conflictResolution;
2131
- this.version = version;
2132
- this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2133
- }
2134
- /**
2135
- * Register an API with its endpoints
2136
- *
2137
- * @param api - API registry entry
2138
- * @throws Error if API already registered or route conflicts detected
2139
- */
2140
- registerApi(api) {
2141
- if (this.apis.has(api.id)) {
2142
- throw new Error(`[ApiRegistry] API '${api.id}' already registered`);
2143
- }
2144
- const fullApi = ApiRegistryEntrySchema.parse(api);
2145
- for (const endpoint of fullApi.endpoints) {
2146
- this.validateEndpoint(endpoint, fullApi.id);
2147
- }
2148
- this.apis.set(fullApi.id, fullApi);
2149
- for (const endpoint of fullApi.endpoints) {
2150
- this.registerEndpoint(fullApi.id, endpoint);
2151
- }
2152
- this.updateIndices(fullApi);
2153
- this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2154
- this.logger.info(`API registered: ${fullApi.id}`, {
2155
- api: fullApi.id,
2156
- type: fullApi.type,
2157
- endpointCount: fullApi.endpoints.length
2158
- });
2159
- }
2160
- /**
2161
- * Unregister an API and all its endpoints
2162
- *
2163
- * @param apiId - API identifier
2164
- */
2165
- unregisterApi(apiId) {
2166
- const api = this.apis.get(apiId);
2167
- if (!api) {
2168
- throw new Error(`[ApiRegistry] API '${apiId}' not found`);
2169
- }
2170
- for (const endpoint of api.endpoints) {
2171
- this.unregisterEndpoint(apiId, endpoint.id);
2172
- }
2173
- this.removeFromIndices(api);
2174
- this.apis.delete(apiId);
2175
- this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2176
- this.logger.info(`API unregistered: ${apiId}`);
2177
- }
2178
- /**
2179
- * Register a single endpoint
2180
- *
2181
- * @param apiId - API identifier
2182
- * @param endpoint - Endpoint registration
2183
- * @throws Error if route conflict detected
2184
- */
2185
- registerEndpoint(apiId, endpoint) {
2186
- const endpointKey = `${apiId}:${endpoint.id}`;
2187
- if (this.endpoints.has(endpointKey)) {
2188
- throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' already registered for API '${apiId}'`);
2189
- }
2190
- this.endpoints.set(endpointKey, { api: apiId, endpoint });
2191
- if (endpoint.path) {
2192
- this.registerRoute(apiId, endpoint);
2193
- }
2194
- }
2195
- /**
2196
- * Unregister a single endpoint
2197
- *
2198
- * @param apiId - API identifier
2199
- * @param endpointId - Endpoint identifier
2200
- */
2201
- unregisterEndpoint(apiId, endpointId) {
2202
- const endpointKey = `${apiId}:${endpointId}`;
2203
- const entry = this.endpoints.get(endpointKey);
2204
- if (!entry) {
2205
- return;
2206
- }
2207
- if (entry.endpoint.path) {
2208
- const routeKey = this.getRouteKey(entry.endpoint);
2209
- this.routes.delete(routeKey);
2210
- }
2211
- this.endpoints.delete(endpointKey);
2212
- }
2213
- /**
2214
- * Register a route with conflict detection
2215
- *
2216
- * @param apiId - API identifier
2217
- * @param endpoint - Endpoint registration
2218
- * @throws Error if route conflict detected (based on strategy)
2219
- */
2220
- registerRoute(apiId, endpoint) {
2221
- const routeKey = this.getRouteKey(endpoint);
2222
- const priority = endpoint.priority ?? 100;
2223
- const existingRoute = this.routes.get(routeKey);
2224
- if (existingRoute) {
2225
- this.handleRouteConflict(routeKey, apiId, endpoint, existingRoute, priority);
2226
- return;
2227
- }
2228
- this.routes.set(routeKey, {
2229
- api: apiId,
2230
- endpointId: endpoint.id,
2231
- priority
2232
- });
2233
- }
2234
- /**
2235
- * Handle route conflict based on resolution strategy
2236
- *
2237
- * @param routeKey - Route key
2238
- * @param apiId - New API identifier
2239
- * @param endpoint - New endpoint
2240
- * @param existingRoute - Existing route registration
2241
- * @param newPriority - New endpoint priority
2242
- * @throws Error if strategy is 'error'
2243
- */
2244
- handleRouteConflict(routeKey, apiId, endpoint, existingRoute, newPriority) {
2245
- const strategy = this.conflictResolution;
2246
- switch (strategy) {
2247
- case "error":
2248
- throw new Error(
2249
- `[ApiRegistry] Route conflict detected: '${routeKey}' is already registered by API '${existingRoute.api}' endpoint '${existingRoute.endpointId}'`
2250
- );
2251
- case "priority":
2252
- if (newPriority > existingRoute.priority) {
2253
- this.logger.warn(
2254
- `Route conflict: replacing '${routeKey}' (priority ${existingRoute.priority} -> ${newPriority})`,
2255
- {
2256
- oldApi: existingRoute.api,
2257
- oldEndpoint: existingRoute.endpointId,
2258
- newApi: apiId,
2259
- newEndpoint: endpoint.id
2260
- }
2261
- );
2262
- this.routes.set(routeKey, {
2263
- api: apiId,
2264
- endpointId: endpoint.id,
2265
- priority: newPriority
2266
- });
2267
- } else {
2268
- this.logger.warn(
2269
- `Route conflict: keeping existing '${routeKey}' (priority ${existingRoute.priority} >= ${newPriority})`,
2270
- {
2271
- existingApi: existingRoute.api,
2272
- existingEndpoint: existingRoute.endpointId,
2273
- newApi: apiId,
2274
- newEndpoint: endpoint.id
2275
- }
2276
- );
2277
- }
2278
- break;
2279
- case "first-wins":
2280
- this.logger.warn(
2281
- `Route conflict: keeping first registered '${routeKey}'`,
2282
- {
2283
- existingApi: existingRoute.api,
2284
- newApi: apiId
2285
- }
2286
- );
2287
- break;
2288
- case "last-wins":
2289
- this.logger.warn(
2290
- `Route conflict: replacing with last registered '${routeKey}'`,
2291
- {
2292
- oldApi: existingRoute.api,
2293
- newApi: apiId
2294
- }
2295
- );
2296
- this.routes.set(routeKey, {
2297
- api: apiId,
2298
- endpointId: endpoint.id,
2299
- priority: newPriority
2300
- });
2301
- break;
2302
- default:
2303
- throw new Error(`[ApiRegistry] Unknown conflict resolution strategy: ${strategy}`);
2304
- }
2305
- }
2306
- /**
2307
- * Generate a unique route key for conflict detection
2308
- *
2309
- * NOTE: This implementation uses exact string matching for route conflict detection.
2310
- * It works well for static paths but has limitations with parameterized routes.
2311
- * For example, `/api/users/:id` and `/api/users/:userId` will NOT be detected as conflicts
2312
- * even though they are semantically identical parameterized patterns. Similarly,
2313
- * `/api/:resource/list` and `/api/:entity/list` would also not be detected as conflicting.
2314
- *
2315
- * For more advanced conflict detection (e.g., path-to-regexp pattern matching),
2316
- * consider integrating with your routing library's conflict detection mechanism.
2317
- *
2318
- * @param endpoint - Endpoint registration
2319
- * @returns Route key (e.g., "GET:/api/v1/customers/:id")
2320
- */
2321
- getRouteKey(endpoint) {
2322
- const method = endpoint.method || "ANY";
2323
- return `${method}:${endpoint.path}`;
2324
- }
2325
- /**
2326
- * Validate endpoint registration
2327
- *
2328
- * @param endpoint - Endpoint to validate
2329
- * @param apiId - API identifier (for error messages)
2330
- * @throws Error if endpoint is invalid
2331
- */
2332
- validateEndpoint(endpoint, apiId) {
2333
- if (!endpoint.id) {
2334
- throw new Error(`[ApiRegistry] Endpoint in API '${apiId}' missing 'id' field`);
2335
- }
2336
- if (!endpoint.path) {
2337
- throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' in API '${apiId}' missing 'path' field`);
2338
- }
2339
- }
2340
- /**
2341
- * Get an API by ID
2342
- *
2343
- * @param apiId - API identifier
2344
- * @returns API registry entry or undefined
2345
- */
2346
- getApi(apiId) {
2347
- return this.apis.get(apiId);
2348
- }
2349
- /**
2350
- * Get all registered APIs
2351
- *
2352
- * @returns Array of all APIs
2353
- */
2354
- getAllApis() {
2355
- return Array.from(this.apis.values());
2356
- }
2357
- /**
2358
- * Find APIs matching query criteria
2359
- *
2360
- * Performance optimized with auxiliary indices for O(1) lookups on type, tags, and status.
2361
- *
2362
- * @param query - Discovery query parameters
2363
- * @returns Matching APIs
2364
- */
2365
- findApis(query) {
2366
- let resultIds;
2367
- if (query.type) {
2368
- const typeIds = this.apisByType.get(query.type);
2369
- if (!typeIds || typeIds.size === 0) {
2370
- return { apis: [], total: 0, filters: query };
2371
- }
2372
- resultIds = new Set(typeIds);
2373
- }
2374
- if (query.status) {
2375
- const statusIds = this.apisByStatus.get(query.status);
2376
- if (!statusIds || statusIds.size === 0) {
2377
- return { apis: [], total: 0, filters: query };
2378
- }
2379
- if (resultIds) {
2380
- resultIds = new Set([...resultIds].filter((id) => statusIds.has(id)));
2381
- } else {
2382
- resultIds = new Set(statusIds);
2383
- }
2384
- if (resultIds.size === 0) {
2385
- return { apis: [], total: 0, filters: query };
2386
- }
2387
- }
2388
- if (query.tags && query.tags.length > 0) {
2389
- const tagMatches = /* @__PURE__ */ new Set();
2390
- for (const tag of query.tags) {
2391
- const tagIds = this.apisByTag.get(tag);
2392
- if (tagIds) {
2393
- tagIds.forEach((id) => tagMatches.add(id));
2394
- }
2395
- }
2396
- if (tagMatches.size === 0) {
2397
- return { apis: [], total: 0, filters: query };
2398
- }
2399
- if (resultIds) {
2400
- resultIds = new Set([...resultIds].filter((id) => tagMatches.has(id)));
2401
- } else {
2402
- resultIds = tagMatches;
2403
- }
2404
- if (resultIds.size === 0) {
2405
- return { apis: [], total: 0, filters: query };
2406
- }
2407
- }
2408
- let results;
2409
- if (resultIds) {
2410
- results = Array.from(resultIds).map((id) => this.apis.get(id)).filter((api) => api !== void 0);
2411
- } else {
2412
- results = Array.from(this.apis.values());
2413
- }
2414
- if (query.pluginSource) {
2415
- results = results.filter(
2416
- (api) => api.metadata?.pluginSource === query.pluginSource
2417
- );
2418
- }
2419
- if (query.version) {
2420
- results = results.filter((api) => api.version === query.version);
2421
- }
2422
- if (query.search) {
2423
- const searchLower = query.search.toLowerCase();
2424
- results = results.filter(
2425
- (api) => api.name.toLowerCase().includes(searchLower) || api.description && api.description.toLowerCase().includes(searchLower)
2426
- );
2427
- }
2428
- return {
2429
- apis: results,
2430
- total: results.length,
2431
- filters: query
2432
- };
2433
- }
2434
- /**
2435
- * Get endpoint by API ID and endpoint ID
2436
- *
2437
- * @param apiId - API identifier
2438
- * @param endpointId - Endpoint identifier
2439
- * @returns Endpoint registration or undefined
2440
- */
2441
- getEndpoint(apiId, endpointId) {
2442
- const key = `${apiId}:${endpointId}`;
2443
- return this.endpoints.get(key)?.endpoint;
2444
- }
2445
- /**
2446
- * Find endpoint by route (method + path)
2447
- *
2448
- * @param method - HTTP method
2449
- * @param path - URL path
2450
- * @returns Endpoint registration or undefined
2451
- */
2452
- findEndpointByRoute(method, path) {
2453
- const routeKey = `${method}:${path}`;
2454
- const route = this.routes.get(routeKey);
2455
- if (!route) {
2456
- return void 0;
2457
- }
2458
- const api = this.apis.get(route.api);
2459
- const endpoint = this.getEndpoint(route.api, route.endpointId);
2460
- if (!api || !endpoint) {
2461
- return void 0;
2462
- }
2463
- return { api, endpoint };
2464
- }
2465
- /**
2466
- * Get complete registry snapshot
2467
- *
2468
- * @returns Current registry state
2469
- */
2470
- getRegistry() {
2471
- const apis = Array.from(this.apis.values());
2472
- const byType = {};
2473
- for (const api of apis) {
2474
- if (!byType[api.type]) {
2475
- byType[api.type] = [];
2476
- }
2477
- byType[api.type].push(api);
2478
- }
2479
- const byStatus = {};
2480
- for (const api of apis) {
2481
- const status = api.metadata?.status || "active";
2482
- if (!byStatus[status]) {
2483
- byStatus[status] = [];
2484
- }
2485
- byStatus[status].push(api);
2486
- }
2487
- const totalEndpoints = apis.reduce(
2488
- (sum, api) => sum + api.endpoints.length,
2489
- 0
2490
- );
2491
- return {
2492
- version: this.version,
2493
- conflictResolution: this.conflictResolution,
2494
- apis,
2495
- totalApis: apis.length,
2496
- totalEndpoints,
2497
- byType,
2498
- byStatus,
2499
- updatedAt: this.updatedAt
2500
- };
2501
- }
2502
- /**
2503
- * Clear all registered APIs
2504
- *
2505
- * **⚠️ SAFETY WARNING:**
2506
- * This method clears all registered APIs and should be used with caution.
2507
- *
2508
- * **Usage Restrictions:**
2509
- * - In production environments (NODE_ENV=production), a `force: true` parameter is required
2510
- * - Primarily intended for testing and development hot-reload scenarios
2511
- *
2512
- * @param options - Clear options
2513
- * @param options.force - Force clear in production environment (default: false)
2514
- * @throws Error if called in production without force flag
2515
- *
2516
- * @example Safe usage in tests
2517
- * ```typescript
2518
- * beforeEach(() => {
2519
- * registry.clear(); // OK in test environment
2520
- * });
2521
- * ```
2522
- *
2523
- * @example Usage in production (requires explicit force)
2524
- * ```typescript
2525
- * // In production, explicit force is required
2526
- * registry.clear({ force: true });
2527
- * ```
2528
- */
2529
- clear(options = {}) {
2530
- const isProduction = this.isProductionEnvironment();
2531
- if (isProduction && !options.force) {
2532
- throw new Error(
2533
- "[ApiRegistry] Cannot clear registry in production environment without force flag. Use clear({ force: true }) if you really want to clear the registry."
2534
- );
2535
- }
2536
- this.apis.clear();
2537
- this.endpoints.clear();
2538
- this.routes.clear();
2539
- this.apisByType.clear();
2540
- this.apisByTag.clear();
2541
- this.apisByStatus.clear();
2542
- this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2543
- if (isProduction) {
2544
- this.logger.warn("API registry forcefully cleared in production", { force: options.force });
2545
- } else {
2546
- this.logger.info("API registry cleared");
2547
- }
2548
- }
2549
- /**
2550
- * Get registry statistics
2551
- *
2552
- * @returns Registry statistics
2553
- */
2554
- getStats() {
2555
- const apis = Array.from(this.apis.values());
2556
- const apisByType = {};
2557
- for (const api of apis) {
2558
- apisByType[api.type] = (apisByType[api.type] || 0) + 1;
2559
- }
2560
- const endpointsByApi = {};
2561
- for (const api of apis) {
2562
- endpointsByApi[api.id] = api.endpoints.length;
2563
- }
2564
- return {
2565
- totalApis: this.apis.size,
2566
- totalEndpoints: this.endpoints.size,
2567
- totalRoutes: this.routes.size,
2568
- apisByType,
2569
- endpointsByApi
2570
- };
2571
- }
2572
- /**
2573
- * Update auxiliary indices when an API is registered
2574
- *
2575
- * @param api - API entry to index
2576
- * @private
2577
- * @internal
2578
- */
2579
- updateIndices(api) {
2580
- this.ensureIndexSet(this.apisByType, api.type).add(api.id);
2581
- const status = api.metadata?.status || "active";
2582
- this.ensureIndexSet(this.apisByStatus, status).add(api.id);
2583
- const tags = api.metadata?.tags || [];
2584
- for (const tag of tags) {
2585
- this.ensureIndexSet(this.apisByTag, tag).add(api.id);
2586
- }
2587
- }
2588
- /**
2589
- * Remove API from auxiliary indices when unregistered
2590
- *
2591
- * @param api - API entry to remove from indices
2592
- * @private
2593
- * @internal
2594
- */
2595
- removeFromIndices(api) {
2596
- this.removeFromIndexSet(this.apisByType, api.type, api.id);
2597
- const status = api.metadata?.status || "active";
2598
- this.removeFromIndexSet(this.apisByStatus, status, api.id);
2599
- const tags = api.metadata?.tags || [];
2600
- for (const tag of tags) {
2601
- this.removeFromIndexSet(this.apisByTag, tag, api.id);
2602
- }
2603
- }
2604
- /**
2605
- * Helper to ensure an index set exists and return it
2606
- *
2607
- * @param map - Index map
2608
- * @param key - Index key
2609
- * @returns The Set for this key (created if needed)
2610
- * @private
2611
- * @internal
2612
- */
2613
- ensureIndexSet(map, key) {
2614
- let set = map.get(key);
2615
- if (!set) {
2616
- set = /* @__PURE__ */ new Set();
2617
- map.set(key, set);
2618
- }
2619
- return set;
2620
- }
2621
- /**
2622
- * Helper to remove an ID from an index set and clean up empty sets
2623
- *
2624
- * @param map - Index map
2625
- * @param key - Index key
2626
- * @param id - API ID to remove
2627
- * @private
2628
- * @internal
2629
- */
2630
- removeFromIndexSet(map, key, id) {
2631
- const set = map.get(key);
2632
- if (set) {
2633
- set.delete(id);
2634
- if (set.size === 0) {
2635
- map.delete(key);
2636
- }
2637
- }
2638
- }
2639
- /**
2640
- * Check if running in production environment
2641
- *
2642
- * @returns true if NODE_ENV is 'production'
2643
- * @private
2644
- * @internal
2645
- */
2646
- isProductionEnvironment() {
2647
- return getEnv("NODE_ENV") === "production";
2648
- }
2649
- };
2650
-
2651
- // src/api-registry-plugin.ts
2652
- function createApiRegistryPlugin(config = {}) {
2653
- const {
2654
- conflictResolution = "error",
2655
- version = "1.0.0"
2656
- } = config;
2657
- return {
2658
- name: "com.objectstack.core.api-registry",
2659
- /**
2660
- * Services init() registers on every path (ADR-0116, #4131) — lets the
2661
- * kernel name this plugin when a consumer requires one before it inits.
2662
- */
2663
- providesServices: ["api-registry"],
2664
- type: "standard",
2665
- version: "1.0.0",
2666
- init: async (ctx) => {
2667
- const registry = new ApiRegistry(
2668
- ctx.logger,
2669
- conflictResolution,
2670
- version
2671
- );
2672
- ctx.registerService("api-registry", registry);
2673
- ctx.logger.info("API Registry plugin initialized", {
2674
- conflictResolution,
2675
- version
2676
- });
2677
- }
2678
- };
2679
- }
2680
-
2681
2340
  // src/qa/index.ts
2682
2341
  var qa_exports = {};
2683
2342
  __export(qa_exports, {
@@ -5656,10 +5315,11 @@ var PluginHealthMonitor = class {
5656
5315
  const checks = [];
5657
5316
  try {
5658
5317
  if (config.checkMethod && typeof plugin[config.checkMethod] === "function") {
5659
- const checkResult = await Promise.race([
5318
+ const checkResult = await this.raceCheckTimeout(
5660
5319
  plugin[config.checkMethod](),
5661
- this.timeout(config.timeout, `Health check timeout after ${config.timeout}ms`)
5662
- ]);
5320
+ config.timeout,
5321
+ `Health check timeout after ${config.timeout}ms`
5322
+ );
5663
5323
  if (checkResult === false || checkResult && checkResult.status === "unhealthy") {
5664
5324
  status = "unhealthy";
5665
5325
  message = checkResult?.message || "Custom health check failed";
@@ -5815,12 +5475,39 @@ var PluginHealthMonitor = class {
5815
5475
  this.logger.info("Health monitor shutdown complete");
5816
5476
  }
5817
5477
  /**
5818
- * Timeout helper
5478
+ * Race a plugin's custom health check against its timeout guard, and
5479
+ * reclaim the guard the moment the race settles (#4875).
5480
+ *
5481
+ * Same shape, same reasoning as `ObjectKernel.raceStartupTimeout()` (#4813,
5482
+ * PR #4874): the guard used to be armed and then abandoned — when the check
5483
+ * won the race, its `setTimeout` stayed ref'd in the event loop for the full
5484
+ * `config.timeout`. Health checks are *periodic*, so unlike the kernel's
5485
+ * one-shot startup guards the orphans here accumulate: one per plugin per
5486
+ * round, each pinning the loop for `config.timeout`.
5487
+ *
5488
+ * Clearing on settle rather than `unref()`-ing at arm time is deliberate.
5489
+ * An unref'd guard also stops pinning the loop, but it stops being a guard
5490
+ * as well: if the check never settles and nothing else keeps the loop alive,
5491
+ * Node exits before the timer can fire and the timeout is never reported.
5492
+ * The guard has to stay ref'd exactly as long as the race is undecided,
5493
+ * which is what `clearTimeout` in a `finally` expresses.
5494
+ *
5495
+ * `check` is widened to `T | PromiseLike<T>` because `checkMethod` is called
5496
+ * dynamically off the plugin and may be synchronous; such a check wins the
5497
+ * race immediately and the guard is reclaimed on the same turn.
5819
5498
  */
5820
- timeout(ms, message) {
5821
- return new Promise((_, reject) => {
5822
- setTimeout(() => reject(new Error(message)), ms);
5499
+ async raceCheckTimeout(check, ms, message) {
5500
+ let guard;
5501
+ const timeoutPromise = new Promise((_, reject) => {
5502
+ guard = setTimeout(() => {
5503
+ reject(new Error(message));
5504
+ }, ms);
5823
5505
  });
5506
+ try {
5507
+ return await Promise.race([check, timeoutPromise]);
5508
+ } finally {
5509
+ clearTimeout(guard);
5510
+ }
5824
5511
  }
5825
5512
  };
5826
5513
 
@@ -6012,11 +5699,11 @@ var HotReloadManager = class {
6012
5699
  }
6013
5700
  if (plugin.destroy) {
6014
5701
  this.logger.debug("Destroying plugin", { plugin: pluginName });
6015
- const shutdownPromise = plugin.destroy();
6016
- const timeoutPromise = new Promise((_, reject) => {
6017
- setTimeout(() => reject(new Error("Shutdown timeout")), config.shutdownTimeout);
6018
- });
6019
- await Promise.race([shutdownPromise, timeoutPromise]);
5702
+ await this.raceShutdownTimeout(
5703
+ plugin.destroy(),
5704
+ config.shutdownTimeout,
5705
+ "Shutdown timeout"
5706
+ );
6020
5707
  this.logger.debug("Plugin destroyed successfully", { plugin: pluginName });
6021
5708
  }
6022
5709
  this.logger.debug("Plugin module would be reloaded here", { plugin: pluginName });
@@ -6043,6 +5730,41 @@ var HotReloadManager = class {
6043
5730
  return false;
6044
5731
  }
6045
5732
  }
5733
+ /**
5734
+ * Race a plugin's `destroy()` against its shutdown-timeout guard, and
5735
+ * reclaim the guard the moment the race settles (#4952).
5736
+ *
5737
+ * The guard used to be armed and then abandoned — byte-for-byte the leak
5738
+ * #4813 fixed in the kernel's startup guards (PR #4874) and #4875 fixed in
5739
+ * the periodic health checks (PR #4950): when `destroy()` won the race, its
5740
+ * `setTimeout` stayed ref'd in the event loop for the full
5741
+ * `shutdownTimeout`, so a hot reload that finished in milliseconds still
5742
+ * pinned the loop for the whole budget — once per reload, per plugin.
5743
+ *
5744
+ * Clearing on settle rather than `unref()`-ing at arm time is deliberate.
5745
+ * An unref'd guard also stops pinning the loop, but it stops being a guard
5746
+ * as well: if `destroy()` never settles and nothing else keeps the loop
5747
+ * alive, Node exits before the timer can fire and the timeout is never
5748
+ * reported. The guard has to stay ref'd exactly as long as the race is
5749
+ * undecided, which is what `clearTimeout` in a `finally` expresses.
5750
+ *
5751
+ * `shutdown` is widened to `T | PromiseLike<T>` because the Plugin contract
5752
+ * permits a synchronous `destroy()` (`Promise<void> | void`); such a hook
5753
+ * wins the race immediately and the guard is reclaimed on the same turn.
5754
+ */
5755
+ async raceShutdownTimeout(shutdown, timeout, message) {
5756
+ let guard;
5757
+ const timeoutPromise = new Promise((_, reject) => {
5758
+ guard = setTimeout(() => {
5759
+ reject(new Error(message));
5760
+ }, timeout);
5761
+ });
5762
+ try {
5763
+ return await Promise.race([shutdown, timeoutPromise]);
5764
+ } finally {
5765
+ clearTimeout(guard);
5766
+ }
5767
+ }
6046
5768
  /**
6047
5769
  * Schedule a reload with debouncing
6048
5770
  */
@@ -6501,7 +6223,6 @@ export {
6501
6223
  ANONYMOUS_DENY_MESSAGE,
6502
6224
  ANONYMOUS_DENY_STATUS,
6503
6225
  API_KEY_PREFIX,
6504
- ApiRegistry,
6505
6226
  CORE_FALLBACK_FACTORIES,
6506
6227
  DependencyResolver,
6507
6228
  HotReloadManager,
@@ -6537,7 +6258,6 @@ export {
6537
6258
  calendarPartsInTz,
6538
6259
  calendarPartsInTzOrUtc,
6539
6260
  counterSignPayload,
6540
- createApiRegistryPlugin,
6541
6261
  createLogger,
6542
6262
  createMemoryCache,
6543
6263
  createMemoryI18n,