@nocobase/flow-engine 2.0.0-alpha.63 → 2.0.0-alpha.65

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.
@@ -17,10 +17,20 @@ import { FlowExitException, resolveDefaultParams } from '../utils';
17
17
  import { FlowExitAllException } from '../utils/exceptions';
18
18
  import { setupRuntimeContextSteps } from '../utils/setupRuntimeContextSteps';
19
19
  import { createEphemeralContext } from '../utils/createEphemeralContext';
20
+ import type { ScheduledCancel } from '../scheduler/ModelOperationScheduler';
20
21
 
21
22
  export class FlowExecutor {
22
23
  constructor(private readonly engine: FlowEngine) {}
23
24
 
25
+ private async emitModelEventIf(
26
+ eventName: string | undefined,
27
+ topic: string,
28
+ payload: Record<string, any>,
29
+ ): Promise<void> {
30
+ if (!eventName) return;
31
+ await this.engine.emitter.emitAsync(`model:event:${eventName}:${topic}`, payload);
32
+ }
33
+
24
34
  /** Cache wrapper for applyFlow cache lifecycle */
25
35
  private async withApplyFlowCache<T>(cacheKey: string | null, executor: () => Promise<T>): Promise<T> {
26
36
  if (!cacheKey || !this.engine) return await executor();
@@ -57,7 +67,13 @@ export class FlowExecutor {
57
67
  /**
58
68
  * Execute a single flow on model.
59
69
  */
60
- async runFlow(model: FlowModel, flowKey: string, inputArgs?: Record<string, any>, runId?: string): Promise<any> {
70
+ async runFlow(
71
+ model: FlowModel,
72
+ flowKey: string,
73
+ inputArgs?: Record<string, any>,
74
+ runId?: string,
75
+ eventName?: string,
76
+ ): Promise<any> {
61
77
  const flow = model.getFlow(flowKey);
62
78
 
63
79
  if (!flow) {
@@ -99,6 +115,16 @@ export class FlowExecutor {
99
115
  setupRuntimeContextSteps(flowContext, stepDefs, model, flowKey);
100
116
  const stepsRuntime = flowContext.steps as Record<string, { params: any; uiSchema?: any; result?: any }>;
101
117
 
118
+ const flowEventBasePayload = {
119
+ uid: model.uid,
120
+ model,
121
+ runId: flowContext.runId,
122
+ inputArgs,
123
+ flowKey,
124
+ };
125
+
126
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:start`, flowEventBasePayload);
127
+
102
128
  for (const [stepKey, step] of Object.entries(stepDefs) as [string, StepDefinition][]) {
103
129
  // Resolve handler and params
104
130
  let handler: ActionDefinition<FlowModel, FlowRuntimeContext>['handler'] | undefined;
@@ -156,6 +182,11 @@ export class FlowExecutor {
156
182
  );
157
183
  continue;
158
184
  }
185
+
186
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:start`, {
187
+ ...flowEventBasePayload,
188
+ stepKey,
189
+ });
159
190
  const currentStepResult = handler(runtimeCtx, combinedParams);
160
191
  const isAwait = step.isAwait !== false;
161
192
  lastResult = isAwait ? await currentStepResult : currentStepResult;
@@ -163,15 +194,47 @@ export class FlowExecutor {
163
194
  // Store step result and update context
164
195
  stepResults[stepKey] = lastResult;
165
196
  stepsRuntime[stepKey].result = stepResults[stepKey];
197
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:end`, {
198
+ ...flowEventBasePayload,
199
+ result: lastResult,
200
+ stepKey,
201
+ });
166
202
  } catch (error) {
203
+ if (!(error instanceof FlowExitException) && !(error instanceof FlowExitAllException)) {
204
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:error`, {
205
+ ...flowEventBasePayload,
206
+ error,
207
+ stepKey,
208
+ });
209
+ }
167
210
  if (error instanceof FlowExitException) {
168
211
  flowContext.logger.info(`[FlowEngine] ${error.message}`);
212
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:end`, {
213
+ ...flowEventBasePayload,
214
+ stepKey,
215
+ });
216
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:end`, {
217
+ ...flowEventBasePayload,
218
+ result: stepResults,
219
+ });
169
220
  return Promise.resolve(stepResults);
170
221
  }
171
222
  if (error instanceof FlowExitAllException) {
172
223
  flowContext.logger.info(`[FlowEngine] ${error.message}`);
224
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:end`, {
225
+ ...flowEventBasePayload,
226
+ stepKey,
227
+ });
228
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:end`, {
229
+ ...flowEventBasePayload,
230
+ result: error,
231
+ });
173
232
  return Promise.resolve(error);
174
233
  }
234
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:error`, {
235
+ ...flowEventBasePayload,
236
+ error,
237
+ });
175
238
  flowContext.logger.error(
176
239
  { err: error },
177
240
  `BaseModel.applyFlow: Error executing step '${stepKey}' in flow '${flowKey}':`,
@@ -179,11 +242,13 @@ export class FlowExecutor {
179
242
  return Promise.reject(error);
180
243
  }
181
244
  }
245
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:end`, {
246
+ ...flowEventBasePayload,
247
+ result: stepResults,
248
+ });
182
249
  return Promise.resolve(stepResults);
183
250
  }
184
251
 
185
- // runAutoFlows 已移除:统一通过 dispatchEvent('beforeRender') + useCache 控制
186
-
187
252
  /**
188
253
  * Dispatch an event to flows bound via flow.on and execute them.
189
254
  */
@@ -202,14 +267,15 @@ export class FlowExecutor {
202
267
 
203
268
  const runId = `${model.uid}-${eventName}-${Date.now()}`;
204
269
  const logger = model.context.logger;
270
+ const eventBasePayload = {
271
+ uid: model.uid,
272
+ model,
273
+ runId,
274
+ inputArgs,
275
+ };
205
276
 
206
277
  try {
207
- await this.engine.emitter.emitAsync(`model:event:${eventName}:start`, {
208
- uid: model.uid,
209
- model,
210
- runId,
211
- inputArgs,
212
- });
278
+ await this.emitModelEventIf(eventName, 'start', eventBasePayload);
213
279
  await model.onDispatchEventStart?.(eventName, options, inputArgs);
214
280
  } catch (err) {
215
281
  if (isBeforeRender && err instanceof FlowExitException) {
@@ -237,11 +303,25 @@ export class FlowExecutor {
237
303
  return false;
238
304
  });
239
305
 
306
+ // 路由系统的“重放打开视图”会再次 dispatchEvent('click'),但这不应重复触发用户配置的动态事件流。
307
+ // 约定:由路由重放触发时,会在 inputArgs 中携带 triggerByRouter: true
308
+ const isRouterReplayClick = eventName === 'click' && inputArgs?.triggerByRouter === true;
309
+ const flowsToRun = isRouterReplayClick
310
+ ? flows.filter((flow) => {
311
+ const reg = flow['flowRegistry'] as any;
312
+ const type = reg?.constructor?._type as 'instance' | 'global' | undefined;
313
+ return type !== 'instance';
314
+ })
315
+ : flows;
316
+
317
+ // 记录本次 dispatchEvent 内注册的调度任务,用于在结束/错误后兜底清理未触发的任务
318
+ const scheduledCancels: ScheduledCancel[] = [];
319
+
240
320
  // 组装执行函数(返回值用于缓存;beforeRender 返回 results:any[],其它返回 true)
241
321
  const execute = async () => {
242
322
  if (sequential) {
243
323
  // 顺序执行:动态流(实例级)优先,其次静态流;各自组内再按 sort 升序,最后保持原始顺序稳定
244
- const flowsWithIndex = flows.map((f, i) => ({ f, i }));
324
+ const flowsWithIndex = flowsToRun.map((f, i) => ({ f, i }));
245
325
  const ordered = flowsWithIndex
246
326
  .slice()
247
327
  .sort((a, b) => {
@@ -259,12 +339,103 @@ export class FlowExecutor {
259
339
  })
260
340
  .map((x) => x.f);
261
341
  const results: any[] = [];
342
+
343
+ // 预处理:当事件流配置了 on.phase 时,将其执行移动到指定节点,并从“立即执行列表”中移除
344
+ const staticFlowsByKey = new Map(
345
+ ordered
346
+ .filter((f) => {
347
+ const reg = f['flowRegistry'] as any;
348
+ const type = reg?.constructor?._type as 'instance' | 'global' | undefined;
349
+ return type !== 'instance';
350
+ })
351
+ .map((f) => [f.key, f] as const),
352
+ );
353
+ const scheduled = new Set<string>();
354
+ const scheduleGroups = new Map<string, Array<{ flow: any; order: number }>>();
355
+ ordered.forEach((flow, indexInOrdered) => {
356
+ const on = flow.on;
357
+ const onObj = typeof on === 'object' ? (on as any) : undefined;
358
+ if (!onObj) return;
359
+
360
+ const phase: any = onObj.phase;
361
+ const flowKey: any = onObj.flowKey;
362
+ const stepKey: any = onObj.stepKey;
363
+
364
+ // 默认:beforeAllFlows(保持现有行为)
365
+ if (!phase || phase === 'beforeAllFlows') return;
366
+
367
+ let whenKey: string | null = null;
368
+ if (phase === 'afterAllFlows') {
369
+ whenKey = `event:${eventName}:end`;
370
+ } else if (phase === 'beforeFlow' || phase === 'afterFlow') {
371
+ if (!flowKey) {
372
+ // 配置不完整:降级到“全部静态流之后”
373
+ whenKey = `event:${eventName}:end`;
374
+ } else {
375
+ const anchorFlow = staticFlowsByKey.get(String(flowKey));
376
+ if (anchorFlow) {
377
+ const anchorPhase = phase === 'beforeFlow' ? 'start' : 'end';
378
+ whenKey = `event:${eventName}:flow:${String(flowKey)}:${anchorPhase}`;
379
+ } else {
380
+ // 锚点不存在(flow 被删除或覆盖等):降级到“全部静态流之后”
381
+ whenKey = `event:${eventName}:end`;
382
+ }
383
+ }
384
+ } else if (phase === 'beforeStep' || phase === 'afterStep') {
385
+ if (!flowKey || !stepKey) {
386
+ // 配置不完整:降级到“全部静态流之后”
387
+ whenKey = `event:${eventName}:end`;
388
+ } else {
389
+ const anchorFlow = staticFlowsByKey.get(String(flowKey));
390
+ const anchorStepExists = !!anchorFlow?.hasStep?.(String(stepKey));
391
+ if (anchorFlow && anchorStepExists) {
392
+ const anchorPhase = phase === 'beforeStep' ? 'start' : 'end';
393
+ whenKey = `event:${eventName}:flow:${String(flowKey)}:step:${String(stepKey)}:${anchorPhase}`;
394
+ } else {
395
+ // 锚点不存在(flow/step 被删除或覆盖等):降级到“全部静态流之后”
396
+ whenKey = `event:${eventName}:end`;
397
+ }
398
+ }
399
+ } else {
400
+ // 未知 phase:忽略
401
+ return;
402
+ }
403
+
404
+ if (!whenKey) return;
405
+ scheduled.add(flow.key);
406
+ const list = scheduleGroups.get(whenKey) || [];
407
+ list.push({ flow, order: indexInOrdered });
408
+ scheduleGroups.set(whenKey, list);
409
+ });
410
+
411
+ // 注册调度(同锚点按 flow.sort 升序;sort 相同保持稳定顺序)
412
+ for (const [whenKey, list] of scheduleGroups.entries()) {
413
+ const sorted = list.slice().sort((a, b) => {
414
+ const sa = a.flow.sort ?? 0;
415
+ const sb = b.flow.sort ?? 0;
416
+ if (sa !== sb) return sa - sb;
417
+ return a.order - b.order;
418
+ });
419
+ for (const it of sorted) {
420
+ const cancel = model.scheduleModelOperation(
421
+ model.uid,
422
+ async (m) => {
423
+ const res = await this.runFlow(m, it.flow.key, inputArgs, runId, eventName);
424
+ results.push(res);
425
+ },
426
+ { when: whenKey as any },
427
+ );
428
+ scheduledCancels.push(cancel);
429
+ }
430
+ }
431
+
262
432
  for (const flow of ordered) {
433
+ if (scheduled.has(flow.key)) continue;
263
434
  try {
264
435
  logger.debug(
265
436
  `BaseModel '${model.uid}' dispatching event '${eventName}' to flow '${flow.key}' (sequential).`,
266
437
  );
267
- const result = await this.runFlow(model, flow.key, inputArgs, runId);
438
+ const result = await this.runFlow(model, flow.key, inputArgs, runId, eventName);
268
439
  if (result instanceof FlowExitAllException) {
269
440
  logger.debug(`[FlowEngine.dispatchEvent] ${result.message}`);
270
441
  break; // 终止后续
@@ -283,10 +454,10 @@ export class FlowExecutor {
283
454
 
284
455
  // 并行
285
456
  const results = await Promise.all(
286
- flows.map(async (flow) => {
457
+ flowsToRun.map(async (flow) => {
287
458
  logger.debug(`BaseModel '${model.uid}' dispatching event '${eventName}' to flow '${flow.key}'.`);
288
459
  try {
289
- return await this.runFlow(model, flow.key, inputArgs, runId);
460
+ return await this.runFlow(model, flow.key, inputArgs, runId, eventName);
290
461
  } catch (error) {
291
462
  logger.error(
292
463
  { err: error },
@@ -318,11 +489,8 @@ export class FlowExecutor {
318
489
  } catch (hookErr) {
319
490
  logger.error({ err: hookErr }, `BaseModel.dispatchEvent: End hook error for event '${eventName}'`);
320
491
  }
321
- await this.engine.emitter.emitAsync(`model:event:${eventName}:end`, {
322
- uid: model.uid,
323
- model,
324
- runId,
325
- inputArgs,
492
+ await this.emitModelEventIf(eventName, 'end', {
493
+ ...eventBasePayload,
326
494
  result,
327
495
  });
328
496
  return result;
@@ -337,14 +505,16 @@ export class FlowExecutor {
337
505
  { err: error },
338
506
  `BaseModel.dispatchEvent: Error executing event '${eventName}' for model '${model.uid}':`,
339
507
  );
340
- await this.engine.emitter.emitAsync(`model:event:${eventName}:error`, {
341
- uid: model.uid,
342
- model,
343
- runId,
344
- inputArgs,
508
+ await this.emitModelEventIf(eventName, 'error', {
509
+ ...eventBasePayload,
345
510
  error,
346
511
  });
347
512
  if (throwOnError) throw error;
513
+ } finally {
514
+ // 清理未触发的调度任务,避免跨事件/跨 runId 残留导致意外执行
515
+ for (const cancel of scheduledCancels) {
516
+ cancel();
517
+ }
348
518
  }
349
519
  }
350
520
  }
@@ -139,6 +139,72 @@ describe('FlowExecutor', () => {
139
139
  expect(submitHandler).not.toHaveBeenCalled();
140
140
  });
141
141
 
142
+ it("dispatchEvent('click') skips instance flows when triggerByRouter is true", async () => {
143
+ class MyModel extends FlowModel {}
144
+
145
+ const globalHandler = vi.fn().mockResolvedValue('global-ok');
146
+ MyModel.registerFlow('globalClick', {
147
+ on: 'click',
148
+ steps: {
149
+ s: { handler: globalHandler },
150
+ },
151
+ });
152
+
153
+ const instanceHandler = vi.fn().mockResolvedValue('instance-ok');
154
+ const model = new MyModel({
155
+ uid: 'm-click-router-replay',
156
+ flowEngine: engine,
157
+ flowRegistry: {
158
+ instanceClick: {
159
+ on: 'click',
160
+ steps: {
161
+ s: { handler: instanceHandler },
162
+ },
163
+ },
164
+ },
165
+ stepParams: {},
166
+ subModels: {},
167
+ } as FlowModelOptions);
168
+
169
+ await engine.executor.dispatchEvent(model, 'click', { triggerByRouter: true }, { sequential: true });
170
+
171
+ expect(globalHandler).toHaveBeenCalledTimes(1);
172
+ expect(instanceHandler).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it("dispatchEvent('click') keeps instance flows when triggerByRouter is not true", async () => {
176
+ class MyModel extends FlowModel {}
177
+
178
+ const globalHandler = vi.fn().mockResolvedValue('global-ok');
179
+ MyModel.registerFlow('globalClick', {
180
+ on: 'click',
181
+ steps: {
182
+ s: { handler: globalHandler },
183
+ },
184
+ });
185
+
186
+ const instanceHandler = vi.fn().mockResolvedValue('instance-ok');
187
+ const model = new MyModel({
188
+ uid: 'm-click-normal',
189
+ flowEngine: engine,
190
+ flowRegistry: {
191
+ instanceClick: {
192
+ on: 'click',
193
+ steps: {
194
+ s: { handler: instanceHandler },
195
+ },
196
+ },
197
+ },
198
+ stepParams: {},
199
+ subModels: {},
200
+ } as FlowModelOptions);
201
+
202
+ await engine.executor.dispatchEvent(model, 'click', { triggerByRouter: false }, { sequential: true });
203
+
204
+ expect(globalHandler).toHaveBeenCalledTimes(1);
205
+ expect(instanceHandler).toHaveBeenCalledTimes(1);
206
+ });
207
+
142
208
  it('dispatchEvent default parallel does not stop on exitAll', async () => {
143
209
  const calls: string[] = [];
144
210
  const mkFlow = (key: string, opts?: { exitAll?: boolean }) => ({
@@ -45,6 +45,7 @@ import { FlowExitAllException } from './utils/exceptions';
45
45
  import { enqueueVariablesResolve, JSONValue } from './utils/params-resolvers';
46
46
  import type { RecordRef } from './utils/serverContextParams';
47
47
  import { buildServerContextParams as _buildServerContextParams } from './utils/serverContextParams';
48
+ import { inferRecordRef } from './utils/variablesParams';
48
49
  import { FlowView, FlowViewer } from './views/FlowView';
49
50
  import { RunJSContextRegistry, getModelClassName } from './runjs-context/registry';
50
51
  import { createEphemeralContext } from './utils/createEphemeralContext';
@@ -71,6 +72,61 @@ function filterBuilderOutputByPaths(built: any, neededPaths: string[]): any {
71
72
  return undefined;
72
73
  }
73
74
 
75
+ // Helper: extract top-level segment of a subpath (e.g. 'a.b' -> 'a', 'tags[0].name' -> 'tags')
76
+ function topLevelOf(subPath: string): string | undefined {
77
+ if (!subPath) return undefined;
78
+ const m = String(subPath).match(/^([^.[]+)/);
79
+ return m?.[1];
80
+ }
81
+
82
+ // Helper: infer selects (fields/appends) from usage paths (mirrors server-side inferSelectsFromUsage)
83
+ function inferSelectsFromUsage(paths: string[] = []): { generatedAppends?: string[]; generatedFields?: string[] } {
84
+ if (!Array.isArray(paths) || paths.length === 0) {
85
+ return { generatedAppends: undefined, generatedFields: undefined };
86
+ }
87
+
88
+ const appendSet = new Set<string>();
89
+ const fieldSet = new Set<string>();
90
+
91
+ const normalizePath = (raw: string): string => {
92
+ if (!raw) return '';
93
+ let s = String(raw);
94
+ // remove numeric indexes like [0]
95
+ s = s.replace(/\[(?:\d+)\]/g, '');
96
+ // normalize string indexes like ["name"] / ['name'] into .name
97
+ s = s.replace(/\[(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')\]/g, (_m, g1, g2) => `.${(g1 || g2) as string}`);
98
+ s = s.replace(/\.\.+/g, '.');
99
+ s = s.replace(/^\./, '').replace(/\.$/, '');
100
+ return s;
101
+ };
102
+
103
+ for (let path of paths) {
104
+ if (!path) continue;
105
+ // drop leading numeric index like [0].name
106
+ while (/^\[(\d+)\](\.|$)/.test(path)) {
107
+ path = path.replace(/^\[(\d+)\]\.?/, '');
108
+ }
109
+ const norm = normalizePath(path);
110
+ if (!norm) continue;
111
+ const segments = norm.split('.').filter(Boolean);
112
+ if (segments.length === 0) continue;
113
+
114
+ if (segments.length === 1) {
115
+ fieldSet.add(segments[0]);
116
+ continue;
117
+ }
118
+
119
+ for (let i = 0; i < segments.length - 1; i++) {
120
+ appendSet.add(segments.slice(0, i + 1).join('.'));
121
+ }
122
+ fieldSet.add(segments.join('.'));
123
+ }
124
+
125
+ const generatedAppends = appendSet.size ? Array.from(appendSet) : undefined;
126
+ const generatedFields = fieldSet.size ? Array.from(fieldSet) : undefined;
127
+ return { generatedAppends, generatedFields };
128
+ }
129
+
74
130
  type Getter<T = any> = (ctx: FlowContext) => T | Promise<T>;
75
131
 
76
132
  export interface MetaTreeNode {
@@ -1030,6 +1086,22 @@ export class FlowEngineContext extends BaseFlowEngineContext {
1030
1086
  const needServer = Object.keys(serverVarPaths).length > 0;
1031
1087
  let serverResolved = template;
1032
1088
  if (needServer) {
1089
+ const inferRecordRefWithMeta = (ctx: any): RecordRef | undefined => {
1090
+ const ref = inferRecordRef(ctx as any);
1091
+ if (ref) return ref as RecordRef;
1092
+ try {
1093
+ const tk = ctx?.resource?.getMeta?.('currentFilterByTk');
1094
+ if (typeof tk === 'undefined' || tk === null) return undefined;
1095
+ const collection =
1096
+ ctx?.collection?.name || ctx?.resource?.getResourceName?.()?.split?.('.')?.slice?.(-1)?.[0];
1097
+ if (!collection) return undefined;
1098
+ const dataSourceKey = ctx?.collection?.dataSourceKey || ctx?.resource?.getDataSourceKey?.();
1099
+ return { collection, dataSourceKey, filterByTk: tk } as RecordRef;
1100
+ } catch (_) {
1101
+ return undefined;
1102
+ }
1103
+ };
1104
+
1033
1105
  const collectFromMeta = async (): Promise<Record<string, any>> => {
1034
1106
  const out: Record<string, any> = {};
1035
1107
  try {
@@ -1069,7 +1141,62 @@ export class FlowEngineContext extends BaseFlowEngineContext {
1069
1141
  };
1070
1142
 
1071
1143
  const inputFromMeta = await collectFromMeta();
1072
- const autoInput = { ...inputFromMeta };
1144
+ const autoInput = { ...inputFromMeta } as Record<string, any>;
1145
+
1146
+ // Special-case: formValues
1147
+ // If server needs to resolve some formValues paths but meta params only cover association anchors
1148
+ // (e.g. formValues.customer) and some top-level paths are missing (e.g. formValues.status),
1149
+ // inject a top-level record anchor (formValues -> { collection, filterByTk, fields/appends }) so server can fetch DB values.
1150
+ // This anchor MUST be selective (fields/appends derived from serverVarPaths['formValues']) to avoid server overriding
1151
+ // client-only values for configured form fields in the same template.
1152
+ try {
1153
+ const varName = 'formValues';
1154
+ const neededPaths = serverVarPaths[varName] || [];
1155
+ if (neededPaths.length) {
1156
+ const requiredTop = new Set<string>();
1157
+ for (const p of neededPaths) {
1158
+ const top = topLevelOf(p);
1159
+ if (top) requiredTop.add(top);
1160
+ }
1161
+ const metaOut = inputFromMeta?.[varName];
1162
+ const builtTop = new Set<string>();
1163
+ if (metaOut && typeof metaOut === 'object' && !Array.isArray(metaOut) && !isRecordRefLike(metaOut)) {
1164
+ Object.keys(metaOut).forEach((k) => builtTop.add(k));
1165
+ }
1166
+
1167
+ const missing = [...requiredTop].filter((k) => !builtTop.has(k));
1168
+ if (missing.length) {
1169
+ const ref = inferRecordRefWithMeta(this);
1170
+ if (ref) {
1171
+ const { generatedFields, generatedAppends } = inferSelectsFromUsage(neededPaths);
1172
+ const recordRef: RecordRef = {
1173
+ ...ref,
1174
+ fields: generatedFields,
1175
+ appends: generatedAppends,
1176
+ };
1177
+
1178
+ // Preserve existing association anchors by lifting them to dotted keys before overwriting formValues
1179
+ const existing = autoInput[varName];
1180
+ if (
1181
+ existing &&
1182
+ typeof existing === 'object' &&
1183
+ !Array.isArray(existing) &&
1184
+ !isRecordRefLike(existing)
1185
+ ) {
1186
+ for (const [k, v] of Object.entries(existing)) {
1187
+ autoInput[`${varName}.${k}`] = v;
1188
+ }
1189
+ delete autoInput[varName];
1190
+ }
1191
+
1192
+ autoInput[varName] = recordRef;
1193
+ }
1194
+ }
1195
+ }
1196
+ } catch (_) {
1197
+ // ignore
1198
+ }
1199
+
1073
1200
  const autoContextParams = Object.keys(autoInput).length
1074
1201
  ? _buildServerContextParams(this, autoInput)
1075
1202
  : undefined;