@lowdefy/build 5.5.1 → 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 (107) hide show
  1. package/dist/build/buildAgents.js +70 -17
  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 +69 -0
  12. package/dist/build/buildAuth/buildAuth.js +14 -0
  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/getAgentRoles.js +39 -0
  17. package/dist/build/buildAuth/getProtectedAgents.js +29 -0
  18. package/dist/build/buildAuth/getProtectedWebsockets.js +29 -0
  19. package/dist/build/buildAuth/getWebsocketRoles.js +39 -0
  20. package/dist/build/buildAuth/rejectReservedEntityIds.js +52 -0
  21. package/dist/build/buildAuth/validateAuthConfig.js +15 -3
  22. package/dist/build/buildImports/buildImportsDev.js +15 -0
  23. package/dist/build/buildImports/buildImportsProd.js +4 -1
  24. package/dist/build/buildJs/generateClientJsModule.js +27 -0
  25. package/dist/build/buildJs/writeJs.js +3 -5
  26. package/dist/build/buildMcp.js +73 -0
  27. package/dist/build/buildModuleDefs.js +90 -48
  28. package/dist/build/buildModules.js +61 -14
  29. package/dist/build/buildNotifications.js +98 -0
  30. package/dist/build/buildPages/buildBlock/buildBlock.js +3 -1
  31. package/dist/build/buildPages/buildBlock/buildDynamicBlock.js +131 -0
  32. package/dist/build/buildPages/buildBlock/buildEvents.js +197 -22
  33. package/dist/build/buildPages/buildBlock/buildRequests.js +9 -0
  34. package/dist/build/buildPages/buildBlock/normalizeClassAndStyles.js +2 -11
  35. package/dist/build/buildPages/buildBlock/setBlockId.js +5 -2
  36. package/dist/build/buildPages/buildBlock/validateBlock.js +19 -1
  37. package/dist/build/buildPages/buildPage.js +27 -3
  38. package/dist/build/buildPages/buildSubscriptions.js +145 -0
  39. package/dist/build/buildPages/validateDynamicBlockRefs.js +30 -0
  40. package/dist/build/buildPages/validateStateReferences.js +4 -2
  41. package/dist/build/buildPages/validateWebsocketRefs.js +30 -0
  42. package/dist/build/buildRefs/buildRefs.js +21 -12
  43. package/dist/build/buildRefs/cloneWithMarkers.js +57 -0
  44. package/dist/build/buildRefs/deferredRegions.js +80 -0
  45. package/dist/build/buildRefs/deferredRegistry.js +292 -0
  46. package/dist/build/buildRefs/getKey.js +16 -5
  47. package/dist/build/buildRefs/getModuleRefContent.js +21 -6
  48. package/dist/build/buildRefs/getUserJavascriptFunction.js +1 -1
  49. package/dist/build/buildRefs/parseRefContent.js +24 -0
  50. package/dist/build/buildRefs/{evaluateStaticOperators.js → precomputeRuntimeOperators.js} +7 -6
  51. package/dist/build/buildRefs/walker.js +490 -209
  52. package/dist/build/buildTypes.js +22 -1
  53. package/dist/build/buildWebsockets.js +99 -0
  54. package/dist/build/collectPageContent.js +7 -1
  55. package/dist/build/copyAgentFileSystems.js +2 -2
  56. package/dist/build/fetchModules.js +18 -4
  57. package/dist/build/full/buildPages.js +16 -0
  58. package/dist/build/full/updateServerPackageJson.js +21 -0
  59. package/dist/build/jit/buildPageJit.js +46 -6
  60. package/dist/build/jit/collectSkeletonSourceFiles.js +14 -0
  61. package/dist/build/jit/pageContentKeys.js +1 -0
  62. package/dist/build/jit/shallowBuild.js +81 -1
  63. package/dist/build/jit/writePageJit.js +11 -8
  64. package/dist/build/registerModules.js +149 -42
  65. package/dist/build/validateCallAgentSteps.js +58 -0
  66. package/dist/build/validateConfig.js +4 -0
  67. package/dist/build/validateCronConfig.js +110 -0
  68. package/dist/build/validateRenderNotificationSteps.js +50 -0
  69. package/dist/build/writeApi.js +58 -1
  70. package/dist/build/writeI18n.js +8 -1
  71. package/dist/build/writeMcp.js +21 -0
  72. package/dist/build/writeNotifications.js +30 -0
  73. package/dist/build/writePluginImports/importPluginModule.js +39 -0
  74. package/dist/build/writePluginImports/writeActionSchemaMap.js +6 -7
  75. package/dist/build/writePluginImports/writeAuthImports.js +4 -0
  76. package/dist/build/writePluginImports/writeAvailableTypes.js +99 -0
  77. package/dist/build/writePluginImports/writeBlockSchemaMap.js +10 -9
  78. package/dist/build/writePluginImports/writeConnectionSchemaMap.js +101 -0
  79. package/dist/build/writePluginImports/writeGlobalsCss.js +20 -11
  80. package/dist/build/writePluginImports/writeNotificationImports.js +31 -0
  81. package/dist/build/writePluginImports/writeOperatorSchemaMap.js +6 -7
  82. package/dist/build/writePluginImports/writePluginImports.js +18 -4
  83. package/dist/build/writePluginImports/writeWebsocketImports.js +22 -0
  84. package/dist/build/writeWebsockets.js +30 -0
  85. package/dist/createContext.js +6 -1
  86. package/dist/defaultPackages.js +7 -1
  87. package/dist/defaultTypesMap.js +689 -386
  88. package/dist/dynamic/buildDynamicBlocks.js +120 -0
  89. package/dist/index.js +78 -8
  90. package/dist/indexDev.js +4 -0
  91. package/dist/lowdefySchema.js +861 -6
  92. package/dist/scripts/generateDefaultTypes.js +5 -2
  93. package/dist/test-utils/expectTerminates.js +33 -0
  94. package/dist/test-utils/runBuild.js +20 -1
  95. package/dist/test-utils/runBuildForSnapshots.js +34 -4
  96. package/dist/test-utils/testContext.js +8 -2
  97. package/dist/utils/countOperators.js +5 -17
  98. package/dist/utils/createPluginTypesMap.js +21 -0
  99. package/dist/utils/getCronEnvironmentNames.js +25 -0
  100. package/dist/utils/logCollectedErrors.js +44 -10
  101. package/dist/utils/serializeBuildException.js +29 -0
  102. package/dist/utils/validateCronExpression.js +131 -0
  103. package/dist/utils/validateId.js +11 -0
  104. package/dist/utils/writeBuildArtifact.js +5 -2
  105. package/package.json +66 -59
  106. package/dist/build/writePluginImports/writeServerExternalPackages.js +0 -121
  107. /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, 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) {
@@ -203,26 +174,46 @@ function evaluateBuildOperator(node, ctx) {
203
174
  }
204
175
  return output;
205
176
  }
