bunsane 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/CHANGELOG.md +471 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +93 -1020
  4. package/core/EntityHookManager.ts +52 -754
  5. package/core/Logger.ts +10 -0
  6. package/core/RequestContext.ts +94 -85
  7. package/core/RequestLoaders.ts +98 -5
  8. package/core/SchedulerManager.ts +28 -600
  9. package/core/app/cors.ts +2 -11
  10. package/core/app/preparedStatementWarmup.ts +9 -49
  11. package/core/app/requestRouter.ts +9 -8
  12. package/core/app/restRegistry.ts +8 -0
  13. package/core/archetype/fieldResolvers.ts +85 -40
  14. package/core/archetype/relationLoader.ts +135 -92
  15. package/core/cache/CacheManager.ts +91 -302
  16. package/core/cache/CompressionUtils.ts +34 -3
  17. package/core/cache/MemoryCache.ts +40 -37
  18. package/core/cache/RedisCache.ts +8 -7
  19. package/core/cache/health.ts +30 -0
  20. package/core/cache/invalidation.ts +96 -0
  21. package/core/cache/strategies/writeInvalidate.ts +111 -0
  22. package/core/cache/strategies/writeThrough.ts +233 -0
  23. package/core/components/BaseComponent.ts +25 -10
  24. package/core/components/ComponentRegistry.ts +28 -0
  25. package/core/decorators/IndexedField.ts +1 -1
  26. package/core/entity/cacheStrategies.ts +97 -0
  27. package/core/entity/componentAccess.ts +383 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/getCacheManager.ts +10 -0
  30. package/core/entity/pendingOps.ts +72 -0
  31. package/core/entity/saveEntity.ts +375 -0
  32. package/core/health.ts +93 -4
  33. package/core/hooks/dispatcher.ts +439 -0
  34. package/core/hooks/guards.ts +155 -0
  35. package/core/hooks/registry.ts +247 -0
  36. package/core/metadata/definitions/Component.ts +1 -1
  37. package/core/metadata/index.ts +15 -4
  38. package/core/middleware/RateLimit.ts +102 -105
  39. package/core/middleware/RequestId.ts +2 -9
  40. package/core/middleware/SecurityHeaders.ts +2 -11
  41. package/core/middleware/headers.ts +28 -0
  42. package/core/remote/OutboxWorker.ts +213 -183
  43. package/core/remote/RemoteManager.ts +401 -400
  44. package/core/remote/StreamConsumer.ts +535 -535
  45. package/core/remote/types.ts +153 -151
  46. package/core/requestScope.ts +34 -0
  47. package/core/scheduler/cronEvaluator.ts +174 -0
  48. package/core/scheduler/lifecycleHooks.ts +21 -0
  49. package/core/scheduler/lockCoordinator.ts +27 -0
  50. package/core/scheduler/metrics.ts +14 -0
  51. package/core/scheduler/taskRunner.ts +420 -0
  52. package/core/validateEnv.ts +10 -0
  53. package/database/DatabaseHelper.ts +128 -101
  54. package/database/IndexingStrategy.ts +72 -2
  55. package/database/PreparedStatementCache.ts +8 -2
  56. package/database/cancellable.ts +35 -22
  57. package/database/index.ts +29 -3
  58. package/database/instrumentedDb.ts +141 -141
  59. package/database/sqlHelpers.ts +3 -1
  60. package/endpoints/archetypes.ts +2 -8
  61. package/endpoints/tables.ts +6 -1
  62. package/gql/index.ts +1 -1
  63. package/gql/schema/index.ts +15 -4
  64. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  65. package/package.json +22 -1
  66. package/query/CTENode.ts +5 -3
  67. package/query/ComponentInclusionNode.ts +245 -14
  68. package/query/OrNode.ts +8 -19
  69. package/query/Query.ts +208 -79
  70. package/query/QueryContext.ts +6 -0
  71. package/query/QueryDAG.ts +7 -2
  72. package/query/membershipSource.ts +66 -0
  73. package/storage/LocalStorageProvider.ts +8 -3
  74. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  75. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  76. package/studio/{index.html → dist/index.html} +3 -2
  77. package/swagger/generator.ts +11 -1
  78. package/upload/UploadManager.ts +8 -6
  79. package/utils/uuid.ts +40 -10
  80. package/.claude/scheduled_tasks.lock +0 -1
  81. package/.claude/settings.local.json +0 -47
  82. package/.prettierrc +0 -4
  83. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  84. package/.serena/memories/architecture.md +0 -154
  85. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  86. package/.serena/memories/code_style_and_conventions.md +0 -76
  87. package/.serena/memories/project_overview.md +0 -43
  88. package/.serena/memories/schema-dsl-plan.md +0 -107
  89. package/.serena/memories/suggested_commands.md +0 -80
  90. package/.serena/memories/typescript-compilation-status.md +0 -54
  91. package/.serena/project.yml +0 -114
  92. package/BunSane.jpg +0 -0
  93. package/CLAUDE.md +0 -198
  94. package/TODO.md +0 -2
  95. package/bun.lock +0 -302
  96. package/bunfig.toml +0 -10
  97. package/docs/RFC_APP_REFACTOR.md +0 -248
  98. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  99. package/docs/SCALABILITY_PLAN.md +0 -175
  100. package/studio/bun.lock +0 -482
  101. package/studio/package.json +0 -39
  102. package/studio/postcss.config.js +0 -6
  103. package/studio/src/components/DataTable.tsx +0 -211
  104. package/studio/src/components/Layout.tsx +0 -13
  105. package/studio/src/components/PageContainer.tsx +0 -9
  106. package/studio/src/components/PageHeader.tsx +0 -13
  107. package/studio/src/components/SearchBar.tsx +0 -57
  108. package/studio/src/components/Sidebar.tsx +0 -294
  109. package/studio/src/components/ui/button.tsx +0 -56
  110. package/studio/src/components/ui/checkbox.tsx +0 -26
  111. package/studio/src/components/ui/input.tsx +0 -25
  112. package/studio/src/hooks/useDataTable.ts +0 -131
  113. package/studio/src/index.css +0 -36
  114. package/studio/src/lib/api.ts +0 -186
  115. package/studio/src/lib/utils.ts +0 -13
  116. package/studio/src/main.tsx +0 -17
  117. package/studio/src/pages/ArcheType.tsx +0 -239
  118. package/studio/src/pages/Components.tsx +0 -124
  119. package/studio/src/pages/EntityInspector.tsx +0 -302
  120. package/studio/src/pages/QueryRunner.tsx +0 -246
  121. package/studio/src/pages/Table.tsx +0 -94
  122. package/studio/src/pages/Welcome.tsx +0 -241
  123. package/studio/src/routes.tsx +0 -45
  124. package/studio/src/store/archeTypeSettings.ts +0 -30
  125. package/studio/src/store/studio.ts +0 -65
  126. package/studio/src/utils/columnHelpers.tsx +0 -114
  127. package/studio/studio-instructions.md +0 -81
  128. package/studio/tailwind.config.js +0 -77
  129. package/studio/utils.ts +0 -54
  130. package/studio/vite.config.js +0 -19
  131. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  132. package/tests/benchmark/bunfig.toml +0 -9
  133. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  134. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  135. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  136. package/tests/benchmark/fixtures/index.ts +0 -6
  137. package/tests/benchmark/index.ts +0 -22
  138. package/tests/benchmark/noop-preload.ts +0 -3
  139. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  140. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  141. package/tests/benchmark/runners/index.ts +0 -4
  142. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  143. package/tests/benchmark/scripts/generate-db.ts +0 -344
  144. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  145. package/tests/e2e/http.test.ts +0 -130
  146. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  147. package/tests/fixtures/components/TestOrder.ts +0 -23
  148. package/tests/fixtures/components/TestProduct.ts +0 -23
  149. package/tests/fixtures/components/TestUser.ts +0 -20
  150. package/tests/fixtures/components/index.ts +0 -6
  151. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  152. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  153. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  154. package/tests/helpers/MockRedisClient.ts +0 -113
  155. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  156. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  157. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  158. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  159. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  160. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  161. package/tests/integration/query/Query.abort.test.ts +0 -66
  162. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  163. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  164. package/tests/integration/query/Query.exec.test.ts +0 -576
  165. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  166. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  167. package/tests/integration/remote/dlq.test.ts +0 -175
  168. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  169. package/tests/integration/remote/outbox.test.ts +0 -130
  170. package/tests/integration/remote/rpc.test.ts +0 -177
  171. package/tests/pglite-setup.ts +0 -62
  172. package/tests/setup.ts +0 -164
  173. package/tests/stress/BenchmarkRunner.ts +0 -203
  174. package/tests/stress/DataSeeder.ts +0 -190
  175. package/tests/stress/StressTestReporter.ts +0 -229
  176. package/tests/stress/cursor-perf-test.ts +0 -171
  177. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  178. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  179. package/tests/stress/index.ts +0 -7
  180. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  181. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  182. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  183. package/tests/unit/BatchLoader.test.ts +0 -196
  184. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  185. package/tests/unit/cache/CacheManager.test.ts +0 -498
  186. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  187. package/tests/unit/cache/RedisCache.test.ts +0 -411
  188. package/tests/unit/database/cancellable.test.ts +0 -81
  189. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  190. package/tests/unit/entity/Entity.components.test.ts +0 -317
  191. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  192. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  193. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  194. package/tests/unit/entity/Entity.test.ts +0 -345
  195. package/tests/unit/gql/depthLimit.test.ts +0 -203
  196. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  197. package/tests/unit/health/Health.test.ts +0 -129
  198. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  199. package/tests/unit/middleware/Middleware.test.ts +0 -98
  200. package/tests/unit/middleware/RequestId.test.ts +0 -54
  201. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  202. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  203. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  204. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  205. package/tests/unit/query/Query.test.ts +0 -310
  206. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  207. package/tests/unit/remote/RemoteError.test.ts +0 -55
  208. package/tests/unit/remote/decorators.test.ts +0 -195
  209. package/tests/unit/remote/metrics.test.ts +0 -115
  210. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  211. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  212. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  213. package/tests/unit/schema/schema-integration.test.ts +0 -426
  214. package/tests/unit/schema/schema.test.ts +0 -580
  215. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  216. package/tests/unit/upload/RestUpload.test.ts +0 -267
  217. package/tests/unit/validateEnv.test.ts +0 -82
  218. package/tests/utils/entity-tracker.ts +0 -57
  219. package/tests/utils/index.ts +0 -13
  220. package/tests/utils/test-context.ts +0 -149
