code-docs/architecture/operator-system.md
How operators are evaluated in Lowdefy.
Operators are the expression system in Lowdefy that:
File: packages/operators/src/evaluateOperators.js
Context: Build-time operator evaluation (replaces old BuildParser class)
Runtime: Node.js
const { output, errors } = evaluateOperators({
input, // data to walk
operators, // operator implementations
operatorPrefix: '_build.', // or '_' for static operators
env: process.env,
dynamicIdentifiers, // Set of operators requiring runtime evaluation
typeNames, // Set of registered type names (for ~dyn type boundaries)
args, // optional arguments (for _function callbacks)
});
Available Data:
env - Environment variablesoperators - Operator implementationsargs - Function arguments (for _function callbacks)File: packages/operators/src/webParser.js
Context: Runtime browser evaluation Runtime: Browser
new WebParser({
context, // Complete browser context
operators, // Operator registry
});
Available Data:
state - Page staterequests - API request responsesapiResponses - API endpoint responsesuser - Current userinputs - Block inputsmenus - Navigation menuslowdefyGlobal - Global configeventLog - Event history_internal.globals - Browser globalsFile: packages/operators/src/serverParser.js
Context: Runtime backend evaluation Runtime: Node.js
new ServerParser({
env, // Environment variables
jsMap, // JavaScript mapping
operators, // Operator registry
payload, // Request payload
secrets, // Application secrets
state, // Workflow state
steps, // Previous step results
user, // Authenticated user
});
Available Data:
env - Environment variablespayload - Request payloadsecrets - Application secretsstate - Workflow statesteps - Previous step resultsuser - Authenticated userevaluateOperators walks the tree in-place with a recursive function (no serializer.copy JSON round-trips):
function walk(node) {
// Walk children first (bottom-up)
for (const key of Object.keys(node)) {
node[key] = walk(node[key]);
}
// Bubble up ~dyn from children (skip for _build.* operators)
if (hasDynChild(node)) setDynamicMarker(node);
// Type boundary: reset ~dyn at registered types
if (typeNames?.has(node.type)) delete node['~dyn'];
// Detect operator: single non-tilde key starting with operatorPrefix
const keys = Object.keys(node).filter(k => !k.startsWith('~'));
if (keys.length !== 1 || !keys[0].startsWith(operatorPrefix)) return node;
// Dynamic check (skip for _build.* — always evaluate)
if (operatorPrefix !== '_build.' && hasDynamicMarker(params)) {
return setDynamicMarker(node);
}
return operators[op]({ args, env, methodName, params, parser, ... });
}
The parser interface passed to operators recurses into evaluateOperators itself, enabling _function/_build.array.map callbacks.
WebParser and ServerParser use serializer.copy with JSON revivers for runtime evaluation:
parse({ args, input, location, operatorPrefix = '_' }) {
const result = serializer.copy(input, {
reviver: (key, value) => {
if (isOperator(value)) {
const [op, method] = splitOperator(key);
return this.operators[op]({
args, location, methodName: method, params: value[key],
});
}
return value;
}
});
return { output: result, errors: this.errors };
}
Files: packages/build/src/build/buildRefs/walker.js
Loads and merges external configuration files:
# String form
blocks:
_ref: blocks/header.yaml
# Object form
blocks:
_ref:
path: blocks/header.yaml
vars:
title: "My Title"
key: "blocks[0]"
resolver: "./customResolver"
transformer: "./transform"
Processing (via walker's resolveRef):
makeRefDefinition() - Create ref definition, register in refMapresolve() in parent contextctx.refChaingetRefContent() → parseRefContent() - Load and parse fileWalkContext via forRef() (new vars, refChain copy)resolve(content, childCtx) - Walk file content recursivelyrunTransformer() - Apply transformer (optional)_ref.key)tagRefDeep() - Tag all result nodes with ~r provenanceFile: packages/build/src/build/buildRefs/walker.js (resolveVar function)
Template variable substitution:
# In ref definition
_ref:
path: component.yaml
vars:
buttonLabel: "Submit"
# In component.yaml
label:
_var: buttonLabel
# With default
label:
_var:
key: buttonLabel
default: "Click"
File: packages/build/src/build/buildRefs/walker.js (resolveModuleVar, resolveEffectiveVar, resolveVarDefault functions)
Module variable substitution, resolved lazily during the full-resolve walker pass:
collection:
_module.var: collection
# Defaults are expressions declared in module.lowdefy.yaml.
# vars:
# page_title:
# default:
# _module.var: label_plural
The walker resolves _module.var with a three-way branch on the WalkContext:
moduleEntry set → lazy resolve via resolveModuleVar. Reads the consumer value from moduleEntry.consumerVars first; otherwise calls resolveEffectiveVar to walk the manifest's raw default expression.moduleEntry null, moduleRoot set (Phase 1a local resolve) → preserve the node untouched; the full-resolve pass resolves it.ConfigError.Defaults resolve in a fresh WalkContext anchored at module.lowdefy.yaml so cross-module refs, circular detection, and error messages work correctly. Resolution results cache on moduleEntry.resolvedVarCache, shared across all walks of the module and across cross-module ref calls.
File: packages/build/src/build/buildRefs/walker.js
The ID operators (_module.pageId, _module.connectionId, _module.endpointId, _module.id) resolve during the walker pass, alongside _module.var. They are detected after child walking (bottom-up) — after _var and _module.var but before _build.*.
Both string form (same-module) and object form (cross-module { id, module }) are supported:
_module.pageId: users-list → team-users/users-list_module.pageId: { id: contact-detail, module: contacts } → contacts/contact-detail_module.connectionId: users-db → team-users/users-db (or remapped app connection ID)_module.endpointId: invite-user → team-users/invite-user_module.id: true → team-users_module.id: { module: contacts } → resolved dependency entry IDThe object form uses resolveDepTarget() (packages/build/src/build/resolveDepTarget.js) to resolve the abstract dependency name to a concrete module entry via the moduleDependencies map on WalkContext. Each operator validates that the referenced ID exists in the target module's exports declarations.
The moduleEntry property on WalkContext propagates through child() unchanged, and is overridden in forRef() for component/menu refs — switching to the source module's context when entering cross-module content.
Evaluated inline by walker (packages/build/src/build/buildRefs/walker.js) via evaluateOperators with operatorPrefix: '_build.'.
Build-time operators with _build. prefix:
apiUrl:
_build.env: API_URL
debug:
_build.vars: debugEnabled
File: packages/plugins/operators/operators-js/src/operators/server/secret.js
Access secrets (filtered for security):
function _secret({ location, params, secrets = {} }) {
// Filter sensitive keys
const { OPENID_CLIENT_SECRET, JWT_SECRET, ...rest } = secrets;
if (params === true || params.all) {
throw new Error('Getting all secrets is not allowed');
}
return getFromObject({ object: rest, params });
}
connectionString:
_secret: MONGODB_URI
File: packages/plugins/operators/operators-js/src/operators/shared/state.js
Access page state:
function _state({ arrayIndices, location, params, state }) {
return getFromObject({
arrayIndices,
location,
object: state,
operator: '_state',
params,
});
}
value:
_state: user.name
# With default
value:
_state:
key: user.name
default: "Anonymous"
Access request responses:
items:
_request: getUsers.response
# First response
firstUser:
_request: getUsers.response[0]
Access authenticated user:
greeting:
_string:
- 'Hello, '
- _user: session.user.name
Access block inputs:
searchValue:
_input: searchBox.value
Access global configuration:
appName:
_global: config.appName
Access URL query parameters:
# URL: ?page=2&filter=active
currentPage:
_url_query: page
filterValue:
_url_query:
key: filter
default: 'all'
Access event data:
events:
onChange:
- id: log
type: SetState
params:
lastValue:
_event: value
Access browser location:
currentPath:
_location: pathname
Access media query data:
isMobile:
_media: mobile
Access request payload:
# In connection/request properties
query:
userId:
_payload: userId
Access previous workflow step results:
# In API endpoint routines
nextStep:
data:
_step: previousStep.result
function _state({ arrayIndices, location, params, state }) {
return getFromObject({
arrayIndices,
location,
object: state,
operator: '_state',
params,
});
}
getFromObject Parameters:
params: true → Return all (deep copy)params: string/int → Key pathparams: { key, default, all } → Object formconst meta = {
concat: { validTypes: ['array'] },
filter: { namedArgs: ['on', 'callback'], validTypes: ['array', 'object'] },
slice: { namedArgs: ['on', 'start', 'end'], validTypes: ['array', 'object'] },
};
function _array({ params, location, methodName }) {
return runInstance({
location,
meta,
methodName,
operator: '_array',
params,
instanceType: 'array',
});
}
# Usage
filtered:
_array.filter:
on:
_state: items
callback:
_function:
__gt:
- __args: 0.price
- 100
const meta = {
keys: { singleArg: true, validTypes: ['object'] },
values: { singleArg: true, validTypes: ['object'] },
assign: { spreadArgs: 'objects', validTypes: ['object'] },
};
function _object({ params, location, methodName }) {
return runClass({
location,
meta,
methodName,
operator: '_object',
params,
functions: ObjectFunctions,
});
}
function _if({ location, params }) {
if (params.test === true) return params.then;
if (params.test === false) return params.else;
throw new Error('_if takes a boolean test');
}
status:
_if:
test:
_eq:
- _state: count
- 0
then: 'Empty'
else: 'Has items'
| Context | Build | Web | Server |
|---|---|---|---|
env | ✓ | ✓ | |
secrets | ✓ | ✓ | |
user | ✓ | ✓ | ✓ |
state | ✓ | ✓ | |
requests | ✓ | ||
event | ✓ | ||
payload | ✓ | ✓ | |
apiResponses | ✓ | ||
inputs | ✓ | ||
menus | ✓ | ||
steps | ✓ | ||
jsMap | ✓ | ✓ |
Parsers collect errors rather than throwing:
parse({ input, location }) {
// ... parsing logic
try {
result = operator({ params, ... });
} catch (error) {
this.errors.push({ error, location, operator });
result = null;
}
return { output, errors: this.errors };
}
null~r (reference ID) to track source files — used for error attribution~r marker during operator detection (already resolved refs)~dyn as non-enumerable property for dynamic content tracking~k (metadata key) before operator evaluationFile: packages/operators/src/getFromObject.js
getFromObject({
arrayIndices, // For dynamic path resolution
location, // Error reporting
object, // Data source
operator, // Operator name
params, // Access parameters
});
// Param formats:
// true → return all (deep copy)
// string/int → key path
// { key, default, all } → object form
File: packages/operators/src/runInstance.js
For instance method operators (_array.filter):
runInstance({
location,
meta, // Method definitions
methodName, // Method to call
operator, // Operator name
params, // Parameters
instanceType, // Expected instance type
});
File: packages/operators/src/runClass.js
For class/static method operators:
runClass({
location,
meta, // Method definitions
methodName, // Method to call
operator, // Operator name
params, // Parameters
functions, // Function implementations
});
_eq: [a, b] # a === b
_ne: [a, b] # a !== b
_gt: [a, b] # a > b
_gte: [a, b] # a >= b
_lt: [a, b] # a < b
_lte: [a, b] # a <= b
_and: [cond1, cond2, ...]
_or: [cond1, cond2, ...]
_not: condition
_if:
test: condition
then: valueIfTrue
else: valueIfFalse
_array.concat: [[1, 2], [3, 4]]
_array.filter:
on: array
callback: function
_array.map:
on: array
callback: function
_array.find:
on: array
callback: function
_array.includes:
on: array
value: searchValue
_string.concat: ['Hello', ' ', 'World']
_string.includes:
on: text
value: search
_string.split:
on: text
delimiter: ','
_object.keys: object
_object.values: object
_object.assign: [obj1, obj2]
_type: value # Returns type name
_type.isString: value
_type.isNumber: value
_type.isArray: value
_type.isObject: value
_type.isNull: value
_type.isUndefined: value
| Component | File |
|---|---|
| evaluateOperators | packages/operators/src/evaluateOperators.js |
| WebParser | packages/operators/src/webParser.js |
| ServerParser | packages/operators/src/serverParser.js |
| getFromObject | packages/operators/src/getFromObject.js |
| runInstance | packages/operators/src/runInstance.js |
| runClass | packages/operators/src/runClass.js |
| Walker | packages/build/src/build/buildRefs/walker.js |
| Build Operators | packages/build/src/build/buildRefs/ |
| JS Operators | packages/plugins/operators/operators-js/ |
| MQL Operators | packages/plugins/operators/operators-mql/ |
| Nunjucks Operators | packages/plugins/operators/operators-nunjucks/ |
Operators declare whether they can be safely evaluated at build time or must be deferred to runtime. This is controlled by the dynamic flag and enforced by collectDynamicIdentifiers and evaluateOperators.
Files:
packages/build/src/build/collectDynamicIdentifiers.js — Builds the Set of dynamic identifierspackages/build/src/build/validateOperatorsDynamic.js — Validates all operators have the flagpackages/build/src/build/buildRefs/evaluateStaticOperators.js — Runs static evaluation passevaluateStaticOperators.js imports operators from @lowdefy/operators-js/operators/build (the operatorsBuild.js export). Only operators in this set are subject to build-time static evaluation.collectDynamicIdentifiers builds a Set of identifiers to skip.evaluateOperators, the walker checks each operator against this set. Matches are marked with ~dyn and preserved for runtime.// Operator-level: ALL usages are dynamic
_date.dynamic = true;
// Method-level: only specific methods are dynamic
_number.dynamic = false;
_number.meta = meta; // must expose meta for collectDynamicIdentifiers
// where meta contains: toLocaleString: { ..., dynamic: true }
collectDynamicIdentifiers processes these in order:
operatorFn.dynamic === true → adds operator name (e.g. _date) and returns early (skips method check)operatorFn.dynamic === false and operatorFn.meta exists → checks each method for dynamic: true, adds qualified names (e.g. _number.toLocaleString)At evaluation time, the check is:
dynamicIdentifiers.has(fullIdentifier) || dynamicIdentifiers.has(op)
So operator-level true catches all usages (with or without method), while method-level only catches specific methods.
| Scenario | Flag Level | Example |
|---|---|---|
| All methods need runtime context | Operator-level dynamic = true | _date (time-dependent), _intl (locale-dependent) |
| Most methods are pure, a few need runtime | Method-level dynamic: true | _math.random, _number.toLocaleString |
| All methods are pure transformations | Operator-level dynamic = false, no method flags | _string, _array, _json |
Only operators exported from packages/plugins/operators/operators-js/src/operatorsBuild.js are subject to static evaluation. Other plugin operators (_nunjucks, _moment, _mql, _yaml, etc.) are not in this set, so their dynamic flag has no effect on build-time behavior.
_date)_intl, _number.toLocaleString)_random, _math.random)_state, _regex, _type)_log)evaluateOperators for build-time, WebParser/ServerParser for runtimeevaluateOperators walks the tree recursively without JSON round-tripsWebParser/ServerParser use serializer.copy with reviverstype field reset ~dyn propagation~dyn — they work on YAML structure~r/~l for file/line attribution_array.filter, _string.concat syntax