bazis 0.132.0 → 0.133.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/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
- package/core/workflow/definitions_module/http/Processes.controller.ts +2 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +2 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -2
- package/core/workflow/generated/deps.ts +4 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -0
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
- package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
- package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
- package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
- package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
- package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
- package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +518 -132
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +37 -6
- package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
- package/core/workflow/runtime_module/model/WfJob.model.ts +13 -4
- package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +38 -3
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +48 -7
- package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
- package/library/workflow/ProcessDocument.ts +14 -0
- package/library/workflow/expressions.ts +5 -1
- package/library/workflow/index.ts +1 -1
- package/library/workflow/masking.ts +17 -3
- package/library/workflow/mermaid.ts +22 -6
- package/library/workflow/nodeKinds.ts +59 -0
- package/library/workflow/palette.ts +3 -1
- package/library/workflow/paths.ts +8 -2
- package/library/workflow/structure.ts +33 -4
- package/library/workflow/validateProcess.ts +129 -17
- package/package.json +1 -1
|
@@ -12,6 +12,8 @@ export type JsonSchema = Readonly<Record<string, unknown>>;
|
|
|
12
12
|
export interface NodeKindDefinition {
|
|
13
13
|
readonly kind: NodeKind;
|
|
14
14
|
readonly role: NodeRole;
|
|
15
|
+
/** A container: other nodes name it as their `parent`. */
|
|
16
|
+
readonly container?: boolean;
|
|
15
17
|
/** Checks the node `config`; returns a problem or `null`. */
|
|
16
18
|
readonly check: (config: Record<string, unknown>) => string | null;
|
|
17
19
|
/** The node `config` as a JSON Schema: what a designer builds the form of a step from. */
|
|
@@ -22,6 +24,12 @@ export interface NodeKindDefinition {
|
|
|
22
24
|
export const ISO_DURATION = /^P(?!$)(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
|
|
23
25
|
/** An event key or a Tool name: `payments.received`, `payments.charge`. */
|
|
24
26
|
export const DOTTED_KEY = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
|
|
27
|
+
/** A process key. */
|
|
28
|
+
export const PROCESS_KEY = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
29
|
+
/** The most iterations a loop may have. */
|
|
30
|
+
export const MAX_ITERATIONS = 10_000;
|
|
31
|
+
/** The most iterations of a loop that run at once. */
|
|
32
|
+
export const MAX_LOOP_PARALLEL = 100;
|
|
25
33
|
/** A parameter of a Tool. */
|
|
26
34
|
const PARAMETER = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
27
35
|
const ROLE = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
@@ -115,6 +123,29 @@ function assignments(config: Record<string, unknown>): string | null {
|
|
|
115
123
|
return null;
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
/** What a loop runs over and how; its expressions are checked with the whole document. */
|
|
127
|
+
function loop(config: Record<string, unknown>): string | null {
|
|
128
|
+
const sources = ["over", "while", "times"].filter((key) => config[key] !== undefined);
|
|
129
|
+
if (sources.length !== 1) return "config needs exactly one of over, while or times";
|
|
130
|
+
const { maxIterations, times, parallel, onItemError: onItem, collect } = config;
|
|
131
|
+
if (!Number.isSafeInteger(maxIterations) || (maxIterations as number) < 1 || (maxIterations as number) > MAX_ITERATIONS) return `config.maxIterations must be 1–${MAX_ITERATIONS}`;
|
|
132
|
+
if (times !== undefined && (!Number.isSafeInteger(times) || (times as number) < 1 || (times as number) > (maxIterations as number))) return "config.times must be an integer from 1 to maxIterations";
|
|
133
|
+
if (parallel !== undefined) {
|
|
134
|
+
if (typeof parallel !== "object" || parallel === null || Array.isArray(parallel)) return "config.parallel must be an object";
|
|
135
|
+
const { limit } = parallel as { limit?: unknown };
|
|
136
|
+
if (!Number.isSafeInteger(limit) || (limit as number) < 1 || (limit as number) > MAX_LOOP_PARALLEL) return `config.parallel.limit must be 1–${MAX_LOOP_PARALLEL}`;
|
|
137
|
+
if (config.while !== undefined) return "config.parallel does not go with while: its condition depends on the iteration before";
|
|
138
|
+
}
|
|
139
|
+
if (onItem !== undefined && onItem !== "stop" && onItem !== "skip") return "config.onItemError must be stop or skip";
|
|
140
|
+
if (collect !== undefined) {
|
|
141
|
+
if (typeof collect !== "object" || collect === null || Array.isArray(collect)) return "config.collect must be an object";
|
|
142
|
+
const { from, to } = collect as { from?: unknown; to?: unknown };
|
|
143
|
+
if (from === undefined) return "config.collect.from is required";
|
|
144
|
+
if (typeof to !== "string" || !FIELD_NAME.test(to)) return "config.collect.to is invalid";
|
|
145
|
+
}
|
|
146
|
+
return first(string(config, "item", FIELD_NAME, false), string(config, "index", FIELD_NAME, false));
|
|
147
|
+
}
|
|
148
|
+
|
|
118
149
|
const duration = { type: "string", format: "duration" };
|
|
119
150
|
const text = { type: "string" };
|
|
120
151
|
const dataPath = { type: "string", pattern: DATA_PATH.source };
|
|
@@ -154,6 +185,34 @@ export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
|
|
|
154
185
|
check: assignments,
|
|
155
186
|
config: object({ assignments: { type: "array", minItems: 1, items: object({ var: { type: "string", pattern: FIELD_NAME.source }, value: expression }, ["var", "value"]) } }, ["assignments"]),
|
|
156
187
|
},
|
|
188
|
+
{
|
|
189
|
+
kind: "loop",
|
|
190
|
+
role: "activity",
|
|
191
|
+
container: true,
|
|
192
|
+
check: loop,
|
|
193
|
+
config: object({
|
|
194
|
+
over: expression,
|
|
195
|
+
while: expression,
|
|
196
|
+
times: { type: "integer", minimum: 1, maximum: MAX_ITERATIONS },
|
|
197
|
+
item: saveTo,
|
|
198
|
+
index: saveTo,
|
|
199
|
+
maxIterations: { type: "integer", minimum: 1, maximum: MAX_ITERATIONS },
|
|
200
|
+
parallel: object({ limit: { type: "integer", minimum: 1, maximum: MAX_LOOP_PARALLEL } }, ["limit"]),
|
|
201
|
+
onItemError: { enum: ["stop", "skip"] },
|
|
202
|
+
collect: object({ from: expression, to: saveTo }, ["from", "to"]),
|
|
203
|
+
}, ["maxIterations"]),
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
kind: "callProcess",
|
|
207
|
+
role: "activity",
|
|
208
|
+
check: (c) => first(string(c, "process", PROCESS_KEY), inputMapping(c), string(c, "saveTo", FIELD_NAME, false), onError(c)),
|
|
209
|
+
config: object({
|
|
210
|
+
process: { type: "string", pattern: PROCESS_KEY.source },
|
|
211
|
+
input: { type: "object", description: "Parameter → a literal, a path string or an expression" },
|
|
212
|
+
saveTo,
|
|
213
|
+
onError: { enum: ["fail", "skip"] },
|
|
214
|
+
}, ["process"]),
|
|
215
|
+
},
|
|
157
216
|
{ kind: "timerCatch", role: "activity", check: timer, config: object(timerConfig) },
|
|
158
217
|
{
|
|
159
218
|
kind: "eventWait",
|
|
@@ -17,6 +17,8 @@ export interface PaletteTool {
|
|
|
17
17
|
export interface PaletteNodeKind {
|
|
18
18
|
readonly kind: NodeKind;
|
|
19
19
|
readonly role: NodeRole;
|
|
20
|
+
/** A container: other nodes name it as their `parent`. */
|
|
21
|
+
readonly container?: boolean;
|
|
20
22
|
readonly config: JsonSchema;
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -31,7 +33,7 @@ export interface ProcessPalette {
|
|
|
31
33
|
/** The palette with the application's Tools: a new Tool appears in a designer without a release of the framework. */
|
|
32
34
|
export function processPalette(tools: readonly PaletteTool[]): ProcessPalette {
|
|
33
35
|
return {
|
|
34
|
-
nodeKinds: NODE_KINDS.map(({ kind, role, config }) => ({ kind, role, config })),
|
|
36
|
+
nodeKinds: NODE_KINDS.map(({ kind, role, container, config }) => ({ kind, role, ...(container === undefined ? {} : { container }), config })),
|
|
35
37
|
functions: EXPRESSION_FUNCTIONS,
|
|
36
38
|
paths: DATA_PATHS,
|
|
37
39
|
tools,
|
|
@@ -16,10 +16,14 @@ export interface InstanceData {
|
|
|
16
16
|
readonly run?: unknown;
|
|
17
17
|
/** The engine's time as an ISO string. */
|
|
18
18
|
readonly now?: string;
|
|
19
|
+
/** Inside a loop body: the element of this iteration. */
|
|
20
|
+
readonly item?: unknown;
|
|
21
|
+
/** Inside a loop body: the number of this iteration, from 0. */
|
|
22
|
+
readonly index?: number;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
/** `$input.amount`, `$vars.payment.id`, `$nodes.Pay.paymentId`, `$event.record.id`, `$run.partition`, `$now`. */
|
|
22
|
-
export const DATA_PATH = /^\$(input|vars|nodes|event|trigger|run|now)((\.[A-Za-z_][A-Za-z0-9_]*)*)$/;
|
|
25
|
+
/** `$input.amount`, `$vars.payment.id`, `$nodes.Pay.paymentId`, `$event.record.id`, `$run.partition`, `$now`, `$item.id`, `$index`. */
|
|
26
|
+
export const DATA_PATH = /^\$(input|vars|nodes|event|trigger|run|now|item|index)((\.[A-Za-z_][A-Za-z0-9_]*)*)$/;
|
|
23
27
|
|
|
24
28
|
/** The roots of `$` paths, for a designer. */
|
|
25
29
|
export const DATA_PATHS: readonly { readonly path: string; readonly description: string }[] = Object.freeze([
|
|
@@ -30,6 +34,8 @@ export const DATA_PATHS: readonly { readonly path: string; readonly description:
|
|
|
30
34
|
{ path: "$trigger", description: "How it was started: { via, at, actor, eventKey?, cause? }" },
|
|
31
35
|
{ path: "$run", description: "This run: { id, processKey, version, partition, test }" },
|
|
32
36
|
{ path: "$now", description: "The engine's time" },
|
|
37
|
+
{ path: "$item", description: "Inside a loop body: the element of this iteration" },
|
|
38
|
+
{ path: "$index", description: "Inside a loop body: the number of this iteration, from 0" },
|
|
33
39
|
]);
|
|
34
40
|
|
|
35
41
|
/** `true` if the value is a path to instance data. */
|
|
@@ -30,8 +30,20 @@ export interface StructureBoundary {
|
|
|
30
30
|
readonly exitTitle?: LocalizedText;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** A step of the list: a single node, an exclusive fork or a parallel block. */
|
|
33
|
+
/** A step of the list: a single node, a loop around a list, an exclusive fork or a parallel block. */
|
|
34
34
|
export type StructureStep =
|
|
35
|
+
| {
|
|
36
|
+
readonly type: "loop";
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly node: ProcessNode;
|
|
39
|
+
/** The steps of an iteration. */
|
|
40
|
+
readonly body: readonly StructureStep[];
|
|
41
|
+
/** The end of the body. */
|
|
42
|
+
readonly bodyEnd: { readonly id: string; readonly node: ProcessNode };
|
|
43
|
+
readonly boundaries?: readonly StructureBoundary[];
|
|
44
|
+
readonly next: string;
|
|
45
|
+
readonly nextTitle?: LocalizedText;
|
|
46
|
+
}
|
|
35
47
|
| {
|
|
36
48
|
readonly type: "step";
|
|
37
49
|
readonly id: string;
|
|
@@ -141,7 +153,8 @@ class Reader {
|
|
|
141
153
|
this.visit(current);
|
|
142
154
|
const [flowId, flow] = this.single(current);
|
|
143
155
|
const boundaries = (this.boundaries.get(current) ?? []).map(([id, boundary]) => this.boundary(id, boundary, flow.target));
|
|
144
|
-
|
|
156
|
+
const common = { id: current, node, ...(boundaries.length === 0 ? {} : { boundaries }), next: flowId, ...(flow.title === undefined ? {} : { nextTitle: flow.title }) };
|
|
157
|
+
steps.push(node.kind === "loop" ? { type: "loop", ...common, ...this.body(current) } : { type: "step", ...common });
|
|
145
158
|
exit = { flow: flowId, ...(flow.title === undefined ? {} : { title: flow.title }) };
|
|
146
159
|
current = flow.target;
|
|
147
160
|
continue;
|
|
@@ -158,6 +171,18 @@ class Reader {
|
|
|
158
171
|
}
|
|
159
172
|
}
|
|
160
173
|
|
|
174
|
+
/** The body of a loop: a list from its one entry to its one end. */
|
|
175
|
+
private body(loop: string): { readonly body: StructureStep[]; readonly bodyEnd: { readonly id: string; readonly node: ProcessNode } } {
|
|
176
|
+
const members = Object.entries(this.document.nodes).filter(([, node]) => node.parent === loop && node.attachedTo === undefined);
|
|
177
|
+
const entries = members.filter(([id]) => (this.incoming.get(id)?.length ?? 0) === 0);
|
|
178
|
+
if (entries.length !== 1) throw new NotStructured();
|
|
179
|
+
const { steps, stop } = this.sequence(entries[0]![0]);
|
|
180
|
+
const end = this.node(stop);
|
|
181
|
+
if (nodeKindOf(end.kind)?.role !== "end" || end.parent !== loop || steps.length === 0) throw new NotStructured();
|
|
182
|
+
this.visited.add(stop);
|
|
183
|
+
return { body: steps, bodyEnd: { id: stop, node: end } };
|
|
184
|
+
}
|
|
185
|
+
|
|
161
186
|
private fork(id: string, node: ProcessNode, outs: readonly (readonly [string, ProcessFlow])[]): { readonly step: Extract<StructureStep, { type: "choice" | "parallel" }>; readonly stop: string } {
|
|
162
187
|
if ((this.incoming.get(id)?.length ?? 0) !== 1) throw new NotStructured();
|
|
163
188
|
this.visit(id);
|
|
@@ -207,7 +232,7 @@ class Reader {
|
|
|
207
232
|
/**
|
|
208
233
|
* A graph as a tree of steps, or `undefined` when it is outside the subset a
|
|
209
234
|
* list can draw (WORKFLOW-002 §3.6): sequences, an exclusive gateway with one
|
|
210
|
-
* merge, a parallel gateway with a join, boundary events on steps.
|
|
235
|
+
* merge, a parallel gateway with a join, a loop, boundary events on steps.
|
|
211
236
|
*/
|
|
212
237
|
export function toStructure(document: ProcessDocument): ProcessStructure | undefined {
|
|
213
238
|
try {
|
|
@@ -242,9 +267,13 @@ export function fromStructure(structure: ProcessStructure): ProcessDocument {
|
|
|
242
267
|
const target = index + 1 < list.length ? list[index + 1]!.id : after;
|
|
243
268
|
const last = index + 1 === list.length && leave !== undefined;
|
|
244
269
|
nodes[step.id] = step.node;
|
|
245
|
-
if (step.type === "step") {
|
|
270
|
+
if (step.type === "step" || step.type === "loop") {
|
|
246
271
|
flow(last ? leave.flow : step.next, step.id, target, titled(last ? leave.title : step.nextTitle));
|
|
247
272
|
for (const boundary of step.boundaries ?? []) writeBoundary(boundary, step, target);
|
|
273
|
+
if (step.type === "loop") {
|
|
274
|
+
write(step.body, step.bodyEnd.id);
|
|
275
|
+
nodes[step.bodyEnd.id] = step.bodyEnd.node;
|
|
276
|
+
}
|
|
248
277
|
return;
|
|
249
278
|
}
|
|
250
279
|
const meet = step.merge?.id ?? target;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { checkExpression, checkPath, isExpressionNode, type ExpressionScope } from "./expressions";
|
|
2
2
|
import { checkExtensions, variableProblems } from "./formFields";
|
|
3
3
|
import { checkLocalizedText } from "./localized";
|
|
4
|
-
import { ISO_DURATION, nodeKindOf, type NodeRole } from "./nodeKinds";
|
|
4
|
+
import { ISO_DURATION, nodeKindOf, PROCESS_KEY, type NodeRole } from "./nodeKinds";
|
|
5
5
|
import { isPath } from "./paths";
|
|
6
6
|
import { PROCESS_SCHEMA_VERSION, type ProcessDocument } from "./ProcessDocument";
|
|
7
7
|
import { toStructure } from "./structure";
|
|
@@ -32,6 +32,10 @@ export type ProcessIssueCode =
|
|
|
32
32
|
| "title_invalid"
|
|
33
33
|
| "config_invalid"
|
|
34
34
|
| "unknown_tool"
|
|
35
|
+
| "unknown_process"
|
|
36
|
+
| "container_invalid"
|
|
37
|
+
| "flow_across_container"
|
|
38
|
+
| "loop_body"
|
|
35
39
|
| "flow_dangling"
|
|
36
40
|
| "flow_self"
|
|
37
41
|
| "flow_duplicate"
|
|
@@ -61,7 +65,9 @@ export const PROCESS_LIMITS = Object.freeze({ nodes: 500, flows: 1000, bytes: 1_
|
|
|
61
65
|
|
|
62
66
|
const ID = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
63
67
|
/** Paths that come with later stages of WORKFLOW-002: until then they are an error, not a silent literal. */
|
|
64
|
-
const NOT_YET = /^\$
|
|
68
|
+
const NOT_YET = /^\$secrets(\.|$)/;
|
|
69
|
+
/** Who a boundary error may hang on: the steps that fail by an error of their own. */
|
|
70
|
+
const CAN_FAIL: ReadonlySet<string | undefined> = new Set(["serviceTask", "callProcess", "loop"]);
|
|
65
71
|
|
|
66
72
|
type Json = Record<string, unknown>;
|
|
67
73
|
|
|
@@ -79,6 +85,8 @@ export interface ValidateProcessOptions {
|
|
|
79
85
|
* `callers`. Without it Tool names are only checked for their form.
|
|
80
86
|
*/
|
|
81
87
|
readonly tools?: (name: string) => boolean;
|
|
88
|
+
/** Whether a process may be called or handle errors. Without it process keys are only checked for their form. */
|
|
89
|
+
readonly processes?: (key: string) => boolean;
|
|
82
90
|
/** Whether an `extensions` namespace has a check. Without it namespaces are only checked for their form. */
|
|
83
91
|
readonly extensions?: (namespace: string) => boolean;
|
|
84
92
|
}
|
|
@@ -106,6 +114,10 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
|
|
|
106
114
|
if (document.settings !== undefined) {
|
|
107
115
|
const problem = checkSettings(document.settings);
|
|
108
116
|
if (problem) error("settings_invalid", problem);
|
|
117
|
+
else {
|
|
118
|
+
const handler = (document.settings as Json).errorHandler;
|
|
119
|
+
if (typeof handler === "string" && options.processes && !options.processes(handler)) error("unknown_process", `Process ${JSON.stringify(handler)} of settings.errorHandler is not published`);
|
|
120
|
+
}
|
|
109
121
|
}
|
|
110
122
|
checkNamespaces(document.extensions, "extensions", {}, options, error);
|
|
111
123
|
for (const variable of declared) checkNamespaces(variable.extensions, "extensions", { variable: String(variable.name) }, options, error);
|
|
@@ -143,6 +155,8 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
|
|
|
143
155
|
if (problem) error("config_invalid", problem, { node: id });
|
|
144
156
|
else if (definition.kind === "serviceTask" && options.tools && !options.tools(config.tool as string)) {
|
|
145
157
|
error("unknown_tool", `Tool ${JSON.stringify(config.tool)} does not exist or does not list "workflow" in its callers`, { node: id });
|
|
158
|
+
} else if (definition.kind === "callProcess" && options.processes && !options.processes(config.process as string)) {
|
|
159
|
+
error("unknown_process", `Process ${JSON.stringify(config.process)} is not published`, { node: id });
|
|
146
160
|
}
|
|
147
161
|
}
|
|
148
162
|
if (definition.role === "boundary") {
|
|
@@ -154,24 +168,95 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
|
|
|
154
168
|
}
|
|
155
169
|
for (const [id, host] of attached) {
|
|
156
170
|
if (roles.get(host) !== "activity") error("boundary_attach", `attachedTo ${JSON.stringify(host)} is not a step`, { node: id });
|
|
157
|
-
else if (kinds.get(id) === "boundaryError" && kinds.get(host)
|
|
171
|
+
else if (kinds.get(id) === "boundaryError" && !CAN_FAIL.has(kinds.get(host))) error("boundary_attach", "error boundary attaches only to a service task, a called process or a loop", { node: id });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Containers: `parent` names a loop, and a chain of parents ends.
|
|
175
|
+
const parents = new Map<string, string>();
|
|
176
|
+
for (const [id, raw] of Object.entries(nodes)) {
|
|
177
|
+
const parent = isObject(raw) ? raw.parent : undefined;
|
|
178
|
+
if (parent === undefined) continue;
|
|
179
|
+
if (typeof parent !== "string" || kinds.get(parent) !== "loop" || parent === id) error("container_invalid", "parent must be a loop", { node: id });
|
|
180
|
+
else if (roles.get(id) === "start") error("container_invalid", "a start cannot be inside a loop", { node: id });
|
|
181
|
+
else parents.set(id, parent);
|
|
182
|
+
}
|
|
183
|
+
for (const id of [...parents.keys()]) {
|
|
184
|
+
const seen = new Set([id]);
|
|
185
|
+
for (let up = parents.get(id); up !== undefined; up = parents.get(up)) {
|
|
186
|
+
if (seen.has(up)) {
|
|
187
|
+
error("container_invalid", "loops cannot contain each other", { node: id });
|
|
188
|
+
parents.delete(id);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
seen.add(up);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const [id, host] of attached) {
|
|
195
|
+
if (roles.has(host) && parents.get(id) !== parents.get(host)) error("boundary_attach", "a boundary node must be in the same container as its step", { node: id });
|
|
158
196
|
}
|
|
159
197
|
|
|
160
198
|
// What expressions may read: declared variables, what steps save and existing nodes.
|
|
161
199
|
const named = (direction: (value: unknown) => boolean) => new Set(declared.filter((variable) => direction(variable.direction)).map((variable) => String(variable.name)));
|
|
162
200
|
const writable = named((direction) => direction !== "in");
|
|
163
|
-
const saved = new Set(writable);
|
|
164
|
-
for (const raw of Object.values(nodes)) {
|
|
165
|
-
const saveTo = isObject(raw) && isObject(raw.config) ? raw.config.saveTo : undefined;
|
|
166
|
-
if (typeof saveTo === "string") saved.add(saveTo);
|
|
167
|
-
}
|
|
168
201
|
const known = variables.length === 0;
|
|
169
|
-
const
|
|
202
|
+
const input = named((direction) => direction === "in");
|
|
203
|
+
/** What a step in a container reads: a body sees what is outside it, the outside does not see the body. */
|
|
204
|
+
const scopes = new Map<string | undefined, { readonly scope: ExpressionScope; readonly names: ReadonlySet<string> }>();
|
|
205
|
+
const scopeIn = (container: string | undefined): { readonly scope: ExpressionScope; readonly names: ReadonlySet<string> } => {
|
|
206
|
+
const ready = scopes.get(container);
|
|
207
|
+
if (ready) return ready;
|
|
208
|
+
const outer = container === undefined ? undefined : scopeIn(parents.get(container));
|
|
209
|
+
const vars = new Set<string>(outer ? outer.scope.vars : writable);
|
|
210
|
+
const seen = new Set<string>(outer ? outer.scope.nodes : []);
|
|
211
|
+
const names = new Set<string>(outer ? outer.names : []);
|
|
212
|
+
const own = container === undefined ? undefined : (nodes[container] as Json).config;
|
|
213
|
+
for (const key of ["item", "index"]) {
|
|
214
|
+
const name = isObject(own) ? own[key] : undefined;
|
|
215
|
+
if (typeof name === "string") names.add(name);
|
|
216
|
+
}
|
|
217
|
+
for (const name of names) vars.add(name);
|
|
218
|
+
for (const [id, raw] of Object.entries(nodes)) {
|
|
219
|
+
if (parents.get(id) !== container) continue;
|
|
220
|
+
seen.add(id);
|
|
221
|
+
const saveTo = isObject(raw) && isObject(raw.config) ? raw.config.saveTo : undefined;
|
|
222
|
+
if (typeof saveTo === "string") vars.add(saveTo);
|
|
223
|
+
}
|
|
224
|
+
const made = { scope: { ...(known ? { input, vars } : { vars }), nodes: seen, loop: container !== undefined }, names };
|
|
225
|
+
scopes.set(container, made);
|
|
226
|
+
return made;
|
|
227
|
+
};
|
|
228
|
+
const scopeOf = (container: string | undefined): ExpressionScope => {
|
|
229
|
+
const { scope } = scopeIn(container);
|
|
230
|
+
return known ? scope : { nodes: scope.nodes!, loop: scope.loop! };
|
|
231
|
+
};
|
|
170
232
|
|
|
171
233
|
// What steps read: an assign writes declared variables, inputs and waits read what exists.
|
|
172
234
|
for (const [id, raw] of Object.entries(nodes)) {
|
|
173
235
|
if (!isObject(raw) || !isObject(raw.config) || definitionProblem(raw)) continue;
|
|
174
|
-
|
|
236
|
+
const container = parents.get(id);
|
|
237
|
+
for (const problem of configExpressionProblems(raw.kind as string, raw.config, scopeOf(container), known ? writable : undefined)) error("config_invalid", problem, { node: id });
|
|
238
|
+
if (raw.kind !== "loop") continue;
|
|
239
|
+
const body = scopeOf(id);
|
|
240
|
+
if (raw.config.while !== undefined) {
|
|
241
|
+
// Before an iteration: it knows its number, not what the body of the last one kept to itself.
|
|
242
|
+
const outside = scopeOf(container);
|
|
243
|
+
const own = [raw.config.item, raw.config.index].filter((name): name is string => typeof name === "string");
|
|
244
|
+
const problem = checkExpression(raw.config.while, { ...outside, loop: true, ...(outside.vars ? { vars: new Set([...outside.vars, ...own]) } : {}) });
|
|
245
|
+
if (problem) error("config_invalid", `config.while: ${problem}`, { node: id });
|
|
246
|
+
}
|
|
247
|
+
const collect = raw.config.collect as Json | undefined;
|
|
248
|
+
if (collect !== undefined) {
|
|
249
|
+
const problem = checkExpression(collect.from, body);
|
|
250
|
+
if (problem) error("config_invalid", `config.collect.from: ${problem}`, { node: id });
|
|
251
|
+
if (known && !writable.has(collect.to as string)) error("config_invalid", `config.collect.to: ${String(collect.to)} is not a declared local or out variable`, { node: id });
|
|
252
|
+
}
|
|
253
|
+
for (const key of ["item", "index"]) {
|
|
254
|
+
const name = raw.config[key];
|
|
255
|
+
if (typeof name !== "string") continue;
|
|
256
|
+
if (known && (writable.has(name) || input.has(name))) error("config_invalid", `config.${key}: ${name} is already a variable of the process`, { node: id });
|
|
257
|
+
if (scopeIn(container).names.has(name)) error("config_invalid", `config.${key}: ${name} is already taken by an outer loop`, { node: id });
|
|
258
|
+
}
|
|
259
|
+
if (raw.config.item !== undefined && raw.config.item === raw.config.index) error("config_invalid", "config.item and config.index must differ", { node: id });
|
|
175
260
|
}
|
|
176
261
|
|
|
177
262
|
// Flows: ends, duplicates, conditions.
|
|
@@ -193,6 +278,7 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
|
|
|
193
278
|
continue;
|
|
194
279
|
}
|
|
195
280
|
if (source === target) error("flow_self", "flow connects a node to itself", { flow: id });
|
|
281
|
+
if (parents.get(source) !== parents.get(target)) error("flow_across_container", "a flow cannot cross the border of a loop", { flow: id });
|
|
196
282
|
const pair = `${source}\u0000${target}`;
|
|
197
283
|
if (pairs.has(pair)) error("flow_duplicate", "another flow already connects these nodes", { flow: id });
|
|
198
284
|
pairs.add(pair);
|
|
@@ -207,7 +293,7 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
|
|
|
207
293
|
if (raw.condition !== undefined) {
|
|
208
294
|
if (!fromChoice) error("condition_not_allowed", "only flows leaving an exclusive gateway have conditions", { flow: id });
|
|
209
295
|
else {
|
|
210
|
-
const problem = checkExpression(raw.condition,
|
|
296
|
+
const problem = checkExpression(raw.condition, scopeOf(parents.get(source)));
|
|
211
297
|
if (problem) error("condition_invalid", problem, { flow: id });
|
|
212
298
|
}
|
|
213
299
|
}
|
|
@@ -217,7 +303,19 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
|
|
|
217
303
|
// Rules by node role.
|
|
218
304
|
const starts = [...roles].filter(([, role]) => role === "start").map(([id]) => id);
|
|
219
305
|
if (starts.length === 0) error("no_start", "process needs a start node");
|
|
220
|
-
if (![...roles.
|
|
306
|
+
if (![...roles].some(([id, role]) => role === "end" && !parents.has(id))) error("no_end", "process needs an end node");
|
|
307
|
+
// A body: one step without incoming flows to begin with and an end of its own.
|
|
308
|
+
const entries = new Map<string, string>();
|
|
309
|
+
for (const [loop, kind] of kinds) {
|
|
310
|
+
if (kind !== "loop") continue;
|
|
311
|
+
const members = [...parents].filter(([, parent]) => parent === loop).map(([id]) => id);
|
|
312
|
+
const first = members.filter((id) => roles.get(id) !== "boundary" && (incoming.get(id)?.length ?? 0) === 0);
|
|
313
|
+
if (first.length !== 1) error("loop_body", `the body of a loop needs exactly one step without incoming flows, not ${first.length}`, { node: loop });
|
|
314
|
+
else if (roles.get(first[0]!) !== "activity" && roles.get(first[0]!) !== "gateway") error("loop_body", "the body of a loop begins with a step", { node: loop });
|
|
315
|
+
else entries.set(loop, first[0]!);
|
|
316
|
+
if (!members.some((id) => roles.get(id) === "end")) error("loop_body", "the body of a loop needs an end of its own", { node: loop });
|
|
317
|
+
}
|
|
318
|
+
const opening = new Set(entries.values());
|
|
221
319
|
for (const [id, role] of roles) {
|
|
222
320
|
const ins = incoming.get(id)?.length ?? 0;
|
|
223
321
|
const outs = outgoing.get(id) ?? [];
|
|
@@ -231,7 +329,7 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
|
|
|
231
329
|
if (ins === 0) error("end_incoming", "end node needs an incoming flow", { node: id });
|
|
232
330
|
break;
|
|
233
331
|
case "activity":
|
|
234
|
-
if (ins === 0) error("activity_incoming", "step needs an incoming flow", { node: id });
|
|
332
|
+
if (ins === 0 && !opening.has(id)) error("activity_incoming", "step needs an incoming flow", { node: id });
|
|
235
333
|
if (outs.length !== 1) error("activity_outgoing", "step needs exactly one outgoing flow; branch with a gateway", { node: id });
|
|
236
334
|
break;
|
|
237
335
|
case "boundary":
|
|
@@ -239,7 +337,7 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
|
|
|
239
337
|
if (outs.length !== 1) error("boundary_outgoing", "boundary node needs exactly one outgoing flow", { node: id });
|
|
240
338
|
break;
|
|
241
339
|
case "gateway":
|
|
242
|
-
if (ins === 0 || outs.length === 0 || (ins < 2 && outs.length < 2)) {
|
|
340
|
+
if ((ins === 0 && !opening.has(id)) || outs.length === 0 || (ins < 2 && outs.length < 2 && !opening.has(id))) {
|
|
243
341
|
error("gateway_flows", "gateway needs incoming and outgoing flows and must split or join", { node: id });
|
|
244
342
|
}
|
|
245
343
|
if (kinds.get(id) === "exclusiveGateway" && outs.length > 1) checkChoice(id, outs, flows, error);
|
|
@@ -251,8 +349,10 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
|
|
|
251
349
|
const forward = new Map<string, string[]>();
|
|
252
350
|
for (const [id, list] of outgoing) forward.set(id, list.map((flow) => (flows[flow] as Json).target as string));
|
|
253
351
|
for (const [boundary, host] of attached) if (roles.has(host)) forward.set(host, [...forward.get(host) ?? [], boundary]);
|
|
352
|
+
// The way back goes only inside a container: the end of a body says nothing about what follows the loop.
|
|
254
353
|
const backward = new Map<string, string[]>();
|
|
255
354
|
for (const [from, list] of forward) for (const to of list) backward.set(to, [...backward.get(to) ?? [], from]);
|
|
355
|
+
for (const [loop, entry] of entries) forward.set(loop, [...forward.get(loop) ?? [], entry]);
|
|
256
356
|
const reached = walk(starts, forward);
|
|
257
357
|
const ending = walk([...roles].filter(([, role]) => role === "end").map(([id]) => id), backward);
|
|
258
358
|
for (const id of roles.keys()) {
|
|
@@ -291,8 +391,16 @@ function checkChoice(id: string, outs: readonly string[], flows: Json, error: (c
|
|
|
291
391
|
function checkSettings(value: unknown): string | null {
|
|
292
392
|
if (!isObject(value)) return "settings must be an object";
|
|
293
393
|
for (const [key, item] of Object.entries(value)) {
|
|
294
|
-
if (key === "stepTimeout") {
|
|
295
|
-
if (typeof item !== "string" || !ISO_DURATION.test(item)) return
|
|
394
|
+
if (key === "stepTimeout" || key === "runTimeout") {
|
|
395
|
+
if (typeof item !== "string" || !ISO_DURATION.test(item)) return `settings.${key} must be an ISO duration without months and years`;
|
|
396
|
+
} else if (key === "runAs") {
|
|
397
|
+
if (item !== "starter" && item !== "process") return "settings.runAs must be starter or process";
|
|
398
|
+
} else if (key === "sameKey") {
|
|
399
|
+
if (item !== "allow" && item !== "skip" && item !== "queue") return "settings.sameKey must be allow, skip or queue";
|
|
400
|
+
} else if (key === "maxConcurrent") {
|
|
401
|
+
if (!Number.isSafeInteger(item) || (item as number) < 1 || (item as number) > 100_000) return "settings.maxConcurrent must be an integer 1–100000";
|
|
402
|
+
} else if (key === "errorHandler") {
|
|
403
|
+
if (typeof item !== "string" || !PROCESS_KEY.test(item)) return "settings.errorHandler must be a process key";
|
|
296
404
|
} else if (key === "log") {
|
|
297
405
|
if (!isObject(item)) return "settings.log must be an object";
|
|
298
406
|
for (const [flag, on] of Object.entries(item)) {
|
|
@@ -338,7 +446,11 @@ function configExpressionProblems(kind: string, config: Json, scope: ExpressionS
|
|
|
338
446
|
if (problem) out.push(`config.assignments[${index}].value: ${problem}`);
|
|
339
447
|
}
|
|
340
448
|
}
|
|
341
|
-
if (kind === "serviceTask" && config.input !== undefined) walkInput(config.input, "config.input", scope, out);
|
|
449
|
+
if ((kind === "serviceTask" || kind === "callProcess") && config.input !== undefined) walkInput(config.input, "config.input", scope, out);
|
|
450
|
+
if (kind === "loop" && config.over !== undefined) {
|
|
451
|
+
const problem = checkExpression(config.over, scope);
|
|
452
|
+
if (problem) out.push(`config.over: ${problem}`);
|
|
453
|
+
}
|
|
342
454
|
for (const key of ["at", "correlation"]) {
|
|
343
455
|
if (typeof config[key] === "string") {
|
|
344
456
|
const problem = checkPath(config[key], scope);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.133.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|