@lowdefy/build 5.6.0 → 6.0.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 (100) hide show
  1. package/dist/build/buildAgents.js +61 -16
  2. package/dist/build/buildApi/buildApi.js +4 -1
  3. package/dist/build/buildApi/buildEndpoint.js +8 -2
  4. package/dist/build/buildApi/buildRoutine/countStepTypes.js +1 -1
  5. package/dist/build/buildApi/buildRoutine/setStepId.js +2 -0
  6. package/dist/build/buildApi/buildRoutine/validateStep.js +61 -0
  7. package/dist/build/buildApi/resolveEndpointSchedules.js +37 -0
  8. package/dist/build/buildApi/validateEndpoint.js +96 -1
  9. package/dist/build/buildApp.js +13 -20
  10. package/dist/build/buildAppMeta.js +130 -0
  11. package/dist/build/buildAuth/buildAgentAuth.js +14 -1
  12. package/dist/build/buildAuth/buildAuth.js +8 -3
  13. package/dist/build/buildAuth/buildAuthPlugins.js +6 -0
  14. package/dist/build/buildAuth/buildAuthStrategies.js +117 -0
  15. package/dist/build/buildAuth/buildWebsocketAuth.js +55 -0
  16. package/dist/build/buildAuth/getProtectedWebsockets.js +29 -0
  17. package/dist/build/buildAuth/getWebsocketRoles.js +39 -0
  18. package/dist/build/buildAuth/validateAuthConfig.js +15 -3
  19. package/dist/build/buildImports/buildImportsDev.js +15 -0
  20. package/dist/build/buildImports/buildImportsProd.js +4 -1
  21. package/dist/build/buildJs/generateClientJsModule.js +27 -0
  22. package/dist/build/buildJs/writeJs.js +3 -5
  23. package/dist/build/buildMcp.js +73 -0
  24. package/dist/build/buildModuleDefs.js +90 -48
  25. package/dist/build/buildModules.js +61 -14
  26. package/dist/build/buildNotifications.js +98 -0
  27. package/dist/build/buildPages/buildBlock/buildBlock.js +3 -1
  28. package/dist/build/buildPages/buildBlock/buildDynamicBlock.js +131 -0
  29. package/dist/build/buildPages/buildBlock/buildEvents.js +186 -21
  30. package/dist/build/buildPages/buildBlock/buildRequests.js +9 -0
  31. package/dist/build/buildPages/buildBlock/normalizeClassAndStyles.js +2 -11
  32. package/dist/build/buildPages/buildBlock/setBlockId.js +5 -2
  33. package/dist/build/buildPages/buildBlock/validateBlock.js +7 -0
  34. package/dist/build/buildPages/buildPage.js +27 -3
  35. package/dist/build/buildPages/buildSubscriptions.js +145 -0
  36. package/dist/build/buildPages/validateDynamicBlockRefs.js +30 -0
  37. package/dist/build/buildPages/validateStateReferences.js +4 -2
  38. package/dist/build/buildPages/validateWebsocketRefs.js +30 -0
  39. package/dist/build/buildRefs/buildRefs.js +21 -12
  40. package/dist/build/buildRefs/cloneWithMarkers.js +57 -0
  41. package/dist/build/buildRefs/deferredRegions.js +80 -0
  42. package/dist/build/buildRefs/deferredRegistry.js +292 -0
  43. package/dist/build/buildRefs/getModuleRefContent.js +21 -6
  44. package/dist/build/buildRefs/getUserJavascriptFunction.js +1 -1
  45. package/dist/build/buildRefs/parseRefContent.js +24 -0
  46. package/dist/build/buildRefs/{evaluateStaticOperators.js → precomputeRuntimeOperators.js} +7 -6
  47. package/dist/build/buildRefs/walker.js +471 -207
  48. package/dist/build/buildTypes.js +22 -1
  49. package/dist/build/buildWebsockets.js +99 -0
  50. package/dist/build/collectPageContent.js +7 -1
  51. package/dist/build/copyAgentFileSystems.js +2 -2
  52. package/dist/build/full/buildPages.js +16 -0
  53. package/dist/build/full/updateServerPackageJson.js +21 -0
  54. package/dist/build/jit/buildPageJit.js +46 -6
  55. package/dist/build/jit/collectSkeletonSourceFiles.js +14 -0
  56. package/dist/build/jit/pageContentKeys.js +1 -0
  57. package/dist/build/jit/shallowBuild.js +81 -1
  58. package/dist/build/jit/writePageJit.js +11 -8
  59. package/dist/build/registerModules.js +125 -24
  60. package/dist/build/validateCallAgentSteps.js +58 -0
  61. package/dist/build/validateConfig.js +4 -0
  62. package/dist/build/validateCronConfig.js +110 -0
  63. package/dist/build/validateRenderNotificationSteps.js +50 -0
  64. package/dist/build/writeApi.js +58 -1
  65. package/dist/build/writeMcp.js +21 -0
  66. package/dist/build/writeNotifications.js +30 -0
  67. package/dist/build/writePluginImports/importPluginModule.js +39 -0
  68. package/dist/build/writePluginImports/writeActionSchemaMap.js +6 -7
  69. package/dist/build/writePluginImports/writeAuthImports.js +4 -0
  70. package/dist/build/writePluginImports/writeAvailableTypes.js +99 -0
  71. package/dist/build/writePluginImports/writeBlockSchemaMap.js +10 -9
  72. package/dist/build/writePluginImports/writeConnectionSchemaMap.js +101 -0
  73. package/dist/build/writePluginImports/writeGlobalsCss.js +20 -10
  74. package/dist/build/writePluginImports/writeNotificationImports.js +31 -0
  75. package/dist/build/writePluginImports/writeOperatorSchemaMap.js +6 -7
  76. package/dist/build/writePluginImports/writePluginImports.js +18 -4
  77. package/dist/build/writePluginImports/writeWebsocketImports.js +22 -0
  78. package/dist/build/writeWebsockets.js +30 -0
  79. package/dist/createContext.js +6 -1
  80. package/dist/defaultPackages.js +7 -1
  81. package/dist/defaultTypesMap.js +666 -388
  82. package/dist/dynamic/buildDynamicBlocks.js +120 -0
  83. package/dist/index.js +78 -8
  84. package/dist/indexDev.js +4 -0
  85. package/dist/lowdefySchema.js +861 -6
  86. package/dist/scripts/generateDefaultTypes.js +5 -2
  87. package/dist/test-utils/expectTerminates.js +33 -0
  88. package/dist/test-utils/runBuild.js +12 -1
  89. package/dist/test-utils/runBuildForSnapshots.js +34 -4
  90. package/dist/test-utils/testContext.js +8 -2
  91. package/dist/utils/countOperators.js +5 -17
  92. package/dist/utils/createPluginTypesMap.js +21 -0
  93. package/dist/utils/getCronEnvironmentNames.js +25 -0
  94. package/dist/utils/logCollectedErrors.js +15 -9
  95. package/dist/utils/serializeBuildException.js +29 -0
  96. package/dist/utils/validateCronExpression.js +131 -0
  97. package/dist/utils/writeBuildArtifact.js +5 -2
  98. package/package.json +66 -59
  99. package/dist/build/writePluginImports/writeServerExternalPackages.js +0 -121
  100. /package/dist/build/{resolveModuleOperators.js → buildRefs/scopeMenuItemIds.js} +0 -0