177
+ // _var keys are author-written YAML, so a reserved name (e.g. "constructor") is a config
178
+ // mistake, not a missing var. Wraps both call shapes for that check. The object form call
179
+ // site below passes no `options` argument at all, so a missing key comes back from `get` as
180
+ // `undefined` instead of a default — resolveVar relies on that `undefined` to tell "var not
181
+ // provided" apart from "var provided as null".
182
+ function readVar(key, ctx, options) {
183
+ try {
184
+ return get(ctx.vars, key, options);
185
+ } catch (error) {
186
+ if (!(error instanceof ReservedKeyError)) throw error;
187
+ throw new ConfigError(`_var key "${key}" is a reserved name.`, {
188
+ cause: error,
189
+ filePath: ctx.currentFile
190
+ });
191
+ }
192
+ }
206
193
  // Resolve a _var node
207
194
  function resolveVar(node, ctx) {
208
195
  const varDef = node._var;
209
196
  // String form: { _var: "key" }
210
197
  if (type.isString(varDef)) {
211
- const value = get(ctx.vars, varDef, {
198
+ const value = readVar(varDef, ctx, {
212
199
  default: null
213
200
  });
214
- return cloneVarValue(value, ctx.sourceRefId);
201
+ return cloneWithMarkers(value, {
202
+ assignRefId: ctx.sourceRefId
203
+ });
215
204
  }
216
205
  // Object form: { _var: { key, default } }
217
206
  if (type.isObject(varDef) && type.isString(varDef.key)) {
218
- const varFromParent = get(ctx.vars, varDef.key);
207
+ const varFromParent = readVar(varDef.key, ctx);
219
208
  // Var provided (even if null) → use parent's sourceRefId for location
220
209
  if (!type.isUndefined(varFromParent)) {
221
- return cloneVarValue(varFromParent, ctx.sourceRefId);
210
+ return cloneWithMarkers(varFromParent, {
211
+ assignRefId: ctx.sourceRefId
212
+ });
222
213
  }
223
214
  // Not provided → use default, preserve template's ~r
224
215
  const defaultValue = type.isNone(varDef.default) ? null : varDef.default;
225
- return cloneVarValue(defaultValue, null);
216
+ return cloneWithMarkers(defaultValue);
226
217
  }
227
218
  throw new ConfigError('_var operator takes a string or object with "key" field as arguments.', {
228
219
  filePath: ctx.currentFile
@@ -237,7 +228,9 @@ async function resolveModuleVar(node, ctx) {
237
228
  });
238
229
  }
239
230
  const value = await resolveEffectiveVar(key, ctx.moduleEntry, ctx);
240
- return cloneVarValue(value, ctx.sourceRefId);
231
+ return cloneWithMarkers(value, {
232
+ assignRefId: ctx.sourceRefId
233
+ });
241
234
  }
242
235
  // Navigate the var definitions tree by dot-path key, following `properties` nesting.
243
236
  function getVarDef(varDefs, key) {
@@ -258,27 +251,27 @@ function getVarDef(varDefs, key) {
258
251
  // at the module manifest. The fresh context prevents false circular-ref detection
259
252
  // from the consumer's refChain and ensures _ref paths resolve relative to the
260
253
  // module root.
261
- async function resolveVarDefault(rawDefault, moduleEntry, ctx) {
262
- const moduleYamlPath = path.join(moduleEntry.moduleRoot, 'module.lowdefy.yaml');
263
- const defaultCtx = new WalkContext({
264
- buildContext: ctx.buildContext,
265
- refId: moduleEntry.refDef.id,
266
- sourceRefId: null,
267
- vars: {},
268
- moduleDependencies: moduleEntry.moduleDependencies,
269
- moduleEntry,
270
- moduleRoot: moduleEntry.moduleRoot,
271
- packageRoot: moduleEntry.packageRoot,
272
- path: '',
273
- currentFile: moduleYamlPath,
274
- refChain: new Set(moduleEntry.refDef.path ? [
275
- moduleEntry.refDef.path
276
- ] : []),
277
- operators: ctx.operators,
278
- env: ctx.env,
279
- dynamicIdentifiers: ctx.dynamicIdentifiers
280
- });
281
- 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;
282
275
  }
283
276
  // Build a merged object for namespace vars (vars with `properties`). Each
284
277
  // declared property resolves through resolveEffectiveVar — consumer values
@@ -291,22 +284,53 @@ async function resolveNamespaceVar(prefix, varDef, moduleEntry, ctx) {
291
284
  }
292
285
  return result;
293
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
+ }
294
314
  // Core lazy var resolution with caching on the module entry.
295
315
  async function resolveEffectiveVar(key, moduleEntry, ctx) {
296
316
  if (Object.hasOwn(moduleEntry.resolvedVarCache, key)) {
297
317
  return moduleEntry.resolvedVarCache[key];
298
318
  }
299
- const consumerValue = get(moduleEntry.consumerVars, key, {
300
- default: undefined
301
- });
319
+ const consumerValue = await readConsumerValue(moduleEntry, key, ctx);
302
320
  const varDef = getVarDef(moduleEntry.varDefs, key);
303
321
  let result;
304
322
  if (varDef?.properties) {
305
323
  result = await resolveNamespaceVar(key, varDef, moduleEntry, ctx);
306
324
  } else if (!type.isNone(consumerValue)) {
307
- 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);
308
328
  } else if (varDef && !type.isUndefined(varDef.default)) {
309
- 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;
310
334
  } else {
311
335
  result = null;
312
336
  }
@@ -318,6 +342,8 @@ const MODULE_ID_OPERATOR_KEYS = [
318
342
  '_module.pageId',
319
343
  '_module.connectionId',
320
344
  '_module.endpointId',
345
+ '_module.agentId',
346
+ '_module.notificationId',
321
347
  '_module.id'
322
348
  ];
323
349
  function isModuleIdOperator(node) {
@@ -348,16 +374,28 @@ function resolveModulePageId(arg, moduleEntry, context, configKey) {
348
374
  });
349
375
  }
350
376
  // Resolve _module.connectionId
351
- 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
+ };
352
390
  if (type.isString(arg)) {
353
391
  if (!moduleEntry) {
354
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.', {
355
393
  configKey
356
394
  });
357
395
  }
