code-docs/architecture/request-lifecycle.md
How data flows from user action to database and back in Lowdefy.
The request lifecycle involves:
request action)USER ACTION (Click/Change)
↓
[Events.triggerEvent]
↓
[Actions.callActions]
↓
[request() action]
↓
[Requests.callRequest]
↓
[HTTP POST: /api/request/{pageId}/{requestId}]
↓
============ NETWORK BOUNDARY ============
↓
[API: callRequest handler]
↓
[Authorize → Load Config → Evaluate Operators]
↓
[Connection Execution]
↓
[HTTP Response]
↓
============ NETWORK BOUNDARY ============
↓
[Update context.requests]
↓
[Trigger re-render]
↓
[Display data in UI]
File: packages/engine/src/Events.js
triggerEvent() {
eventDescription.loading = true;
this.block.update = true;
this.context._internal.update();
// Execute action chain
await callActionLoop(actions);
// Update event history
eventDescription.loading = false;
context._internal.update();
}
File: packages/engine/src/Actions.js
callActions() {
for (action of actions) {
// Validate action type
// Parse params with operators
// Check skip conditions
// Display loading message
response = await this.actions[action.type]({
globals, methods, params
});
responses[action.id] = response;
}
return { success, responses };
}
File: packages/engine/src/actions/createRequest.js
function request(params) {
// params: string | array | { all: true }
return context._internal.Requests.callRequests({
actions,
arrayIndices,
blockId,
event,
params,
});
}
File: packages/engine/src/Requests.js
class Requests {
async callRequest({ requestId, blockId, payload }) {
const requestConfig = this.requestConfig[requestId];
// Parse payload with operators
const { output: payload } = parser.parse({
input: requestConfig.payload,
location: requestId,
});
// Track request state
const request = {
blockId,
loading: true,
payload,
requestId,
response: null,
};
this.context.requests[requestId].unshift(request);
return this.fetch(request);
}
async fetch(request) {
const response = await this.context._internal.lowdefy._internal.callRequest({
blockId,
pageId,
payload: serialize(request.payload),
requestId,
});
request.response = deserialize(response.response);
request.loading = false;
this.context._internal.update();
return request.response;
}
}
File: packages/client/src/createCallRequest.js
function createCallRequest({ basePath }) {
return function callRequest({ pageId, payload, requestId }) {
return request({
url: `${basePath}/api/request/${pageId}/${requestId}`,
method: 'POST',
body: { payload },
});
};
}
File: packages/api/src/routes/request/callRequest.js
async function callRequest(context, { blockId, pageId, payload, requestId }) {
// Setup context
context.blockId = blockId;
context.pageId = pageId;
context.payload = deserialize(payload);
context.evaluateOperators = createEvaluateOperators(context);
// Load configurations
const requestConfig = await getRequestConfig(context, { pageId, requestId });
const connectionConfig = await getConnectionConfig(context, { requestConfig });
// Authorization check
authorizeRequest(context, { requestConfig });
// Get connection and resolver
const connection = getConnection(context, { connectionConfig });
const requestResolver = getRequestResolver(context, { connection, requestConfig });
// Evaluate operators in properties
const { connectionProperties, requestProperties } = evaluateOperators(context, {
connectionConfig, requestConfig
});
// Security checks
checkConnectionRead(context, { ... });
checkConnectionWrite(context, { ... });
// Schema validation
validateSchemas(context, { ... });
// Execute request
const response = await callRequestResolver(context, {
connectionProperties, requestConfig, requestProperties, requestResolver
});
return {
id: requestConfig.id,
success: true,
type: requestConfig.type,
response: serialize(response)
};
}
File: packages/api/src/routes/request/callRequestResolver.js
async function callRequestResolver(
context,
{ connectionProperties, requestConfig, requestProperties, requestResolver }
) {
const response = await requestResolver({
blockId,
endpointId,
connection: connectionProperties,
connectionId: requestConfig.connectionId,
pageId,
payload,
request: requestProperties,
requestId: requestConfig.requestId,
});
return response;
}
File: packages/api/src/routes/request/getConnection.js
function getConnection({ connections }, { connectionConfig }) {
const connection = connections[connectionConfig.type];
if (!connection) {
throw new ConfigurationError(`Connection type "${connectionConfig.type}" not found.`);
}
return connection;
}
File: packages/api/src/routes/request/getRequestResolver.js
function getRequestResolver({}, { connection, requestConfig }) {
const requestResolver = connection.requests[requestConfig.type];
if (!requestResolver) {
throw new ConfigurationError(`Request type "${requestConfig.type}" not found.`);
}
return requestResolver;
}
Example: packages/plugins/connections/connection-mongodb/src/connections/MongoDBCollection/MongoDBCollection.js
export default {
schema: {
/* JSON Schema */
},
requests: {
MongoDBAggregation,
MongoDBDeleteMany,
MongoDBDeleteOne,
MongoDBFind,
MongoDBFindOne,
MongoDBInsertMany,
MongoDBInsertOne,
MongoDBUpdateMany,
MongoDBUpdateOne,
},
};
File: packages/plugins/connections/connection-mongodb/src/connections/MongoDBCollection/MongoDBFindOne/MongoDBFindOne.js
async function MongodbFindOne({ request, connection }) {
const { query, options } = deserialize(request);
const { collection, client } = await getCollection({ connection });
try {
const res = await collection.findOne(query, options);
return serialize(res);
} finally {
await client.close();
}
}
MongodbFindOne.meta = {
checkRead: true,
checkWrite: false,
};
Files: packages/api/src/routes/request/checkConnectionRead.js, checkConnectionWrite.js
function checkConnectionRead(context, { connectionProperties, requestResolver }) {
if (requestResolver.meta.checkRead && connectionProperties.read === false) {
throw new ConfigurationError(`Connection does not allow reads.`);
}
}
function checkConnectionWrite(context, { connectionProperties, requestResolver }) {
if (requestResolver.meta.checkWrite && connectionProperties.write !== true) {
throw new ConfigurationError(`Connection does not allow writes.`);
}
}
File: packages/api/src/routes/request/validateSchemas.js
function validateSchemas(
context,
{ connection, connectionProperties, requestResolver, requestProperties }
) {
validate({ schema: connection.schema, data: connectionProperties });
validate({ schema: requestResolver.schema, data: requestProperties });
}
File: packages/engine/src/State.js
class State {
set(field, value) {
set(this.context.state, field, value);
}
resetState() {
// Restore from frozenState snapshot
const frozen = deserializeFromString(this.frozenState);
Object.keys(frozen).forEach((key) => this.set(key, frozen[key]));
}
}
File: packages/engine/src/getContext.js
const ctx = {
pageId: config.pageId,
eventLog: [],
requests: {}, // Indexed by requestId
state: {},
_internal: {
State: new State(ctx),
Actions: new Actions(ctx),
Requests: new Requests(ctx),
update: () => _internal.RootAreas.update(),
},
};
// Access in templates:
// {{ requests.getUserData[0].response.user.name }}
File: packages/api/src/routes/endpoints/callEndpoint.js
async function callEndpoint(context, { blockId, endpointId, pageId, payload }) {
context.evaluateOperators = createEvaluateOperators(context);
const endpointConfig = await getEndpointConfig(context, { endpointId });
// InternalApi endpoints are server-only — block HTTP access
if (endpointConfig.type === 'InternalApi') {
throw new ConfigError(`API Endpoint "${endpointId}" does not exist.`);
}
authorizeApiEndpoint(context, { endpointConfig });
const routineContext = {
steps: {}, // Per-invocation step results
payload: serializer.deserialize(payload), // Per-invocation payload
arrayIndices: [],
items: {},
endpointDepth: 0, // Recursion depth counter
};
const { error, response, status } = await runRoutine(context, routineContext, {
routine: endpointConfig.routine,
});
return { error, response, status, success: !['error', 'reject'].includes(status) };
}
routineContext carries per-invocation state — each routine invocation gets its own steps and payload. This enables endpoint-to-endpoint calls (see below) where a called endpoint has an isolated namespace.
File: packages/api/src/routes/endpoints/runRoutine.js
async function runRoutine(context, routineContext, { routine }) {
if (type.isObject(routine)) {
if (routine.id?.startsWith?.('request:')) {
return await handleRequest(context, routineContext, { request: routine });
}
if (routine.id?.startsWith?.('endpoint:')) {
return await handleEndpointCall(context, routineContext, { step: routine });
}
return await handleControl(context, routineContext, { control: routine });
}
if (type.isArray(routine)) {
for (const item of routine) {
const res = await runRoutine(context, routineContext, { routine: item });
if (['return', 'error', 'reject'].includes(res.status)) {
return res;
}
}
return { status: 'continue' };
}
}
Steps are dispatched by ID prefix: request: → database/API call via handleRequest, endpoint: → server-side endpoint call via handleEndpointCall, no prefix → control flow via handleControl.
File: packages/api/src/routes/endpoints/handleEndpointCall.js
When a routine contains a CallApi step (built with endpoint: prefix), handleEndpointCall loads the target endpoint, authorizes the current user, and runs the target's routine in a fresh routineContext:
async function handleEndpointCall(context, routineContext, { step }) {
const evaluatedProperties = context.evaluateOperators({
input: step.properties, // Resolve operators in endpointId, payload
steps: routineContext.steps,
payload: routineContext.payload,
});
// Recursion guard (max depth 10)
if ((routineContext.endpointDepth ?? 0) >= 10) {
throw new ConfigError('Endpoint call depth exceeded maximum of 10.');
}
const endpointConfig = await getEndpointConfig(context, {
endpointId: evaluatedProperties.endpointId,
});
authorizeApiEndpoint(context, { endpointConfig });
// Isolated context — target gets its own steps and payload
const childRoutineContext = {
steps: {},
payload: evaluatedProperties.payload ?? {},
arrayIndices: [],
items: {},
endpointDepth: (routineContext.endpointDepth ?? 0) + 1,
};
const result = await runRoutine(context, childRoutineContext, {
routine: endpointConfig.routine,
});
// Store target's :return value in caller's steps
addStepResult(context, routineContext, {
result: result.status === 'return' ? result.response : null,
stepId: step.stepId,
});
}
Isolation: The called endpoint's internal step results never appear in the caller's routineContext.steps — only the :return value does. This prevents namespace collisions between routines.
Endpoint types: Api endpoints are callable from both HTTP and other endpoints. InternalApi endpoints are server-only — callEndpoint blocks HTTP access, but handleEndpointCall can reach them.
Scenario: User searches for data
onClick eventrequest('searchUsers')Requests.callRequest('searchUsers'){{ inputs.searchTerm.value }} → 'john'/api/request/dashboard/searchUsersrequests.searchUsers[0].loading = truesearchUsers configcollection.find({ name: /john/i })requests.searchUsers[0].loading = falserequests.searchUsers[0].response = [...]context._internal.update(){{ requests.searchUsers[0].response }}| Component | File |
|---|---|
| Event Trigger | packages/engine/src/Events.js |
| Action Runner | packages/engine/src/Actions.js |
| Request Manager | packages/engine/src/Requests.js |
| HTTP Client | packages/client/src/createCallRequest.js |
| Server Handler | packages/api/src/routes/request/callRequest.js |
| Connection Resolver | packages/api/src/routes/request/getConnection.js |
| Request Resolver | packages/api/src/routes/request/getRequestResolver.js |
| Operator Evaluator | packages/api/src/routes/request/evaluateOperators.js |
| Authorization | packages/api/src/routes/request/authorizeRequest.js |
| Validation | packages/api/src/routes/request/validateSchemas.js |
| Endpoint Handler | packages/api/src/routes/endpoints/callEndpoint.js |
| Endpoint Call | packages/api/src/routes/endpoints/handleEndpointCall.js |
| Routine Dispatch | packages/api/src/routes/endpoints/runRoutine.js |
| Step Result Storage | packages/api/src/routes/endpoints/addStepResult.js |
| State Manager | packages/engine/src/State.js |