bazis 0.131.0 → 0.132.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 +26 -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 +65 -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 +33 -10
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +88 -28
- package/core/workflow/generated/deps.ts +11 -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 +0 -2
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +2 -2
- package/core/workflow/runtime_module/http/Instances.controller.ts +16 -11
- package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
- package/core/workflow/runtime_module/internal/ToolStep.ts +40 -9
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +60 -10
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +2 -4
- 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 +28 -3
- package/core/workflow/runtime_module/model/WfJob.model.ts +17 -1
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfToken.model.ts +11 -1
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +5 -1
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +9 -7
- 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 +3 -3
- package/library/openapi/codegen.ts +3 -0
- package/library/validation/types/ValidatorOptions.ts +2 -0
- package/library/workflow/ProcessDocument.ts +59 -23
- package/library/workflow/expressionSyntax.ts +243 -0
- package/library/workflow/expressions.ts +343 -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 +58 -0
- package/library/workflow/mermaid.ts +18 -8
- package/library/workflow/nodeKinds.ts +88 -17
- package/library/workflow/palette.ts +108 -0
- package/library/workflow/paths.ts +31 -6
- package/library/workflow/structure.ts +282 -0
- package/library/workflow/upgradeDocument.ts +84 -0
- package/library/workflow/validateProcess.ts +130 -48
- package/package.json +1 -1
- package/library/workflow/conditions.ts +0 -44
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { expressionFunction, isExpressionNode, type Expr, type ExprFunctionName } from "./expressions";
|
|
2
|
+
|
|
3
|
+
/** A text that is not an expression; `position` is the offset of the problem. */
|
|
4
|
+
export class ExpressionSyntaxError extends Error {
|
|
5
|
+
override readonly name = "ExpressionSyntaxError";
|
|
6
|
+
|
|
7
|
+
constructor(message: string, readonly position: number) {
|
|
8
|
+
super(`${message} at ${position}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Binary operators by precedence; the same functions are also callable by name. */
|
|
13
|
+
const LEVELS: readonly (readonly (readonly [symbol: string, op: ExprFunctionName])[])[] = [
|
|
14
|
+
[["or", "or"]],
|
|
15
|
+
[["and", "and"]],
|
|
16
|
+
[["==", "eq"], ["!=", "ne"], [">=", "gte"], ["<=", "lte"], [">", "gt"], ["<", "lt"]],
|
|
17
|
+
[["+", "add"], ["-", "sub"]],
|
|
18
|
+
[["*", "mul"], ["/", "div"], ["%", "mod"]],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const INFIX = new Map<string, { readonly symbol: string; readonly level: number }>(
|
|
22
|
+
LEVELS.flatMap((level, index) => level.map(([symbol, op]) => [op as string, { symbol, level: index }] as const)),
|
|
23
|
+
);
|
|
24
|
+
const UNARY_LEVEL = LEVELS.length;
|
|
25
|
+
/** Comparisons do not chain: `a < b < c` means nothing, so they do not nest without brackets. */
|
|
26
|
+
const NON_ASSOCIATIVE = 2;
|
|
27
|
+
/** `{amount}` is `$vars.amount`; other roots are written in full: `{$input.orderId}`. */
|
|
28
|
+
const VARIABLE_PREFIX = "$vars.";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The designer's text of an expression (WORKFLOW-002 §3.8): `{amount} * 1.2`,
|
|
32
|
+
* `concat({a}, "-", {b})`, `now()`. One syntax for every designer. Lists and
|
|
33
|
+
* objects are JSON literals: their items are not expressions.
|
|
34
|
+
*/
|
|
35
|
+
export function parseExpression(text: string): Expr {
|
|
36
|
+
return new Parser(text).parse();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class Parser {
|
|
40
|
+
private position = 0;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly text: string) {}
|
|
43
|
+
|
|
44
|
+
parse(): Expr {
|
|
45
|
+
const value = this.binary(0);
|
|
46
|
+
this.space();
|
|
47
|
+
if (this.position < this.text.length) this.fail(`unexpected ${JSON.stringify(this.text[this.position])}`);
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private fail(message: string, position = this.position): never {
|
|
52
|
+
throw new ExpressionSyntaxError(message, position);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private space(): void {
|
|
56
|
+
while (/\s/.test(this.text[this.position] ?? "")) this.position += 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private binary(level: number): Expr {
|
|
60
|
+
if (level === LEVELS.length) return this.unary();
|
|
61
|
+
let left = this.binary(level + 1);
|
|
62
|
+
for (;;) {
|
|
63
|
+
const found = this.operator(level);
|
|
64
|
+
if (found === undefined) return left;
|
|
65
|
+
const right = this.binary(level + 1);
|
|
66
|
+
left = { op: found, args: [left, right] };
|
|
67
|
+
if (level === NON_ASSOCIATIVE && this.operator(level, false) !== undefined) this.fail("comparisons do not chain: use brackets");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private operator(level: number, take = true): ExprFunctionName | undefined {
|
|
72
|
+
this.space();
|
|
73
|
+
for (const [symbol, op] of LEVELS[level]!) {
|
|
74
|
+
if (!this.text.startsWith(symbol, this.position)) continue;
|
|
75
|
+
// A word operator must end: `order` is a name, not `or` and `der`.
|
|
76
|
+
if (/^[a-z]/.test(symbol) && /[A-Za-z0-9_]/.test(this.text[this.position + symbol.length] ?? "")) continue;
|
|
77
|
+
if (take) this.position += symbol.length;
|
|
78
|
+
return op;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private unary(): Expr {
|
|
84
|
+
this.space();
|
|
85
|
+
if (this.word("not")) return { op: "not", args: [this.unary()] };
|
|
86
|
+
if (this.text[this.position] === "-") {
|
|
87
|
+
const next = this.text[this.position + 1] ?? "";
|
|
88
|
+
if (/[0-9.]/.test(next)) return this.number();
|
|
89
|
+
this.position += 1;
|
|
90
|
+
return { op: "sub", args: [0, this.unary()] };
|
|
91
|
+
}
|
|
92
|
+
return this.primary();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private word(word: string): boolean {
|
|
96
|
+
if (!this.text.startsWith(word, this.position) || /[A-Za-z0-9_]/.test(this.text[this.position + word.length] ?? "")) return false;
|
|
97
|
+
// `not(a)` is the function call too, with the same meaning.
|
|
98
|
+
this.position += word.length;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private primary(): Expr {
|
|
103
|
+
this.space();
|
|
104
|
+
const char = this.text[this.position];
|
|
105
|
+
if (char === undefined) this.fail("the expression ends too early");
|
|
106
|
+
if (char === "(") {
|
|
107
|
+
this.position += 1;
|
|
108
|
+
const value = this.binary(0);
|
|
109
|
+
this.space();
|
|
110
|
+
if (this.text[this.position] !== ")") this.fail("expected )");
|
|
111
|
+
this.position += 1;
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
if (char === "\"") return this.string();
|
|
115
|
+
if (/[0-9.]/.test(char)) return this.number();
|
|
116
|
+
if (char === "[") return this.json("]");
|
|
117
|
+
if (char === "{") {
|
|
118
|
+
const after = /\s*(.)/.exec(this.text.slice(this.position + 1))?.[1];
|
|
119
|
+
return after === "\"" || after === "}" ? this.json("}") : this.path();
|
|
120
|
+
}
|
|
121
|
+
if (/[A-Za-z_]/.test(char)) return this.name();
|
|
122
|
+
return this.fail(`unexpected ${JSON.stringify(char)}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private path(): Expr {
|
|
126
|
+
const start = this.position;
|
|
127
|
+
const end = this.text.indexOf("}", start);
|
|
128
|
+
if (end < 0) this.fail("a path has no closing }", start);
|
|
129
|
+
const inner = this.text.slice(start + 1, end).trim();
|
|
130
|
+
if (!/^\$?[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(inner)) this.fail(`${JSON.stringify(inner)} is not a path`, start);
|
|
131
|
+
this.position = end + 1;
|
|
132
|
+
return { path: inner.startsWith("$") ? inner : `${VARIABLE_PREFIX}${inner}` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private string(): string {
|
|
136
|
+
const start = this.position;
|
|
137
|
+
let at = start + 1;
|
|
138
|
+
while (at < this.text.length && this.text[at] !== "\"") at += this.text[at] === "\\" ? 2 : 1;
|
|
139
|
+
if (at >= this.text.length) this.fail("a string has no closing quote", start);
|
|
140
|
+
this.position = at + 1;
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(this.text.slice(start, at + 1)) as string;
|
|
143
|
+
} catch {
|
|
144
|
+
return this.fail("the string has a bad escape", start);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private number(): number {
|
|
149
|
+
const match = /^-?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?/.exec(this.text.slice(this.position));
|
|
150
|
+
if (!match) this.fail("a number was expected");
|
|
151
|
+
this.position += match[0].length;
|
|
152
|
+
return Number(match[0]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A JSON list or object, scanned to its closing bracket. */
|
|
156
|
+
private json(close: "]" | "}"): Expr {
|
|
157
|
+
const start = this.position;
|
|
158
|
+
let depth = 0;
|
|
159
|
+
let at = start;
|
|
160
|
+
for (; at < this.text.length; at += 1) {
|
|
161
|
+
const char = this.text[at]!;
|
|
162
|
+
if (char === "\"") {
|
|
163
|
+
at += 1;
|
|
164
|
+
while (at < this.text.length && this.text[at] !== "\"") at += this.text[at] === "\\" ? 2 : 1;
|
|
165
|
+
} else if (char === "[" || char === "{") {
|
|
166
|
+
depth += 1;
|
|
167
|
+
} else if (char === "]" || char === "}") {
|
|
168
|
+
depth -= 1;
|
|
169
|
+
if (depth === 0) break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (at >= this.text.length || this.text[at] !== close) this.fail(`expected ${close}`, start);
|
|
173
|
+
this.position = at + 1;
|
|
174
|
+
let value: unknown;
|
|
175
|
+
try {
|
|
176
|
+
value = JSON.parse(this.text.slice(start, at + 1));
|
|
177
|
+
} catch {
|
|
178
|
+
this.fail("a list or an object is JSON: its items are not expressions", start);
|
|
179
|
+
}
|
|
180
|
+
if (isExpressionNode(value)) this.fail("an object with op or path is not a literal", start);
|
|
181
|
+
return value as Expr;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private name(): Expr {
|
|
185
|
+
const start = this.position;
|
|
186
|
+
const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.text.slice(start))!;
|
|
187
|
+
const name = match[0];
|
|
188
|
+
this.position += name.length;
|
|
189
|
+
if (name === "true") return true;
|
|
190
|
+
if (name === "false") return false;
|
|
191
|
+
if (name === "null") return null;
|
|
192
|
+
this.space();
|
|
193
|
+
if (this.text[this.position] !== "(") this.fail(`unknown word ${name}: a variable is written {${name}}`, start);
|
|
194
|
+
const definition = expressionFunction(name);
|
|
195
|
+
if (!definition) this.fail(`unknown function ${name}`, start);
|
|
196
|
+
this.position += 1;
|
|
197
|
+
const args: Expr[] = [];
|
|
198
|
+
this.space();
|
|
199
|
+
if (this.text[this.position] === ")") {
|
|
200
|
+
this.position += 1;
|
|
201
|
+
return { op: definition.name, args };
|
|
202
|
+
}
|
|
203
|
+
for (;;) {
|
|
204
|
+
args.push(this.binary(0));
|
|
205
|
+
this.space();
|
|
206
|
+
const char = this.text[this.position];
|
|
207
|
+
this.position += 1;
|
|
208
|
+
if (char === ")") return { op: definition.name, args };
|
|
209
|
+
if (char !== ",") this.fail("expected , or )", this.position - 1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The text of an expression: `parseExpression(printExpression(e))` gives `e`
|
|
216
|
+
* back. A call goes infix only with two arguments: `add(a, b, c)` stays a call,
|
|
217
|
+
* because `a + b + c` reads as two calls.
|
|
218
|
+
*/
|
|
219
|
+
export function printExpression(expression: Expr): string {
|
|
220
|
+
return print(expression, 0);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function print(expression: Expr, outer: number): string {
|
|
224
|
+
if (!isExpressionNode(expression)) return JSON.stringify(expression) ?? "null";
|
|
225
|
+
if ("path" in expression) {
|
|
226
|
+
const path = String(expression.path);
|
|
227
|
+
return `{${path.startsWith(VARIABLE_PREFIX) ? path.slice(VARIABLE_PREFIX.length) : path}}`;
|
|
228
|
+
}
|
|
229
|
+
const { op, args } = expression;
|
|
230
|
+
const infix = INFIX.get(op);
|
|
231
|
+
if (infix && args.length === 2) {
|
|
232
|
+
const { level, symbol } = infix;
|
|
233
|
+
// The right side binds one level tighter: `a - (b - c)` keeps its brackets. Comparisons do not chain at all.
|
|
234
|
+
const leftLevel = level === NON_ASSOCIATIVE ? level + 1 : level;
|
|
235
|
+
const text = `${print(args[0]!, leftLevel)} ${symbol} ${print(args[1]!, level + 1)}`;
|
|
236
|
+
return level < outer ? `(${text})` : text;
|
|
237
|
+
}
|
|
238
|
+
if (op === "not" && args.length === 1) {
|
|
239
|
+
const text = `not ${print(args[0]!, UNARY_LEVEL)}`;
|
|
240
|
+
return UNARY_LEVEL < outer ? `(${text})` : text;
|
|
241
|
+
}
|
|
242
|
+
return `${op}(${args.map((arg) => print(arg, 0)).join(", ")})`;
|
|
243
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
}
|
|
269
|
+
|
|
270
|
+
/** What is wrong with an expression, or `null`: its shape, functions, argument types, paths and limits. */
|
|
271
|
+
export function checkExpression(expression: unknown, scope: ExpressionScope = {}): string | null {
|
|
272
|
+
const budget = { nodes: 0 };
|
|
273
|
+
const result = check(expression, scope, 0, budget);
|
|
274
|
+
return result.problem ?? null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
interface Checked {
|
|
278
|
+
readonly problem?: string;
|
|
279
|
+
readonly type: ExprType;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function literalType(value: unknown): ExprType {
|
|
283
|
+
if (typeof value === "number") return "number";
|
|
284
|
+
if (typeof value === "string") return "string";
|
|
285
|
+
if (typeof value === "boolean") return "boolean";
|
|
286
|
+
return Array.isArray(value) ? "list" : "any";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function check(value: unknown, scope: ExpressionScope, depth: number, budget: { nodes: number }): Checked {
|
|
290
|
+
budget.nodes += 1;
|
|
291
|
+
if (budget.nodes > EXPRESSION_LIMITS.nodes) return { problem: `expression has more than ${EXPRESSION_LIMITS.nodes} nodes`, type: "any" };
|
|
292
|
+
if (depth > EXPRESSION_LIMITS.depth) return { problem: `expression is nested deeper than ${EXPRESSION_LIMITS.depth}`, type: "any" };
|
|
293
|
+
if (value === undefined) return { problem: "expression has no value", type: "any" };
|
|
294
|
+
if (!isExpressionNode(value)) return { type: literalType(value) };
|
|
295
|
+
const keys = Object.keys(value);
|
|
296
|
+
if ("path" in value && !("op" in value)) {
|
|
297
|
+
if (keys.length !== 1) return { problem: "a path has only the key path", type: "any" };
|
|
298
|
+
const problem = checkPath(value.path, scope);
|
|
299
|
+
return problem === null ? { type: "any" } : { problem, type: "any" };
|
|
300
|
+
}
|
|
301
|
+
if (keys.some((key) => key !== "op" && key !== "args")) return { problem: "a call has only the keys op and args", type: "any" };
|
|
302
|
+
const definition = expressionFunction((value as { op?: unknown }).op);
|
|
303
|
+
if (!definition) return { problem: `unknown function ${JSON.stringify((value as { op?: unknown }).op)}`, type: "any" };
|
|
304
|
+
const args = (value as { args?: unknown }).args;
|
|
305
|
+
if (!Array.isArray(args)) return { problem: `${definition.name}: args must be a list`, type: "any" };
|
|
306
|
+
const required = definition.args.length - (definition.optional ?? 0);
|
|
307
|
+
if (args.length < required) return { problem: `${definition.name} takes at least ${required} arguments`, type: "any" };
|
|
308
|
+
if (definition.rest === undefined && args.length > definition.args.length) return { problem: `${definition.name} takes at most ${definition.args.length} arguments`, type: "any" };
|
|
309
|
+
for (const [index, arg] of args.entries()) {
|
|
310
|
+
const checked = check(arg, scope, depth + 1, budget);
|
|
311
|
+
if (checked.problem !== undefined) return checked;
|
|
312
|
+
const expected = definition.args[index] ?? definition.rest ?? "any";
|
|
313
|
+
if (!fits(checked.type, expected)) return { problem: `${definition.name}: argument ${index + 1} must be ${expected}, not ${checked.type}`, type: "any" };
|
|
314
|
+
}
|
|
315
|
+
const problem = literalArgumentProblem(definition.name, args);
|
|
316
|
+
return problem === null ? { type: definition.returns } : { problem, type: "any" };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** `any` fits everything; a date is a string in JSON. */
|
|
320
|
+
function fits(actual: ExprType, expected: ExprType): boolean {
|
|
321
|
+
if (actual === "any" || expected === "any" || actual === expected) return true;
|
|
322
|
+
return (actual === "string" && expected === "date") || (actual === "date" && expected === "string");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Arguments that must be one of a few literals are checked when they are written literally. */
|
|
326
|
+
function literalArgumentProblem(name: ExprFunctionName, args: readonly unknown[]): string | null {
|
|
327
|
+
if (name === "dateAdd" && typeof args[1] === "string" && durationMs(args[1]) === undefined) return `dateAdd: ${args[1]} is not an ISO duration without months and years`;
|
|
328
|
+
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(", ")}`;
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** What is wrong with a `$` path, or `null`. */
|
|
333
|
+
export function checkPath(path: unknown, scope: ExpressionScope = {}): string | null {
|
|
334
|
+
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 or $now`;
|
|
335
|
+
const [root, first] = path.split(".");
|
|
336
|
+
if (root === "$now" && first !== undefined) return "$now has no fields";
|
|
337
|
+
if (root === "$nodes" && first === undefined) return "$nodes needs a node: $nodes.Node.field";
|
|
338
|
+
if (first === undefined) return null;
|
|
339
|
+
if (root === "$input" && scope.input && !scope.input.has(first)) return `path ${path}: the input has no field ${first}`;
|
|
340
|
+
if (root === "$vars" && scope.vars && !scope.vars.has(first)) return `path ${path}: there is no variable ${first}`;
|
|
341
|
+
if (root === "$nodes" && scope.nodes && !scope.nodes.has(first)) return `path ${path}: there is no node ${first}`;
|
|
342
|
+
return null;
|
|
343
|
+
}
|