@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.
- package/dist/build/buildAgents.js +70 -17
- package/dist/build/buildApi/buildApi.js +4 -1
- package/dist/build/buildApi/buildEndpoint.js +8 -2
- package/dist/build/buildApi/buildRoutine/countStepTypes.js +1 -1
- package/dist/build/buildApi/buildRoutine/setStepId.js +2 -0
- package/dist/build/buildApi/buildRoutine/validateStep.js +61 -0
- package/dist/build/buildApi/resolveEndpointSchedules.js +37 -0
- package/dist/build/buildApi/validateEndpoint.js +96 -1
- package/dist/build/buildApp.js +13 -20
- package/dist/build/buildAppMeta.js +130 -0
- package/dist/build/buildAuth/buildAgentAuth.js +69 -0
- package/dist/build/buildAuth/buildAuth.js +14 -0
- package/dist/build/buildAuth/buildAuthPlugins.js +6 -0
- package/dist/build/buildAuth/buildAuthStrategies.js +117 -0
- package/dist/build/buildAuth/buildWebsocketAuth.js +55 -0
- package/dist/build/buildAuth/getAgentRoles.js +39 -0
- package/dist/build/buildAuth/getProtectedAgents.js +29 -0
- package/dist/build/buildAuth/getProtectedWebsockets.js +29 -0
- package/dist/build/buildAuth/getWebsocketRoles.js +39 -0
- package/dist/build/buildAuth/rejectReservedEntityIds.js +52 -0
- package/dist/build/buildAuth/validateAuthConfig.js +15 -3
- package/dist/build/buildImports/buildImportsDev.js +15 -0
- package/dist/build/buildImports/buildImportsProd.js +4 -1
- package/dist/build/buildJs/generateClientJsModule.js +27 -0
- package/dist/build/buildJs/writeJs.js +3 -5
- package/dist/build/buildMcp.js +73 -0
- package/dist/build/buildModuleDefs.js +90 -48
- package/dist/build/buildModules.js +61 -14
- package/dist/build/buildNotifications.js +98 -0
- package/dist/build/buildPages/buildBlock/buildBlock.js +3 -1
- package/dist/build/buildPages/buildBlock/buildDynamicBlock.js +131 -0
- package/dist/build/buildPages/buildBlock/buildEvents.js +197 -22
- package/dist/build/buildPages/buildBlock/buildRequests.js +9 -0
- package/dist/build/buildPages/buildBlock/normalizeClassAndStyles.js +2 -11
- package/dist/build/buildPages/buildBlock/setBlockId.js +5 -2
- package/dist/build/buildPages/buildBlock/validateBlock.js +19 -1
- package/dist/build/buildPages/buildPage.js +27 -3
- package/dist/build/buildPages/buildSubscriptions.js +145 -0
- package/dist/build/buildPages/validateDynamicBlockRefs.js +30 -0
- package/dist/build/buildPages/validateStateReferences.js +4 -2
- package/dist/build/buildPages/validateWebsocketRefs.js +30 -0
- package/dist/build/buildRefs/buildRefs.js +21 -12
- package/dist/build/buildRefs/cloneWithMarkers.js +57 -0
- package/dist/build/buildRefs/deferredRegions.js +80 -0
- package/dist/build/buildRefs/deferredRegistry.js +292 -0
- package/dist/build/buildRefs/getKey.js +16 -5
- package/dist/build/buildRefs/getModuleRefContent.js +21 -6
- package/dist/build/buildRefs/getUserJavascriptFunction.js +1 -1
- package/dist/build/buildRefs/parseRefContent.js +24 -0
- package/dist/build/buildRefs/{evaluateStaticOperators.js → precomputeRuntimeOperators.js} +7 -6
- package/dist/build/buildRefs/walker.js +490 -209
- package/dist/build/buildTypes.js +22 -1
- package/dist/build/buildWebsockets.js +99 -0
- package/dist/build/collectPageContent.js +7 -1
- package/dist/build/copyAgentFileSystems.js +2 -2
- package/dist/build/fetchModules.js +18 -4
- package/dist/build/full/buildPages.js +16 -0
- package/dist/build/full/updateServerPackageJson.js +21 -0
- package/dist/build/jit/buildPageJit.js +46 -6
- package/dist/build/jit/collectSkeletonSourceFiles.js +14 -0
- package/dist/build/jit/pageContentKeys.js +1 -0
- package/dist/build/jit/shallowBuild.js +81 -1
- package/dist/build/jit/writePageJit.js +11 -8
- package/dist/build/registerModules.js +149 -42
- package/dist/build/validateCallAgentSteps.js +58 -0
- package/dist/build/validateConfig.js +4 -0
- package/dist/build/validateCronConfig.js +110 -0
- package/dist/build/validateRenderNotificationSteps.js +50 -0
- package/dist/build/writeApi.js +58 -1
- package/dist/build/writeI18n.js +8 -1
- package/dist/build/writeMcp.js +21 -0
- package/dist/build/writeNotifications.js +30 -0
- package/dist/build/writePluginImports/importPluginModule.js +39 -0
- package/dist/build/writePluginImports/writeActionSchemaMap.js +6 -7
- package/dist/build/writePluginImports/writeAuthImports.js +4 -0
- package/dist/build/writePluginImports/writeAvailableTypes.js +99 -0
- package/dist/build/writePluginImports/writeBlockSchemaMap.js +10 -9
- package/dist/build/writePluginImports/writeConnectionSchemaMap.js +101 -0
- package/dist/build/writePluginImports/writeGlobalsCss.js +20 -11
- package/dist/build/writePluginImports/writeNotificationImports.js +31 -0
- package/dist/build/writePluginImports/writeOperatorSchemaMap.js +6 -7
- package/dist/build/writePluginImports/writePluginImports.js +18 -4
- package/dist/build/writePluginImports/writeWebsocketImports.js +22 -0
- package/dist/build/writeWebsockets.js +30 -0
- package/dist/createContext.js +6 -1
- package/dist/defaultPackages.js +7 -1
- package/dist/defaultTypesMap.js +689 -386
- package/dist/dynamic/buildDynamicBlocks.js +120 -0
- package/dist/index.js +78 -8
- package/dist/indexDev.js +4 -0
- package/dist/lowdefySchema.js +861 -6
- package/dist/scripts/generateDefaultTypes.js +5 -2
- package/dist/test-utils/expectTerminates.js +33 -0
- package/dist/test-utils/runBuild.js +20 -1
- package/dist/test-utils/runBuildForSnapshots.js +34 -4
- package/dist/test-utils/testContext.js +8 -2
- package/dist/utils/countOperators.js +5 -17
- package/dist/utils/createPluginTypesMap.js +21 -0
- package/dist/utils/getCronEnvironmentNames.js +25 -0
- package/dist/utils/logCollectedErrors.js +44 -10
- package/dist/utils/serializeBuildException.js +29 -0
- package/dist/utils/validateCronExpression.js +131 -0
- package/dist/utils/validateId.js +11 -0
- package/dist/utils/writeBuildArtifact.js +5 -2
- package/package.json +66 -59
- package/dist/build/writePluginImports/writeServerExternalPackages.js +0 -121
- /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:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(`
|
|
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
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// Falls back to refDef.path if ~r is missing (shouldn't
|
|
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
|
|
53
|
+
export default precomputeRuntimeOperators;
|