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.
Files changed (71) hide show
  1. package/cli/templates/process.ts +1 -1
  2. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  3. package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
  4. package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
  5. package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
  6. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +16 -3
  7. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
  8. package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
  9. package/core/workflow/definitions_module/access/Process.references.ts +1 -1
  10. package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
  11. package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
  12. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +27 -16
  13. package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
  14. package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
  15. package/core/workflow/definitions_module/http/Processes.controller.ts +66 -23
  16. package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
  17. package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
  18. package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
  19. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +35 -10
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +90 -28
  21. package/core/workflow/generated/deps.ts +15 -3
  22. package/core/workflow/index.ts +4 -2
  23. package/core/workflow/internal/WorkflowLanguages.ts +35 -0
  24. package/core/workflow/internal/canonicalJson.ts +8 -0
  25. package/core/workflow/internal/http.ts +20 -1
  26. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -2
  27. package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
  28. package/core/workflow/runtime_module/background/WorkflowRetention.ts +3 -3
  29. package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
  30. package/core/workflow/runtime_module/events/Runtime.events.ts +6 -6
  31. package/core/workflow/runtime_module/http/Instances.controller.ts +30 -13
  32. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  33. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  34. package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
  35. package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
  36. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  37. package/core/workflow/runtime_module/internal/ToolStep.ts +43 -12
  38. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +559 -123
  39. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +39 -10
  40. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
  41. package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
  42. package/core/workflow/runtime_module/model/WfInstance.model.ts +34 -6
  43. package/core/workflow/runtime_module/model/WfJob.model.ts +29 -4
  44. package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
  45. package/core/workflow/runtime_module/model/WfToken.model.ts +15 -3
  46. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +43 -4
  47. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +56 -13
  48. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  49. package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
  50. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
  51. package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
  52. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
  53. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +4 -4
  54. package/library/openapi/codegen.ts +3 -0
  55. package/library/validation/types/ValidatorOptions.ts +2 -0
  56. package/library/workflow/ProcessDocument.ts +73 -23
  57. package/library/workflow/expressionSyntax.ts +243 -0
  58. package/library/workflow/expressions.ts +347 -0
  59. package/library/workflow/formFields.ts +118 -33
  60. package/library/workflow/index.ts +48 -9
  61. package/library/workflow/localized.ts +35 -0
  62. package/library/workflow/masking.ts +72 -0
  63. package/library/workflow/mermaid.ts +39 -13
  64. package/library/workflow/nodeKinds.ts +147 -17
  65. package/library/workflow/palette.ts +110 -0
  66. package/library/workflow/paths.ts +37 -6
  67. package/library/workflow/structure.ts +311 -0
  68. package/library/workflow/upgradeDocument.ts +84 -0
  69. package/library/workflow/validateProcess.ts +246 -52
  70. package/package.json +1 -1
  71. 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
- /** Field types of a process input and of a task form. */
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 code: string;
7
- readonly type: FormFieldType;
8
- readonly title?: string;
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<FormFieldType>(["string", "text", "number", "integer", "boolean", "date", "datetime", "enum"]);
28
- const CODE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
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
- if (!isObject(field)) return `${where} must be an object`;
44
- if (typeof field.code !== "string" || !CODE.test(field.code) || field.code === "__proto__") return `${where}.code is invalid`;
45
- if (seen.has(field.code)) return `${where}.code ${field.code} is repeated`;
46
- seen.add(field.code);
47
- if (typeof field.type !== "string" || !TYPES.has(field.type as FormFieldType)) return `${where}.type must be one of ${[...TYPES].join(", ")}`;
48
- if (field.title !== undefined && (typeof field.title !== "string" || field.title.length > 200)) return `${where}.title must be up to 200 characters`;
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
- /** The field codes of a form spec. */
62
- export function formFieldCodes(spec: FormSpec): string[] {
63
- return spec.fields.map((field) => field.code);
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. Returns the value with only the declared fields and every issue.
69
- * Without a spec the form takes no data: any field is unknown.
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.code));
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 item = Object.hasOwn(source, field.code) ? source[field.code] : undefined;
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.code, code: "required", message: `field ${field.code} is required` });
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.code, ...problem });
89
- else out[field.code] = item;
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.code} must be ${expected}` });
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.code} must be one of ${(field.values ?? []).join(", ")}` };
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/v1` (WORKFLOW-001 §2): types,
3
- * node kinds, validation, data paths and conditions. Pure functions without DI
4
- * or a database: usable by a designer, a CLI or a browser.
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 { evaluateCondition } from "./conditions";
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 { checkFormSpec, formFieldCodes, validateForm, type FormField, type FormFieldType, type FormIssue, type FormSpec } from "./formFields";
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";