358
- const remapping = moduleEntry.connections ?? {};
359
- if (remapping[arg]) {
360
- return remapping[arg];
396
+ const remapValue = await readRemapValue(moduleEntry, arg);
397
+ if (remapValue) {
398
+ return remapValue;
361
399
  }
362
400
  return `${moduleEntry.id}/${arg}`;
363
401
  }
@@ -369,9 +407,9 @@ function resolveModuleConnectionId(arg, moduleEntry, context, configKey) {
369
407
  configKey,
370
408
  usage: `_module.connectionId { id: "${arg.id}", module: "${arg.module}" }`
371
409
  });
372
- const targetRemapping = targetEntry.connections ?? {};
373
- if (targetRemapping[arg.id]) {
374
- return targetRemapping[arg.id];
410
+ const remapValue = await readRemapValue(targetEntry, arg.id);
411
+ if (remapValue) {
412
+ return remapValue;
375
413
  }
376
414
  return `${targetEntry.id}/${arg.id}`;
377
415
  }
@@ -403,6 +441,54 @@ function resolveModuleEndpointId(arg, moduleEntry, context, configKey) {
403
441
  configKey
404
442
  });
405
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
+ }
406
492
  // Resolve _module.id
407
493
  function resolveModuleId(arg, moduleEntry, context, configKey) {
408
494
  if (!type.isObject(arg)) {
@@ -428,7 +514,7 @@ function resolveModuleId(arg, moduleEntry, context, configKey) {
428
514
  });
429
515
  }
