@forgehive/task 0.1.7 → 0.1.9

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/src/index.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { Schema, type SchemaType, type InferSchema, type SchemaDescription } from '@forgehive/schema'
2
- import { createBoundary, type Mode, type Boundaries, type WrappedBoundaries, type WrappedBoundaryFunction } from './utils/boundary'
2
+ import {
3
+ createBoundary,
4
+ type Mode,
5
+ type Boundaries,
6
+ type WrappedBoundaries,
7
+ type WrappedBoundaryFunction,
8
+ type BoundaryRecord,
9
+ type BoundaryTapeData
10
+ } from './utils/boundary'
3
11
 
4
12
  export interface Task {
5
13
  id: string;
@@ -11,7 +19,17 @@ export interface Task {
11
19
  export type BaseFunction = (...args: any[]) => any
12
20
 
13
21
  // Re-export the boundary types for external use
14
- export type { BoundaryFunction, WrappedBoundaryFunction, Boundaries, WrappedBoundaries, Mode } from './utils/boundary'
22
+ export type {
23
+ BoundaryFunction,
24
+ WrappedBoundaryFunction,
25
+ Boundaries,
26
+ WrappedBoundaries,
27
+ Mode,
28
+ BoundarySuccessRecord,
29
+ BoundaryErrorRecord,
30
+ BoundaryRecord,
31
+ BoundaryTapeData
32
+ } from './utils/boundary'
15
33
 
16
34
  // Re-export Schema for external use
17
35
  export { Schema }
@@ -20,7 +38,14 @@ export interface TaskConfig<B extends Boundaries = Boundaries> {
20
38
  schema?: Schema<Record<string, SchemaType>>
21
39
  mode?: Mode
22
40
  boundaries?: B
23
- boundariesData?: Record<string, unknown>
41
+ boundariesData?: BoundaryTapeData
42
+ }
43
+
44
+ // Interface for safeReplay configuration
45
+ export interface ReplayConfig<B extends Boundaries = Boundaries> {
46
+ boundaries: {
47
+ [K in keyof B]?: Mode
48
+ }
24
49
  }
25
50
 
26
51
  // ToDo: Add a type for the boundaries data
@@ -39,11 +64,7 @@ export interface TaskRecord<InputType = unknown, OutputType = unknown> {
39
64
  }
40
65
 
41
66
  // Make BoundaryLog generic
42
- export type BoundaryLog<I extends unknown[] = unknown[], O = unknown> = {
43
- input: I
44
- output: O | null
45
- error: string | null
46
- }
67
+ export type BoundaryLog<I extends unknown[] = unknown[], O = unknown> = BoundaryRecord<I, O>;
47
68
 
48
69
  // Mapped type for boundaries
49
70
  export type BoundaryLogsFor<B extends Boundaries> = {
@@ -89,7 +110,7 @@ export interface TaskInstanceType<Func extends BaseFunction = BaseFunction, B ex
89
110
  // Boundary methods
90
111
  asBoundary: () => (args: Parameters<Func>[0]) => Promise<ReturnType<Func>>
91
112
  getBoundaries: () => WrappedBoundaries<B>
92
- setBoundariesData: (boundariesData: Record<string, unknown>) => void
113
+ setBoundariesData: (boundariesData: BoundaryTapeData) => void
93
114
  getBondariesData: () => Record<string, unknown>
94
115
 
95
116
  // Mocking methods for testing
@@ -99,8 +120,17 @@ export interface TaskInstanceType<Func extends BaseFunction = BaseFunction, B ex
99
120
 
100
121
  run: (argv?: Parameters<Func>[0]) => Promise<ReturnType<Func>>
101
122
  safeRun: (argv?: Parameters<Func>[0]) => Promise<[ReturnType<Func> | null, Error | null, ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>]>
123
+
124
+ // Method for replaying task execution
125
+ safeReplay: (
126
+ executionLog: ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>,
127
+ config: ReplayConfig<B>
128
+ ) => Promise<[ReturnType<Func> | null, Error | null, ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>]>
102
129
  }
103
130
 
131
+ // Define a type for the accumulated boundary data
132
+ type BoundaryData = Array<{input: unknown[], output?: unknown}>
133
+
104
134
  // Helper type to infer schema type
105
135
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
136
  export type InferSchemaType<S> = S extends Schema<any> ? InferSchema<S> : Record<string, unknown>;
@@ -110,9 +140,6 @@ export type TaskFunction<S, B extends Boundaries> =
110
140
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
141
  (argv: InferSchemaType<S>, boundaries: WrappedBoundaries<B>) => Promise<any>;
112
142
 
113
- // Define a type for the accumulated boundary data
114
- type BoundaryData = Array<{input: unknown[], output?: unknown}>
115
-
116
143
  export const Task = class Task<
117
144
  B extends Boundaries = Boundaries,
118
145
  Func extends BaseFunction = BaseFunction
@@ -125,7 +152,7 @@ export const Task = class Task<
125
152
  _description?: string
126
153
 
127
154
  _boundariesDefinition: B
128
- _boundariesData: Record<string, unknown> | null
155
+ _boundariesData: BoundaryTapeData | null
129
156
  _accumulatedBoundariesData: Record<string, BoundaryData> = {}
130
157
 
131
158
  // For storing mocks
@@ -250,7 +277,7 @@ export const Task = class Task<
250
277
  })
