bazis 0.131.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/cli/templates/process.ts +1 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
- package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
- package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +16 -3
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
- package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
- package/core/workflow/definitions_module/access/Process.references.ts +1 -1
- package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
- package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +27 -16
- package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
- package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
- package/core/workflow/definitions_module/http/Processes.controller.ts +66 -23
- package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
- package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
- package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +35 -10
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +90 -28
- package/core/workflow/generated/deps.ts +15 -3
- package/core/workflow/index.ts +4 -2
- package/core/workflow/internal/WorkflowLanguages.ts +35 -0
- package/core/workflow/internal/canonicalJson.ts +8 -0
- package/core/workflow/internal/http.ts +20 -1
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -2
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +3 -3
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +6 -6
- package/core/workflow/runtime_module/http/Instances.controller.ts +30 -13
- 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/Schedules.ts +2 -2
- package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
- package/core/workflow/runtime_module/internal/ToolStep.ts +43 -12
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +559 -123
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +39 -10
- package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
- package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
- package/core/workflow/runtime_module/model/WfInstance.model.ts +34 -6
- package/core/workflow/runtime_module/model/WfJob.model.ts +29 -4
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfToken.model.ts +15 -3
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +43 -4
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +56 -13
- package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
- package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
- package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
- package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +4 -4
- package/library/openapi/codegen.ts +3 -0
- package/library/validation/types/ValidatorOptions.ts +2 -0
- package/library/workflow/ProcessDocument.ts +73 -23
- package/library/workflow/expressionSyntax.ts +243 -0
- package/library/workflow/expressions.ts +347 -0
- package/library/workflow/formFields.ts +118 -33
- package/library/workflow/index.ts +48 -9
- package/library/workflow/localized.ts +35 -0
- package/library/workflow/masking.ts +72 -0
- package/library/workflow/mermaid.ts +39 -13
- package/library/workflow/nodeKinds.ts +147 -17
- package/library/workflow/palette.ts +110 -0
- package/library/workflow/paths.ts +37 -6
- package/library/workflow/structure.ts +311 -0
- package/library/workflow/upgradeDocument.ts +84 -0
- package/library/workflow/validateProcess.ts +246 -52
- package/package.json +1 -1
- package/library/workflow/conditions.ts +0 -44
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { durationMs } from "./duration";
|
|
2
|
+
import { DATA_PATH, readPath, type InstanceData } from "./paths";
|
|
3
|
+
|
|
4
|
+
export interface ExprPath {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ExprCall {
|
|
9
|
+
readonly op: ExprFunctionName;
|
|
10
|
+
readonly args: readonly Expr[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An expression: a JSON tree run without `eval`. A leaf is a JSON literal or a
|
|
15
|
+
* `{ path }`; an object with `op` is a call of a function of a closed list.
|
|
16
|
+
*
|
|
17
|
+
* ```json
|
|
18
|
+
* { "op": "mul", "args": [{ "path": "$vars.amount" }, 1.2] }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export type Expr = string | number | boolean | null | readonly unknown[] | ExprPath | ExprCall | { readonly [key: string]: unknown };
|
|
22
|
+
|
|
23
|
+
/** What a function takes and gives. `date` is an ISO string, `any` takes everything. */
|
|
24
|
+
export type ExprType = "any" | "number" | "string" | "boolean" | "date" | "list";
|
|
25
|
+
|
|
26
|
+
export type ExprFunctionName =
|
|
27
|
+
| "add" | "sub" | "mul" | "div" | "mod" | "round"
|
|
28
|
+
| "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in" | "contains" | "empty" | "notEmpty"
|
|
29
|
+
| "and" | "or" | "not" | "if" | "coalesce"
|
|
30
|
+
| "concat" | "lower" | "upper" | "trim" | "length" | "format"
|
|
31
|
+
| "now" | "dateAdd" | "dateDiff" | "toDate"
|
|
32
|
+
| "first" | "pluck";
|
|
33
|
+
|
|
34
|
+
export interface ExprFunction {
|
|
35
|
+
readonly name: ExprFunctionName;
|
|
36
|
+
readonly group: "arithmetic" | "comparison" | "logic" | "text" | "date" | "list";
|
|
37
|
+
/** The types of the positional arguments. */
|
|
38
|
+
readonly args: readonly ExprType[];
|
|
39
|
+
/** How many of the last `args` may be left out. */
|
|
40
|
+
readonly optional?: number;
|
|
41
|
+
/** The type of any further arguments: the function takes as many as given. */
|
|
42
|
+
readonly rest?: ExprType;
|
|
43
|
+
readonly returns: ExprType;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const fn = (name: ExprFunctionName, group: ExprFunction["group"], args: readonly ExprType[], returns: ExprType, more: Pick<ExprFunction, "optional" | "rest"> = {}): ExprFunction => ({ name, group, args, returns, ...more });
|
|
47
|
+
|
|
48
|
+
/** The closed list of functions, also for a designer. A new function comes with a new minor version of the format. */
|
|
49
|
+
export const EXPRESSION_FUNCTIONS: readonly ExprFunction[] = Object.freeze([
|
|
50
|
+
fn("add", "arithmetic", ["number", "number"], "number", { rest: "number" }),
|
|
51
|
+
fn("sub", "arithmetic", ["number", "number"], "number"),
|
|
52
|
+
fn("mul", "arithmetic", ["number", "number"], "number", { rest: "number" }),
|
|
53
|
+
fn("div", "arithmetic", ["number", "number"], "number"),
|
|
54
|
+
fn("mod", "arithmetic", ["number", "number"], "number"),
|
|
55
|
+
fn("round", "arithmetic", ["number", "number"], "number", { optional: 1 }),
|
|
56
|
+
fn("eq", "comparison", ["any", "any"], "boolean"),
|
|
57
|
+
fn("ne", "comparison", ["any", "any"], "boolean"),
|
|
58
|
+
fn("gt", "comparison", ["any", "any"], "boolean"),
|
|
59
|
+
fn("gte", "comparison", ["any", "any"], "boolean"),
|
|
60
|
+
fn("lt", "comparison", ["any", "any"], "boolean"),
|
|
61
|
+
fn("lte", "comparison", ["any", "any"], "boolean"),
|
|
62
|
+
fn("in", "comparison", ["any", "list"], "boolean"),
|
|
63
|
+
fn("contains", "comparison", ["any", "any"], "boolean"),
|
|
64
|
+
fn("empty", "comparison", ["any"], "boolean"),
|
|
65
|
+
fn("notEmpty", "comparison", ["any"], "boolean"),
|
|
66
|
+
fn("and", "logic", ["any"], "boolean", { rest: "any" }),
|
|
67
|
+
fn("or", "logic", ["any"], "boolean", { rest: "any" }),
|
|
68
|
+
fn("not", "logic", ["any"], "boolean"),
|
|
69
|
+
fn("if", "logic", ["any", "any", "any"], "any"),
|
|
70
|
+
fn("coalesce", "logic", ["any"], "any", { rest: "any" }),
|
|
71
|
+
fn("concat", "text", ["any"], "string", { rest: "any" }),
|
|
72
|
+
fn("lower", "text", ["string"], "string"),
|
|
73
|
+
fn("upper", "text", ["string"], "string"),
|
|
74
|
+
fn("trim", "text", ["string"], "string"),
|
|
75
|
+
fn("length", "text", ["any"], "number"),
|
|
76
|
+
fn("format", "text", ["string"], "string", { rest: "any" }),
|
|
77
|
+
fn("now", "date", [], "date"),
|
|
78
|
+
fn("dateAdd", "date", ["date", "string"], "date"),
|
|
79
|
+
fn("dateDiff", "date", ["date", "date", "string"], "number", { optional: 1 }),
|
|
80
|
+
fn("toDate", "date", ["any"], "date"),
|
|
81
|
+
fn("first", "list", ["list"], "any"),
|
|
82
|
+
fn("pluck", "list", ["list", "string"], "list"),
|
|
83
|
+
] satisfies ExprFunction[]);
|
|
84
|
+
|
|
85
|
+
const FUNCTIONS = new Map(EXPRESSION_FUNCTIONS.map((definition) => [definition.name as string, definition]));
|
|
86
|
+
|
|
87
|
+
export const EXPRESSION_LIMITS = Object.freeze({ depth: 16, nodes: 200 });
|
|
88
|
+
|
|
89
|
+
const DIFF_UNITS: Readonly<Record<string, number>> = { ms: 1, seconds: 1000, minutes: 60_000, hours: 3_600_000, days: 86_400_000 };
|
|
90
|
+
|
|
91
|
+
/** The function by name; `undefined` outside the closed list. */
|
|
92
|
+
export function expressionFunction(name: unknown): ExprFunction | undefined {
|
|
93
|
+
return typeof name === "string" ? FUNCTIONS.get(name) : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `true` for an object an expression reads as a call or a path; any other object is a literal. */
|
|
97
|
+
export function isExpressionNode(value: unknown): value is ExprPath | ExprCall {
|
|
98
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && (Object.hasOwn(value, "op") || Object.hasOwn(value, "path"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The value of an expression. It never throws: what cannot be computed (a
|
|
103
|
+
* missing value in arithmetic, division by zero, a bad date) is `null`, and a
|
|
104
|
+
* comparison with `null` is false, so a condition on missing data does not hold.
|
|
105
|
+
*/
|
|
106
|
+
export function evaluateExpression(expression: Expr, data: InstanceData): unknown {
|
|
107
|
+
if (!isExpressionNode(expression)) return expression;
|
|
108
|
+
if ("path" in expression) return readPath(data, expression.path);
|
|
109
|
+
return call(expression, data);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** How a value counts as a condition: `null`, `false`, `0`, an empty string and an empty list do not hold. */
|
|
113
|
+
export function isTruthy(value: unknown): boolean {
|
|
114
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
115
|
+
return Boolean(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function call({ op, args }: ExprCall, data: InstanceData): unknown {
|
|
119
|
+
const value = (index: number) => (index < args.length ? evaluateExpression(args[index]!, data) : undefined);
|
|
120
|
+
const values = () => args.map((arg) => evaluateExpression(arg, data));
|
|
121
|
+
switch (op) {
|
|
122
|
+
case "add": return arithmetic(values(), (a, b) => a + b);
|
|
123
|
+
case "sub": return arithmetic(values(), (a, b) => a - b);
|
|
124
|
+
case "mul": return arithmetic(values(), (a, b) => a * b);
|
|
125
|
+
case "div": return arithmetic(values(), (a, b) => (b === 0 ? Number.NaN : a / b));
|
|
126
|
+
case "mod": return arithmetic(values(), (a, b) => (b === 0 ? Number.NaN : a % b));
|
|
127
|
+
case "round": {
|
|
128
|
+
const number = numeric(value(0));
|
|
129
|
+
const digits = args.length > 1 ? numeric(value(1)) : 0;
|
|
130
|
+
if (number === null || digits === null) return null;
|
|
131
|
+
const scale = 10 ** Math.trunc(digits);
|
|
132
|
+
return Math.round(number * scale) / scale;
|
|
133
|
+
}
|
|
134
|
+
case "eq": return same(value(0), value(1));
|
|
135
|
+
case "ne": return !same(value(0), value(1));
|
|
136
|
+
case "gt": return compare(value(0), value(1), (a, b) => a > b);
|
|
137
|
+
case "gte": return compare(value(0), value(1), (a, b) => a >= b);
|
|
138
|
+
case "lt": return compare(value(0), value(1), (a, b) => a < b);
|
|
139
|
+
case "lte": return compare(value(0), value(1), (a, b) => a <= b);
|
|
140
|
+
case "in": {
|
|
141
|
+
const list = value(1);
|
|
142
|
+
const item = value(0);
|
|
143
|
+
return Array.isArray(list) && list.some((other) => same(item, other));
|
|
144
|
+
}
|
|
145
|
+
case "contains": {
|
|
146
|
+
const whole = value(0);
|
|
147
|
+
const part = value(1);
|
|
148
|
+
if (typeof whole === "string") return typeof part === "string" && whole.includes(part);
|
|
149
|
+
return Array.isArray(whole) && whole.some((item) => same(item, part));
|
|
150
|
+
}
|
|
151
|
+
case "empty": return isEmpty(value(0));
|
|
152
|
+
case "notEmpty": return !isEmpty(value(0));
|
|
153
|
+
case "and": return args.every((arg) => isTruthy(evaluateExpression(arg, data)));
|
|
154
|
+
case "or": return args.some((arg) => isTruthy(evaluateExpression(arg, data)));
|
|
155
|
+
case "not": return !isTruthy(value(0));
|
|
156
|
+
case "if": return isTruthy(value(0)) ? value(1) : value(2);
|
|
157
|
+
case "coalesce":
|
|
158
|
+
for (const arg of args) {
|
|
159
|
+
const item = evaluateExpression(arg, data);
|
|
160
|
+
if (item !== null && item !== undefined) return item;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
case "concat": return values().map(textOfValue).join("");
|
|
164
|
+
case "lower": return mapText(value(0), (text) => text.toLowerCase());
|
|
165
|
+
case "upper": return mapText(value(0), (text) => text.toUpperCase());
|
|
166
|
+
case "trim": return mapText(value(0), (text) => text.trim());
|
|
167
|
+
case "length": {
|
|
168
|
+
const item = value(0);
|
|
169
|
+
return typeof item === "string" || Array.isArray(item) ? item.length : 0;
|
|
170
|
+
}
|
|
171
|
+
case "format": {
|
|
172
|
+
const template = value(0);
|
|
173
|
+
if (typeof template !== "string") return null;
|
|
174
|
+
const items = values().slice(1);
|
|
175
|
+
return template.replace(/\{(\d+)\}/g, (whole, index: string) => (Number(index) < items.length ? textOfValue(items[Number(index)]) : whole));
|
|
176
|
+
}
|
|
177
|
+
case "now": return data.now ?? null;
|
|
178
|
+
case "dateAdd": {
|
|
179
|
+
const date = timeOf(value(0));
|
|
180
|
+
const by = value(1);
|
|
181
|
+
const ms = typeof by === "string" ? durationMs(by) : undefined;
|
|
182
|
+
return date === null || ms === undefined ? null : new Date(date + ms).toISOString();
|
|
183
|
+
}
|
|
184
|
+
case "dateDiff": {
|
|
185
|
+
const end = timeOf(value(0));
|
|
186
|
+
const start = timeOf(value(1));
|
|
187
|
+
const unit = DIFF_UNITS[args.length > 2 ? String(value(2)) : "ms"];
|
|
188
|
+
return end === null || start === null || unit === undefined ? null : (end - start) / unit;
|
|
189
|
+
}
|
|
190
|
+
case "toDate": {
|
|
191
|
+
const time = timeOf(value(0));
|
|
192
|
+
return time === null ? null : new Date(time).toISOString();
|
|
193
|
+
}
|
|
194
|
+
case "first": {
|
|
195
|
+
const list = value(0);
|
|
196
|
+
return Array.isArray(list) ? list[0] ?? null : null;
|
|
197
|
+
}
|
|
198
|
+
case "pluck": {
|
|
199
|
+
const list = value(0);
|
|
200
|
+
const field = value(1);
|
|
201
|
+
if (!Array.isArray(list) || typeof field !== "string") return null;
|
|
202
|
+
return list.map((item) => (typeof item === "object" && item !== null && Object.hasOwn(item, field) ? (item as Record<string, unknown>)[field] : null));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Decimals arrive as strings, so numeric strings count as numbers. */
|
|
208
|
+
function numeric(value: unknown): number | null {
|
|
209
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
210
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
211
|
+
const number = Number(value);
|
|
212
|
+
return Number.isFinite(number) ? number : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function arithmetic(values: readonly unknown[], step: (a: number, b: number) => number): number | null {
|
|
216
|
+
let result: number | null = null;
|
|
217
|
+
for (const [index, value] of values.entries()) {
|
|
218
|
+
const number = numeric(value);
|
|
219
|
+
if (number === null) return null;
|
|
220
|
+
result = index === 0 ? number : step(result!, number);
|
|
221
|
+
if (!Number.isFinite(result)) return null;
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function same(a: unknown, b: unknown): boolean {
|
|
227
|
+
if ((typeof a === "number" && typeof b === "string") || (typeof a === "string" && typeof b === "number")) return Number(a) === Number(b);
|
|
228
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Numbers compare as numbers (a decimal arrives as a string), other strings as strings (ISO dates compare right). */
|
|
232
|
+
function compare(a: unknown, b: unknown, test: (a: number | string, b: number | string) => boolean): boolean {
|
|
233
|
+
if (a === null || a === undefined || b === null || b === undefined) return false;
|
|
234
|
+
const na = Number(a), nb = Number(b);
|
|
235
|
+
if (typeof a !== "boolean" && typeof b !== "boolean" && a !== "" && b !== "" && Number.isFinite(na) && Number.isFinite(nb)) return test(na, nb);
|
|
236
|
+
return typeof a === "string" && typeof b === "string" ? test(a, b) : false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isEmpty(value: unknown): boolean {
|
|
240
|
+
if (value === null || value === undefined || value === "") return true;
|
|
241
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
242
|
+
return typeof value === "object" && Object.keys(value).length === 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function textOfValue(value: unknown): string {
|
|
246
|
+
if (value === null || value === undefined) return "";
|
|
247
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function mapText(value: unknown, change: (text: string) => string): string | null {
|
|
251
|
+
return typeof value === "string" ? change(value) : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function timeOf(value: unknown): number | null {
|
|
255
|
+
if (typeof value !== "string" && typeof value !== "number") return null;
|
|
256
|
+
const time = new Date(value).getTime();
|
|
257
|
+
return Number.isNaN(time) ? null : time;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** What an expression may read; a set left out is not checked. */
|
|
261
|
+
export interface ExpressionScope {
|
|
262
|
+
/** Input fields: `$input.<field>`. */
|
|
263
|
+
readonly input?: ReadonlySet<string>;
|
|
264
|
+
/** Variables: `$vars.<name>`. */
|
|
265
|
+
readonly vars?: ReadonlySet<string>;
|
|
266
|
+
/** Nodes: `$nodes.<node>`. */
|
|
267
|
+
readonly nodes?: ReadonlySet<string>;
|
|
268
|
+
/** Whether it is read inside a loop body: `$item` and `$index` exist only there. */
|
|
269
|
+
readonly loop?: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** What is wrong with an expression, or `null`: its shape, functions, argument types, paths and limits. */
|
|
273
|
+
export function checkExpression(expression: unknown, scope: ExpressionScope = {}): string | null {
|
|
274
|
+
const budget = { nodes: 0 };
|
|
275
|
+
const result = check(expression, scope, 0, budget);
|
|
276
|
+
return result.problem ?? null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface Checked {
|
|
280
|
+
readonly problem?: string;
|
|
281
|
+
readonly type: ExprType;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function literalType(value: unknown): ExprType {
|
|
285
|
+
if (typeof value === "number") return "number";
|
|
286
|
+
if (typeof value === "string") return "string";
|
|
287
|
+
if (typeof value === "boolean") return "boolean";
|
|
288
|
+
return Array.isArray(value) ? "list" : "any";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function check(value: unknown, scope: ExpressionScope, depth: number, budget: { nodes: number }): Checked {
|
|
292
|
+
budget.nodes += 1;
|
|
293
|
+
if (budget.nodes > EXPRESSION_LIMITS.nodes) return { problem: `expression has more than ${EXPRESSION_LIMITS.nodes} nodes`, type: "any" };
|
|
294
|
+
if (depth > EXPRESSION_LIMITS.depth) return { problem: `expression is nested deeper than ${EXPRESSION_LIMITS.depth}`, type: "any" };
|
|
295
|
+
if (value === undefined) return { problem: "expression has no value", type: "any" };
|
|
296
|
+
if (!isExpressionNode(value)) return { type: literalType(value) };
|
|
297
|
+
const keys = Object.keys(value);
|
|
298
|
+
if ("path" in value && !("op" in value)) {
|
|
299
|
+
if (keys.length !== 1) return { problem: "a path has only the key path", type: "any" };
|
|
300
|
+
const problem = checkPath(value.path, scope);
|
|
301
|
+
return problem === null ? { type: "any" } : { problem, type: "any" };
|
|
302
|
+
}
|
|
303
|
+
if (keys.some((key) => key !== "op" && key !== "args")) return { problem: "a call has only the keys op and args", type: "any" };
|
|
304
|
+
const definition = expressionFunction((value as { op?: unknown }).op);
|
|
305
|
+
if (!definition) return { problem: `unknown function ${JSON.stringify((value as { op?: unknown }).op)}`, type: "any" };
|
|
306
|
+
const args = (value as { args?: unknown }).args;
|
|
307
|
+
if (!Array.isArray(args)) return { problem: `${definition.name}: args must be a list`, type: "any" };
|
|
308
|
+
const required = definition.args.length - (definition.optional ?? 0);
|
|
309
|
+
if (args.length < required) return { problem: `${definition.name} takes at least ${required} arguments`, type: "any" };
|
|
310
|
+
if (definition.rest === undefined && args.length > definition.args.length) return { problem: `${definition.name} takes at most ${definition.args.length} arguments`, type: "any" };
|
|
311
|
+
for (const [index, arg] of args.entries()) {
|
|
312
|
+
const checked = check(arg, scope, depth + 1, budget);
|
|
313
|
+
if (checked.problem !== undefined) return checked;
|
|
314
|
+
const expected = definition.args[index] ?? definition.rest ?? "any";
|
|
315
|
+
if (!fits(checked.type, expected)) return { problem: `${definition.name}: argument ${index + 1} must be ${expected}, not ${checked.type}`, type: "any" };
|
|
316
|
+
}
|
|
317
|
+
const problem = literalArgumentProblem(definition.name, args);
|
|
318
|
+
return problem === null ? { type: definition.returns } : { problem, type: "any" };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** `any` fits everything; a date is a string in JSON. */
|
|
322
|
+
function fits(actual: ExprType, expected: ExprType): boolean {
|
|
323
|
+
if (actual === "any" || expected === "any" || actual === expected) return true;
|
|
324
|
+
return (actual === "string" && expected === "date") || (actual === "date" && expected === "string");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Arguments that must be one of a few literals are checked when they are written literally. */
|
|
328
|
+
function literalArgumentProblem(name: ExprFunctionName, args: readonly unknown[]): string | null {
|
|
329
|
+
if (name === "dateAdd" && typeof args[1] === "string" && durationMs(args[1]) === undefined) return `dateAdd: ${args[1]} is not an ISO duration without months and years`;
|
|
330
|
+
if (name === "dateDiff" && typeof args[2] === "string" && !Object.hasOwn(DIFF_UNITS, args[2])) return `dateDiff: the unit must be one of ${Object.keys(DIFF_UNITS).join(", ")}`;
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** What is wrong with a `$` path, or `null`. */
|
|
335
|
+
export function checkPath(path: unknown, scope: ExpressionScope = {}): string | null {
|
|
336
|
+
if (typeof path !== "string" || !DATA_PATH.test(path)) return `path ${JSON.stringify(path)} must look like $input.field, $vars.name, $nodes.Node.field, $event.field, $trigger, $run.id, $now, $item.field or $index`;
|
|
337
|
+
const [root, first] = path.split(".");
|
|
338
|
+
if (root === "$now" && first !== undefined) return "$now has no fields";
|
|
339
|
+
if (root === "$index" && first !== undefined) return "$index has no fields";
|
|
340
|
+
if ((root === "$item" || root === "$index") && scope.loop === false) return `path ${path}: ${root} exists only inside a loop body`;
|
|
341
|
+
if (root === "$nodes" && first === undefined) return "$nodes needs a node: $nodes.Node.field";
|
|
342
|
+
if (first === undefined) return null;
|
|
343
|
+
if (root === "$input" && scope.input && !scope.input.has(first)) return `path ${path}: the input has no field ${first}`;
|
|
344
|
+
if (root === "$vars" && scope.vars && !scope.vars.has(first)) return `path ${path}: there is no variable ${first}`;
|
|
345
|
+
if (root === "$nodes" && scope.nodes && !scope.nodes.has(first)) return `path ${path}: there is no node ${first}`;
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
export type FormFieldType = "string" | "text" | "number" | "integer" | "boolean" | "date" | "datetime" | "enum";
|
|
1
|
+
import { checkLocalizedText, type LocalizedText } from "./localized";
|
|
3
2
|
|
|
3
|
+
/** The types of variables and of task form fields. */
|
|
4
|
+
export type FieldType = "string" | "text" | "number" | "integer" | "boolean" | "date" | "datetime" | "enum" | "object" | "list" | "ref";
|
|
5
|
+
|
|
6
|
+
/** A field of a task form; a variable of a process is the same with a direction. */
|
|
4
7
|
export interface FormField {
|
|
5
8
|
/** The key in the value: `amount`, `reason`. */
|
|
6
|
-
readonly
|
|
7
|
-
readonly type:
|
|
8
|
-
readonly title?:
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly type: FieldType;
|
|
11
|
+
readonly title?: LocalizedText;
|
|
12
|
+
readonly default?: unknown;
|
|
9
13
|
readonly required?: boolean;
|
|
10
14
|
/** Allowed values of an `enum` field. */
|
|
11
15
|
readonly values?: readonly (string | number)[];
|
|
16
|
+
/** For `ref`: the kind of reference, for example `orm.entity:Order`. */
|
|
17
|
+
readonly ref?: string;
|
|
18
|
+
/** Personal data: masked in the history under `log.maskPersonal`. */
|
|
19
|
+
readonly personal?: boolean;
|
|
20
|
+
/** The application's rules by namespace; `WorkflowInputValidator` reads them. */
|
|
21
|
+
readonly extensions?: Readonly<Record<string, unknown>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `in`: the input of a start. `local`: inside the process. `out`: what the process gives back. */
|
|
25
|
+
export type VariableDirection = "in" | "local" | "out";
|
|
26
|
+
|
|
27
|
+
export interface VariableSpec extends FormField {
|
|
28
|
+
readonly direction: VariableDirection;
|
|
12
29
|
}
|
|
13
30
|
|
|
14
31
|
/** The fields of a process input or a task form. */
|
|
@@ -24,8 +41,12 @@ export interface FormIssue {
|
|
|
24
41
|
readonly message: string;
|
|
25
42
|
}
|
|
26
43
|
|
|
27
|
-
const TYPES = new Set<
|
|
28
|
-
const
|
|
44
|
+
const TYPES = new Set<FieldType>(["string", "text", "number", "integer", "boolean", "date", "datetime", "enum", "object", "list", "ref"]);
|
|
45
|
+
const DIRECTIONS = new Set<VariableDirection>(["in", "local", "out"]);
|
|
46
|
+
/** A variable or field name. */
|
|
47
|
+
export const FIELD_NAME = /^[a-z][A-Za-z0-9_]{0,39}$/;
|
|
48
|
+
const REF_KIND = /^[a-z][A-Za-z0-9_.:-]{0,127}$/;
|
|
49
|
+
const NAMESPACE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
29
50
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
30
51
|
const MAX_FIELDS = 200;
|
|
31
52
|
|
|
@@ -33,6 +54,46 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
33
54
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
55
|
}
|
|
35
56
|
|
|
57
|
+
/** What is wrong with `extensions`, or `null`: an object of namespace → anything. */
|
|
58
|
+
export function checkExtensions(value: unknown, at: string): string | null {
|
|
59
|
+
if (value === undefined) return null;
|
|
60
|
+
if (!isObject(value)) return `${at} must be an object of namespace → data`;
|
|
61
|
+
const bad = Object.keys(value).find((key) => !NAMESPACE.test(key));
|
|
62
|
+
return bad === undefined ? null : `${at} has an invalid namespace ${JSON.stringify(bad)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What is wrong with one field, or `null`. */
|
|
66
|
+
function checkField(field: unknown, where: string): string | null {
|
|
67
|
+
if (!isObject(field)) return `${where} must be an object`;
|
|
68
|
+
if (typeof field.name !== "string" || !FIELD_NAME.test(field.name)) return `${where}.name must match ${FIELD_NAME.source}`;
|
|
69
|
+
if (typeof field.type !== "string" || !TYPES.has(field.type as FieldType)) return `${where}.type must be one of ${[...TYPES].join(", ")}`;
|
|
70
|
+
if (field.title !== undefined) {
|
|
71
|
+
const problem = checkLocalizedText(field.title, 200);
|
|
72
|
+
if (problem) return `${where}.title ${problem}`;
|
|
73
|
+
}
|
|
74
|
+
if (field.required !== undefined && typeof field.required !== "boolean") return `${where}.required must be a boolean`;
|
|
75
|
+
if (field.personal !== undefined && typeof field.personal !== "boolean") return `${where}.personal must be a boolean`;
|
|
76
|
+
if (field.type === "enum") {
|
|
77
|
+
if (!Array.isArray(field.values) || field.values.length === 0 || !field.values.every((item) => typeof item === "string" || typeof item === "number")) {
|
|
78
|
+
return `${where}.values must be a non-empty list of strings or numbers`;
|
|
79
|
+
}
|
|
80
|
+
} else if (field.values !== undefined) {
|
|
81
|
+
return `${where}.values is only for enum fields`;
|
|
82
|
+
}
|
|
83
|
+
if (field.type === "ref") {
|
|
84
|
+
if (typeof field.ref !== "string" || !REF_KIND.test(field.ref)) return `${where}.ref must name a kind of reference`;
|
|
85
|
+
} else if (field.ref !== undefined) {
|
|
86
|
+
return `${where}.ref is only for ref fields`;
|
|
87
|
+
}
|
|
88
|
+
const extensions = checkExtensions(field.extensions, `${where}.extensions`);
|
|
89
|
+
if (extensions) return extensions;
|
|
90
|
+
if (field.default !== undefined && field.default !== null) {
|
|
91
|
+
const problem = checkType(field as unknown as FormField, field.default);
|
|
92
|
+
if (problem) return `${where}.default: ${problem.message}`;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
36
97
|
/** Checks the shape of a form spec; returns a problem or `null`. `at` names it in the text. */
|
|
37
98
|
export function checkFormSpec(value: unknown, at: string): string | null {
|
|
38
99
|
if (!isObject(value) || !Array.isArray(value.fields)) return `${at} must be an object with a fields list`;
|
|
@@ -40,59 +101,77 @@ export function checkFormSpec(value: unknown, at: string): string | null {
|
|
|
40
101
|
const seen = new Set<string>();
|
|
41
102
|
for (const [index, field] of value.fields.entries()) {
|
|
42
103
|
const where = `${at}.fields[${index}]`;
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
if (field.required !== undefined && typeof field.required !== "boolean") return `${where}.required must be a boolean`;
|
|
50
|
-
if (field.type === "enum") {
|
|
51
|
-
if (!Array.isArray(field.values) || field.values.length === 0 || !field.values.every((item) => typeof item === "string" || typeof item === "number")) {
|
|
52
|
-
return `${where}.values must be a non-empty list of strings or numbers`;
|
|
53
|
-
}
|
|
54
|
-
} else if (field.values !== undefined) {
|
|
55
|
-
return `${where}.values is only for enum fields`;
|
|
56
|
-
}
|
|
104
|
+
const problem = checkField(field, where);
|
|
105
|
+
if (problem) return problem;
|
|
106
|
+
if (isObject(field) && Object.hasOwn(field, "direction")) return `${where}: a form field has no direction`;
|
|
107
|
+
const name = (field as FormField).name;
|
|
108
|
+
if (seen.has(name)) return `${where}.name ${name} is repeated`;
|
|
109
|
+
seen.add(name);
|
|
57
110
|
}
|
|
58
111
|
return null;
|
|
59
112
|
}
|
|
60
113
|
|
|
61
|
-
/**
|
|
62
|
-
export function
|
|
63
|
-
|
|
114
|
+
/** Every problem of the variables of a document, with the variable it is about. */
|
|
115
|
+
export function variableProblems(value: unknown): readonly { readonly variable?: string; readonly message: string }[] {
|
|
116
|
+
if (!Array.isArray(value)) return [{ message: "variables must be a list" }];
|
|
117
|
+
if (value.length > MAX_FIELDS) return [{ message: `there are more than ${MAX_FIELDS} variables` }];
|
|
118
|
+
const out: { variable?: string; message: string }[] = [];
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
for (const [index, variable] of value.entries()) {
|
|
121
|
+
const where = `variables[${index}]`;
|
|
122
|
+
const name = isObject(variable) && typeof variable.name === "string" ? variable.name : undefined;
|
|
123
|
+
const at = name === undefined ? {} : { variable: name };
|
|
124
|
+
const problem = checkField(variable, where);
|
|
125
|
+
if (problem) {
|
|
126
|
+
out.push({ ...at, message: problem });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const spec = variable as Record<string, unknown>;
|
|
130
|
+
if (typeof spec.direction !== "string" || !DIRECTIONS.has(spec.direction as VariableDirection)) out.push({ ...at, message: `${where}.direction must be in, local or out` });
|
|
131
|
+
else if (spec.required !== undefined && spec.direction !== "in") out.push({ ...at, message: `${where}.required is only for in variables` });
|
|
132
|
+
if (seen.has(name!)) out.push({ ...at, message: `${where}.name ${name} is repeated` });
|
|
133
|
+
seen.add(name!);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The field names of a form spec. */
|
|
139
|
+
export function formFieldNames(spec: FormSpec): string[] {
|
|
140
|
+
return spec.fields.map((field) => field.name);
|
|
64
141
|
}
|
|
65
142
|
|
|
66
143
|
/**
|
|
67
144
|
* Checks a value against a form spec: required fields, types, enum values and
|
|
68
|
-
* unknown fields
|
|
69
|
-
* Without a spec the form takes no
|
|
145
|
+
* unknown fields; a missing field takes its `default`. Returns the value with
|
|
146
|
+
* only the declared fields and every issue. Without a spec the form takes no
|
|
147
|
+
* data: any field is unknown.
|
|
70
148
|
*/
|
|
71
149
|
export function validateForm(spec: FormSpec | undefined, value: unknown): { readonly value: Record<string, unknown>; readonly issues: readonly FormIssue[] } {
|
|
72
150
|
const issues: FormIssue[] = [];
|
|
73
151
|
const source = value === undefined || value === null ? {} : value;
|
|
74
152
|
if (!isObject(source)) return { value: {}, issues: [{ field: "", code: "type", message: "value must be an object" }] };
|
|
75
153
|
const fields = spec?.fields ?? [];
|
|
76
|
-
const declared = new Set(fields.map((field) => field.
|
|
154
|
+
const declared = new Set(fields.map((field) => field.name));
|
|
77
155
|
for (const key of Object.keys(source)) {
|
|
78
156
|
if (!declared.has(key)) issues.push({ field: key, code: "unknown", message: `field ${key} is not declared` });
|
|
79
157
|
}
|
|
80
158
|
const out: Record<string, unknown> = {};
|
|
81
159
|
for (const field of fields) {
|
|
82
|
-
const
|
|
160
|
+
const given = Object.hasOwn(source, field.name) ? source[field.name] : undefined;
|
|
161
|
+
const item = given === undefined || given === null || given === "" ? field.default : given;
|
|
83
162
|
if (item === undefined || item === null || item === "") {
|
|
84
|
-
if (field.required === true) issues.push({ field: field.
|
|
163
|
+
if (field.required === true) issues.push({ field: field.name, code: "required", message: `field ${field.name} is required` });
|
|
85
164
|
continue;
|
|
86
165
|
}
|
|
87
166
|
const problem = checkType(field, item);
|
|
88
|
-
if (problem) issues.push({ field: field.
|
|
89
|
-
else out[field.
|
|
167
|
+
if (problem) issues.push({ field: field.name, ...problem });
|
|
168
|
+
else out[field.name] = item;
|
|
90
169
|
}
|
|
91
170
|
return { value: out, issues };
|
|
92
171
|
}
|
|
93
172
|
|
|
94
173
|
function checkType(field: FormField, value: unknown): { code: "type" | "enum"; message: string } | null {
|
|
95
|
-
const wrong = (expected: string) => ({ code: "type" as const, message: `field ${field.
|
|
174
|
+
const wrong = (expected: string) => ({ code: "type" as const, message: `field ${field.name} must be ${expected}` });
|
|
96
175
|
switch (field.type) {
|
|
97
176
|
case "string":
|
|
98
177
|
case "text":
|
|
@@ -108,6 +187,12 @@ function checkType(field: FormField, value: unknown): { code: "type" | "enum"; m
|
|
|
108
187
|
case "datetime":
|
|
109
188
|
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? null : wrong("an ISO 8601 date and time");
|
|
110
189
|
case "enum":
|
|
111
|
-
return (field.values ?? []).includes(value as string | number) ? null : { code: "enum", message: `field ${field.
|
|
190
|
+
return (field.values ?? []).includes(value as string | number) ? null : { code: "enum", message: `field ${field.name} must be one of ${(field.values ?? []).join(", ")}` };
|
|
191
|
+
case "object":
|
|
192
|
+
return isObject(value) ? null : wrong("an object");
|
|
193
|
+
case "list":
|
|
194
|
+
return Array.isArray(value) ? null : wrong("a list");
|
|
195
|
+
case "ref":
|
|
196
|
+
return (typeof value === "string" && value !== "") || typeof value === "number" ? null : wrong("a reference: a string or a number");
|
|
112
197
|
}
|
|
113
198
|
}
|
|
@@ -1,26 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The process document format `bazis.workflow/
|
|
3
|
-
* node kinds, validation, data paths and
|
|
4
|
-
*
|
|
2
|
+
* The process document format `bazis.workflow/v2` (WORKFLOW-002 §3): types,
|
|
3
|
+
* node kinds, validation, data paths and expressions, the upgrade from version 1
|
|
4
|
+
* and the core of a designer. Pure functions without DI or a database: usable by
|
|
5
|
+
* a designer, a CLI or a browser.
|
|
5
6
|
*/
|
|
6
7
|
export {
|
|
7
8
|
PROCESS_SCHEMA_VERSION,
|
|
8
9
|
emptyProcess,
|
|
10
|
+
initialVarsOf,
|
|
11
|
+
inputSpecOf,
|
|
12
|
+
outputOf,
|
|
9
13
|
type Bounds,
|
|
10
|
-
type Condition,
|
|
11
|
-
type ConditionOp,
|
|
12
14
|
type NodeKind,
|
|
13
15
|
type Point,
|
|
14
16
|
type ProcessDiagram,
|
|
15
17
|
type ProcessDocument,
|
|
16
18
|
type ProcessFlow,
|
|
19
|
+
type ProcessLogSettings,
|
|
17
20
|
type ProcessNode,
|
|
21
|
+
type ProcessSettings,
|
|
18
22
|
} from "./ProcessDocument";
|
|
19
|
-
export { NODE_KINDS, nodeKindOf, ISO_DURATION, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
|
|
23
|
+
export { NODE_KINDS, nodeKindOf, ISO_DURATION, MAX_ITERATIONS, MAX_LOOP_PARALLEL, PROCESS_KEY, type JsonSchema, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
|
|
20
24
|
export { PROCESS_LIMITS, validateProcess, hasErrors, type ProcessIssue, type ProcessIssueCode, type ValidateProcessOptions } from "./validateProcess";
|
|
21
|
-
export { DATA_PATH, isPath, readPath, resolveInput, type InstanceData } from "./paths";
|
|
22
|
-
export {
|
|
25
|
+
export { DATA_PATH, DATA_PATHS, isPath, readPath, resolveInput, type InstanceData } from "./paths";
|
|
26
|
+
export {
|
|
27
|
+
EXPRESSION_FUNCTIONS,
|
|
28
|
+
EXPRESSION_LIMITS,
|
|
29
|
+
checkExpression,
|
|
30
|
+
checkPath,
|
|
31
|
+
evaluateExpression,
|
|
32
|
+
expressionFunction,
|
|
33
|
+
isExpressionNode,
|
|
34
|
+
isTruthy,
|
|
35
|
+
type Expr,
|
|
36
|
+
type ExprCall,
|
|
37
|
+
type ExpressionScope,
|
|
38
|
+
type ExprFunction,
|
|
39
|
+
type ExprFunctionName,
|
|
40
|
+
type ExprPath,
|
|
41
|
+
type ExprType,
|
|
42
|
+
} from "./expressions";
|
|
43
|
+
export { ExpressionSyntaxError, parseExpression, printExpression } from "./expressionSyntax";
|
|
23
44
|
export { durationMs } from "./duration";
|
|
24
45
|
export { parseCron, type CronSchedule } from "./cron";
|
|
25
46
|
export { processToMermaid } from "./mermaid";
|
|
26
|
-
export {
|
|
47
|
+
export { PERSONAL_MASK, maskByMapping, maskBySchema, masksPersonal, personalPaths, personalVariables, readsPersonal } from "./masking";
|
|
48
|
+
export { allTexts, checkLocalizedText, textOf, type LocalizedText } from "./localized";
|
|
49
|
+
export {
|
|
50
|
+
FIELD_NAME,
|
|
51
|
+
checkExtensions,
|
|
52
|
+
checkFormSpec,
|
|
53
|
+
formFieldNames,
|
|
54
|
+
validateForm,
|
|
55
|
+
variableProblems,
|
|
56
|
+
type FieldType,
|
|
57
|
+
type FormField,
|
|
58
|
+
type FormIssue,
|
|
59
|
+
type FormSpec,
|
|
60
|
+
type VariableDirection,
|
|
61
|
+
type VariableSpec,
|
|
62
|
+
} from "./formFields";
|
|
63
|
+
export { PROCESS_SCHEMA_VERSION_V1, upgradeCondition, upgradeDocument, type ConditionV1, type FormFieldV1, type ProcessDocumentV1 } from "./upgradeDocument";
|
|
64
|
+
export { fromStructure, isStructured, toStructure, type ProcessStructure, type StructureBoundary, type StructureBranch, type StructureStart, type StructureStep } from "./structure";
|
|
65
|
+
export { formOf, processPalette, type PaletteNodeKind, type PaletteTool, type ProcessPalette, type StepForm, type StepFormField } from "./palette";
|