430
516
  // Dispatch _module.*Id operators
431
- function resolveModuleIdOperator(node, ctx) {
517
+ async function resolveModuleIdOperator(node, ctx) {
432
518
  const { moduleEntry } = ctx;
433
519
  const context = ctx.buildContext;
434
520
  const configKey = node['~k'];
@@ -436,18 +522,61 @@ function resolveModuleIdOperator(node, ctx) {
436
522
  return resolveModulePageId(node['_module.pageId'], moduleEntry, context, configKey);
437
523
  }
438
524
  if (!type.isUndefined(node['_module.connectionId'])) {
439
- 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);
440
560
  }
441
561
  if (!type.isUndefined(node['_module.endpointId'])) {
442
562
  return resolveModuleEndpointId(node['_module.endpointId'], moduleEntry, context, configKey);
443
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
+ }
444
570
  if (!type.isUndefined(node['_module.id'])) {
445
571
  return resolveModuleId(node['_module.id'], moduleEntry, context, configKey);
446
572
  }
447
573
  return node;
448
574
  }
449
- // Resolve a _ref node (16-step ref handling)
450
- 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) {
451
580
  // 1. Create ref definition
452
581
  const lineNumber = node['~l'];
453
582
  const refDef = makeRefDefinition(node._ref, ctx.refId, ctx.refMap, lineNumber, ctx.path);
@@ -456,19 +585,32 @@ async function resolveRef(node, ctx) {
456
585
  const varKeys = Object.keys(refDef.vars);
457
586
  if (varKeys.length > 0) {
458
587
  ctx.unresolvedRefVars[refDef.id] = refDef.vars;
459
- refDef.vars = cloneForResolve(refDef.vars);
588
+ refDef.vars = cloneWithMarkers(refDef.vars);
460
589
  }
461
590
  // 3. Resolve dynamic path/vars/key
462
591
  if (type.isObject(refDef.path)) {
463
- 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
+ }));
464
611
  }
465
- await Promise.all(varKeys.map(async (varKey)=>{
466
- if (type.isObject(refDef.vars[varKey]) || type.isArray(refDef.vars[varKey])) {
467
- refDef.vars[varKey] = await resolve(refDef.vars[varKey], ctx);
468
- }
469
- }));
470
612
  if (type.isObject(refDef.key)) {
471
- refDef.key = await resolve(cloneForResolve(refDef.key), ctx);
613
+ refDef.key = await resolve(cloneWithMarkers(refDef.key), ctx);
472
614
  }