251
278
  }
252
279
 
253
- setBoundariesData (boundariesData: Record<string, unknown>): void {
280
+ setBoundariesData (boundariesData: BoundaryTapeData): void {
254
281
  this._boundariesData = boundariesData
255
282
 
256
283
  // Update accumulated data as well
@@ -297,31 +324,36 @@ export const Task = class Task<
297
324
  _createBounderies ({
298
325
  definition,
299
326
  baseData,
300
- mode = 'proxy'
327
+ mode = 'proxy',
328
+ boundaryModes = {}
301
329
  }: {
302
330
  definition: B;
303
- baseData: Record<string, unknown> | null;
331
+ baseData: BoundaryTapeData | null;
304
332
  mode?: Mode;
333
+ boundaryModes?: Record<string, Mode | undefined>;
305
334
  }): WrappedBoundaries<B> {
306
335
  const boundariesFns: Record<string, WrappedBoundaryFunction> = {}
307
336
 
308
337
  for (const name in definition) {
338
+ // Get the configured mode for this boundary or use default
339
+ const boundaryMode = boundaryModes[name] || mode
340
+
309
341
  // Check if we have a mock for this boundary
310
342
  if (this._boundaryMocks[name]) {
311
343
  boundariesFns[name] = this._boundaryMocks[name]
312
344
  continue
313
345
  }
314
346
 
315
- // Otherwise create the normal boundary
347
+ // Create the boundary
316
348
  const boundary = createBoundary(definition[name])
317
349
 
318
350
  if (baseData !== null && typeof baseData[name] !== 'undefined') {
319
- const tape = baseData[name]
320
-
321
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
322
- boundary.setTape(tape as any)
351
+ const boundaryData = baseData[name] as Array<BoundaryRecord<Parameters<B[Extract<keyof B, string>]>, Awaited<ReturnType<B[Extract<keyof B, string>]>>>>
352
+ boundary.setTape(boundaryData)
323
353
  }
324
- boundary.setMode(mode as Mode)
354
+
355
+ // Set the mode after setting the tape
356
+ boundary.setMode(boundaryMode)
325
357
 
326
358
  boundariesFns[name] = boundary
327
359
  }
@@ -436,6 +468,120 @@ export const Task = class Task<
436
468
  return [output, error, logItem]
437
469
  }
438
470
 
471
+ async safeReplay(
472
+ executionLog: ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>,
473
+ config: ReplayConfig<B> = {
474
+ boundaries: {}
475
+ }
476
+ ): Promise<[
477
+ ReturnType<Func> | null,
478
+ Error | null,
479
+ ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>
480
+ ]> {
481
+ // Extract the input from the execution log
482
+ const argv = executionLog.input
483
+
484
+ // Initialize log item for this replay
485
+ const logItem: ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B> = {
486
+ input: argv,
487
+ boundaries: {} as BoundaryLogsFor<B>
488
+ }
489
+
490
+ // Create boundaries for this replay execution with custom modes based on config
491
+ const boundariesConfig: BoundaryTapeData = {}
492
+
493
+ // Setup boundary data for replay mode boundaries
494
+ for (const name in this._boundariesDefinition) {
495
+ // Check if this boundary is configured for replay mode
496
+ const mode = config.boundaries[name] || 'proxy'
497
+
498
+ if (mode === 'replay' && executionLog.boundaries[name]) {
499
+ // Add boundary data from the execution log for replay mode
500
+ boundariesConfig[name] = executionLog.boundaries[name]
501
+ }
502
+ }
503
+
504
+ // Create fresh boundaries for this execution
505
+ const executionBoundaries = this._createBounderies({
506
+ definition: this._boundariesDefinition,
507
+ baseData: boundariesConfig,
508
+ mode: 'proxy',
509
+ boundaryModes: config.boundaries
510
+ })
511
+
512
+ // Start run for each boundary
513
+ for (const name in executionBoundaries) {
514
+ const boundary = executionBoundaries[name]
515
+ boundary.startRun()
516
+ }
517
+
518
+ // Handle schema validation - reusing the input from the execution log
519
+ if (this._schema) {
520
+ const validation = this._schema.safeParse(argv)
521
+ if (!validation.success) {
522
+ const errorDetails = validation.error?.errors.map(err =>
523
+ `${err.path.join('.')}: ${err.message}`
524
+ ).join(', ')
525
+
526
+ const errorMessage = errorDetails
527
+ ? `Invalid input on: ${errorDetails}`
528
+ : 'Invalid input'
529
+
530
+ logItem.error = errorMessage
531
+ logItem.output = executionLog.output // Keep the original output
532
+
533
+ // Copy the boundary data from the execution log
534
+ logItem.boundaries = executionLog.boundaries
535
+
536
+ this.emit(logItem)
537
+ return [null, new Error(errorMessage), logItem]
538
+ }
539
+ }
540
+
541
+ let output: ReturnType<Func> | null = null
542
+ let error: Error | null = null
543
+
544
+ try {
545
+ // Execute the task function with replay boundaries
546
+ output = await this._fn(
547
+ argv,
548
+ executionBoundaries as unknown as Parameters<Func>[1]
549
+ )
550
+
551
+ logItem.output = output
552
+ } catch (caughtError) {
553
+ const errorMessage = caughtError instanceof Error ? caughtError.message : String(caughtError)
554
+ logItem.error = errorMessage
555
+ error = new Error(errorMessage)
556
+ }
557
+
558
+ // Process boundary data after execution
559
+ const boundariesRunLog: BoundaryLogsFor<B> = {} as BoundaryLogsFor<B>
560
+
561
+ for (const name in executionBoundaries) {
562
+ const boundary = executionBoundaries[name]
563
+ const runData = boundary.getRunData()
564
+
565
+ // For boundaries in replay mode, use the original log data instead
566
+ const mode = config.boundaries[name] || 'proxy'
567
+ if (mode === 'replay' && executionLog.boundaries[name]) {
568
+ boundariesRunLog[name as keyof B] = executionLog.boundaries[name as keyof B]
569
+ } else {
570
+ // For other modes, use the actual run data
571
+ boundariesRunLog[name as keyof B] = runData as unknown as BoundaryLogsFor<B>[typeof name]
572
+ }
573
+ }
574
+
575
+ // Set boundaries in log item before emitting
576
+ logItem.boundaries = boundariesRunLog
577
+
578
+ // Emit the log item
579
+ this.emit(logItem)
580
+
581
+ // Return the output, error, and log item
582
+ return [output, error, logItem]
583
+ }
584
+
439
585
  async run (argv?: Parameters<Func>[0]): Promise<ReturnType<Func>> {
440
586
  const [result, error] = await this.safeRun(argv)
441
587
 
@@ -0,0 +1,385 @@
1
+ import { Schema } from '@forgehive/schema'
2
+ import { createTask, ExecutionRecord } from '../index'
3
+
4
+ describe('Complex boundary replay tests', () => {
5
+ // Define types for our portfolio data
6
+ type Stock = {
7
+ ticker: string;
8
+ quantity: number;
9
+ }
10
+
11
+ type Portfolio = {
12
+ id: string;
13
+ userId: string;
14
+ stocks: Stock[];
15
+ }
16
+
17
+ // Test data
18
+ let priceData: Record<string, number>
19
+ let portfolioData: Record<string, Portfolio>
20
+
21
+ // Boundaries for the portfolio task
22
+ let boundaries: {
23
+ fetchPrice: (ticker: string) => Promise<number>;
24
+ fetchPortfolio: (userId: string) => Promise<Portfolio>;
25
+ }
26
+
27
+ // The task - using eslint-disable to allow any type for this test
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ let calculatePortfolioValue: any
30
+
31
+ beforeEach(() => {
32
+ // Setup mock data
33
+ priceData = {
34
+ 'AAPL': 182.63,
35
+ 'MSFT': 421.90,
36
+ 'GOOGL': 171.04,
37
+ 'AMZN': 184.72
38
+ }
39
+
40
+ portfolioData = {
41
+ 'user1': {
42
+ id: 'portfolio1',
43
+ userId: 'user1',
44
+ stocks: [
45
+ { ticker: 'AAPL', quantity: 10 },
46
+ { ticker: 'MSFT', quantity: 5 },
47
+ { ticker: 'GOOGL', quantity: 8 }
48
+ ]
49
+ },
50
+ 'user2': {
51
+ id: 'portfolio2',
52
+ userId: 'user2',
53
+ stocks: [
54
+ { ticker: 'AMZN', quantity: 15 },
55
+ { ticker: 'MSFT', quantity: 3 }
56
+ ]
57
+ }
58
+ }
59
+
60
+ // Define boundaries with realistic functions
61
+ boundaries = {
62
+ fetchPrice: async (ticker: string): Promise<number> => {
63
+ // Check if we have a price for this ticker
64
+ if (!priceData[ticker]) {
65
+ throw new Error(`Price data not available for ticker: ${ticker}`)
66
+ }
67
+ return priceData[ticker]
68
+ },
69
+ fetchPortfolio: async (userId: string): Promise<Portfolio> => {
70
+ // Check if we have a portfolio for this user
71
+ if (!portfolioData[userId]) {
72
+ throw new Error(`Portfolio not found for user: ${userId}`)
73
+ }
74
+ return portfolioData[userId]
75
+ }
76
+ }
77
+
78
+ // Create a schema for the task
79
+ const schema = new Schema({
80
+ userId: Schema.string()
81
+ })
82
+
83
+ // Create the portfolio value calculation task
84
+ calculatePortfolioValue = createTask(
85
+ schema,
86
+ boundaries,
87
+ async ({ userId }, { fetchPortfolio, fetchPrice }) => {
88
+ // First fetch the portfolio for the user
89
+ const portfolio = await fetchPortfolio(userId)
90
+
91
+ // Then calculate the value of each stock and the total portfolio value
92
+ const stocksWithValue = await Promise.all(
93
+ portfolio.stocks.map(async (stock) => {
94
+ const price = await fetchPrice(stock.ticker)
95
+ const value = price * stock.quantity
96
+
97
+ return {
98
+ ticker: stock.ticker,
99
+ quantity: stock.quantity,
100
+ price,
101
+ value
102
+ }
103
+ })
104
+ )
105
+
106
+ // Calculate total value
107
+ const totalValue = stocksWithValue.reduce((sum, stock) => sum + stock.value, 0)
108
+
109
+ // Return portfolio value information
110
+ return {
111
+ id: portfolio.id,
112
+ userId: portfolio.userId,
113
+ totalValue,
114
+ stocks: stocksWithValue
115
+ }
116
+ }
117
+ )
118
+ })
119
+
120
+ it('Should calculate portfolio value using multiple boundaries', async () => {
121
+ // Run the task for user1
122
+ const result = await calculatePortfolioValue.run({ userId: 'user1' })
123
+
124
+ // Verify the structure of the result
125
+ expect(result).toHaveProperty('id', 'portfolio1')
126
+ expect(result).toHaveProperty('userId', 'user1')
127
+ expect(result).toHaveProperty('totalValue')
128
+ expect(result).toHaveProperty('stocks')
129
+
130
+ // Verify portfolio calculations
131
+ expect(result.stocks).toHaveLength(3)
132
+
133
+ // Calculate expected values
134
+ const expectedTotalValue =
135
+ (priceData['AAPL'] * 10) +
136
+ (priceData['MSFT'] * 5) +
137
+ (priceData['GOOGL'] * 8)
138
+
139
+ // Verify total value matches expected
140
+ expect(result.totalValue).toBeCloseTo(expectedTotalValue)
141
+
142
+ // Verify each stock's value
143
+ const applStock = result.stocks.find((s: { ticker: string }) => s.ticker === 'AAPL')
144
+ expect(applStock).toBeDefined()
145
+ expect(applStock?.price).toBe(priceData['AAPL'])
146
+ expect(applStock?.value).toBeCloseTo(priceData['AAPL'] * 10)
147
+
148
+ const msftStock = result.stocks.find((s: { ticker: string }) => s.ticker === 'MSFT')
149
+ expect(msftStock).toBeDefined()
150
+ expect(msftStock?.price).toBe(priceData['MSFT'])
151
+ expect(msftStock?.value).toBeCloseTo(priceData['MSFT'] * 5)
152
+
153
+ const googlStock = result.stocks.find((s: { ticker: string }) => s.ticker === 'GOOGL')
154
+ expect(googlStock).toBeDefined()
155
+ expect(googlStock?.price).toBe(priceData['GOOGL'])
156
+ expect(googlStock?.value).toBeCloseTo(priceData['GOOGL'] * 8)
157
+ })
158
+
159
+ it('Should replay portfolio calculation from an execution log', async () => {
160
+ // First create a portfolio value calculation execution log
161
+ const executionLog: ExecutionRecord = {
162
+ input: { userId: 'user1' },
163
+ output: {
164
+ id: 'portfolio1',
165
+ userId: 'user1',
166
+ totalValue: 4363.02,
167
+ stocks: [
168
+ { ticker: 'AAPL', quantity: 10, price: 190.50, value: 1905.00 },
169
+ { ticker: 'MSFT', quantity: 5, price: 405.75, value: 2028.75 },
170
+ { ticker: 'GOOGL', quantity: 8, price: 161.16, value: 429.27 }
171
+ ]
172
+ },
173
+ boundaries: {
174
+ fetchPortfolio: [
175
+ {
176
+ input: ['user1'],
177
+ output: {
178
+ id: 'portfolio1',
179
+ userId: 'user1',
180
+ stocks: [
181
+ { ticker: 'AAPL', quantity: 10 },
182
+ { ticker: 'MSFT', quantity: 5 },
183
+ { ticker: 'GOOGL', quantity: 8 }
184
+ ]
185
+ }
186
+ }
187
+ ],
188
+ fetchPrice: [
189
+ {
190
+ input: ['AAPL'],
191
+ output: 190.50
192
+ },
193
+ {
194
+ input: ['MSFT'],
195
+ output: 405.75
196
+ },
197
+ {
198
+ input: ['GOOGL'],
199
+ output: 161.16
200
+ }
201
+ ]
202
+ }
203
+ }
204
+
205
+ // Use replay mode for all boundaries
206
+ const [replayResult, replayError, replayLog] = await calculatePortfolioValue.safeReplay(
207
+ executionLog,
208
+ {
209
+ boundaries: {
210
+ fetchPortfolio: 'replay',
211
+ fetchPrice: 'replay'
212
+ }
213
+ }
214
+ )
215
+
216
+ // Verify there was no error
217
+ expect(replayError).toBeNull()
218
+
219
+ // Verify the replayed result
220
+ expect(replayResult).toHaveProperty('id', 'portfolio1')
221
+ expect(replayResult).toHaveProperty('userId', 'user1')
222
+ expect(replayResult).toHaveProperty('totalValue', 5223.03)
223
+ expect(replayResult).toHaveProperty('stocks')
224
+ expect(replayResult.stocks).toHaveLength(3)
225
+
226
+ // Check values match the execution log exactly
227
+ const applStock = replayResult.stocks.find((s: { ticker: string }) => s.ticker === 'AAPL')
228
+ expect(applStock?.price).toBe(190.50)
229
+ expect(applStock?.value).toBe(1905.00)
230
+
231
+ // Verify that boundary calls were properly replayed
232
+ expect(replayLog.boundaries.fetchPortfolio).toHaveLength(1)
233
+ expect(replayLog.boundaries.fetchPrice).toHaveLength(3)
234
+ })
235
+
236
+ it('Should handle errors during replay', async () => {
237
+ // Create an execution log with an error in one of the price fetches
238
+ const executionLog: ExecutionRecord = {
239
+ input: { userId: 'user1' },
240
+ error: 'Price data not available for ticker: GOOGL',
241
+ boundaries: {
242
+ fetchPortfolio: [
243
+ {
244
+ input: ['user1'],
245
+ output: {
246
+ id: 'portfolio1',
247
+ userId: 'user1',
248
+ stocks: [
249
+ { ticker: 'AAPL', quantity: 10 },
250
+ { ticker: 'MSFT', quantity: 5 },
251
+ { ticker: 'GOOGL', quantity: 8 }
252
+ ]
253
+ }
254
+ }
255
+ ],
256
+ fetchPrice: [
257
+ {
258
+ input: ['AAPL'],
259
+ output: 190.50
260
+ },
261
+ {
262
+ input: ['MSFT'],
263
+ output: 405.75
264
+ },
265
+ {
266
+ input: ['GOOGL'],
267
+ error: 'Price data not available for ticker: GOOGL'
268
+ }
269
+ ]
270
+ }
271
+ }
272
+
273
+ // Use replay mode for all boundaries
274
+ const [replayResult, replayError, replayLog] = await calculatePortfolioValue.safeReplay(
275
+ executionLog,
276
+ {
277
+ boundaries: {
278
+ fetchPortfolio: 'replay',
279
+ fetchPrice: 'replay'
280
+ }
281
+ }
282
+ )
283
+
284
+ // Verify error was properly replayed
285
+ expect(replayResult).toBeNull()
286
+ expect(replayError).not.toBeNull()
287
+ expect(replayError?.message).toBe('Price data not available for ticker: GOOGL')
288
+
289
+ // Verify that boundary calls were properly replayed up to the error
290
+ expect(replayLog.boundaries.fetchPortfolio).toHaveLength(1)
291
+ expect(replayLog.boundaries.fetchPrice).toHaveLength(3)
292
+
293
+ // Check the error in the boundary
294
+ const googPriceCall = replayLog.boundaries.fetchPrice.find(
295
+ (call: { input?: unknown[] }) => call.input && call.input[0] === 'GOOGL'
296
+ )
297
+ expect(googPriceCall).toBeDefined()
298
+ expect(googPriceCall?.error).toBe('Price data not available for ticker: GOOGL')
299
+ })
300
+
301
+ it('Should support mixed replay with some boundaries in replay mode and others in proxy mode', async () => {
302
+ // Update prices for the test
303
+ priceData['AAPL'] = 195.00 // Different from replay data
304
+
305
+ // Create an execution log with historical data
306
+ const executionLog: ExecutionRecord = {
307
+ input: { userId: 'user1' },
308
+ output: {
309
+ id: 'portfolio1',
310
+ userId: 'user1',
311
+ totalValue: 4363.02,
312
+ stocks: [
313
+ { ticker: 'AAPL', quantity: 10, price: 190.50, value: 1905.00 },
314
+ { ticker: 'MSFT', quantity: 5, price: 405.75, value: 2028.75 },
315
+ { ticker: 'GOOGL', quantity: 8, price: 161.16, value: 429.27 }
316
+ ]
317
+ },
318
+ boundaries: {
319
+ fetchPortfolio: [
320
+ {
321
+ input: ['user1'],
322
+ output: {
323
+ id: 'portfolio1',
324
+ userId: 'user1',
325
+ stocks: [
326
+ { ticker: 'AAPL', quantity: 10 },
327
+ { ticker: 'MSFT', quantity: 5 },
328
+ { ticker: 'GOOGL', quantity: 8 }
329
+ ]
330
+ }
331
+ }
332
+ ],
333
+ fetchPrice: [
334
+ {
335
+ input: ['AAPL'],
336
+ output: 190.50
337
+ },
338
+ {
339
+ input: ['MSFT'],
340
+ output: 405.75
341
+ },
342
+ {
343
+ input: ['GOOGL'],
344
+ output: 161.16
345
+ }
346
+ ]
347
+ }
348
+ }
349
+
350
+ // Use replay mode for portfolio but proxy mode for prices
351
+ const [replayResult, replayError, replayLog] = await calculatePortfolioValue.safeReplay(
352
+ executionLog,
353
+ {
354
+ boundaries: {
355
+ fetchPortfolio: 'replay', // Use replay for portfolio
356
+ fetchPrice: 'proxy' // Use proxy (real execution) for prices
357
+ }
358
+ }
359
+ )
360
+
361
+ // Verify there was no error
362
+ expect(replayError).toBeNull()
363
+
364
+ // The result should reflect the updated AAPL price of 195.00
365
+ expect(replayResult).toHaveProperty('id', 'portfolio1')
366
+ expect(replayResult).toHaveProperty('userId', 'user1')
367
+
368
+ // Calculate expected value with current prices
369
+ const expectedValue =
370
+ (priceData['AAPL'] * 10) + // AAPL: 195.00 * 10
371
+ (priceData['MSFT'] * 5) + // MSFT: 421.90 * 5
372
+ (priceData['GOOGL'] * 8) // GOOGL: 171.04 * 8
373
+
374
+ expect(replayResult.totalValue).toBeCloseTo(expectedValue)
375
+
376
+ // Check AAPL price reflects the current price, not the replay data
377
+ const applStock = replayResult.stocks.find((s: { ticker: string }) => s.ticker === 'AAPL')
378
+ expect(applStock?.price).toBe(195.00)
379
+ expect(applStock?.value).toBeCloseTo(195.00 * 10)
380
+
381
+ // Verify the log shows the mixed sources correctly
382
+ expect(replayLog.boundaries.fetchPortfolio).toHaveLength(1)
383
+ expect(replayLog.boundaries.fetchPrice).toHaveLength(3)
384
+ })
385
+ })