@@ -13,7 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import path from 'path';
16
- import { get, ReservedKeyError, serializer, type } from '@lowdefy/helpers';
16
+ import { get, ReservedKeyError, type } from '@lowdefy/helpers';
17
17
  import { ConfigError } from '@lowdefy/errors';
18
18
  import { evaluateOperators } from '@lowdefy/operators';
19
19
  import makeRefDefinition from './makeRefDefinition.js';
@@ -22,10 +22,12 @@ import getRefContent from './getRefContent.js';
22
22
  import getModuleRefContent from './getModuleRefContent.js';
23
23
  import runTransformer from './runTransformer.js';
24
24
  import getKey from './getKey.js';
25
- import { scopeMenuItemIds } from '../resolveModuleOperators.js';
25
+ import { scopeMenuItemIds } from './scopeMenuItemIds.js';
26
26
  import resolveDepTarget from '../resolveDepTarget.js';
27
27
  import setNonEnumerableProperty from '../../utils/setNonEnumerableProperty.js';
28
+ import cloneWithMarkers from './cloneWithMarkers.js';
28
29
  import collectExceptions from '../../utils/collectExceptions.js';
30
+ import { createRecord, getPlaceholderId, getRecord, makePlaceholder, makeRecordId, resolveDeferred } from './deferredRegistry.js';
29
31
  let WalkContext = class WalkContext {
30
32
  child(segment) {
31
33
  return new WalkContext({
@@ -40,10 +42,15 @@ let WalkContext = class WalkContext {
40
42
  path: this.path ? `${this.path}.${segment}` : segment,
41
43
  currentFile: this.currentFile,
42
44
  refChain: this.refChain,
45
+ deferModuleRefs: this.deferModuleRefs,
46
+ entrySection: this.entrySection,
43
47
  operators: this.operators,
44
48
  env: this.env,
49
+ lowdefyApp: this.lowdefyApp,
45
50
  dynamicIdentifiers: this.dynamicIdentifiers,
46
- shouldStop: this.shouldStop
51
+ shouldStop: this.shouldStop,
52
+ entryId: this.entryId,
53
+ activeRecord: this.activeRecord
47
54
  });
48
55
  }
49
56
  forRef({ refId, vars, filePath, moduleRoot, packageRoot, moduleDependencies, moduleEntry, extraRefChainKeys }) {
@@ -68,10 +75,15 @@ let WalkContext = class WalkContext {
68
75
  path: this.path,
69
76
  currentFile: filePath ?? this.currentFile,
70
77
  refChain: newChain,
78
+ deferModuleRefs: this.deferModuleRefs,
79
+ entrySection: this.entrySection,
71
80
  operators: this.operators,
72
81
  env: this.env,
82
+ lowdefyApp: this.lowdefyApp,
73
83
  dynamicIdentifiers: this.dynamicIdentifiers,
74
- shouldStop: this.shouldStop
84
+ shouldStop: this.shouldStop,
85
+ entryId: (moduleEntry ?? this.moduleEntry)?.id ?? this.entryId,
86
+ activeRecord: this.activeRecord
75
87
  });
76
88
  }
77
89
  collectError(error) {
@@ -83,20 +95,34 @@ let WalkContext = class WalkContext {
83
95
  get unresolvedRefVars() {
84
96
  return this.buildContext.unresolvedRefVars;
85
97
  }
86
- constructor({ buildContext, refId, sourceRefId, vars, moduleDependencies, moduleEntry, moduleRoot, packageRoot, path, currentFile, refChain, operators, env, dynamicIdentifiers, shouldStop }){
98
+ constructor({ buildContext, refId, sourceRefId, vars, moduleDependencies, moduleEntry, moduleRoot, packageRoot, path, currentFile, refChain, deferModuleRefs, operators, env, lowdefyApp, dynamicIdentifiers, shouldStop, entryId, entrySection, activeRecord }){
87
99
  this.buildContext = buildContext;
88
100
  this.refId = refId;
89
101
  this.sourceRefId = sourceRefId;
90
102
  this.vars = vars;
91
103
  this.moduleDependencies = moduleDependencies;
92
104
  this.moduleEntry = moduleEntry ?? null;
105
+ // The owning module entry id for deferred-record coordinates. Set by the
106
+ // manifest resolvers even before the entry object exists (the local pass
107
+ // walks the manifest before registration completes).
108
+ this.entryId = entryId ?? this.moduleEntry?.id ?? null;
109
+ // The deferred record this walk resolves (null in ordinary walks). Threads
110
+ // through child() and forRef() unchanged, like refChain — every demand made
111
+ // anywhere inside a record's resolution attributes to that record in the
112
+ // wait-graph.
113
+ this.activeRecord = activeRecord ?? null;
93
114
  this.moduleRoot = moduleRoot;
94
115
  this.packageRoot = packageRoot;
95
116
  this.path = path;
96
117
  this.currentFile = currentFile;
97
118
  this.refChain = refChain;
119
+ this.deferModuleRefs = deferModuleRefs ?? false;
120
+ // Which entry-config section a prepare walk serves ('consumerVars' or
121
+ // 'connections') — entryRef record coordinates and slots need it.
122
+ this.entrySection = entrySection ?? null;
98
123
  this.operators = operators;
99
124
  this.env = env;
125
+ this.lowdefyApp = lowdefyApp;
100
126
  this.dynamicIdentifiers = dynamicIdentifiers;
101
127
  this.shouldStop = shouldStop;
102
128
  }
@@ -130,62 +156,6 @@ function tagRefDeep(node, refId) {
130
156
  }
131
157
  }
132
158
  }
133
- // Deep clone preserving non-enumerable build markers (~r, ~l, ~k, ~arr, ~deferredFrom).
134
- // Used before resolving ref def path/vars to prevent mutation of stored originals.
135
- function cloneForResolve(value) {
136
- if (!type.isObject(value) && !type.isArray(value)) return value;
137
- if (type.isArray(value)) {
138
- const clone = value.map((item)=>cloneForResolve(item));
139
- if (value['~r'] !== undefined) setNonEnumerableProperty(clone, '~r', value['~r']);
140
- if (value['~l'] !== undefined) setNonEnumerableProperty(clone, '~l', value['~l']);
141
- if (value['~k'] !== undefined) setNonEnumerableProperty(clone, '~k', value['~k']);
142
- if (value['~arr'] !== undefined) setNonEnumerableProperty(clone, '~arr', value['~arr']);
143
- if (value['~deferredFrom'] !== undefined) setNonEnumerableProperty(clone, '~deferredFrom', value['~deferredFrom']);
144
- return clone;
145
- }
146
- const clone = {};
147
- for (const key of Object.keys(value)){
148
- clone[key] = cloneForResolve(value[key]);
149
- }
150
- if (value['~r'] !== undefined) setNonEnumerableProperty(clone, '~r', value['~r']);
151
- if (value['~l'] !== undefined) setNonEnumerableProperty(clone, '~l', value['~l']);
152
- if (value['~k'] !== undefined) setNonEnumerableProperty(clone, '~k', value['~k']);
153
- if (value['~deferredFrom'] !== undefined) setNonEnumerableProperty(clone, '~deferredFrom', value['~deferredFrom']);
154
- return clone;
155
- }
156
- // Deep clone a var value, preserving markers and setting ~r provenance.
157
- // When sourceRefId is null, preserves the template's existing ~r markers.
158
- function cloneVarValue(value, sourceRefId) {
159
- if (!type.isObject(value) && !type.isArray(value)) return value;
160
- return cloneDeepWithProvenance(value, sourceRefId);
161
- }
162
- function cloneDeepWithProvenance(node, sourceRefId) {
163
- if (!type.isObject(node) && !type.isArray(node)) return node;
164
- if (type.isArray(node)) {
165
- const clone = node.map((item)=>cloneDeepWithProvenance(item, sourceRefId));
166
- if (node['~r'] !== undefined) {
167
- setNonEnumerableProperty(clone, '~r', node['~r']);
168
- } else if (sourceRefId) {
169
- setNonEnumerableProperty(clone, '~r', sourceRefId);
170
- }
171
- if (node['~l'] !== undefined) setNonEnumerableProperty(clone, '~l', node['~l']);
172
- if (node['~k'] !== undefined) setNonEnumerableProperty(clone, '~k', node['~k']);
173
- if (node['~arr'] !== undefined) setNonEnumerableProperty(clone, '~arr', node['~arr']);
174
- return clone;
175
- }
176
- const clone = {};
177
- for (const key of Object.keys(node)){
178
- clone[key] = cloneDeepWithProvenance(node[key], sourceRefId);
179
- }
180
- if (node['~r'] !== undefined) {
181
- setNonEnumerableProperty(clone, '~r', node['~r']);
182
- } else if (sourceRefId) {
183
- setNonEnumerableProperty(clone, '~r', sourceRefId);
184
- }
185
- if (node['~l'] !== undefined) setNonEnumerableProperty(clone, '~l', node['~l']);
186
- if (node['~k'] !== undefined) setNonEnumerableProperty(clone, '~k', node['~k']);
187
- return clone;
188
- }
189
159
  // Evaluate a _build.* operator using evaluateOperators
190
160
  function evaluateBuildOperator(node, ctx) {
191
161
  const { output, errors } = evaluateOperators({
@@ -193,6 +163,7 @@ function evaluateBuildOperator(node, ctx) {
193
163
  operators: ctx.operators,
194
164
  operatorPrefix: '_build.',
195
165
  env: ctx.env,
166
+ lowdefyApp: ctx.lowdefyApp,
196
167
  dynamicIdentifiers: ctx.dynamicIdentifiers
197
168
  });
198
169
  if (errors.length > 0) {
@@ -227,18 +198,22 @@ function resolveVar(node, ctx) {
227
198
  const value = readVar(varDef, ctx, {
228
199
  default: null
229
200
  });
230
- return cloneVarValue(value, ctx.sourceRefId);
201
+ return cloneWithMarkers(value, {
202
+ assignRefId: ctx.sourceRefId
203
+ });
231
204
  }
232
205
  // Object form: { _var: { key, default } }
233
206
  if (type.isObject(varDef) && type.isString(varDef.key)) {
234
207
  const varFromParent = readVar(varDef.key, ctx);
235
208
  // Var provided (even if null) → use parent's sourceRefId for location
236
209
  if (!type.isUndefined(varFromParent)) {
237
- return cloneVarValue(varFromParent, ctx.sourceRefId);
210
+ return cloneWithMarkers(varFromParent, {
211
+ assignRefId: ctx.sourceRefId
212
+ });
238
213
  }
239
214
  // Not provided → use default, preserve template's ~r
240
215
  const defaultValue = type.isNone(varDef.default) ? null : varDef.default;
241
- return cloneVarValue(defaultValue, null);
216
+ return cloneWithMarkers(defaultValue);
242
217
  }
243
218
  throw new ConfigError('_var operator takes a string or object with "key" field as arguments.', {
244
219
  filePath: ctx.currentFile
@@ -253,7 +228,9 @@ async function resolveModuleVar(node, ctx) {
253
228
  });
254
229
  }
255
230
  const value = await resolveEffectiveVar(key, ctx.moduleEntry, ctx);
256
- return cloneVarValue(value, ctx.sourceRefId);
231
+ return cloneWithMarkers(value, {
232
+ assignRefId: ctx.sourceRefId
233
+ });
257
234
  }
258
235
  // Navigate the var definitions tree by dot-path key, following `properties` nesting.
259
236
  function getVarDef(varDefs, key) {
@@ -274,27 +251,27 @@ function getVarDef(varDefs, key) {
274
251
  // at the module manifest. The fresh context prevents false circular-ref detection
275
252
  // from the consumer's refChain and ensures _ref paths resolve relative to the
276
253
  // module root.
277
- async function resolveVarDefault(rawDefault, moduleEntry, ctx) {
278
- const moduleYamlPath = path.join(moduleEntry.moduleRoot, 'module.lowdefy.yaml');
279
- const defaultCtx = new WalkContext({
280
- buildContext: ctx.buildContext,
281
- refId: moduleEntry.refDef.id,
282
- sourceRefId: null,
283
- vars: {},
284
- moduleDependencies: moduleEntry.moduleDependencies,
285
- moduleEntry,
286
- moduleRoot: moduleEntry.moduleRoot,
287
- packageRoot: moduleEntry.packageRoot,
288
- path: '',
289
- currentFile: moduleYamlPath,
290
- refChain: new Set(moduleEntry.refDef.path ? [
291
- moduleEntry.refDef.path
292
- ] : []),
293
- operators: ctx.operators,
294
- env: ctx.env,
295
- dynamicIdentifiers: ctx.dynamicIdentifiers
296
- });
297
- return await resolve(rawDefault, defaultCtx);
254
+ // Deep-force: demand every deferred-record placeholder in a value's subtree
255
+ // and splice the results in place, so cached var values are placeholder-free
256
+ // pure data — no consumer of the var cache needs to know records exist.
257
+ async function deepForcePlaceholders(value, ctx) {
258
+ const rootId = getPlaceholderId(value);
259
+ if (rootId !== undefined) {
260
+ return cloneWithMarkers(await resolveDeferred(ctx, rootId));
261
+ }
262
+ if (type.isArray(value)) {
263
+ for(let i = 0; i < value.length; i++){
264
+ value[i] = await deepForcePlaceholders(value[i], ctx);
265
+ }
266
+ return value;
267
+ }
268
+ if (type.isObject(value)) {
269
+ for (const key of Object.keys(value)){
270
+ value[key] = await deepForcePlaceholders(value[key], ctx);
271
+ }
272
+ return value;
273
+ }
274
+ return value;
298
275
  }
299
276
  // Build a merged object for namespace vars (vars with `properties`). Each
300
277
  // declared property resolves through resolveEffectiveVar — consumer values
@@ -307,22 +284,53 @@ async function resolveNamespaceVar(prefix, varDef, moduleEntry, ctx) {
307
284
  }
308
285
  return result;
309
286
  }
287
+ // Read a consumer value by dotted key, forcing any entryRef placeholder on the
288
+ // path: a placeholder at the key or any ancestor resolves through
289
+ // resolveDeferred and is spliced into consumerVars in place, then the read
290
+ // continues. There is no case split on entry state and no structural-snapshot
291
+ // read — a read forces exactly the records its value depends on.
292
+ async function readConsumerValue(moduleEntry, key, ctx) {
293
+ // Whole-blob deferral: the entire consumerVars value can be a placeholder
294
+ // (vars: { _ref: { module, component } }) — force it before walking keys.
295
+ const rootPlaceholderId = getPlaceholderId(moduleEntry.consumerVars);
296
+ if (rootPlaceholderId !== undefined) {
297
+ moduleEntry.consumerVars = cloneWithMarkers(await resolveDeferred(ctx, rootPlaceholderId)) ?? {};
298
+ }
299
+ const parts = key.split('.');
300
+ let parent = moduleEntry.consumerVars;
301
+ for(let i = 0; i < parts.length; i++){
302
+ if (!type.isObject(parent)) return undefined;
303
+ let node = parent[parts[i]];
304
+ const placeholderId = getPlaceholderId(node);
305
+ if (placeholderId !== undefined) {
306
+ node = cloneWithMarkers(await resolveDeferred(ctx, placeholderId));
307
+ parent[parts[i]] = node;
308
+ }
309
+ if (i === parts.length - 1) return node;
310
+ parent = node;
311
+ }
312
+ return undefined;
313
+ }
310
314
  // Core lazy var resolution with caching on the module entry.
311
315
  async function resolveEffectiveVar(key, moduleEntry, ctx) {
312
316
  if (Object.hasOwn(moduleEntry.resolvedVarCache, key)) {
313
317
  return moduleEntry.resolvedVarCache[key];
314
318
  }
315
- const consumerValue = get(moduleEntry.consumerVars, key, {
316
- default: undefined
317
- });
319
+ const consumerValue = await readConsumerValue(moduleEntry, key, ctx);
318
320
  const varDef = getVarDef(moduleEntry.varDefs, key);
319
321
  let result;
320
322
  if (varDef?.properties) {
321
323
  result = await resolveNamespaceVar(key, varDef, moduleEntry, ctx);
322
324
  } else if (!type.isNone(consumerValue)) {
323
- result = consumerValue;
325
+ // Deep-force: a consumer value can carry deferred-record placeholders at
326
+ // any depth — demand each and splice, so the cache holds pure data.
327
+ result = await deepForcePlaceholders(consumerValue, ctx);
324
328
  } else if (varDef && !type.isUndefined(varDef.default)) {
325
- result = await resolveVarDefault(varDef.default, moduleEntry, ctx);
329
+ // Object/array defaults are varDefault records (demand-only — never swept,
330
+ // a broken default nobody reads must not fail the build); scalar defaults
331
+ // stay raw in varDefs and need no walk.
332
+ const defaultRecordId = getPlaceholderId(varDef.default);
333
+ result = defaultRecordId !== undefined ? await resolveDeferred(ctx, defaultRecordId) : varDef.default;
326
334
  } else {
327
335
  result = null;
328
336
  }
@@ -334,6 +342,8 @@ const MODULE_ID_OPERATOR_KEYS = [
334
342
  '_module.pageId',
335
343
  '_module.connectionId',
336
344
  '_module.endpointId',
345
+ '_module.agentId',
346
+ '_module.notificationId',
337
347
  '_module.id'
338
348
  ];
339
349
  function isModuleIdOperator(node) {
@@ -364,16 +374,28 @@ function resolveModulePageId(arg, moduleEntry, context, configKey) {
364
374
  });
365
375
  }
366
376
  // Resolve _module.connectionId
367
- function resolveModuleConnectionId(arg, moduleEntry, context, configKey) {
377
+ async function resolveModuleConnectionId(arg, moduleEntry, ctx, configKey) {
378
+ const context = ctx.buildContext;
379
+ // A connections value can be an entryRef placeholder mid-build — demand it
380
+ // through the read path and splice, exactly like consumerVars reads. True
381
+ // remap cycles surface as named wait-graph errors from resolveDeferred.
382
+ const readRemapValue = async (entry, id)=>{
383
+ const remapping = entry.connections ?? {};
384
+ const placeholderId = getPlaceholderId(remapping[id]);
385
+ if (placeholderId !== undefined) {
386
+ remapping[id] = cloneWithMarkers(await resolveDeferred(ctx, placeholderId));
387
+ }
388
+ return remapping[id];
389
+ };
368
390
  if (type.isString(arg)) {
369
391
  if (!moduleEntry) {
370
392
  throw new ConfigError('_module.connectionId string form is ambiguous at the app level — no module to scope against. Use { id, module } to specify the target module.', {
371
393
  configKey
372
394
  });
373
395
  }
374
- const remapping = moduleEntry.connections ?? {};
375
- if (remapping[arg]) {
376
- return remapping[arg];
396
+ const remapValue = await readRemapValue(moduleEntry, arg);
397
+ if (remapValue) {
398
+ return remapValue;
377
399
  }
378
400
  return `${moduleEntry.id}/${arg}`;
379
401
  }
@@ -385,9 +407,9 @@ function resolveModuleConnectionId(arg, moduleEntry, context, configKey) {
385
407
  configKey,
386
408
  usage: `_module.connectionId { id: "${arg.id}", module: "${arg.module}" }`
387
409
  });
388
- const targetRemapping = targetEntry.connections ?? {};
389
- if (targetRemapping[arg.id]) {
390
- return targetRemapping[arg.id];
410
+ const remapValue = await readRemapValue(targetEntry, arg.id);
411
+ if (remapValue) {
412
+ return remapValue;
391
413
  }
392
414
  return `${targetEntry.id}/${arg.id}`;
393
415
  }
@@ -419,6 +441,54 @@ function resolveModuleEndpointId(arg, moduleEntry, context, configKey) {
419
441
  configKey
420
442
  });
421
443
  }
444
+ // Resolve _module.agentId
445
+ function resolveModuleAgentId(arg, moduleEntry, context, configKey) {
446
+ if (type.isString(arg)) {
447
+ if (!moduleEntry) {
448
+ throw new ConfigError('_module.agentId string form is ambiguous at the app level — no module to scope against. Use { id, module } to specify the target module.', {
449
+ configKey
450
+ });
451
+ }
452
+ return `${moduleEntry.id}/${arg}`;
453
+ }
454
+ if (type.isObject(arg) && type.isString(arg.id) && type.isString(arg.module)) {
455
+ const targetEntry = resolveDepTarget({
456
+ moduleEntry,
457
+ depName: arg.module,
458
+ context,
459
+ configKey,
460
+ usage: `_module.agentId { id: "${arg.id}", module: "${arg.module}" }`
461
+ });
462
+ return `${targetEntry.id}/${arg.id}`;
463
+ }
464
+ throw new ConfigError('_module.agentId requires a string or object { id, module }.', {
465
+ configKey
466
+ });
467
+ }
468
+ // Resolve _module.notificationId
469
+ function resolveModuleNotificationId(arg, moduleEntry, context, configKey) {
470
+ if (type.isString(arg)) {
471
+ if (!moduleEntry) {
472
+ throw new ConfigError('_module.notificationId string form is ambiguous at the app level — no module to scope against. Use { id, module } to specify the target module.', {
473
+ configKey
474
+ });
475
+ }
476
+ return `${moduleEntry.id}/${arg}`;
477
+ }
478
+ if (type.isObject(arg) && type.isString(arg.id) && type.isString(arg.module)) {
479
+ const targetEntry = resolveDepTarget({
480
+ moduleEntry,
481
+ depName: arg.module,
482
+ context,
483
+ configKey,
484
+ usage: `_module.notificationId { id: "${arg.id}", module: "${arg.module}" }`
485
+ });
486
+ return `${targetEntry.id}/${arg.id}`;
487
+ }
488
+ throw new ConfigError('_module.notificationId requires a string or object { id, module }.', {
489
+ configKey
490
+ });
491
+ }
422
492
  // Resolve _module.id
423
493
  function resolveModuleId(arg, moduleEntry, context, configKey) {
424
494
  if (!type.isObject(arg)) {
@@ -444,7 +514,7 @@ function resolveModuleId(arg, moduleEntry, context, configKey) {
444
514
  });
445
515
  }
446
516
  // Dispatch _module.*Id operators
447
- function resolveModuleIdOperator(node, ctx) {
517
+ async function resolveModuleIdOperator(node, ctx) {
448
518
  const { moduleEntry } = ctx;
449
519
  const context = ctx.buildContext;
450
520
  const configKey = node['~k'];
@@ -452,18 +522,61 @@ function resolveModuleIdOperator(node, ctx) {
452
522
  return resolveModulePageId(node['_module.pageId'], moduleEntry, context, configKey);
453
523
  }
454
524
  if (!type.isUndefined(node['_module.connectionId'])) {
455
- return resolveModuleConnectionId(node['_module.connectionId'], moduleEntry, context, configKey);
525
+ const connectionArg = node['_module.connectionId'];
526
+ // Prepare (deferModuleRefs) must not resolve the object form: it reads the
527
+ // TARGET entry's remap table, which may not be prepared yet — an eager read
528
+ // would see a raw ref instead of a demandable placeholder. It becomes a
529
+ // connRemap record; demand resolves it once every entry is prepared, so
530
+ // chained remaps force each other value-granularly and true remap cycles
531
+ // surface as named wait-graph errors.
532
+ if (ctx.deferModuleRefs && type.isObject(connectionArg)) {
533
+ const nested = ctx.path.split('.').includes('$refvars');
534
+ const section = ctx.entrySection ?? 'connections';
535
+ const recordId = makeRecordId({
536
+ entryId: ctx.entryId,
537
+ configPath: ctx.path ? `${section}.${ctx.path}` : section
538
+ });
539
+ createRecord(ctx.buildContext, {
540
+ id: recordId,
541
+ kind: 'connRemap',
542
+ body: node,
543
+ env: {
544
+ file: ctx.currentFile,
545
+ moduleRoot: null,
546
+ packageRoot: null,
547
+ entryId: null,
548
+ refId: ctx.refId,
549
+ configKey: configKey ?? null
550
+ },
551
+ slot: nested ? null : {
552
+ entryId: ctx.entryId,
553
+ section,
554
+ path: ctx.path
555
+ }
556
+ });
557
+ return makePlaceholder(recordId);
558
+ }
559
+ return resolveModuleConnectionId(connectionArg, moduleEntry, ctx, configKey);
456
560
  }
457
561
  if (!type.isUndefined(node['_module.endpointId'])) {
458
562
  return resolveModuleEndpointId(node['_module.endpointId'], moduleEntry, context, configKey);
459
563
  }
564
+ if (!type.isUndefined(node['_module.agentId'])) {
565
+ return resolveModuleAgentId(node['_module.agentId'], moduleEntry, context, configKey);
566
+ }
567
+ if (!type.isUndefined(node['_module.notificationId'])) {
568
+ return resolveModuleNotificationId(node['_module.notificationId'], moduleEntry, context, configKey);
569
+ }
460
570
  if (!type.isUndefined(node['_module.id'])) {
461
571
  return resolveModuleId(node['_module.id'], moduleEntry, context, configKey);
462
572
  }
463
573
  return node;
464
574
  }
465
- // Resolve a _ref node (16-step ref handling)
466
- async function resolveRef(node, ctx) {
575
+ // Steps 1–7: scope-dependent prepare — resolves path/vars/key, normalises module
576
+ // paths, updates refMap, and checks for package-root escapes and circular refs.
577
+ // Returns the fully-resolved refDef; all side-effects (unresolvedRefVars, refMap)
578
+ // happen here. No file I/O is performed.
579
+ async function prepareRef(node, ctx) {
467
580
  // 1. Create ref definition
468
581
  const lineNumber = node['~l'];
469
582
  const refDef = makeRefDefinition(node._ref, ctx.refId, ctx.refMap, lineNumber, ctx.path);
@@ -472,19 +585,32 @@ async function resolveRef(node, ctx) {
472
585
  const varKeys = Object.keys(refDef.vars);
473
586
  if (varKeys.length > 0) {
474
587
  ctx.unresolvedRefVars[refDef.id] = refDef.vars;
475
- refDef.vars = cloneForResolve(refDef.vars);
588
+ refDef.vars = cloneWithMarkers(refDef.vars);
476
589
  }
477
590
  // 3. Resolve dynamic path/vars/key
478
591
  if (type.isObject(refDef.path)) {
479
- refDef.path = await resolve(cloneForResolve(refDef.path), ctx);
592
+ refDef.path = await resolve(cloneWithMarkers(refDef.path), ctx);
593
+ }
594
+ if (type.isObject(refDef.vars) && !type.isUndefined(refDef.vars._ref)) {
595
+ // A _ref at the root of vars resolves to the whole vars object
596
+ // (vars: { _ref: config.yaml }).
597
+ const varCtx = ctx.deferModuleRefs && refDef.module ? ctx.child('$refvars') : ctx;
598
+ refDef.vars = await resolve(refDef.vars, varCtx) ?? {};
599
+ } else {
600
+ await Promise.all(varKeys.map(async (varKey)=>{
601
+ if (type.isObject(refDef.vars[varKey]) || type.isArray(refDef.vars[varKey])) {
602
+ // Under prepare (deferModuleRefs), nested module refs inside this ref's
603
+ // vars become entryRef records too. Extend the path with a reserved
604
+ // segment so their coordinates are unique (distinct from this ref's own
605
+ // record) and recognizably nested (slot: null — their placeholder lives
606
+ // in the prepared body, not in consumerVars).
607
+ const varCtx = ctx.deferModuleRefs && refDef.module ? ctx.child('$refvars').child(varKey) : ctx;
608
+ refDef.vars[varKey] = await resolve(refDef.vars[varKey], varCtx);
609
+ }
610
+ }));
480
611
  }
481
- await Promise.all(varKeys.map(async (varKey)=>{
482
- if (type.isObject(refDef.vars[varKey]) || type.isArray(refDef.vars[varKey])) {
483
- refDef.vars[varKey] = await resolve(refDef.vars[varKey], ctx);
484
- }
485
- }));
486
612
  if (type.isObject(refDef.key)) {
487
- refDef.key = await resolve(cloneForResolve(refDef.key), ctx);
613
+ refDef.key = await resolve(cloneWithMarkers(refDef.key), ctx);
488
614
  }
489
615
  // 4. Module path resolution: resolve relative paths from the module root
490
616
  rebaseModuleRefPaths({
@@ -520,114 +646,133 @@ async function resolveRef(node, ctx) {
520
646
  lineNumber: ctx.currentFile ? lineNumber : null
521
647
  });
522
648
  }
523
- // Steps 8-16: File operations that can fail independently per ref.
524
- // Errors are collected so the walker can continue processing sibling refs,
525
- // allowing multiple errors to be reported at once.
649
+ return refDef;
650
+ }
651
+ // Steps 8–12, module flavor: content lookup, cross-module cycle key, module
652
+ // child context, consumer-var forwarding into file-ref bodies, walk under the
653
+ // module context, menu item scoping. Returns the walked content; loadAndWalkRef
654
+ // applies the shared tail (steps 13–16). Replayed deferred sentinels route here
655
+ // too — `fromFile` carries their original provenance.
656
+ async function resolveModuleExportRef(refDef, ctx, { configKey, fromFile }) {
657
+ // 8. Load content. Components and menus dereference a deferred record and
658
+ // clone its immutable body — the record env names the source file
659
+ // explicitly. Legacy live content (scalar bodies, sections produced inline
660
+ // by static operators) resolves against the manifest file.
661
+ const result = await getModuleRefContent({
662
+ context: ctx.buildContext,
663
+ refDef,
664
+ referencedFrom: fromFile,
665
+ walkCtx: ctx,
666
+ configKey
667
+ });
668
+ const resolvedEntryId = result.entryId;
669
+ const moduleEntry = ctx.buildContext.modules[resolvedEntryId];
670
+ let content;
671
+ let sourceFile;
672
+ if (result.recordId !== undefined) {
673
+ const record = getRecord(ctx.buildContext, result.recordId);
674
+ content = cloneWithMarkers(record.body);
675
+ sourceFile = record.env.file;
676
+ } else {
677
+ content = cloneWithMarkers(result.content);
678
+ sourceFile = path.join(moduleEntry.moduleRoot, 'module.lowdefy.yaml');
679
+ }
680
+ // 9. Circular detection for cross-module component/menu refs.
681
+ // File-based cycle detection (prepareRef step 7) misses these because each
682
+ // module has a different file path. Use a synthetic key with the resolved
683
+ // concrete entry ID: "module:<entryId>/<type>:<name>".
684
+ const exportType = refDef.component ? 'component' : 'menu';
685
+ const exportName = refDef.component ?? refDef.menu;
686
+ const cycleKey = `module:${resolvedEntryId}/${exportType}:${exportName}`;
687
+ if (ctx.refChain.has(cycleKey)) {
688
+ const chainDisplay = [
689
+ ...ctx.refChain,
690
+ cycleKey
691
+ ].join('\n -> ');
692
+ throw new ConfigError(`Circular module reference detected. Module "${resolvedEntryId}" ${exportType} "${exportName}" ` + `references itself through:\n -> ${chainDisplay}`, {
693
+ filePath: fromFile
694
+ });
695
+ }
696
+ // 10. Create module child context for the ref
697
+ const childCtx = ctx.forRef({
698
+ refId: refDef.id,
699
+ vars: refDef.vars,
700
+ filePath: sourceFile,
701
+ moduleRoot: moduleEntry.moduleRoot,
702
+ packageRoot: moduleEntry.packageRoot,
703
+ moduleDependencies: moduleEntry.moduleDependencies,
704
+ moduleEntry,
705
+ extraRefChainKeys: [
706
+ cycleKey
707
+ ]
708
+ });
709
+ // When component/menu content is a file _ref, the inner ref would create
710
+ // a fresh var scope and lose the consumer's vars. Inject them into the clone.
711
+ if (type.isObject(content) && content._ref) {
712
+ if (type.isObject(content._ref)) {
713
+ content._ref.vars = {
714
+ ...content._ref.vars ?? {},
715
+ ...refDef.vars
716
+ };
717
+ } else if (type.isString(content._ref) && Object.keys(refDef.vars).length > 0) {
718
+ content._ref = {
719
+ path: content._ref,
720
+ vars: refDef.vars
721
+ };
722
+ }
723
+ }
724
+ // 11. Walk the content
725
+ content = await resolve(content, childCtx);
726
+ // 12. Scope menu item IDs (menu refs only)
727
+ if (refDef.menu) {
728
+ scopeMenuItemIds(content, moduleEntry.id);
729
+ }
730
+ return content;
731
+ }
732
+ // Steps 8–16: load content, build child context, walk, transform, tag.
733
+ // Module refs take steps 8–12 in resolveModuleExportRef; both paths share the
734
+ // tail (steps 13–16). `referencedFrom` overrides ctx.currentFile for provenance
735
+ // (used when replaying a deferred sentinel whose origin file differs from the
736
+ // stage-2 ctx.currentFile).
737
+ async function loadAndWalkRef(refDef, ctx, { configKey, referencedFrom } = {}) {
738
+ const fromFile = referencedFrom ?? ctx.currentFile;
739
+ // Errors here are collected (not thrown) so the walker can continue
740
+ // processing sibling refs and report multiple errors at once.
526
741
  try {
527
- // 8. Load content
528
742
  let content;
529
- let resolvedEntryId = null;
530
743
  if (refDef.module) {
531
- const result = await getModuleRefContent({
532
- context: ctx.buildContext,
533
- refDef,
534
- referencedFrom: ctx.currentFile,
535
- walkCtx: ctx,
536
- configKey: node['~k']
744
+ content = await resolveModuleExportRef(refDef, ctx, {
745
+ configKey,
746
+ fromFile
537
747
  });
538
- content = cloneForResolve(result.content);
539
- resolvedEntryId = result.entryId;
540
748
  } else {
749
+ // 8. Load content
541
750
  content = await getRefContent({
542
751
  context: ctx.buildContext,
543
752
  refDef,
544
- referencedFrom: ctx.currentFile
753
+ referencedFrom: fromFile
545
754
  });
546
- }
547
- // 9. Circular detection for cross-module component/menu refs.
548
- // File-based cycle detection (step 7) misses these because each module
549
- // has a different file path. Use a synthetic key with the resolved
550
- // concrete entry ID: "module:<entryId>/<type>:<name>".
551
- if (resolvedEntryId && (refDef.component || refDef.menu)) {
552
- const exportType = refDef.component ? 'component' : 'menu';
553
- const exportName = refDef.component ?? refDef.menu;
554
- const cycleKey = `module:${resolvedEntryId}/${exportType}:${exportName}`;
555
- if (ctx.refChain.has(cycleKey)) {
556
- const chainDisplay = [
557
- ...ctx.refChain,
558
- cycleKey
559
- ].join('\n -> ');
560
- throw new ConfigError(`Circular module reference detected. Module "${resolvedEntryId}" ${exportType} "${exportName}" ` + `references itself through:\n -> ${chainDisplay}`, {
561
- filePath: ctx.currentFile
562
- });
563
- }
564
- }
565
- // 10. Create child context for the ref
566
- let childCtx;
567
- if (refDef.module && (refDef.component || refDef.menu)) {
568
- const moduleEntry = ctx.buildContext.modules[resolvedEntryId];
569
- const deferredFrom = content['~deferredFrom'];
570
- const exportType = refDef.component ? 'component' : 'menu';
571
- const exportName = refDef.component ?? refDef.menu;
572
- const cycleKey = `module:${resolvedEntryId}/${exportType}:${exportName}`;
573
- childCtx = ctx.forRef({
574
- refId: refDef.id,
575
- vars: refDef.vars,
576
- filePath: deferredFrom ?? path.join(moduleEntry.moduleRoot, 'module.lowdefy.yaml'),
577
- moduleRoot: moduleEntry.moduleRoot,
578
- packageRoot: moduleEntry.packageRoot,
579
- moduleDependencies: moduleEntry.moduleDependencies,
580
- moduleEntry,
581
- extraRefChainKeys: [
582
- cycleKey
583
- ]
584
- });
585
- // Clone so each consumer gets an independent copy — getModuleRefContent
586
- // returns a shared reference, and resolve() mutates in place.
587
- // deferredFrom was read above before the clone (serializer.copy strips
588
- // non-enumerable properties).
589
- content = serializer.copy(content);
590
- // When component/menu content is a file _ref, the inner ref would create
591
- // a fresh var scope and lose the consumer's vars. Inject them into the clone.
592
- if ((refDef.component || refDef.menu) && type.isObject(content) && content._ref) {
593
- if (type.isObject(content._ref)) {
594
- content._ref.vars = {
595
- ...content._ref.vars ?? {},
596
- ...refDef.vars
597
- };
598
- } else if (type.isString(content._ref) && Object.keys(refDef.vars).length > 0) {
599
- content._ref = {
600
- path: content._ref,
601
- vars: refDef.vars
602
- };
603
- }
604
- }
605
- } else {
606
- childCtx = ctx.forRef({
755
+ // 10. Create child context for the ref
756
+ const childCtx = ctx.forRef({
607
757
  refId: refDef.id,
608
758
  vars: refDef.vars,
609
759
  filePath: refDef.path
610
760
  });
611
- }
612
- // 11. Walk the content
613
- content = await resolve(content, childCtx);
614
- // 12. Scope menu item IDs (module menu refs only)
615
- if (refDef.module && refDef.menu) {
616
- const moduleEntry = ctx.buildContext.modules[resolvedEntryId];
617
- scopeMenuItemIds(content, moduleEntry.id);
761
+ // 11. Walk the content
762
+ content = await resolve(content, childCtx);
618
763
  }
619
764
  // 13. Run transformer
620
765
  content = await runTransformer({
621
766
  context: ctx.buildContext,
622
767
  input: content,
623
768
  refDef,
624
- referencedFrom: ctx.currentFile
769
+ referencedFrom: fromFile
625
770
  });
626
771
  // 14. Extract key
627
772
  content = getKey({
628
773
  input: content,
629
774
  refDef,
630
- filePath: ctx.currentFile
775
+ filePath: fromFile
631
776
  });
632
777
  // 15. Tag all nodes with ~r for provenance
633
778
  tagRefDeep(content, refDef.id);
@@ -652,22 +797,81 @@ async function resolveRef(node, ctx) {
652
797
  throw error;
653
798
  }
654
799
  }
800
+ // Resolve a _ref node: prepare (steps 1–7), then optionally defer module refs
801
+ // as sentinels (when ctx.deferModuleRefs is true), otherwise load-and-walk (steps 8–16).
802
+ async function resolveRef(node, ctx) {
803
+ const refDef = await prepareRef(node, ctx);
804
+ if (ctx.deferModuleRefs && refDef.module) {
805
+ // Prepare-time deferral: the refDef's dynamic parts are already resolved
806
+ // in the enclosing scope (steps 1-7); only the content pull defers. The
807
+ // prepared refDef becomes an entryRef record and the tree gets a
808
+ // placeholder. Records created inside another prepared ref's vars
809
+ // ($refvars segment) have no consumerVars slot — their placeholder lives
810
+ // in the parent body and is spliced by the walk that encounters it.
811
+ const nested = ctx.path.split('.').includes('$refvars');
812
+ const section = ctx.entrySection ?? 'consumerVars';
813
+ const id = makeRecordId({
814
+ entryId: ctx.entryId,
815
+ // ctx.path is '' when the whole section value is the ref (whole-blob).
816
+ configPath: ctx.path ? `${section}.${ctx.path}` : section
817
+ });
818
+ createRecord(ctx.buildContext, {
819
+ id,
820
+ kind: 'entryRef',
821
+ body: refDef,
822
+ env: {
823
+ // entryRef resolution runs at app level: no module scope; provenance
824
+ // is the file the ref was written in (replaces ~deferredFrom).
825
+ file: ctx.currentFile,
826
+ moduleRoot: null,
827
+ packageRoot: null,
828
+ entryId: null,
829
+ refId: ctx.refId,
830
+ configKey: node['~k'] ?? null
831
+ },
832
+ slot: nested ? null : {
833
+ entryId: ctx.entryId,
834
+ section,
835
+ path: ctx.path
836
+ }
837
+ });
838
+ return makePlaceholder(id);
839
+ }
840
+ return loadAndWalkRef(refDef, ctx, {
841
+ configKey: node['~k']
842
+ });
843
+ }
655
844
  // Core walk function — single-pass async tree walker
656
845
  async function resolve(node, ctx) {
657
846
  // 1. Primitives pass through
658
847
  if (!type.isObject(node) && !type.isArray(node)) return node;
659
- // 2. _ref — top-down (only operator that needs it)
848
+ // 2. Deferred-record placeholder — kind-aware dispatch. Per-consumer kinds
849
+ // (component, menuLinks) pass through every generic walk untouched; only
850
+ // module-ref consumption dereferences them. Single-value kinds (entryRef,
851
+ // varDefault) force the record and splice a clone in place — the memoized
852
+ // value is shared across demanders, and the tree gets mutated downstream.
853
+ if (type.isObject(node)) {
854
+ const deferredId = getPlaceholderId(node);
855
+ if (deferredId !== undefined) {
856
+ const record = getRecord(ctx.buildContext, deferredId);
857
+ if (record.kind === 'component' || record.kind === 'menuLinks') {
858
+ return node;
859
+ }
860
+ return cloneWithMarkers(await resolveDeferred(ctx, deferredId));
861
+ }
862
+ }
863
+ // 3. _ref — top-down (only operator that needs it)
660
864
  if (type.isObject(node) && !type.isUndefined(node._ref)) {
661
865
  return resolveRef(node, ctx);
662
866
  }
663
- // 3. Array — walk children in parallel
867
+ // 4. Array — walk children in parallel
664
868
  if (type.isArray(node)) {
665
869
  await Promise.all(node.map(async (item, i)=>{
666
870
  node[i] = await resolve(item, ctx.child(String(i)));
667
871
  }));
668
872
  return node;
669
873
  }
670
- // 4. Object — walk children in parallel (with shouldStop)
874
+ // 5. Object — walk children in parallel (with shouldStop)
671
875
  const keys = Object.keys(node);
672
876
  await Promise.all(keys.map(async (key)=>{
673
877
  if (ctx.shouldStop) {
@@ -677,16 +881,49 @@ async function resolve(node, ctx) {
677
881
  delete node[key];
678
882
  return;
679
883
  }
680
- if (stopMode === 'preserve') {
681
- if (type.isObject(node[key]) || type.isArray(node[key])) {
682
- setNonEnumerableProperty(node[key], '~deferredFrom', ctx.currentFile);
683
- }
884
+ if (stopMode === 'skip' || stopMode === 'preserve') {
885
+ // Leave raw — a later phase or consumer handles this region.
886
+ // 'preserve' is a legacy synonym: the ~deferredFrom marker it used
887
+ // to stamp is gone (records carry the source file in env.file).
888
+ return;
889
+ }
890
+ if (type.isString(stopMode) && stopMode.startsWith('record:')) {
891
+ // Record-ify: move the raw body into the registry and splice in the
892
+ // placeholder. The body stays untagged, exactly like an in-place
893
+ // preserved body: manifest walks call resolve() directly (no step 15),
894
+ // so consumer provenance is applied at consumption time by
895
+ // loadAndWalkRef's tagRefDeep over the cloned body.
896
+ const body = node[key];
897
+ // Scalars stay in the tree (mirrors 'preserve', which cannot stamp
898
+ // them); a placeholder means the region was already record-ified by
899
+ // an earlier pass — do not create a duplicate.
900
+ if (!type.isObject(body) && !type.isArray(body)) return;
901
+ if (getPlaceholderId(body) !== undefined) return;
902
+ const kind = stopMode.slice('record:'.length);
903
+ const entryId = ctx.entryId;
904
+ const id = makeRecordId({
905
+ entryId,
906
+ configPath: childPath
907
+ });
908
+ createRecord(ctx.buildContext, {
909
+ id,
910
+ kind,
911
+ body,
912
+ env: {
913
+ file: ctx.currentFile,
914
+ moduleRoot: ctx.moduleRoot ?? null,
915
+ packageRoot: ctx.packageRoot ?? null,
916
+ entryId,
917
+ refId: ctx.refId
918
+ }
919
+ });
920
+ node[key] = makePlaceholder(id);
684
921
  return;
685
922
  }
686
923
  }
687
924
  node[key] = await resolve(node[key], ctx.child(key));
688
925
  }));
689
- // 5. _var — substitution (children already resolved)
926
+ // 6. _var — substitution (children already resolved)
690
927
  if (!type.isUndefined(node._var)) {
691
928
  try {
692
929
  const varResult = resolveVar(node, ctx);
@@ -699,24 +936,51 @@ async function resolve(node, ctx) {
699
936
  throw error;
700
937
  }
701
938
  }
702
- // 6. _module.var — module variable substitution
939
+ // 7. _module.var — module variable substitution
703
940
  if (!type.isUndefined(node['_module.var'])) {
704
941
  if (!ctx.moduleEntry) {
705
- if (ctx.moduleRoot) return node;
942
+ if (ctx.moduleRoot) {
943
+ // Module scope without an entry = the header parse or the exportables
944
+ // pass. Those walk module-static structure only: a _module.var here
945
+ // would make manifest headers or export ids vary per consumer, which
946
+ // makes by-name lookup order-dependent.
947
+ throw new ConfigError('_module.var cannot be used in manifest headers or component/menu ids — ' + 'these are module-static. Move it inside a component body, page, or api section.', {
948
+ filePath: ctx.currentFile
949
+ });
950
+ }
706
951
  throw new ConfigError('_module.var cannot be used at the app level.');
707
952
  }
708
953
  return resolve(await resolveModuleVar(node, ctx), ctx);
709
954
  }
710
- // 7. _module.*Id — resolve to scoped ID string
955
+ // 8. _module.*Id — resolve to scoped ID string
711
956
  if (isModuleIdOperator(node)) {
712
957
  return resolveModuleIdOperator(node, ctx);
713
958
  }
714
- // 8. _build.* operator
959
+ // 9. _build.* operator
715
960
  if (isBuildOperator(node)) {
961
+ // Under prepare (deferModuleRefs), an operand subtree can hold a deferred
962
+ // placeholder where a module ref used to be — the operator cannot fold
963
+ // over it. Leave the node unevaluated: the finalize/demand walk (deferral
964
+ // off) splices concrete values via the placeholder dispatch before this
965
+ // branch runs, and the fold happens then, in the same enclosing scope.
966
+ if (ctx.deferModuleRefs && findPlaceholderInSubtree(node)) {
967
+ return node;
968
+ }
716
969
  const result = evaluateBuildOperator(node, ctx);
717
970
  tagRefDeep(result, ctx.refId);
718
971
  return result;
719
972
  }
720
973
  return node;
721
974
  }
722
- export { resolve, WalkContext, cloneForResolve, tagRefDeep };
975
+ // Does any node in the subtree carry a deferred-record placeholder?
976
+ function findPlaceholderInSubtree(node) {
977
+ if (getPlaceholderId(node) !== undefined) return true;
978
+ if (type.isArray(node)) {
979
+ return node.some((item)=>findPlaceholderInSubtree(item));
980
+ }
981
+ if (type.isObject(node)) {
982
+ return Object.keys(node).some((key)=>findPlaceholderInSubtree(node[key]));
983
+ }
984
+ return false;
985
+ }
986
+ export { resolve, loadAndWalkRef, WalkContext, tagRefDeep };