@objectql/core 1.8.4 → 1.9.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.
@@ -0,0 +1,564 @@
1
+ /**
2
+ * Formula Engine Implementation
3
+ *
4
+ * Evaluates formula expressions defined in object metadata.
5
+ * Formulas are read-only calculated fields computed at query time.
6
+ *
7
+ * @see @objectql/types/formula for type definitions
8
+ * @see docs/spec/formula.md for complete specification
9
+ */
10
+
11
+ import {
12
+ FormulaContext,
13
+ FormulaEvaluationResult,
14
+ FormulaEvaluationOptions,
15
+ FormulaError,
16
+ FormulaErrorType,
17
+ FormulaValue,
18
+ FormulaDataType,
19
+ FormulaMetadata,
20
+ FormulaEngineConfig,
21
+ FormulaCustomFunction,
22
+ } from '@objectql/types';
23
+
24
+ /**
25
+ * Formula Engine for evaluating JavaScript-style expressions
26
+ *
27
+ * Features:
28
+ * - Field references (e.g., `quantity * unit_price`)
29
+ * - System variables (e.g., `$today`, `$current_user.id`)
30
+ * - Lookup chains (e.g., `account.owner.name`)
31
+ * - Built-in functions (Math, String, Date methods)
32
+ * - Conditional logic (ternary, if/else)
33
+ * - Safe sandbox execution
34
+ */
35
+ export class FormulaEngine {
36
+ private config: FormulaEngineConfig;
37
+ private customFunctions: Record<string, FormulaCustomFunction>;
38
+
39
+ constructor(config: FormulaEngineConfig = {}) {
40
+ this.config = {
41
+ enable_cache: config.enable_cache ?? false,
42
+ cache_ttl: config.cache_ttl ?? 300,
43
+ max_execution_time: config.max_execution_time ?? 0, // 0 means no timeout enforcement
44
+ enable_monitoring: config.enable_monitoring ?? false,
45
+ custom_functions: config.custom_functions ?? {},
46
+ sandbox: {
47
+ enabled: config.sandbox?.enabled ?? true,
48
+ allowed_globals: config.sandbox?.allowed_globals ?? ['Math', 'String', 'Number', 'Boolean', 'Date', 'Array', 'Object'],
49
+ blocked_operations: config.sandbox?.blocked_operations ?? ['eval', 'Function', 'require', 'import'],
50
+ },
51
+ };
52
+ this.customFunctions = this.config.custom_functions || {};
53
+ }
54
+
55
+ /**
56
+ * Evaluate a formula expression
57
+ *
58
+ * @param expression - The JavaScript expression to evaluate
59
+ * @param context - Runtime context with record data, system variables, user context
60
+ * @param dataType - Expected return data type
61
+ * @param options - Evaluation options
62
+ * @returns Evaluation result with value, type, success flag, and optional error
63
+ */
64
+ evaluate(
65
+ expression: string,
66
+ context: FormulaContext,
67
+ dataType: FormulaDataType,
68
+ options: FormulaEvaluationOptions = {}
69
+ ): FormulaEvaluationResult {
70
+ const startTime = Date.now();
71
+ const timeout = options.timeout ?? this.config.max_execution_time ?? 0;
72
+
73
+ try {
74
+ // Validate expression
75
+ if (!expression || expression.trim() === '') {
76
+ throw new FormulaError(
77
+ FormulaErrorType.SYNTAX_ERROR,
78
+ 'Formula expression cannot be empty',
79
+ expression
80
+ );
81
+ }
82
+
83
+ // Prepare evaluation context
84
+ const evalContext = this.buildEvaluationContext(context);
85
+
86
+ // Execute expression with timeout
87
+ const value = this.executeExpression(expression, evalContext, timeout, options);
88
+
89
+ // Validate and coerce result type
90
+ const coercedValue = this.coerceValue(value, dataType, expression);
91
+
92
+ const executionTime = Date.now() - startTime;
93
+
94
+ return {
95
+ value: coercedValue,
96
+ type: dataType,
97
+ success: true,
98
+ execution_time: executionTime,
99
+ };
100
+ } catch (error) {
101
+ const executionTime = Date.now() - startTime;
102
+
103
+ if (error instanceof FormulaError) {
104
+ return {
105
+ value: null,
106
+ type: dataType,
107
+ success: false,
108
+ error: error.message,
109
+ stack: error.stack,
110
+ execution_time: executionTime,
111
+ };
112
+ }
113
+
114
+ // Wrap unknown errors
115
+ return {
116
+ value: null,
117
+ type: dataType,
118
+ success: false,
119
+ error: error instanceof Error ? error.message : String(error),
120
+ stack: error instanceof Error ? error.stack : undefined,
121
+ execution_time: executionTime,
122
+ };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Build the evaluation context from FormulaContext
128
+ * Creates a safe object with field values, system variables, and user context
129
+ */
130
+ private buildEvaluationContext(context: FormulaContext): Record<string, any> {
131
+ const evalContext: Record<string, any> = {};
132
+
133
+ // Add all record fields to context
134
+ for (const [key, value] of Object.entries(context.record)) {
135
+ evalContext[key] = value;
136
+ }
137
+
138
+ // Add system variables with $ prefix
139
+ evalContext.$today = context.system.today;
140
+ evalContext.$now = context.system.now;
141
+ evalContext.$year = context.system.year;
142
+ evalContext.$month = context.system.month;
143
+ evalContext.$day = context.system.day;
144
+ evalContext.$hour = context.system.hour;
145
+ evalContext.$minute = context.system.minute;
146
+ evalContext.$second = context.system.second;
147
+
148
+ // Add current user context
149
+ evalContext.$current_user = context.current_user;
150
+
151
+ // Add record context flags
152
+ evalContext.$is_new = context.is_new;
153
+ evalContext.$record_id = context.record_id;
154
+
155
+ // Add custom functions
156
+ for (const [name, func] of Object.entries(this.customFunctions)) {
157
+ evalContext[name] = func;
158
+ }
159
+
160
+ return evalContext;
161
+ }
162
+
163
+ /**
164
+ * Execute the expression in a sandboxed environment
165
+ *
166
+ * SECURITY NOTE: Uses Function constructor for dynamic evaluation.
167
+ * While we check for blocked operations, this is not a complete security sandbox.
168
+ * For production use with untrusted formulas, consider using a proper sandboxing library
169
+ * like vm2 or implementing an AST-based evaluator.
170
+ */
171
+ private executeExpression(
172
+ expression: string,
173
+ context: Record<string, any>,
174
+ timeout: number,
175
+ options: FormulaEvaluationOptions
176
+ ): FormulaValue {
177
+ // Check for blocked operations
178
+ // NOTE: This is a basic check using string matching. It will have false positives
179
+ // (e.g., a field named 'evaluation' contains 'eval') and can be bypassed
180
+ // (e.g., using this['eval'] or globalThis['eval']).
181
+ // For production security with untrusted formulas, use AST parsing or vm2.
182
+ if (this.config.sandbox?.enabled) {
183
+ const blockedOps = this.config.sandbox.blocked_operations || [];
184
+ for (const op of blockedOps) {
185
+ if (expression.includes(op)) {
186
+ throw new FormulaError(
187
+ FormulaErrorType.SECURITY_VIOLATION,
188
+ `Blocked operation detected: ${op}`,
189
+ expression
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ try {
196
+ // Create function parameters from context keys
197
+ const paramNames = Object.keys(context);
198
+ const paramValues = Object.values(context);
199
+
200
+ // Wrap expression to handle both expression-style and statement-style formulas
201
+ const wrappedExpression = this.wrapExpression(expression);
202
+
203
+ // Create and execute function
204
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
205
+ const func = new Function(...paramNames, wrappedExpression);
206
+
207
+ // Execute with timeout protection
208
+ const result = this.executeWithTimeout(func, paramValues, timeout);
209
+
210
+ return result as FormulaValue;
211
+ } catch (error) {
212
+ if (error instanceof FormulaError) {
213
+ throw error;
214
+ }
215
+
216
+ // Parse JavaScript errors
217
+ const err = error as Error;
218
+
219
+ if (error instanceof ReferenceError) {
220
+ throw new FormulaError(
221
+ FormulaErrorType.FIELD_NOT_FOUND,
222
+ `Referenced field not found: ${err.message}`,
223
+ expression,
224
+ { original_error: err.message }
225
+ );
226
+ }
227
+
228
+ if (error instanceof TypeError) {
229
+ throw new FormulaError(
230
+ FormulaErrorType.TYPE_ERROR,
231
+ `Type error in formula: ${err.message}`,
232
+ expression,
233
+ { original_error: err.message }
234
+ );
235
+ }
236
+
237
+ if (error instanceof SyntaxError) {
238
+ throw new FormulaError(
239
+ FormulaErrorType.SYNTAX_ERROR,
240
+ `Syntax error in formula: ${err.message}`,
241
+ expression,
242
+ { original_error: err.message }
243
+ );
244
+ }
245
+
246
+ throw new FormulaError(
247
+ FormulaErrorType.RUNTIME_ERROR,
248
+ `Runtime error: ${error instanceof Error ? err.message : String(error)}`,
249
+ expression,
250
+ { original_error: error instanceof Error ? err.message : String(error) }
251
+ );
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Wrap expression to handle both expression and statement styles
257
+ */
258
+ private wrapExpression(expression: string): string {
259
+ const trimmed = expression.trim();
260
+
261
+ // If it contains a return statement or is a block, use as-is
262
+ if (trimmed.includes('return ') || (trimmed.startsWith('{') && trimmed.endsWith('}'))) {
263
+ return trimmed;
264
+ }
265
+
266
+ // If it's multi-line with if/else, wrap in a function body
267
+ if (trimmed.includes('\n') || trimmed.match(/if\s*\(/)) {
268
+ return trimmed;
269
+ }
270
+
271
+ // Otherwise, treat as expression and return it
272
+ return `return (${trimmed});`;
273
+ }
274
+
275
+ /**
276
+ * Execute function with timeout protection
277
+ *
278
+ * NOTE: This synchronous implementation **cannot** pre-emptively interrupt execution.
279
+ * To avoid giving a false sense of safety, any positive finite timeout configuration
280
+ * is rejected up-front. Callers must not rely on timeout-based protection in this
281
+ * runtime; instead, formulas must be written to be fast and side-effect free.
282
+ */
283
+ private executeWithTimeout(
284
+ func: Function,
285
+ args: any[],
286
+ timeout: number
287
+ ): unknown {
288
+ // Reject any positive finite timeout to avoid misleading "protection" semantics.
289
+ if (Number.isFinite(timeout) && timeout > 0) {
290
+ throw new FormulaError(
291
+ FormulaErrorType.TIMEOUT,
292
+ 'Formula timeout enforcement is not supported for synchronous execution. ' +
293
+ 'Remove the timeout configuration or migrate to an async/isolated runtime ' +
294
+ 'that can safely interrupt long-running formulas.',
295
+ '',
296
+ { requestedTimeoutMs: timeout }
297
+ );
298
+ }
299
+
300
+ // No timeout configured (or non-positive/invalid value): execute directly.
301
+ return func(...args);
302
+ }
303
+
304
+ /**
305
+ * Coerce the result value to the expected data type
306
+ */
307
+ private coerceValue(value: unknown, dataType: FormulaDataType, expression: string): FormulaValue {
308
+ // Handle null/undefined
309
+ if (value === null || value === undefined) {
310
+ return null;
311
+ }
312
+
313
+ try {
314
+ switch (dataType) {
315
+ case 'number':
316
+ case 'currency':
317
+ case 'percent':
318
+ if (typeof value === 'number') {
319
+ // Check for division by zero result
320
+ if (!isFinite(value)) {
321
+ throw new FormulaError(
322
+ FormulaErrorType.DIVISION_BY_ZERO,
323
+ 'Formula resulted in Infinity or NaN (possible division by zero)',
324
+ expression
325
+ );
326
+ }
327
+ return value;
328
+ }
329
+ if (typeof value === 'string') {
330
+ const num = Number(value);
331
+ if (isNaN(num)) {
332
+ throw new FormulaError(
333
+ FormulaErrorType.TYPE_ERROR,
334
+ `Cannot convert "${value}" to number`,
335
+ expression
336
+ );
337
+ }
338
+ return num;
339
+ }
340
+ if (typeof value === 'boolean') {
341
+ return value ? 1 : 0;
342
+ }
343
+ throw new FormulaError(
344
+ FormulaErrorType.TYPE_ERROR,
345
+ `Expected number, got ${typeof value}`,
346
+ expression
347
+ );
348
+
349
+ case 'text':
350
+ return String(value);
351
+
352
+ case 'boolean':
353
+ return Boolean(value);
354
+
355
+ case 'date':
356
+ case 'datetime':
357
+ if (value instanceof Date) {
358
+ return value;
359
+ }
360
+ if (typeof value === 'string') {
361
+ const date = new Date(value);
362
+ if (isNaN(date.getTime())) {
363
+ throw new FormulaError(
364
+ FormulaErrorType.TYPE_ERROR,
365
+ `Cannot convert "${value}" to date`,
366
+ expression
367
+ );
368
+ }
369
+ return date;
370
+ }
371
+ if (typeof value === 'number') {
372
+ return new Date(value);
373
+ }
374
+ throw new FormulaError(
375
+ FormulaErrorType.TYPE_ERROR,
376
+ `Expected date, got ${typeof value}`,
377
+ expression
378
+ );
379
+
380
+ default:
381
+ return value as FormulaValue;
382
+ }
383
+ } catch (error) {
384
+ if (error instanceof FormulaError) {
385
+ throw error;
386
+ }
387
+ throw new FormulaError(
388
+ FormulaErrorType.TYPE_ERROR,
389
+ `Type coercion failed: ${error instanceof Error ? error.message : String(error)}`,
390
+ expression
391
+ );
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Extract metadata from a formula expression
397
+ * Analyzes the expression to determine dependencies and complexity
398
+ */
399
+ extractMetadata(
400
+ fieldName: string,
401
+ expression: string,
402
+ dataType: FormulaDataType
403
+ ): FormulaMetadata {
404
+ const dependencies: string[] = [];
405
+ const lookupChains: string[] = [];
406
+ const systemVariables: string[] = [];
407
+ const validationErrors: string[] = [];
408
+
409
+ try {
410
+ // Extract system variables (start with $)
411
+ const systemVarPattern = /\$([a-z_][a-z0-9_]*)/gi;
412
+ const systemMatches = Array.from(expression.matchAll(systemVarPattern));
413
+ for (const match of systemMatches) {
414
+ const sysVar = '$' + match[1];
415
+ if (!systemVariables.includes(sysVar)) {
416
+ systemVariables.push(sysVar);
417
+ }
418
+ }
419
+
420
+ // Extract field references (but not system variables or keywords)
421
+ const fieldPattern = /\b([a-z_][a-z0-9_]*)\b/gi;
422
+ const matches = Array.from(expression.matchAll(fieldPattern));
423
+
424
+ for (const match of matches) {
425
+ const identifier = match[1];
426
+
427
+ // Skip JavaScript keywords and built-ins
428
+ if (this.isJavaScriptKeyword(identifier)) {
429
+ continue;
430
+ }
431
+
432
+ // Field references
433
+ if (!dependencies.includes(identifier)) {
434
+ dependencies.push(identifier);
435
+ }
436
+ }
437
+
438
+ // Extract lookup chains (e.g., account.owner.name)
439
+ const lookupPattern = /\b([a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)+)\b/gi;
440
+ const lookupMatches = Array.from(expression.matchAll(lookupPattern));
441
+
442
+ for (const match of lookupMatches) {
443
+ const chain = match[1];
444
+ if (!lookupChains.includes(chain)) {
445
+ lookupChains.push(chain);
446
+ }
447
+ }
448
+
449
+ // Basic validation
450
+ if (!expression.trim()) {
451
+ validationErrors.push('Expression cannot be empty');
452
+ }
453
+
454
+ // Estimate complexity
455
+ const complexity = this.estimateComplexity(expression);
456
+
457
+ return {
458
+ field_name: fieldName,
459
+ expression,
460
+ data_type: dataType,
461
+ dependencies,
462
+ lookup_chains: lookupChains,
463
+ system_variables: systemVariables,
464
+ is_valid: validationErrors.length === 0,
465
+ validation_errors: validationErrors.length > 0 ? validationErrors : undefined,
466
+ complexity,
467
+ };
468
+ } catch (error) {
469
+ return {
470
+ field_name: fieldName,
471
+ expression,
472
+ data_type: dataType,
473
+ dependencies: [],
474
+ lookup_chains: [],
475
+ system_variables: [],
476
+ is_valid: false,
477
+ validation_errors: [
478
+ error instanceof Error ? error.message : String(error),
479
+ ],
480
+ complexity: 'simple',
481
+ };
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Check if identifier is a JavaScript keyword or built-in
487
+ */
488
+ private isJavaScriptKeyword(identifier: string): boolean {
489
+ const keywords = new Set([
490
+ 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue',
491
+ 'return', 'function', 'var', 'let', 'const', 'true', 'false', 'null',
492
+ 'undefined', 'this', 'new', 'typeof', 'instanceof', 'in', 'of',
493
+ 'Math', 'String', 'Number', 'Boolean', 'Date', 'Array', 'Object',
494
+ 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
495
+ ]);
496
+ return keywords.has(identifier);
497
+ }
498
+
499
+ /**
500
+ * Estimate formula complexity based on heuristics
501
+ */
502
+ private estimateComplexity(expression: string): 'simple' | 'medium' | 'complex' {
503
+ const lines = expression.split('\n').length;
504
+ const hasConditionals = /if\s*\(|switch\s*\(|\?/.test(expression);
505
+ const hasLoops = /for\s*\(|while\s*\(/.test(expression);
506
+ const hasLookups = /\.[a-z_][a-z0-9_]*/.test(expression);
507
+
508
+ if (lines > 20 || hasLoops) {
509
+ return 'complex';
510
+ }
511
+
512
+ if (lines > 5 || hasConditionals || hasLookups) {
513
+ return 'medium';
514
+ }
515
+
516
+ return 'simple';
517
+ }
518
+
519
+ /**
520
+ * Register a custom function for use in formulas
521
+ */
522
+ registerFunction(name: string, func: FormulaCustomFunction): void {
523
+ this.customFunctions[name] = func;
524
+ }
525
+
526
+ /**
527
+ * Validate a formula expression without executing it
528
+ *
529
+ * SECURITY NOTE: Uses Function constructor for syntax validation.
530
+ * This doesn't execute the code but creates a function object.
531
+ * For stricter validation, consider using a parser library like @babel/parser.
532
+ */
533
+ validate(expression: string): { valid: boolean; errors: string[] } {
534
+ const errors: string[] = [];
535
+
536
+ if (!expression || expression.trim() === '') {
537
+ errors.push('Expression cannot be empty');
538
+ return { valid: false, errors };
539
+ }
540
+
541
+ // Check for blocked operations
542
+ if (this.config.sandbox?.enabled) {
543
+ const blockedOps = this.config.sandbox.blocked_operations || [];
544
+ for (const op of blockedOps) {
545
+ if (expression.includes(op)) {
546
+ errors.push(`Blocked operation detected: ${op}`);
547
+ }
548
+ }
549
+ }
550
+
551
+ // Try to parse as JavaScript (basic syntax check)
552
+ try {
553
+ const wrappedExpression = this.wrapExpression(expression);
554
+ new Function('', wrappedExpression);
555
+ } catch (error) {
556
+ errors.push(`Syntax error: ${error instanceof Error ? error.message : String(error)}`);
557
+ }
558
+
559
+ return {
560
+ valid: errors.length === 0,
561
+ errors,
562
+ };
563
+ }
564
+ }
package/src/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from './object';
7
7
  export * from './validator';
8
8
  export * from './util';
9
9
  export * from './ai-agent';
10
+ export * from './formula-engine';
package/src/repository.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult } from '@objectql/types';
1
+ import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult, FormulaContext } from '@objectql/types';
2
2
  import { Validator } from './validator';
3
+ import { FormulaEngine } from './formula-engine';
3
4
 
4
5
  export class ObjectRepository {
5
6
  private validator: Validator;
7
+ private formulaEngine: FormulaEngine;
6
8
 
7
9
  constructor(
8
10
  private objectName: string,
@@ -10,6 +12,7 @@ export class ObjectRepository {
10
12
  private app: IObjectQL
11
13
  ) {
12
14
  this.validator = new Validator();
15
+ this.formulaEngine = new FormulaEngine();
13
16
  }
14
17
 
15
18
  private getDriver(): Driver {
@@ -130,6 +133,74 @@ export class ObjectRepository {
130
133
  }
131
134
  }
132
135
 
136
+ /**
137
+ * Evaluate formula fields for a record
138
+ * Adds computed formula field values to the record
139
+ */
140
+ private evaluateFormulas(record: any): any {
141
+ const schema = this.getSchema();
142
+ const now = new Date();
143
+
144
+ // Build formula context
145
+ const formulaContext: FormulaContext = {
146
+ record,
147
+ system: {
148
+ today: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
149
+ now: now,
150
+ year: now.getFullYear(),
151
+ month: now.getMonth() + 1,
152
+ day: now.getDate(),
153
+ hour: now.getHours(),
154
+ minute: now.getMinutes(),
155
+ second: now.getSeconds(),
156
+ },
157
+ current_user: {
158
+ id: this.context.userId || '',
159
+ // TODO: Retrieve actual user name from user object if available
160
+ name: undefined,
161
+ email: undefined,
162
+ role: this.context.roles?.[0],
163
+ },
164
+ is_new: false,
165
+ record_id: record._id || record.id,
166
+ };
167
+
168
+ // Evaluate each formula field
169
+ for (const [fieldName, fieldConfig] of Object.entries(schema.fields)) {
170
+ if (fieldConfig.type === 'formula' && fieldConfig.formula) {
171
+ const result = this.formulaEngine.evaluate(
172
+ fieldConfig.formula,
173
+ formulaContext,
174
+ fieldConfig.data_type || 'text',
175
+ { strict: true }
176
+ );
177
+
178
+ if (result.success) {
179
+ record[fieldName] = result.value;
180
+ } else {
181
+ // In case of error, set to null and log for diagnostics
182
+ record[fieldName] = null;
183
+ // Formula evaluation should not throw here, but we need observability
184
+ // This logging is intentionally minimal and side-effect free
185
+ // eslint-disable-next-line no-console
186
+ console.error(
187
+ '[ObjectQL][FormulaEngine] Formula evaluation failed',
188
+ {
189
+ objectName: this.objectName,
190
+ fieldName,
191
+ recordId: formulaContext.record_id,
192
+ formula: fieldConfig.formula,
193
+ error: result.error,
194
+ stack: result.stack,
195
+ }
196
+ );
197
+ }
198
+ }
199
+ }
200
+
201
+ return record;
202
+ }
203
+
133
204
  async find(query: UnifiedQuery = {}): Promise<any[]> {
134
205
  const hookCtx: RetrievalHookContext = {
135
206
  ...this.context,
@@ -145,7 +216,10 @@ export class ObjectRepository {
145
216
  // TODO: Apply basic filters like spaceId
146
217
  const results = await this.getDriver().find(this.objectName, hookCtx.query || {}, this.getOptions());
147
218
 
148
- hookCtx.result = results;
219
+ // Evaluate formulas for each result
220
+ const resultsWithFormulas = results.map(record => this.evaluateFormulas(record));
221
+
222
+ hookCtx.result = resultsWithFormulas;
149
223
  await this.app.triggerHook('afterFind', this.objectName, hookCtx);
150
224
 
151
225
  return hookCtx.result as any[];
@@ -166,7 +240,10 @@ export class ObjectRepository {
166
240
 
167
241
  const result = await this.getDriver().findOne(this.objectName, idOrQuery, hookCtx.query, this.getOptions());
168
242
 
169
- hookCtx.result = result;
243
+ // Evaluate formulas if result exists
244
+ const resultWithFormulas = result ? this.evaluateFormulas(result) : result;
245
+
246
+ hookCtx.result = resultWithFormulas;
170
247
  await this.app.triggerHook('afterFind', this.objectName, hookCtx);
171
248
  return hookCtx.result;
172
249
  } else {