package/core/health.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import db from "../database";
2
+ import { runWithSignal } from "../database/cancellable";
2
3
  import { CacheManager } from "./cache/CacheManager";
3
4
 
4
5
  export interface CheckResult {
@@ -13,6 +14,14 @@ export interface HealthResponse {
13
14
  checks: {
14
15
  database: CheckResult;
15
16
  cache: CheckResult;
17
+ /**
18
+ * Present only when the DB write probe is enabled (default on).
19
+ * Exercises the real `db.transaction()` write path so a wedged write
20
+ * pool — a stuck pooled client or exhausted pool that leaves reads
21
+ * (`SELECT 1`) healthy — fails the liveness check and the orchestrator
22
+ * restarts the container instead of it serving 504s indefinitely.
23
+ */
24
+ database_write?: CheckResult;
16
25
  };
17
26
  }
18
27
 
@@ -24,6 +33,71 @@ export interface HealthResult {
24
33
  export interface HealthDeps {
25
34
  pingDb: () => Promise<boolean>;
26
35
  pingCache: () => Promise<boolean>;
36
+ /**
37
+ * Write-path probe. Optional: when omitted (e.g. tests passing custom
38
+ * deps) the write check is skipped and behavior matches the read-only
39
+ * health check. `defaultDeps` supplies the real probe.
40
+ */
41
+ pingDbWrite?: () => Promise<boolean>;
42
+ }
43
+
44
+ // Independent, short timeout for the write probe so a wedged write path is
45
+ // caught fast (and the container restarted) rather than blocking on the 30s
46
+ // request/save timeout. Configurable via DB_HEALTH_WRITE_TIMEOUT.
47
+ const WRITE_PROBE_TIMEOUT_MS = parseInt(process.env.DB_HEALTH_WRITE_TIMEOUT ?? "5000", 10);
48
+
49
+ function writeProbeDisabled(): boolean {
50
+ return process.env.HEALTH_DB_WRITE_PROBE === "false";
51
+ }
52
+
53
+ /**
54
+ * Exercises a genuine write through the same `db.transaction()` acquisition
55
+ * path `Entity.save` uses. A wedged write pool (stuck pooled client, pool
56
+ * exhausted by leaked transactions) hangs here while `SELECT 1` stays healthy
57
+ * on any idle read connection — exactly the false-healthy scenario that kept a
58
+ * timed-out container "healthy" and unrestarted.
59
+ *
60
+ * The whole transaction is raced against an independent timeout so even a hang
61
+ * during connection *acquisition* (which runWithSignal alone cannot interrupt,
62
+ * since it only wraps in-flight queries) is caught. The temp table is dropped
63
+ * at COMMIT, so the probe has no persistent side effect.
64
+ */
65
+ async function probeDbWrite(): Promise<boolean> {
66
+ const timeoutMs = WRITE_PROBE_TIMEOUT_MS;
67
+ const controller = new AbortController();
68
+ let handle: ReturnType<typeof setTimeout> | undefined;
69
+ const timeoutPromise = new Promise<never>((_, reject) => {
70
+ handle = setTimeout(() => {
71
+ const err = new Error(`DB write health probe timeout after ${timeoutMs}ms`);
72
+ controller.abort(err);
73
+ reject(err);
74
+ }, timeoutMs);
75
+ (handle as any).unref?.();
76
+ });
77
+
78
+ const txn = db.transaction(async (trx) => {
79
+ await runWithSignal(
80
+ trx`CREATE TEMP TABLE IF NOT EXISTS _bunsane_health_write (probed_at timestamptz NOT NULL) ON COMMIT DROP`,
81
+ controller.signal,
82
+ );
83
+ await runWithSignal(
84
+ trx`INSERT INTO _bunsane_health_write (probed_at) VALUES (now())`,
85
+ controller.signal,
86
+ );
87
+ });
88
+
89
+ try {
90
+ await Promise.race([txn, timeoutPromise]);
91
+ return true;
92
+ } finally {
93
+ if (handle) clearTimeout(handle);
94
+ // Abort any in-flight query so the transaction rolls back and the
95
+ // pooled connection is released even when the timeout won the race.
96
+ if (!controller.signal.aborted) controller.abort();
97
+ // Swallow a late transaction settle after a lost race so it cannot
98
+ // surface as an unhandled rejection.
99
+ Promise.resolve(txn).catch(() => { /* ignore post-timeout settle */ });
100
+ }
27
101
  }
28
102
 
29
103
  const defaultDeps: HealthDeps = {
@@ -32,6 +106,7 @@ const defaultDeps: HealthDeps = {
32
106
  return true;
33
107
  },
34
108
  pingCache: () => CacheManager.getInstance().ping(),
109
+ pingDbWrite: probeDbWrite,
35
110
  };
36
111
 
37
112
  async function checkDatabase(pingDb: () => Promise<boolean>): Promise<CheckResult> {
@@ -55,24 +130,30 @@ async function checkCache(pingCache: () => Promise<boolean>): Promise<CheckResul
55
130
  }
56
131
 
57
132
  export async function deepHealthCheck(deps: HealthDeps = defaultDeps): Promise<HealthResult> {
58
- const [database, cache] = await Promise.all([
133
+ const runWrite = !!deps.pingDbWrite && !writeProbeDisabled();
134
+
135
+ const [database, cache, databaseWrite] = await Promise.all([
59
136
  checkDatabase(deps.pingDb),
60
137
  checkCache(deps.pingCache),
138
+ runWrite ? checkDatabase(deps.pingDbWrite!) : Promise.resolve(undefined),
61
139
  ]);
62
140
 
63
141
  const dbUp = database.status === "up";
142
+ const writeUp = !databaseWrite || databaseWrite.status === "up";
64
143
  const cacheUp = cache.status === "up";
65
144
 
66
145
  let status: HealthResponse["status"];
67
146
  let httpStatus: number;
68
147
 
69
- if (dbUp && cacheUp) {
148
+ if (dbUp && writeUp && cacheUp) {
70
149
  status = "ok";
71
150
  httpStatus = 200;
72
- } else if (dbUp && !cacheUp) {
151
+ } else if (dbUp && writeUp && !cacheUp) {
73
152
  status = "degraded";
74
153
  httpStatus = 200;
75
154
  } else {
155
+ // DB read OR write down → unavailable. A wedged write path (reads fine,
156
+ // writes hang) lands here so liveness fails and the container restarts.
76
157
  status = "unavailable";
77
158
  httpStatus = 503;
78
159
  }
@@ -82,7 +163,11 @@ export async function deepHealthCheck(deps: HealthDeps = defaultDeps): Promise<H
82
163
  status,
83
164
  timestamp: new Date().toISOString(),
84
165
  uptime: process.uptime(),
85
- checks: { database, cache },
166
+ checks: {
167
+ database,
168
+ cache,
169
+ ...(databaseWrite ? { database_write: databaseWrite } : {}),
170
+ },
86
171
  },
87
172
  httpStatus,
88
173
  };
@@ -94,6 +179,7 @@ export async function readinessCheck(
94
179
  deps: HealthDeps = defaultDeps,
95
180
  ): Promise<HealthResult> {
96
181
  if (!isReady || isShuttingDown) {
182
+ const includeWrite = !!deps.pingDbWrite && !writeProbeDisabled();
97
183
  return {
98
184
  result: {
99
185
  status: "unavailable",
@@ -102,6 +188,9 @@ export async function readinessCheck(
102
188
  checks: {
103
189
  database: { status: "unknown", latency_ms: 0 },
104
190
  cache: { status: "unknown", latency_ms: 0 },
191
+ ...(includeWrite
192
+ ? { database_write: { status: "unknown", latency_ms: 0 } }
193
+ : {}),
105
194
  },
106
195
  },
107
196
  httpStatus: 503,
@@ -0,0 +1,439 @@
1
+ import type { LifecycleEvent } from "../events/EntityLifecycleEvents";
2
+ import { logger as MainLogger } from "../Logger";
3
+ import type { RegisteredHook, HookMetrics, RegistryState } from "./registry";
4
+ import { matchesComponentTarget } from "./guards";
5
+
6
+ const logger = MainLogger.child({ scope: "EntityHookManager" });
7
+
8
+ /**
9
+ * Dispatcher state owned by the manager instance
10
+ */
11
+ export interface DispatcherState {
12
+ metrics: Map<string, HookMetrics>;
13
+ globalMetrics: HookMetrics;
14
+ }
15
+
16
+ /**
17
+ * Create initial dispatcher state
18
+ */
19
+ export function createDispatcherState(): DispatcherState {
20
+ return {
21
+ metrics: new Map(),
22
+ globalMetrics: {
23
+ totalExecutions: 0,
24
+ totalExecutionTime: 0,
25
+ averageExecutionTime: 0,
26
+ errorCount: 0,
27
+ lastExecutionTime: 0
28
+ }
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Record hook execution metrics
34
+ */
35
+ export function recordMetrics(state: DispatcherState, eventType: string, executionTime: number, hadErrors: boolean): void {
36
+ // Update event-specific metrics
37
+ let eventMetrics = state.metrics.get(eventType);
38
+ if (!eventMetrics) {
39
+ eventMetrics = {
40
+ totalExecutions: 0,
41
+ totalExecutionTime: 0,
42
+ averageExecutionTime: 0,
43
+ errorCount: 0,
44
+ lastExecutionTime: 0
45
+ };
46
+ state.metrics.set(eventType, eventMetrics);
47
+ }
48
+
49
+ eventMetrics.totalExecutions++;
50
+ eventMetrics.totalExecutionTime += executionTime;
51
+ eventMetrics.averageExecutionTime = eventMetrics.totalExecutionTime / eventMetrics.totalExecutions;
52
+ eventMetrics.lastExecutionTime = executionTime;
53
+ if (hadErrors) {
54
+ eventMetrics.errorCount++;
55
+ }
56
+
57
+ // Update global metrics
58
+ state.globalMetrics.totalExecutions++;
59
+ state.globalMetrics.totalExecutionTime += executionTime;
60
+ state.globalMetrics.averageExecutionTime = state.globalMetrics.totalExecutionTime / state.globalMetrics.totalExecutions;
61
+ state.globalMetrics.lastExecutionTime = executionTime;
62
+ if (hadErrors) {
63
+ state.globalMetrics.errorCount++;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get performance metrics for hook execution
69
+ */
70
+ export function getMetrics(state: DispatcherState, eventType?: string): HookMetrics {
71
+ if (eventType) {
72
+ return state.metrics.get(eventType) || {
73
+ totalExecutions: 0,
74
+ totalExecutionTime: 0,
75
+ averageExecutionTime: 0,
76
+ errorCount: 0,
77
+ lastExecutionTime: 0
78
+ };
79
+ }
80
+ return { ...state.globalMetrics };
81
+ }
82
+
83
+ /**
84
+ * Reset performance metrics
85
+ */
86
+ export function resetMetrics(state: DispatcherState, eventType?: string): void {
87
+ if (eventType) {
88
+ state.metrics.delete(eventType);
89
+ } else {
90
+ state.metrics.clear();
91
+ state.globalMetrics = {
92
+ totalExecutions: 0,
93
+ totalExecutionTime: 0,
94
+ averageExecutionTime: 0,
95
+ errorCount: 0,
96
+ lastExecutionTime: 0
97
+ };
98
+ }
99
+ logger.trace(`Reset metrics${eventType ? ` for ${eventType}` : ''}`);
100
+ }
101
+
102
+ /**
103
+ * Execute hooks for a specific event
104
+ */
105
+ export async function executeHooks(registryState: RegistryState, dispatcherState: DispatcherState, event: LifecycleEvent): Promise<void> {
106
+ const eventType = event.getEventType();
107
+ const hooks = registryState.hooks.get(eventType) || [];
108
+ const startTime = performance.now();
109
+ let hadErrors = false;
110
+
111
+ if (hooks.length === 0) {
112
+ return;
113
+ }
114
+
115
+ logger.trace(`Executing ${hooks.length} hooks for event: ${eventType}`);
116
+
117
+ // Separate sync and async hooks
118
+ const syncHooks = hooks.filter(hook => !hook.options.async);
119
+ const asyncHooks = hooks.filter(hook => hook.options.async);
120
+
121
+ // Execute sync hooks immediately
122
+ for (const hook of syncHooks) {
123
+ // Check component targeting first
124
+ if (!matchesComponentTarget(event, hook.options.componentTarget)) {
125
+ continue;
126
+ }
127
+
128
+ // Check filter condition
129
+ if (hook.options.filter && !hook.options.filter(event)) {
130
+ continue;
131
+ }
132
+
133
+ try {
134
+ if (hook.options.timeout && hook.options.timeout > 0) {
135
+ // Execute with timeout. Timer handle is stored so the
136
+ // normal-completion path clears it (no leaked pending
137
+ // timers per successful hook). The underlying callback
138
+ // promise is attached with a detached .catch so a late
139
+ // rejection after timeout does not escape as unhandled
140
+ // (H-HOOK-2 / H-MEM-2).
141
+ let timerHandle: ReturnType<typeof setTimeout> | null = null;
142
+ const timeoutPromise = new Promise<never>((_, reject) => {
143
+ timerHandle = setTimeout(
144
+ () => reject(new Error(`Hook ${hook.id} timed out after ${hook.options.timeout}ms`)),
145
+ hook.options.timeout
146
+ );
147
+ (timerHandle as unknown as { unref?: () => void }).unref?.();
148
+ });
149
+ const hookPromise = Promise.resolve().then(() => hook.callback(event));
150
+ hookPromise.catch((err) => {
151
+ logger.warn({ hookId: hook.id, err }, `Late rejection from hook after timeout`);
152
+ });
153
+ try {
154
+ await Promise.race([hookPromise, timeoutPromise]);
155
+ } finally {
156
+ if (timerHandle) clearTimeout(timerHandle);
157
+ }
158
+ } else {
159
+ // Always await — callback may be an async function declared
160
+ // with async:false by mistake. Without await, a rejection
161
+ // from such a callback escapes as an unhandled rejection
162
+ // and crashes the process under strict mode (C13).
163
+ await hook.callback(event);
164
+ }
165
+ } catch (error) {
166
+ logger.error(`Error executing sync hook ${hook.id} for event ${eventType}: ${error}`);
167
+ hadErrors = true;
168
+ // Continue executing other hooks even if one fails
169
+ }
170
+ }
171
+
172
+ // Execute async hooks in parallel
173
+ if (asyncHooks.length > 0) {
174
+ const asyncPromises = asyncHooks.map(async (hook) => {
175
+ // Check component targeting first
176
+ if (!matchesComponentTarget(event, hook.options.componentTarget)) {
177
+ return;
178
+ }
179
+
180
+ // Check filter condition
181
+ if (hook.options.filter && !hook.options.filter(event)) {
182
+ return;
183
+ }
184
+
185
+ try {
186
+ if (hook.options.timeout && hook.options.timeout > 0) {
187
+ // Execute with timeout. See sync path for rationale —
188
+ // clear the timer on normal completion and detach a
189
+ // .catch on the hook promise so late rejections do
190
+ // not escape (H-HOOK-2 / H-MEM-2).
191
+ let timerHandle: ReturnType<typeof setTimeout> | null = null;
192
+ const hookPromise = Promise.resolve().then(() => hook.callback(event));
193
+ hookPromise.catch((err) => {
194
+ logger.warn({ hookId: hook.id, err }, `Late rejection from hook after timeout`);
195
+ });
196
+ const timeoutPromise = new Promise<never>((_, reject) => {
197
+ timerHandle = setTimeout(
198
+ () => reject(new Error(`Hook ${hook.id} timed out after ${hook.options.timeout}ms`)),
199
+ hook.options.timeout
200
+ );
201
+ (timerHandle as unknown as { unref?: () => void }).unref?.();
202
+ });
203
+ try {
204
+ await Promise.race([hookPromise, timeoutPromise]);
205
+ } finally {
206
+ if (timerHandle) clearTimeout(timerHandle);
207
+ }
208
+ } else {
209
+ // Execute normally
210
+ await hook.callback(event);
211
+ }
212
+ } catch (error) {
213
+ logger.error(`Error executing async hook ${hook.id} for event ${eventType}: ${error}`);
214
+ hadErrors = true;
215
+ // Continue executing other hooks even if one fails
216
+ }
217
+ });
218
+
219
+ await Promise.allSettled(asyncPromises);
220
+ }
221
+
222
+ // Record performance metrics
223
+ const executionTime = performance.now() - startTime;
224
+ recordMetrics(dispatcherState, eventType, executionTime, hadErrors);
225
+ }
226
+
227
+ /**
228
+ * Execute hooks for multiple events in batch
229
+ */
230
+ export async function executeHooksBatch(registryState: RegistryState, dispatcherState: DispatcherState, events: LifecycleEvent[]): Promise<void> {
231
+ if (events.length === 0) {
232
+ return;
233
+ }
234
+
235
+ logger.trace(`Executing hooks for ${events.length} events in batch`);
236
+
237
+ // Group events by type for efficient processing
238
+ const eventsByType = new Map<string, LifecycleEvent[]>();
239
+ for (const event of events) {
240
+ const eventType = event.getEventType();
241
+ if (!eventsByType.has(eventType)) {
242
+ eventsByType.set(eventType, []);
243
+ }
244
+ eventsByType.get(eventType)!.push(event);
245
+ }
246
+
247
+ // Process each event type
248
+ const promises: Promise<void>[] = [];
249
+ for (const [eventType, typeEvents] of eventsByType.entries()) {
250
+ promises.push(executeHooksForType(registryState, dispatcherState, eventType, typeEvents));
251
+ }
252
+
253
+ await Promise.allSettled(promises);
254
+ }
255
+
256
+ /**
257
+ * Execute hooks for a specific event type with multiple events
258
+ */
259
+ async function executeHooksForType(registryState: RegistryState, dispatcherState: DispatcherState, eventType: string, events: LifecycleEvent[]): Promise<void> {
260
+ const hooks = registryState.hooks.get(eventType) || [];
261
+
262
+ if (hooks.length === 0 || events.length === 0) {
263
+ return;
264
+ }
265
+
266
+ logger.trace(`Executing ${hooks.length} hooks for ${events.length} ${eventType} events`);
267
+
268
+ // Pre-filter hooks by component targeting to avoid repeated checks
269
+ const preFilteredHooks = preFilterHooksByComponentTargeting(hooks, events);
270
+
271
+ if (preFilteredHooks.length === 0) {
272
+ return;
273
+ }
274
+
275
+ // Separate sync and async hooks
276
+ const syncHooks = preFilteredHooks.filter(hook => !hook.options.async);
277
+ const asyncHooks = preFilteredHooks.filter(hook => hook.options.async);
278
+
279
+ // Execute sync hooks for all events with batch optimization
280
+ if (syncHooks.length > 0) {
281
+ await executeSyncHooksBatch(dispatcherState, syncHooks, events, eventType);
282
+ }
283
+
284
+ // Execute async hooks in parallel for all events with batch optimization
285
+ if (asyncHooks.length > 0) {
286
+ await executeAsyncHooksBatch(dispatcherState, asyncHooks, events, eventType);
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Pre-filter hooks based on component targeting to optimize batch processing
292
+ */
293
+ function preFilterHooksByComponentTargeting(hooks: RegisteredHook[], events: LifecycleEvent[]): RegisteredHook[] {
294
+ // If no hooks have component targeting, return all hooks (preserving order)
295
+ const hasComponentTargeting = hooks.some(hook => hook.options.componentTarget);
296
+ if (!hasComponentTargeting) {
297
+ return [...hooks]; // Return a copy to avoid modifying the original
298
+ }
299
+
300
+ // For hooks with component targeting, check if they could match any event
301
+ // This is a broad pre-filter to avoid checking every hook against every event
302
+ const filteredHooks = hooks.filter(hook => {
303
+ if (!hook.options.componentTarget) {
304
+ return true; // No targeting means it matches all
305
+ }
306
+
307
+ // Check if this hook could potentially match any of the events
308
+ return events.some(event => matchesComponentTarget(event, hook.options.componentTarget));
309
+ });
310
+
311
+ // Return filtered hooks in their original order (priority should already be sorted)
312
+ return filteredHooks;
313
+ }
314
+
315
+ /**
316
+ * Execute sync hooks for multiple events with batch optimizations
317
+ */
318
+ async function executeSyncHooksBatch(dispatcherState: DispatcherState, syncHooks: RegisteredHook[], events: LifecycleEvent[], eventType: string): Promise<void> {
319
+ const startTime = performance.now();
320
+ let hadErrors = false;
321
+
322
+ // Execute hooks in priority order across all events to maintain deterministic execution
323
+ for (const hook of syncHooks) {
324
+ // Process all events for this hook
325
+ for (const event of events) {
326
+ // Double-check component targeting (pre-filter may have false positives)
327
+ if (!matchesComponentTarget(event, hook.options.componentTarget)) {
328
+ continue;
329
+ }
330
+
331
+ // Check filter condition
332
+ if (hook.options.filter && !hook.options.filter(event)) {
333
+ continue;
334
+ }
335
+
336
+ try {
337
+ if (hook.options.timeout && hook.options.timeout > 0) {
338
+ // Same cleanup pattern as single-event path (H-HOOK-2 / H-MEM-2).
339
+ let timerHandle: ReturnType<typeof setTimeout> | null = null;
340
+ const hookPromise = Promise.resolve().then(() => hook.callback(event));
341
+ hookPromise.catch((err) => {
342
+ logger.warn({ hookId: hook.id, err }, `Late rejection from hook after timeout`);
343
+ });
344
+ const timeoutPromise = new Promise<never>((_, reject) => {
345
+ timerHandle = setTimeout(
346
+ () => reject(new Error(`Hook ${hook.id} timed out after ${hook.options.timeout}ms`)),
347
+ hook.options.timeout
348
+ );
349
+ (timerHandle as unknown as { unref?: () => void }).unref?.();
350
+ });
351
+ try {
352
+ await Promise.race([hookPromise, timeoutPromise]);
353
+ } finally {
354
+ if (timerHandle) clearTimeout(timerHandle);
355
+ }
356
+ } else {
357
+ // Await so async callbacks do not escape as unhandled
358
+ // rejections (C13 parity).
359
+ await hook.callback(event);
360
+ }
361
+ } catch (error) {
362
+ logger.error(`Error executing sync hook ${hook.id} for event ${eventType}: ${error}`);
363
+ hadErrors = true;
364
+ }
365
+ }
366
+ }
367
+
368
+ // Record performance metrics
369
+ const executionTime = performance.now() - startTime;
370
+ recordMetrics(dispatcherState, eventType, executionTime, hadErrors);
371
+ }
372
+
373
+ /**
374
+ * Execute async hooks for multiple events with batch optimizations
375
+ */
376
+ async function executeAsyncHooksBatch(dispatcherState: DispatcherState, asyncHooks: RegisteredHook[], events: LifecycleEvent[], eventType: string): Promise<void> {
377
+ const startTime = performance.now();
378
+ let hadErrors = false;
379
+
380
+ // Collect all async hook executions
381
+ const asyncPromises: Promise<void>[] = [];
382
+
383
+ // Use a more efficient batching strategy for async hooks
384
+ for (const event of events) {
385
+ for (const hook of asyncHooks) {
386
+ // Double-check component targeting
387
+ if (!matchesComponentTarget(event, hook.options.componentTarget)) {
388
+ continue;
389
+ }
390
+
391
+ // Check filter condition
392
+ if (hook.options.filter && !hook.options.filter(event)) {
393
+ continue;
394
+ }
395
+
396
+ asyncPromises.push(
397
+ (async () => {
398
+ try {
399
+ if (hook.options.timeout && hook.options.timeout > 0) {
400
+ // Same cleanup pattern (H-HOOK-2 / H-MEM-2).
401
+ let timerHandle: ReturnType<typeof setTimeout> | null = null;
402
+ const hookPromise = Promise.resolve().then(() => hook.callback(event));
403
+ hookPromise.catch((err) => {
404
+ logger.warn({ hookId: hook.id, err }, `Late rejection from hook after timeout`);
405
+ });
406
+ const timeoutPromise = new Promise<never>((_, reject) => {
407
+ timerHandle = setTimeout(
408
+ () => reject(new Error(`Hook ${hook.id} timed out after ${hook.options.timeout}ms`)),
409
+ hook.options.timeout
410
+ );
411
+ (timerHandle as unknown as { unref?: () => void }).unref?.();
412
+ });
413
+ try {
414
+ await Promise.race([hookPromise, timeoutPromise]);
415
+ } finally {
416
+ if (timerHandle) clearTimeout(timerHandle);
417
+ }
418
+ } else {
419
+ // Execute normally
420
+ await hook.callback(event);
421
+ }
422
+ } catch (error) {
423
+ logger.error(`Error executing async hook ${hook.id} for event ${eventType}: ${error}`);
424
+ hadErrors = true;
425
+ }
426
+ })()
427
+ );
428
+ }
429
+ }
430
+
431
+ // Execute all async hooks in parallel with controlled concurrency
432
+ if (asyncPromises.length > 0) {
433
+ await Promise.allSettled(asyncPromises);
434
+ }
435
+
436
+ // Record performance metrics
437
+ const executionTime = performance.now() - startTime;
438
+ recordMetrics(dispatcherState, eventType, executionTime, hadErrors);
439
+ }