@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
@@ -16,7 +16,6 @@
16
16
  import { resolve, WalkContext } from './walker.js';
17
17
  import getRefContent from './getRefContent.js';
18
18
  import makeRefDefinition from './makeRefDefinition.js';
19
- import evaluateStaticOperators from './evaluateStaticOperators.js';
20
19
  import collectDynamicIdentifiers from '../collectDynamicIdentifiers.js';
21
20
  import validateOperatorsDynamic from '../validateOperatorsDynamic.js';
22
21
  import isPageContentPath from '../jit/isPageContentPath.js';
@@ -30,6 +29,9 @@ const dynamicIdentifiers = collectDynamicIdentifiers({
30
29
  async function buildRefs({ context, shallowOptions }) {
31
30
  context.unresolvedRefVars = context.unresolvedRefVars ?? {};
32
31
  const refDef = makeRefDefinition('lowdefy.yaml', null, context.refMap);
32
+ // Stash for Phase 3.5 (precomputeRuntimeOperators) so it can resolve error
33
+ // source file paths without creating a new makeId entry.
34
+ context.rootRefDef = refDef;
33
35
  const ctx = new WalkContext({
34
36
  buildContext: context,
35
37
  refId: refDef.id,
@@ -42,24 +44,31 @@ async function buildRefs({ context, shallowOptions }) {
42
44
  ] : []),
43
45
  operators,
44
46
  env: process.env,
47
+ lowdefyApp: context.appMeta,
45
48
  dynamicIdentifiers,
46
- shouldStop: shallowOptions ? // JIT can re-resolve them from source files. Inline pages (defined
47
- // directly in lowdefy.yaml) live in the root ref and have no separate
48
- // source file — their content must be preserved for buildShallowPages.
49
- (path, refId)=>isPageContentPath(path) && refId !== refDef.id : null
49
+ shouldStop: (path, refId)=>{
50
+ // Module entry vars/connections were already resolved by parseLowdefyYaml
51
+ // (same predicate) and live on context.modules — preserve the raw blobs so
52
+ // the app pass doesn't pull and walk their refs a second time. buildModules
53
+ // reads entry ids only and deletes components.modules before testSchema.
54
+ if (/^modules\.\d+\.vars$/.test(path)) return 'preserve';
55
+ if (/^modules\.\d+\.connections$/.test(path)) return 'preserve';
56
+ if (shallowOptions) {
57
+ // Strip page content (blocks, events, etc.) from ref-backed pages so
58
+ // JIT can re-resolve them from source files. Inline pages (defined
59
+ // directly in lowdefy.yaml) live in the root ref and have no separate
60
+ // source file — their content must be preserved for buildShallowPages.
61
+ return isPageContentPath(path) && refId !== refDef.id;
62
+ }
63
+ return null;
64
+ }
50
65
  });
51
66
  const content = await getRefContent({
52
67
  context,
53
68
  refDef,
54
69
  referencedFrom: null
55
70
  });
56
- let components = await resolve(content, ctx);
57
- // Evaluate static operators (_sum, _if, etc.) that don't depend on runtime data
58
- components = evaluateStaticOperators({
59
- context,
60
- input: components,
61
- refDef
62
- });
71
+ const components = await resolve(content, ctx);
63
72
  return components ?? {};
64
73
  }
65
74
  export default buildRefs;
@@ -0,0 +1,57 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ import setNonEnumerableProperty from '../../utils/setNonEnumerableProperty.js';
17
+ // THE deep clone for config subtrees inside buildRefs — preserves the
18
+ // non-enumerable provenance markers (~r ~l ~k ~arr), and with assignRefId set
19
+ // stamps ~r on nodes that have none (pass nothing to preserve the template's
20
+ // existing markers only). Non-plain values (Date) pass by reference; nothing
21
+ // in the build mutates them.
22
+ //
23
+ // Do not use serializer.copy on config subtrees in buildRefs: it round-trips
24
+ // the same markers but pays a JSON round-trip and re-instantiates Dates — the
25
+ // serializer is the artifact boundary, this is the in-memory clone.
26
+ function cloneWithMarkers(value, { assignRefId = null } = {}) {
27
+ if (!type.isObject(value) && !type.isArray(value)) return value;
28
+ let clone;
29
+ if (type.isArray(value)) {
30
+ clone = value.map((item)=>cloneWithMarkers(item, {
31
+ assignRefId
32
+ }));
33
+ } else {
34
+ clone = {};
35
+ for (const key of Object.keys(value)){
36
+ clone[key] = cloneWithMarkers(value[key], {
37
+ assignRefId
38
+ });
39
+ }
40
+ }
41
+ if (value['~r'] !== undefined) {
42
+ setNonEnumerableProperty(clone, '~r', value['~r']);
43
+ } else if (assignRefId) {
44
+ setNonEnumerableProperty(clone, '~r', assignRefId);
45
+ }
46
+ for (const marker of [
47
+ '~l',
48
+ '~k',
49
+ '~arr'
50
+ ]){
51
+ if (value[marker] !== undefined) {
52
+ setNonEnumerableProperty(clone, marker, value[marker]);
53
+ }
54
+ }
55
+ return clone;
56
+ }
57
+ export default cloneWithMarkers;
@@ -0,0 +1,80 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ // The single source of truth for which module-manifest regions each build
16
+ // phase defers, records, or skips — one table instead of hand-maintained
17
+ // shouldStop regex sets that must agree.
18
+ //
19
+ // Phases:
20
+ // - 'header' Phase A: parse static header keys (dependencies, plugins,
21
+ // secrets, vars definitions — defaults record-ified); content
22
+ // sections stay raw parsed YAML ('skip' = leave raw, do not
23
+ // descend).
24
+ // - 'exportables' Phase C.5: record-ify per-consumer bodies (components,
25
+ // menu links) in module-static scope; everything else stays
26
+ // raw for Phase D.
27
+ // - 'manifest' Phase D: one full walk; only varDefault placeholders need a
28
+ // rule (single-value kinds would otherwise be forced by the
29
+ // placeholder dispatch — they are demand-only). Per-consumer
30
+ // placeholders pass through the dispatch untouched.
31
+ //
32
+ // entryRef/connRemap creation is value-shape-driven (deferModuleRefs in the
33
+ // walker), not path-driven, so it has no row here. The JIT page-content
34
+ // predicate lives in buildRefs.js (it composes with the app-pass modules
35
+ // preserve and is not a manifest phase).
36
+ const VAR_DEFAULT = /^vars(\.[^.]+\.properties)*\.[^.]+\.default(\..*)?$/;
37
+ const CONTENT_SECTIONS = /^(pages|api|connections|agents|notifications|menus|components)(\..*)?$/;
38
+ const NON_EXPORTABLE_SECTIONS = /^(pages|api|connections|agents|notifications|vars|dependencies|plugins|secrets)(\..*)?$/;
39
+ const DEFERRED_REGIONS = [
40
+ {
41
+ phase: 'header',
42
+ match: VAR_DEFAULT,
43
+ action: 'record:varDefault'
44
+ },
45
+ {
46
+ phase: 'header',
47
+ match: CONTENT_SECTIONS,
48
+ action: 'skip'
49
+ },
50
+ {
51
+ phase: 'exportables',
52
+ match: /^components\.\d+\.component$/,
53
+ action: 'record:component'
54
+ },
55
+ {
56
+ phase: 'exportables',
57
+ match: /^menus\.\d+\.links$/,
58
+ action: 'record:menuLinks'
59
+ },
60
+ {
61
+ phase: 'exportables',
62
+ match: NON_EXPORTABLE_SECTIONS,
63
+ action: 'skip'
64
+ },
65
+ {
66
+ phase: 'manifest',
67
+ match: VAR_DEFAULT,
68
+ action: 'record:varDefault'
69
+ }
70
+ ];
71
+ function makeShouldStop(phase) {
72
+ const rows = DEFERRED_REGIONS.filter((row)=>row.phase === phase);
73
+ return (childPath)=>{
74
+ for (const row of rows){
75
+ if (row.match.test(childPath)) return row.action;
76
+ }
77
+ return false;
78
+ };
79
+ }
80
+ export { DEFERRED_REGIONS, makeShouldStop };
@@ -0,0 +1,292 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { ConfigError, LowdefyInternalError } from '@lowdefy/errors';
16
+ import { serializer, type } from '@lowdefy/helpers';
17
+ // Circular import by design: the walker dispatches placeholders into
18
+ // resolveDeferred, and resolveDeferred walks record bodies. Both modules only
19
+ // reference each other's bindings at call time, after evaluation completes.
20
+ import { loadAndWalkRef, resolve, WalkContext } from './walker.js';
21
+ import cloneWithMarkers from './cloneWithMarkers.js';
22
+ // The deferred-content record registry. Every deferred region of module config
23
+ // becomes a record { kind, body, env, slot } in context.deferred; the config
24
+ // tree holds only an enumerable placeholder { '~deferred': '<id>' }. Records
25
+ // carry the resolution environment explicitly instead of reconstructing it
26
+ // from in-tree markers — the placeholder is plain data that survives every
27
+ // clone, serializer.copy, and JSON round-trip to JIT.
28
+ //
29
+ // Record kinds:
30
+ // - 'component' / 'menuLinks': resolved per consumer (per-ref vars differ) —
31
+ // no promise memo, no write-back; placeholders persist in manifests.
32
+ // - 'varDefault' / 'entryRef': single-value, resolved on demand and
33
+ // promise-memoized per record (resolveDeferred; lands with the wait-graph).
34
+ //
35
+ // Resolution state (promise, waitingOn, done, value) is runtime-only and never
36
+ // serialized; JIT re-derives it empty.
37
+ const DEFERRED_KEY = '~deferred';
38
+ // Record ids derive from the creation site's stable coordinates, never a
39
+ // counter — the walker resolves siblings with Promise.all, so creation order
40
+ // is nondeterministic, and ids leak into build artifacts.
41
+ function makeRecordId({ entryId, configPath }) {
42
+ return `${entryId ?? 'app'}:${configPath}`;
43
+ }
44
+ function createRecord(context, { id, kind, body, env, slot = null }) {
45
+ var _context;
46
+ (_context = context).deferred ?? (_context.deferred = {});
47
+ if (Object.hasOwn(context.deferred, id)) {
48
+ throw new LowdefyInternalError(`Deferred record "${id}" already exists — one record per config position per build.`);
49
+ }
50
+ const record = {
51
+ kind,
52
+ body,
53
+ env,
54
+ slot,
55
+ promise: null,
56
+ waitingOn: new Set(),
57
+ done: false,
58
+ value: undefined
59
+ };
60
+ context.deferred[id] = record;
61
+ return record;
62
+ }
63
+ function getRecord(context, id) {
64
+ const record = context.deferred?.[id];
65
+ if (!record) {
66
+ throw new LowdefyInternalError(`Deferred record "${id}" not found in the registry.`);
67
+ }
68
+ return record;
69
+ }
70
+ function makePlaceholder(id) {
71
+ return {
72
+ [DEFERRED_KEY]: id
73
+ };
74
+ }
75
+ // A placeholder is exactly a one-enumerable-key object { '~deferred': '<id>' }.
76
+ // The key is reserved (parseRefContent rejects it in source content), so any
77
+ // such object in the tree was created by the build.
78
+ function getPlaceholderId(node) {
79
+ if (!type.isObject(node)) return undefined;
80
+ const keys = Object.keys(node);
81
+ if (keys.length !== 1 || keys[0] !== DEFERRED_KEY) return undefined;
82
+ const id = node[DEFERRED_KEY];
83
+ return type.isString(id) ? id : undefined;
84
+ }
85
+ // Wait-graph reachability: is `targetId` reachable from `fromId` through
86
+ // waitingOn edges? A synchronous set walk — edges only change between awaits,
87
+ // so check-then-insert is atomic.
88
+ function findPathTo(context, fromId, targetId, path = []) {
89
+ if (fromId === targetId) return [
90
+ ...path,
91
+ fromId
92
+ ];
93
+ if (path.includes(fromId)) return null;
94
+ const record = context.deferred[fromId];
95
+ if (!record) return null;
96
+ for (const nextId of record.waitingOn){
97
+ const found = findPathTo(context, nextId, targetId, [
98
+ ...path,
99
+ fromId
100
+ ]);
101
+ if (found) return found;
102
+ }
103
+ return null;
104
+ }
105
+ // Demand-driven resolution for single-value record kinds (entryRef,
106
+ // varDefault), promise-memoized per record. Cycle detection is a wait-graph —
107
+ // standard deadlock detection: when resolution of record R demands record S,
108
+ // add edge R → S; if S is in flight and R is reachable from S, the demand
109
+ // closes a cycle in "who is awaiting whom" and awaiting would deadlock — throw
110
+ // the named chain instead. Demands from outside any record
111
+ // (ctx.activeRecord = null) add no edges: they can wait on anything.
112
+ //
113
+ // The returned value is the memoized instance — callers that splice it into
114
+ // mutable config must clone (the walker's placeholder dispatch does).
115
+ // A rejected promise stays memoized: a record whose resolution throws is
116
+ // genuinely broken, and every demander should see the same error once.
117
+ async function resolveDeferred(ctx, id) {
118
+ const context = ctx.buildContext;
119
+ const record = getRecord(context, id);
120
+ if (record.kind === 'component' || record.kind === 'menuLinks') {
121
+ throw new LowdefyInternalError(`Deferred record "${id}" of kind "${record.kind}" resolves per consumer — ` + `it cannot be demanded through resolveDeferred.`);
122
+ }
123
+ if (record.done) return record.value;
124
+ const demanderId = ctx.activeRecord;
125
+ const demander = demanderId ? context.deferred[demanderId] : null;
126
+ if (record.promise && demander) {
127
+ // In flight: if the demander is reachable from this record, awaiting would
128
+ // deadlock — the actual dependency cycle, named record by record.
129
+ const path = findPathTo(context, id, demanderId);
130
+ if (path) {
131
+ throw new ConfigError(`Circular deferred value dependency: ${[
132
+ demanderId,
133
+ ...path
134
+ ].join(' → ')}.`, {
135
+ filePath: record.env?.file ?? null
136
+ });
137
+ }
138
+ }
139
+ if (demander) {
140
+ demander.waitingOn.add(id);
141
+ }
142
+ try {
143
+ if (!record.promise) {
144
+ // Defer the body walk by a microtask so the memo is assigned before any
145
+ // synchronous prefix of the walk can re-enter this record — otherwise a
146
+ // self-cycle recurses on a still-null promise instead of hitting the
147
+ // wait-graph check.
148
+ record.promise = Promise.resolve().then(()=>resolveRecordBody(ctx, id, record));
149
+ }
150
+ return await record.promise;
151
+ } finally{
152
+ if (demander) {
153
+ demander.waitingOn.delete(id);
154
+ }
155
+ }
156
+ }
157
+ // Walk a record's body under a fresh context built from record.env — no
158
+ // reconstruction from in-tree markers. Operators and identifier tables come
159
+ // from the demanding context (they are build-wide singletons); the resolution
160
+ // environment (file, roots, module entry) comes from the record.
161
+ async function resolveRecordBody(ctx, id, record) {
162
+ const context = ctx.buildContext;
163
+ const env = record.env ?? {};
164
+ const moduleEntry = env.entryId ? context.modules[env.entryId] ?? null : null;
165
+ const recordCtx = new WalkContext({
166
+ buildContext: context,
167
+ refId: env.refId ?? null,
168
+ sourceRefId: null,
169
+ vars: {},
170
+ moduleDependencies: moduleEntry?.moduleDependencies,
171
+ moduleEntry,
172
+ moduleRoot: env.moduleRoot ?? null,
173
+ packageRoot: env.packageRoot ?? null,
174
+ path: '',
175
+ currentFile: env.file ?? null,
176
+ refChain: new Set(env.file ? [
177
+ env.file
178
+ ] : []),
179
+ operators: ctx.operators,
180
+ env: ctx.env,
181
+ lowdefyApp: ctx.lowdefyApp,
182
+ dynamicIdentifiers: ctx.dynamicIdentifiers,
183
+ activeRecord: id
184
+ });
185
+ let value;
186
+ if (record.kind === 'entryRef') {
187
+ // The body is a prepared refDef — replay it through loadAndWalkRef with
188
+ // the original provenance (env.file is where the ref was written).
189
+ value = await loadAndWalkRef(record.body, recordCtx, {
190
+ configKey: env.configKey ?? undefined,
191
+ referencedFrom: env.file ?? undefined
192
+ });
193
+ } else {
194
+ // varDefault and connRemap bodies are raw config subtrees. Clone before
195
+ // walking — the raw body must stay pristine in the registry
196
+ // (deferredRecords.json serializes it, and demand order must not change it).
197
+ value = await resolve(cloneWithMarkers(record.body), recordCtx);
198
+ }
199
+ record.value = value;
200
+ record.done = true;
201
+ return value;
202
+ }
203
+ // Placeholder lifetime differs by record kind: component and menuLinks
204
+ // placeholders persist by design (per-consumer resolution; JIT consumes them
205
+ // from modules.json), and varDefault placeholders persist in varDefs
206
+ // (demand-only). Everywhere else a placeholder after the final sweep is a
207
+ // build bug. The allowed positions, per module entry:
208
+ const MANIFEST_PLACEHOLDER_PATHS = [
209
+ /^components\.\d+\.component$/,
210
+ /^menus\.\d+\.links$/,
211
+ // varDefs is extracted from manifest.vars by reference, so demand-only
212
+ // default placeholders legitimately show through the manifest too.
213
+ /^vars(\.[^.]+\.properties)*\.[^.]+\.default$/
214
+ ];
215
+ const VARDEFS_PLACEHOLDER_PATHS = [
216
+ /^[^.]+(\.properties\.[^.]+)*\.default$/
217
+ ];
218
+ function findPlaceholderLeaks(context) {
219
+ const leaks = [];
220
+ const scan = (value, path, allowed, location)=>{
221
+ const id = getPlaceholderId(value);
222
+ if (id !== undefined) {
223
+ if (!allowed.some((pattern)=>pattern.test(path))) {
224
+ leaks.push({
225
+ id,
226
+ location: `${location}.${path}`
227
+ });
228
+ }
229
+ return;
230
+ }
231
+ if (type.isArray(value)) {
232
+ value.forEach((item, i)=>scan(item, path ? `${path}.${i}` : String(i), allowed, location));
233
+ } else if (type.isObject(value)) {
234
+ for (const key of Object.keys(value)){
235
+ scan(value[key], path ? `${path}.${key}` : key, allowed, location);
236
+ }
237
+ }
238
+ };
239
+ for (const [entryId, entry] of Object.entries(context.modules ?? {})){
240
+ scan(entry.manifest ?? {}, '', MANIFEST_PLACEHOLDER_PATHS, `${entryId}.manifest`);
241
+ scan(entry.consumerVars ?? {}, '', [], `${entryId}.consumerVars`);
242
+ scan(entry.connections ?? {}, '', [], `${entryId}.connections`);
243
+ scan(entry.varDefs ?? {}, '', VARDEFS_PLACEHOLDER_PATHS, `${entryId}.varDefs`);
244
+ }
245
+ return leaks;
246
+ }
247
+ // Post-sweep leak check: every entryRef/connRemap placeholder must be gone from
248
+ // entry configs, and manifests may hold placeholders only at per-consumer
249
+ // slots. The runtime var cache is not scanned — the deep-forcing read path
250
+ // guarantees it placeholder-free (pinned by its own tests).
251
+ function assertNoPlaceholderLeaks(context) {
252
+ const leaks = findPlaceholderLeaks(context);
253
+ if (leaks.length > 0) {
254
+ const detail = leaks.map((leak)=>`"${leak.id}" at ${leak.location}`).join('; ');
255
+ throw new LowdefyInternalError(`Deferred placeholder leaked past the final sweep: ${detail}.`);
256
+ }
257
+ }
258
+ // Serialize the registry's data for the deferredRecords.json build artifact.
259
+ // Runtime state (promise, waitingOn, done, value) is stripped — JIT re-derives
260
+ // it empty. Must go through the marker-preserving serializer: record bodies
261
+ // carry non-enumerable ~r/~l/~k markers that plain JSON.stringify would drop,
262
+ // breaking error line-number pairing in JIT.
263
+ function serializeRegistry(context) {
264
+ const data = {};
265
+ for (const [id, record] of Object.entries(context.deferred ?? {})){
266
+ data[id] = {
267
+ kind: record.kind,
268
+ body: record.body,
269
+ env: record.env,
270
+ slot: record.slot
271
+ };
272
+ }
273
+ return serializer.serializeToString(data);
274
+ }
275
+ // Hydrate the registry from deserialized deferredRecords.json data (the caller
276
+ // deserializes with the marker-restoring reviver, e.g. serializer.deserialize).
277
+ function hydrateDeferredRecords(context, data) {
278
+ context.deferred = {};
279
+ for (const [id, record] of Object.entries(data ?? {})){
280
+ context.deferred[id] = {
281
+ kind: record.kind,
282
+ body: record.body,
283
+ env: record.env,
284
+ slot: record.slot ?? null,
285
+ promise: null,
286
+ waitingOn: new Set(),
287
+ done: false,
288
+ value: undefined
289
+ };
290
+ }
291
+ }
292
+ export { DEFERRED_KEY, makeRecordId, createRecord, getRecord, makePlaceholder, getPlaceholderId, resolveDeferred, assertNoPlaceholderLeaks, serializeRegistry, hydrateDeferredRecords };
@@ -12,12 +12,23 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ import { get } from '@lowdefy/helpers';
16
- function getKey({ input, refDef }) {
15
+ */ import { get, ReservedKeyError } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ function getKey({ input, refDef, filePath }) {
17
18
  if (refDef.key) {
18
- return get(input, refDef.key, {
19
- default: null
20
- });
19
+ try {
20
+ return get(input, refDef.key, {
21
+ default: null
22
+ });
23
+ } catch (error) {
24
+ if (!(error instanceof ReservedKeyError)) throw error;
25
+ // The ref key is author-written YAML, so a reserved name is a config mistake. Without
26
+ // this the bare ReservedKeyError reaches the build with no file and no line.
27
+ throw new ConfigError(`_ref key "${refDef.key}" is a reserved name.`, {
28
+ cause: error,
29
+ filePath
30
+ });
31
+ }
21
32
  }
22
33
  return input;
23
34
  }
@@ -13,6 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { ConfigError } from '@lowdefy/errors';
16
+ import { getPlaceholderId } from './deferredRegistry.js';
16
17
  function describeRef(refDef) {
17
18
  const parts = [];
18
19
  if (refDef.module) parts.push(`module: "${refDef.module}"`);
@@ -62,17 +63,31 @@ async function getModuleRefContent({ context, refDef, referencedFrom, walkCtx, c
62
63
  component: manifest.components,
63
64
  menu: manifest.menus
64
65
  }[exportType] ?? [];
65
- let content;
66
- if (exportType === 'component') {
67
- content = manifestArray.find((item)=>item.id === exportName)?.component;
68
- } else if (exportType === 'menu') {
69
- content = manifestArray.find((item)=>item.id === exportName)?.links;
66
+ const entry = manifestArray.find((item)=>item.id === exportName);
67
+ if (!entry) {
68
+ const availableIds = manifestArray.map((item)=>item.id);
69
+ const available = availableIds.length > 0 ? ` Available ${exportType}s: ${availableIds.join(', ')}.` : ` The module defines no ${exportType}s.`;
70
+ throw new ConfigError(`Module "${entryId}" has no ${exportType} "${exportName}".${available}`, {
71
+ configKey
72
+ });
70
73
  }
74
+ const content = exportType === 'component' ? entry.component : entry.links;
71
75
  if (!content) {
72
- throw new ConfigError(`Module "${entryId}" does not export ${exportType} "${exportName}".`, {
76
+ throw new ConfigError(`The ${exportType} "${exportName}" in module "${entryId}" is empty.`, {
73
77
  configKey
74
78
  });
75
79
  }
80
+ // Component bodies live in the deferred-record registry — the manifest holds
81
+ // a placeholder. Return the record id; consumption dereferences it and walks
82
+ // a clone under the record's env. (Menus still return live links until they
83
+ // become records; scalar legacy bodies fall through as live content.)
84
+ const recordId = getPlaceholderId(content);
85
+ if (recordId !== undefined) {
86
+ return {
87
+ recordId,
88
+ entryId
89
+ };
90
+ }
76
91
  return {
77
92
  content,
78
93
  entryId
@@ -25,7 +25,7 @@ async function getUserJavascriptFunction({ context, filePath }) {
25
25
  // when bundling server-dev API routes — otherwise webpack rewrites import()
26
26
  // into __webpack_require__() which can't handle file:// URLs for loading
27
27
  // user-provided resolver/transformer JS files from the config directory.
28
- return (await import(/* webpackIgnore: true */ fileUrl.href)).default;
28
+ return (await import(/* webpackIgnore: true */ /* @vite-ignore */ fileUrl.href)).default;
29
29
  } catch (error) {
30
30
  throw new ConfigError(`Error importing ${filePath}.`, {
31
31
  cause: error,
@@ -19,6 +19,29 @@ import JSON5 from 'json5';
19
19
  import YAML from 'yaml';
20
20
  import addLineNumbers from './addLineNumbers.js';
21
21
  import parseNunjucks from './parseNunjucks.js';
22
+ import { DEFERRED_KEY } from './deferredRegistry.js';
23
+ // '~deferred' is the build's deferred-record placeholder key. Unlike the other
24
+ // build markers it is enumerable (that durability is the point), so user config
25
+ // could collide with it — a literal '~deferred' key would be dispatched as a
26
+ // placeholder far from its source. Reject it where file content first enters
27
+ // the walker, with file provenance.
28
+ function assertNoReservedKeys(value, filePath) {
29
+ if (type.isArray(value)) {
30
+ for (const item of value){
31
+ assertNoReservedKeys(item, filePath);
32
+ }
33
+ return;
34
+ }
35
+ if (!type.isObject(value)) return;
36
+ for (const key of Object.keys(value)){
37
+ if (key === DEFERRED_KEY) {
38
+ throw new ConfigError(`The key "${DEFERRED_KEY}" is reserved by the Lowdefy build and cannot be used in config.`, {
39
+ filePath
40
+ });
41
+ }
42
+ assertNoReservedKeys(value[key], filePath);
43
+ }
44
+ }
22
45
  function parseYamlWithLineNumbers(content) {
23
46
  const doc = YAML.parseDocument(content);
24
47
  if (doc.errors && doc.errors.length > 0) {
@@ -71,6 +94,7 @@ async function parseRefContent({ content, refDef }) {
71
94
  }
72
95
  }
73
96
  }
97
+ assertNoReservedKeys(content, type.isString(path) ? path : null);
74
98
  return content;
75
99
  }
76
100
  export default parseRefContent;
@@ -24,7 +24,7 @@ validateOperatorsDynamic({
24
24
  const dynamicIdentifiers = collectDynamicIdentifiers({
25
25
  operators
26
26
  });
27
- function evaluateStaticOperators({ context, input, refDef }) {
27
+ function precomputeRuntimeOperators({ context, input, refDef }) {
28
28
  const typeNames = collectTypeNames({
29
29
  typesMap: context.typesMap
30
30
  });
@@ -33,20 +33,21 @@ function evaluateStaticOperators({ context, input, refDef }) {
33
33
  operators,
34
34
  operatorPrefix: '_',
35
35
  env: process.env,
36
+ lowdefyApp: context.appMeta,
36
37
  dynamicIdentifiers,
37
38
  typeNames
38
39
  });
39
40
  if (errors.length > 0) {
40
41
  errors.forEach((error)=>{
41
42
  // Resolve source file path for error location.
42
- // Only called from buildRefs top-level where refDef is root lowdefy.yaml,
43
- // but ~r on each operator object identifies the real source file via refMap.
44
- // Falls back to refDef.path if ~r is missing (shouldn't happen at this stage
45
- // since all objects have ~r after recursiveBuild completes).
43
+ // Called from index.js Phase 3.5 (refDef = lowdefy.yaml) and buildPageJit
44
+ // (refDef = page); ~r on each operator object identifies the real source
45
+ // file via refMap. Falls back to refDef.path if ~r is missing (shouldn't
46
+ // happen at this stage since all objects have ~r after recursiveBuild completes).
46
47
  error.filePath = error.refId ? context.refMap[error.refId]?.path : refDef.path;
47
48
  collectExceptions(context, error);
48
49
  });
49
50
  }
50
51
  return output;
51
52
  }
52
- export default evaluateStaticOperators;
53
+ export default precomputeRuntimeOperators;