473
615
  // 4. Module path resolution: resolve relative paths from the module root
474
616
  rebaseModuleRefPaths({
@@ -504,113 +646,133 @@ async function resolveRef(node, ctx) {
504
646
  lineNumber: ctx.currentFile ? lineNumber : null
505
647
  });
506
648
  }
507
- // Steps 8-16: File operations that can fail independently per ref.
508
- // Errors are collected so the walker can continue processing sibling refs,
509
- // 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.
510
741
  try {
511
- // 8. Load content
512
742
  let content;
513
- let resolvedEntryId = null;
514
743
  if (refDef.module) {
515
- const result = await getModuleRefContent({
516
- context: ctx.buildContext,
517
- refDef,
518
- referencedFrom: ctx.currentFile,
519
- walkCtx: ctx,
520
- configKey: node['~k']
744
+ content = await resolveModuleExportRef(refDef, ctx, {
745
+ configKey,
746
+ fromFile
521
747
  });
522
- content = cloneForResolve(result.content);
523
- resolvedEntryId = result.entryId;
524
748
  } else {
749
+ // 8. Load content
525
750
  content = await getRefContent({
526
751
  context: ctx.buildContext,
527
752
  refDef,
528
- referencedFrom: ctx.currentFile
753
+ referencedFrom: fromFile
529
754
  });
530
- }
531
- // 9. Circular detection for cross-module component/menu refs.
532
- // File-based cycle detection (step 7) misses these because each module
533
- // has a different file path. Use a synthetic key with the resolved
534
- // concrete entry ID: "module:<entryId>/<type>:<name>".
535
- if (resolvedEntryId && (refDef.component || refDef.menu)) {
536
- const exportType = refDef.component ? 'component' : 'menu';
537
- const exportName = refDef.component ?? refDef.menu;
538
- const cycleKey = `module:${resolvedEntryId}/${exportType}:${exportName}`;
539
- if (ctx.refChain.has(cycleKey)) {
540
- const chainDisplay = [
541
- ...ctx.refChain,
542
- cycleKey
543
- ].join('\n -> ');
544
- throw new ConfigError(`Circular module reference detected. Module "${resolvedEntryId}" ${exportType} "${exportName}" ` + `references itself through:\n -> ${chainDisplay}`, {
545
- filePath: ctx.currentFile
546
- });
547
- }
548
- }
549
- // 10. Create child context for the ref
550
- let childCtx;
551
- if (refDef.module && (refDef.component || refDef.menu)) {
552
- const moduleEntry = ctx.buildContext.modules[resolvedEntryId];
553
- const deferredFrom = content['~deferredFrom'];
554
- const exportType = refDef.component ? 'component' : 'menu';
555
- const exportName = refDef.component ?? refDef.menu;
556
- const cycleKey = `module:${resolvedEntryId}/${exportType}:${exportName}`;
557
- childCtx = ctx.forRef({
558
- refId: refDef.id,
559
- vars: refDef.vars,
560
- filePath: deferredFrom ?? path.join(moduleEntry.moduleRoot, 'module.lowdefy.yaml'),
561
- moduleRoot: moduleEntry.moduleRoot,
562
- packageRoot: moduleEntry.packageRoot,
563
- moduleDependencies: moduleEntry.moduleDependencies,
564
- moduleEntry,
565
- extraRefChainKeys: [
566
- cycleKey
567
- ]
568
- });
569
- // Clone so each consumer gets an independent copy — getModuleRefContent
570
- // returns a shared reference, and resolve() mutates in place.
571
- // deferredFrom was read above before the clone (serializer.copy strips
572
- // non-enumerable properties).
573
- content = serializer.copy(content);
574
- // When component/menu content is a file _ref, the inner ref would create
575
- // a fresh var scope and lose the consumer's vars. Inject them into the clone.
576
- if ((refDef.component || refDef.menu) && type.isObject(content) && content._ref) {
577
- if (type.isObject(content._ref)) {
578
- content._ref.vars = {
579
- ...content._ref.vars ?? {},
580
- ...refDef.vars
581
- };
582
- } else if (type.isString(content._ref) && Object.keys(refDef.vars).length > 0) {
583
- content._ref = {
584
- path: content._ref,
585
- vars: refDef.vars
586
- };
587
- }
588
- }
589
- } else {
590
- childCtx = ctx.forRef({
755
+ // 10. Create child context for the ref
756
+ const childCtx = ctx.forRef({
591
757
  refId: refDef.id,
592
758
  vars: refDef.vars,
593
759
  filePath: refDef.path
594
760
  });
595
- }
596
- // 11. Walk the content
597
- content = await resolve(content, childCtx);
598
- // 12. Scope menu item IDs (module menu refs only)
599
- if (refDef.module && refDef.menu) {
600
- const moduleEntry = ctx.buildContext.modules[resolvedEntryId];
601
- scopeMenuItemIds(content, moduleEntry.id);
761
+ // 11. Walk the content
762
+ content = await resolve(content, childCtx);
602
763
  }
603
764
  // 13. Run transformer
604
765
  content = await runTransformer({
605
766
  context: ctx.buildContext,
606
767
  input: content,
607
768
  refDef,
608
- referencedFrom: ctx.currentFile
769
+ referencedFrom: fromFile
609
770
  });
610
771
  // 14. Extract key
611
772
  content = getKey({
612
773
  input: content,
613
- refDef
774
+ refDef,
775
+ filePath: fromFile
614
776
  });
615
777
  // 15. Tag all nodes with ~r for provenance
616
778
  tagRefDeep(content, refDef.id);
@@ -635,22 +797,81 @@ async function resolveRef(node, ctx) {
635
797
  throw error;
636
798
  }
637
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
+ }
638
844
  // Core walk function — single-pass async tree walker
639
845
  async function resolve(node, ctx) {
640
846
  // 1. Primitives pass through
641
847
  if (!type.isObject(node) && !type.isArray(node)) return node;
642
- // 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)
643
864
  if (type.isObject(node) && !type.isUndefined(node._ref)) {
644
865
  return resolveRef(node, ctx);
645
866
  }
646
- // 3. Array — walk children in parallel
867
+ // 4. Array — walk children in parallel
647
868
  if (type.isArray(node)) {
648
869
  await Promise.all(node.map(async (item, i)=>{
649
870
  node[i] = await resolve(item, ctx.child(String(i)));
650
871
  }));
651
872
  return node;
652
873
  }
653
- // 4. Object — walk children in parallel (with shouldStop)
874
+ // 5. Object — walk children in parallel (with shouldStop)
654
875
  const keys = Object.keys(node);
655
876
  await Promise.all(keys.map(async (key)=>{
656
877
  if (ctx.shouldStop) {
@@ -660,16 +881,49 @@ async function resolve(node, ctx) {
660
881
  delete node[key];
661
882
  return;
662
883
  }
663
- if (stopMode === 'preserve') {
664
- if (type.isObject(node[key]) || type.isArray(node[key])) {
665
- setNonEnumerableProperty(node[key], '~deferredFrom', ctx.currentFile);
666
- }
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);
667
921
  return;
668
922
  }
669
923
  }
670
924
  node[key] = await resolve(node[key], ctx.child(key));
671
925
  }));
672
- // 5. _var — substitution (children already resolved)
926
+ // 6. _var — substitution (children already resolved)
673
927
  if (!type.isUndefined(node._var)) {
674
928
  try {
675
929
  const varResult = resolveVar(node, ctx);
@@ -682,24 +936,51 @@ async function resolve(node, ctx) {
682
936
  throw error;
683
937
  }
684
938
  }
685
- // 6. _module.var — module variable substitution
939
+ // 7. _module.var — module variable substitution
686
940
  if (!type.isUndefined(node['_module.var'])) {
687
941
  if (!ctx.moduleEntry) {
688
- 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
+ }
689
951
  throw new ConfigError('_module.var cannot be used at the app level.');
690
952
  }
691
953
  return resolve(await resolveModuleVar(node, ctx), ctx);
692
954
  }
693
- // 7. _module.*Id — resolve to scoped ID string
955
+ // 8. _module.*Id — resolve to scoped ID string
694
956
  if (isModuleIdOperator(node)) {
695
957
  return resolveModuleIdOperator(node, ctx);
696
958
  }
697
- // 8. _build.* operator
959
+ // 9. _build.* operator
698
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
+ }
699
969
  const result = evaluateBuildOperator(node, ctx);
700
970
  tagRefDeep(result, ctx.refId);
701
971
  return result;
702
972
  }
703
973
  return node;
704
974
  }
705
- 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 };