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.
Files changed (66) 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 +26 -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 +65 -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 +33 -10
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +88 -28
  21. package/core/workflow/generated/deps.ts +11 -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 +0 -2
  27. package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
  28. package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
  29. package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
  30. package/core/workflow/runtime_module/events/Runtime.events.ts +2 -2
  31. package/core/workflow/runtime_module/http/Instances.controller.ts +16 -11
  32. package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
  33. package/core/workflow/runtime_module/internal/ToolStep.ts +40 -9
  34. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +60 -10
  35. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +2 -4
  36. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
  37. package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
  38. package/core/workflow/runtime_module/model/WfInstance.model.ts +28 -3
  39. package/core/workflow/runtime_module/model/WfJob.model.ts +17 -1
  40. package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
  41. package/core/workflow/runtime_module/model/WfToken.model.ts +11 -1
  42. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +5 -1
  43. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +9 -7
  44. package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
  45. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
  46. package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
  47. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
  48. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +3 -3
  49. package/library/openapi/codegen.ts +3 -0
  50. package/library/validation/types/ValidatorOptions.ts +2 -0
  51. package/library/workflow/ProcessDocument.ts +59 -23
  52. package/library/workflow/expressionSyntax.ts +243 -0
  53. package/library/workflow/expressions.ts +343 -0
  54. package/library/workflow/formFields.ts +118 -33
  55. package/library/workflow/index.ts +48 -9
  56. package/library/workflow/localized.ts +35 -0
  57. package/library/workflow/masking.ts +58 -0
  58. package/library/workflow/mermaid.ts +18 -8
  59. package/library/workflow/nodeKinds.ts +88 -17
  60. package/library/workflow/palette.ts +108 -0
  61. package/library/workflow/paths.ts +31 -6
  62. package/library/workflow/structure.ts +282 -0
  63. package/library/workflow/upgradeDocument.ts +84 -0
  64. package/library/workflow/validateProcess.ts +130 -48
  65. package/package.json +1 -1
  66. package/library/workflow/conditions.ts +0 -44
@@ -0,0 +1,108 @@
1
+ import { EXPRESSION_FUNCTIONS, type ExprFunction } from "./expressions";
2
+ import type { LocalizedText } from "./localized";
3
+ import { NODE_KINDS, nodeKindOf, type JsonSchema, type NodeRole } from "./nodeKinds";
4
+ import { DATA_PATHS } from "./paths";
5
+ import type { NodeKind } from "./ProcessDocument";
6
+
7
+ /** What the palette shows of a Tool: structurally a Tool contract. */
8
+ export interface PaletteTool {
9
+ readonly name: string;
10
+ readonly description: string;
11
+ readonly input?: { readonly kind?: string; readonly schema?: unknown };
12
+ readonly output?: { readonly kind?: string; readonly schema?: unknown };
13
+ readonly sideEffect: string;
14
+ readonly dryRun: string;
15
+ }
16
+
17
+ export interface PaletteNodeKind {
18
+ readonly kind: NodeKind;
19
+ readonly role: NodeRole;
20
+ readonly config: JsonSchema;
21
+ }
22
+
23
+ /** Everything a designer builds its forms from (WORKFLOW-002 §4.2). */
24
+ export interface ProcessPalette {
25
+ readonly nodeKinds: readonly PaletteNodeKind[];
26
+ readonly functions: readonly ExprFunction[];
27
+ readonly paths: typeof DATA_PATHS;
28
+ readonly tools: readonly PaletteTool[];
29
+ }
30
+
31
+ /** The palette with the application's Tools: a new Tool appears in a designer without a release of the framework. */
32
+ export function processPalette(tools: readonly PaletteTool[]): ProcessPalette {
33
+ return {
34
+ nodeKinds: NODE_KINDS.map(({ kind, role, config }) => ({ kind, role, config })),
35
+ functions: EXPRESSION_FUNCTIONS,
36
+ paths: DATA_PATHS,
37
+ tools,
38
+ };
39
+ }
40
+
41
+ /** One field of the form of a step. */
42
+ export interface StepFormField {
43
+ readonly name: string;
44
+ readonly type: string;
45
+ readonly required: boolean;
46
+ readonly title?: LocalizedText;
47
+ readonly description?: string;
48
+ readonly values?: readonly unknown[];
49
+ /** What the field references (`x-ref`), its keys reference (`x-ref-keys`) and the input that gives the area (`x-ref-scope`). */
50
+ readonly ref?: string;
51
+ readonly refKeys?: string;
52
+ readonly refScope?: string;
53
+ /** Personal data (`x-personal`). */
54
+ readonly personal?: boolean;
55
+ /** The fields of an object. */
56
+ readonly fields?: readonly StepFormField[];
57
+ /** A list's items. */
58
+ readonly items?: StepFormField;
59
+ }
60
+
61
+ export interface StepForm {
62
+ readonly fields: readonly StepFormField[];
63
+ }
64
+
65
+ function isObject(value: unknown): value is Record<string, unknown> {
66
+ return typeof value === "object" && value !== null && !Array.isArray(value);
67
+ }
68
+
69
+ function fieldOf(name: string, schema: unknown, required: boolean): StepFormField {
70
+ const source = isObject(schema) ? schema : {};
71
+ const values = Array.isArray(source.enum) ? source.enum : undefined;
72
+ const type = typeof source.type === "string" ? source.type : Array.isArray(source.type) ? String(source.type.find((item) => item !== "null") ?? "any") : values !== undefined ? "enum" : "any";
73
+ const fields = isObject(source.properties) ? fieldsOf(source) : undefined;
74
+ return {
75
+ name,
76
+ type,
77
+ required,
78
+ ...(source.title === undefined ? {} : { title: source.title as LocalizedText }),
79
+ ...(typeof source.description === "string" ? { description: source.description } : {}),
80
+ ...(values === undefined ? {} : { values }),
81
+ ...(typeof source["x-ref"] === "string" ? { ref: source["x-ref"] } : {}),
82
+ ...(typeof source["x-ref-keys"] === "string" ? { refKeys: source["x-ref-keys"] } : {}),
83
+ ...(typeof source["x-ref-scope"] === "string" ? { refScope: source["x-ref-scope"] } : {}),
84
+ ...(source["x-personal"] === true ? { personal: true } : {}),
85
+ ...(fields === undefined ? {} : { fields }),
86
+ ...(isObject(source.items) ? { items: fieldOf("item", source.items, false) } : {}),
87
+ };
88
+ }
89
+
90
+ function fieldsOf(schema: Record<string, unknown>): StepFormField[] {
91
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
92
+ return Object.entries(isObject(schema.properties) ? schema.properties : {}).map(([name, item]) => fieldOf(name, item, required.has(name)));
93
+ }
94
+
95
+ /**
96
+ * The form of a step's settings, by the palette (WORKFLOW-002 §3.8): the
97
+ * `config` of a node kind, or the input of a Tool: fields, types, references
98
+ * and localizable titles. An unknown kind has no fields.
99
+ */
100
+ export function formOf(source: NodeKind | Pick<PaletteTool, "input">): StepForm {
101
+ if (typeof source === "string") {
102
+ const definition = nodeKindOf(source);
103
+ return { fields: definition === undefined ? [] : fieldsOf(definition.config as Record<string, unknown>) };
104
+ }
105
+ const schema = source.input?.schema;
106
+ return { fields: isObject(schema) ? fieldsOf(schema) : [] };
107
+ }
108
+
@@ -1,12 +1,36 @@
1
- /** Instance data that conditions and step inputs read. */
1
+ import { evaluateExpression, isExpressionNode } from "./expressions";
2
+
3
+ /**
4
+ * What expressions and step inputs read. `input`, `vars` and `nodes` are the
5
+ * instance's own data; the rest describes the run. The engine fills them all.
6
+ */
2
7
  export interface InstanceData {
3
8
  readonly input: Readonly<Record<string, unknown>>;
4
9
  readonly vars: Readonly<Record<string, unknown>>;
5
10
  readonly nodes: Readonly<Record<string, unknown>>;
11
+ /** The data of the event that started or continued the process. */
12
+ readonly event?: unknown;
13
+ /** How it was started: `{ via, at, actor, eventKey?, cause? }`. */
14
+ readonly trigger?: unknown;
15
+ /** `{ id, processKey, version, partition, test }`. */
16
+ readonly run?: unknown;
17
+ /** The engine's time as an ISO string. */
18
+ readonly now?: string;
6
19
  }
7
20
 
8
- /** `$input.amount`, `$vars.payment.id`, `$nodes.Pay.paymentId`. */
9
- export const DATA_PATH = /^\$(input|vars|nodes)((\.[A-Za-z_][A-Za-z0-9_]*)+)$/;
21
+ /** `$input.amount`, `$vars.payment.id`, `$nodes.Pay.paymentId`, `$event.record.id`, `$run.partition`, `$now`. */
22
+ export const DATA_PATH = /^\$(input|vars|nodes|event|trigger|run|now)((\.[A-Za-z_][A-Za-z0-9_]*)*)$/;
23
+
24
+ /** The roots of `$` paths, for a designer. */
25
+ export const DATA_PATHS: readonly { readonly path: string; readonly description: string }[] = Object.freeze([
26
+ { path: "$input.<field>", description: "A field of the process input: a variable with direction in" },
27
+ { path: "$vars.<name>", description: "A variable of the process" },
28
+ { path: "$nodes.<node>.<field>", description: "The result of a passed step" },
29
+ { path: "$event", description: "The data of the event that started or continued the process" },
30
+ { path: "$trigger", description: "How it was started: { via, at, actor, eventKey?, cause? }" },
31
+ { path: "$run", description: "This run: { id, processKey, version, partition, test }" },
32
+ { path: "$now", description: "The engine's time" },
33
+ ]);
10
34
 
11
35
  /** `true` if the value is a path to instance data. */
12
36
  export function isPath(value: unknown): value is string {
@@ -18,7 +42,7 @@ export function readPath(data: InstanceData, path: string): unknown {
18
42
  const match = DATA_PATH.exec(path);
19
43
  if (!match) return undefined;
20
44
  let current: unknown = data[match[1] as keyof InstanceData];
21
- for (const key of match[2]!.slice(1).split(".")) {
45
+ for (const key of match[2]!.split(".").slice(1)) {
22
46
  if (typeof current !== "object" || current === null || !Object.prototype.hasOwnProperty.call(current, key)) return undefined;
23
47
  current = (current as Record<string, unknown>)[key];
24
48
  }
@@ -26,11 +50,12 @@ export function readPath(data: InstanceData, path: string): unknown {
26
50
  }
27
51
 
28
52
  /**
29
- * A step input: a path string becomes the instance value, objects and lists are
30
- * resolved deeply, anything else is a literal. A missing value becomes `null`.
53
+ * A step input: a path string and an expression become their value, objects and
54
+ * lists are resolved deeply, anything else is a literal. A missing value becomes `null`.
31
55
  */
32
56
  export function resolveInput(value: unknown, data: InstanceData): unknown {
33
57
  if (isPath(value)) return readPath(data, value) ?? null;
58
+ if (isExpressionNode(value)) return evaluateExpression(value, data) ?? null;
34
59
  if (Array.isArray(value)) return value.map((item) => resolveInput(item, data));
35
60
  if (typeof value === "object" && value !== null) {
36
61
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveInput(item, data)]));
@@ -0,0 +1,282 @@
1
+ import type { ProcessDiagram, ProcessDocument, ProcessFlow, ProcessNode, ProcessSettings } from "./ProcessDocument";
2
+ import type { Expr } from "./expressions";
3
+ import type { VariableSpec } from "./formFields";
4
+ import type { LocalizedText } from "./localized";
5
+ import { nodeKindOf } from "./nodeKinds";
6
+
7
+ /** One branch of a fork: the flow it leaves by and its steps. */
8
+ export interface StructureBranch {
9
+ readonly flow: string;
10
+ readonly title?: LocalizedText;
11
+ readonly condition?: Expr;
12
+ readonly isDefault?: boolean;
13
+ /** The flow from its last step into the merge or the end; `flow` itself for an empty branch. */
14
+ readonly exit: string;
15
+ readonly exitTitle?: LocalizedText;
16
+ readonly steps: readonly StructureStep[];
17
+ }
18
+
19
+ /** A boundary event of a step and what it leads to. */
20
+ export interface StructureBoundary {
21
+ readonly id: string;
22
+ readonly node: ProcessNode;
23
+ readonly flow: string;
24
+ readonly title?: LocalizedText;
25
+ readonly steps: readonly StructureStep[];
26
+ /** The end its branch leads to; without it the branch comes back to the step after the host. */
27
+ readonly end?: { readonly id: string; readonly node: ProcessNode; readonly flow: string; readonly title?: LocalizedText };
28
+ /** The flow that comes back when the branch has steps and no end of its own. */
29
+ readonly exit?: string;
30
+ readonly exitTitle?: LocalizedText;
31
+ }
32
+
33
+ /** A step of the list: a single node, an exclusive fork or a parallel block. */
34
+ export type StructureStep =
35
+ | {
36
+ readonly type: "step";
37
+ readonly id: string;
38
+ readonly node: ProcessNode;
39
+ readonly boundaries?: readonly StructureBoundary[];
40
+ /** The flow to the next step. */
41
+ readonly next: string;
42
+ readonly nextTitle?: LocalizedText;
43
+ }
44
+ | {
45
+ readonly type: "choice" | "parallel";
46
+ readonly id: string;
47
+ readonly node: ProcessNode;
48
+ readonly branches: readonly StructureBranch[];
49
+ /** Where the branches meet; without it they all lead to the end, and the fork is the last step of its list. */
50
+ readonly merge?: { readonly id: string; readonly node: ProcessNode; readonly next: string; readonly nextTitle?: LocalizedText };
51
+ };
52
+
53
+ /** A start of the list and the flow from it to the first step. */
54
+ export interface StructureStart {
55
+ readonly id: string;
56
+ readonly node: ProcessNode;
57
+ readonly flow: string;
58
+ readonly title?: LocalizedText;
59
+ }
60
+
61
+ /** A process as a designer draws it: starts, a list of steps, an end. */
62
+ export interface ProcessStructure {
63
+ readonly variables?: readonly VariableSpec[];
64
+ readonly settings?: ProcessSettings;
65
+ readonly diagram?: ProcessDiagram;
66
+ readonly extensions?: Readonly<Record<string, unknown>>;
67
+ readonly schemaVersion: ProcessDocument["schemaVersion"];
68
+ readonly starts: readonly StructureStart[];
69
+ readonly steps: readonly StructureStep[];
70
+ readonly end: { readonly id: string; readonly node: ProcessNode };
71
+ }
72
+
73
+ class NotStructured extends Error {}
74
+
75
+ const titleOf = (flow: ProcessFlow) => (flow.title === undefined ? {} : { title: flow.title });
76
+
77
+ /** Reads a graph as a tree; a shape outside the subset throws {@link NotStructured}. */
78
+ class Reader {
79
+ private readonly outgoing = new Map<string, (readonly [string, ProcessFlow])[]>();
80
+ private readonly incoming = new Map<string, (readonly [string, ProcessFlow])[]>();
81
+ private readonly boundaries = new Map<string, (readonly [string, ProcessNode])[]>();
82
+ private readonly visited = new Set<string>();
83
+
84
+ constructor(private readonly document: ProcessDocument) {
85
+ for (const [id, flow] of Object.entries(document.flows)) {
86
+ this.outgoing.set(flow.source, [...this.outgoing.get(flow.source) ?? [], [id, flow]]);
87
+ this.incoming.set(flow.target, [...this.incoming.get(flow.target) ?? [], [id, flow]]);
88
+ }
89
+ for (const [id, node] of Object.entries(document.nodes)) {
90
+ if (node.attachedTo !== undefined) this.boundaries.set(node.attachedTo, [...this.boundaries.get(node.attachedTo) ?? [], [id, node]]);
91
+ }
92
+ }
93
+
94
+ private node(id: string): ProcessNode {
95
+ const node = this.document.nodes[id];
96
+ if (!node) throw new NotStructured();
97
+ return node;
98
+ }
99
+
100
+ private single(id: string): readonly [string, ProcessFlow] {
101
+ const flows = this.outgoing.get(id) ?? [];
102
+ if (flows.length !== 1) throw new NotStructured();
103
+ return flows[0]!;
104
+ }
105
+
106
+ private visit(id: string): void {
107
+ if (this.visited.has(id)) throw new NotStructured();
108
+ this.visited.add(id);
109
+ }
110
+
111
+ read(): ProcessStructure {
112
+ const { nodes: _nodes, flows: _flows, ...rest } = this.document;
113
+ const entries = Object.entries(this.document.nodes);
114
+ const starts = entries.filter(([, node]) => nodeKindOf(node.kind)?.role === "start");
115
+ if (starts.length === 0) throw new NotStructured();
116
+ const shaped = starts.map(([id, node]) => {
117
+ const [flow, { target, ...data }] = this.single(id);
118
+ return { start: { id, node, flow, ...titleOf({ ...data, target }) }, target };
119
+ });
120
+ const first = shaped[0]!.target;
121
+ if (shaped.some(({ target }) => target !== first)) throw new NotStructured();
122
+ for (const { start } of shaped) this.visit(start.id);
123
+ const { steps, stop } = this.sequence(first);
124
+ const end = this.node(stop);
125
+ if (nodeKindOf(end.kind)?.role !== "end") throw new NotStructured();
126
+ this.visited.add(stop);
127
+ if (this.visited.size !== entries.length) throw new NotStructured();
128
+ return { ...rest, starts: shaped.map(({ start }) => start), steps, end: { id: stop, node: end } };
129
+ }
130
+
131
+ /** Steps from `from` up to a merge or an end, which is not taken. */
132
+ private sequence(from: string): { readonly steps: StructureStep[]; readonly stop: string; readonly exit?: { readonly flow: string; readonly title?: LocalizedText } } {
133
+ const steps: StructureStep[] = [];
134
+ let current = from;
135
+ let exit: { flow: string; title?: LocalizedText } | undefined;
136
+ for (;;) {
137
+ const node = this.node(current);
138
+ const role = nodeKindOf(node.kind)?.role;
139
+ if (role === "end") return { steps, stop: current, ...(exit === undefined ? {} : { exit }) };
140
+ if (role === "activity") {
141
+ this.visit(current);
142
+ const [flowId, flow] = this.single(current);
143
+ const boundaries = (this.boundaries.get(current) ?? []).map(([id, boundary]) => this.boundary(id, boundary, flow.target));
144
+ steps.push({ type: "step", id: current, node, ...(boundaries.length === 0 ? {} : { boundaries }), next: flowId, ...(flow.title === undefined ? {} : { nextTitle: flow.title }) });
145
+ exit = { flow: flowId, ...(flow.title === undefined ? {} : { title: flow.title }) };
146
+ current = flow.target;
147
+ continue;
148
+ }
149
+ if (role !== "gateway") throw new NotStructured();
150
+ const outs = this.outgoing.get(current) ?? [];
151
+ // A join: whoever walks here stops; the fork that owns it goes on from it.
152
+ if (outs.length === 1) return { steps, stop: current, ...(exit === undefined ? {} : { exit }) };
153
+ const { step, stop } = this.fork(current, node, outs);
154
+ steps.push(step);
155
+ if (step.merge === undefined) return { steps, stop };
156
+ exit = { flow: step.merge.next, ...(step.merge.nextTitle === undefined ? {} : { title: step.merge.nextTitle }) };
157
+ current = this.document.flows[step.merge.next]!.target;
158
+ }
159
+ }
160
+
161
+ private fork(id: string, node: ProcessNode, outs: readonly (readonly [string, ProcessFlow])[]): { readonly step: Extract<StructureStep, { type: "choice" | "parallel" }>; readonly stop: string } {
162
+ if ((this.incoming.get(id)?.length ?? 0) !== 1) throw new NotStructured();
163
+ this.visit(id);
164
+ let merge: string | undefined;
165
+ const branches = outs.map(([flowId, flow]): StructureBranch => {
166
+ const { steps, stop, exit } = this.sequence(flow.target);
167
+ if (merge !== undefined && merge !== stop) throw new NotStructured();
168
+ merge = stop;
169
+ return {
170
+ flow: flowId,
171
+ ...titleOf(flow),
172
+ ...(flow.condition === undefined ? {} : { condition: flow.condition }),
173
+ ...(flow.isDefault === undefined ? {} : { isDefault: flow.isDefault }),
174
+ exit: exit?.flow ?? flowId,
175
+ ...(exit?.title === undefined ? {} : { exitTitle: exit.title }),
176
+ steps,
177
+ };
178
+ });
179
+ const type = node.kind === "parallelGateway" ? "parallel" : "choice";
180
+ const mergeNode = this.node(merge!);
181
+ if (nodeKindOf(mergeNode.kind)?.role === "end") return { step: { type, id, node, branches }, stop: merge! };
182
+ if (mergeNode.kind !== node.kind) throw new NotStructured();
183
+ this.visit(merge!);
184
+ const [next, nextFlow] = this.single(merge!);
185
+ return { step: { type, id, node, branches, merge: { id: merge!, node: mergeNode, next, ...(nextFlow.title === undefined ? {} : { nextTitle: nextFlow.title }) } }, stop: merge! };
186
+ }
187
+
188
+ private boundary(id: string, node: ProcessNode, after: string): StructureBoundary {
189
+ this.visit(id);
190
+ const [flow, data] = this.single(id);
191
+ const base = { id, node, flow, ...titleOf(data) };
192
+ if (data.target === after) return { ...base, steps: [] };
193
+ const { steps, stop, exit } = this.sequence(data.target);
194
+ const stopNode = this.node(stop);
195
+ if (stop === after) {
196
+ if (exit === undefined) throw new NotStructured();
197
+ return { ...base, steps, exit: exit.flow, ...(exit.title === undefined ? {} : { exitTitle: exit.title }) };
198
+ }
199
+ // An end: its own, or the end of the process. A list without steps leads to it by the boundary's own flow.
200
+ if (nodeKindOf(stopNode.kind)?.role !== "end") throw new NotStructured();
201
+ this.visited.add(stop);
202
+ const last = exit ?? { flow, ...titleOf(data) };
203
+ return { ...base, steps, end: { id: stop, node: stopNode, flow: last.flow, ...(last.title === undefined ? {} : { title: last.title }) } };
204
+ }
205
+ }
206
+
207
+ /**
208
+ * A graph as a tree of steps, or `undefined` when it is outside the subset a
209
+ * list can draw (WORKFLOW-002 §3.6): sequences, an exclusive gateway with one
210
+ * merge, a parallel gateway with a join, boundary events on steps.
211
+ */
212
+ export function toStructure(document: ProcessDocument): ProcessStructure | undefined {
213
+ try {
214
+ return new Reader(document).read();
215
+ } catch (error) {
216
+ if (error instanceof NotStructured) return undefined;
217
+ throw error;
218
+ }
219
+ }
220
+
221
+ /** Whether a designer can edit the process as a list. Otherwise it shows the graph read only. */
222
+ export function isStructured(document: ProcessDocument): boolean {
223
+ return toStructure(document) !== undefined;
224
+ }
225
+
226
+ /** A tree of steps as a graph: `fromStructure(toStructure(d))` has the nodes and flows of `d`. */
227
+ export function fromStructure(structure: ProcessStructure): ProcessDocument {
228
+ const { starts, steps, end, ...rest } = structure;
229
+ const nodes: Record<string, ProcessNode> = {};
230
+ const flows: Record<string, ProcessFlow> = {};
231
+ const flow = (id: string, source: string, target: string, data: Partial<ProcessFlow> = {}) => {
232
+ flows[id] = { source, target, ...data };
233
+ };
234
+ const titled = (title: LocalizedText | undefined) => (title === undefined ? {} : { title });
235
+
236
+ /** The first node of a list, or where it goes when it is empty. */
237
+ const entry = (list: readonly StructureStep[], after: string) => list[0]?.id ?? after;
238
+
239
+ /** Writes a list that leads to `after`; `leave` writes the flow out of its last step. */
240
+ const write = (list: readonly StructureStep[], after: string, leave?: { readonly flow: string; readonly title?: LocalizedText }) => {
241
+ list.forEach((step, index) => {
242
+ const target = index + 1 < list.length ? list[index + 1]!.id : after;
243
+ const last = index + 1 === list.length && leave !== undefined;
244
+ nodes[step.id] = step.node;
245
+ if (step.type === "step") {
246
+ flow(last ? leave.flow : step.next, step.id, target, titled(last ? leave.title : step.nextTitle));
247
+ for (const boundary of step.boundaries ?? []) writeBoundary(boundary, step, target);
248
+ return;
249
+ }
250
+ const meet = step.merge?.id ?? target;
251
+ for (const branch of step.branches) {
252
+ const { flow: id, title, condition, isDefault, steps: inner, exit, exitTitle } = branch;
253
+ flow(id, step.id, entry(inner, meet), { ...titled(title), ...(condition === undefined ? {} : { condition }), ...(isDefault === undefined ? {} : { isDefault }) });
254
+ write(inner, meet, inner.length === 0 ? undefined : { flow: exit, ...(exitTitle === undefined ? {} : { title: exitTitle }) });
255
+ }
256
+ if (step.merge === undefined) return;
257
+ nodes[step.merge.id] = step.merge.node;
258
+ flow(last ? leave.flow : step.merge.next, step.merge.id, target, titled(last ? leave.title : step.merge.nextTitle));
259
+ });
260
+ };
261
+
262
+ const writeBoundary = (boundary: StructureBoundary, _host: StructureStep, after: string) => {
263
+ nodes[boundary.id] = boundary.node;
264
+ const target = boundary.end?.id ?? after;
265
+ flow(boundary.flow, boundary.id, entry(boundary.steps, target), titled(boundary.title));
266
+ if (boundary.end !== undefined) nodes[boundary.end.id] = boundary.end.node;
267
+ const leave = boundary.end !== undefined
268
+ ? { flow: boundary.end.flow, ...(boundary.end.title === undefined ? {} : { title: boundary.end.title }) }
269
+ : boundary.exit === undefined ? undefined : { flow: boundary.exit, ...(boundary.exitTitle === undefined ? {} : { title: boundary.exitTitle }) };
270
+ write(boundary.steps, target, boundary.steps.length === 0 ? undefined : leave);
271
+ };
272
+
273
+ const first = entry(steps, end.id);
274
+ for (const start of starts) {
275
+ nodes[start.id] = start.node;
276
+ flow(start.flow, start.id, first, titled(start.title));
277
+ }
278
+ write(steps, end.id);
279
+ nodes[end.id] = end.node;
280
+ return { ...rest, nodes, flows };
281
+ }
282
+
@@ -0,0 +1,84 @@
1
+ import type { Expr } from "./expressions";
2
+ import type { VariableSpec } from "./formFields";
3
+ import { isPath } from "./paths";
4
+ import { PROCESS_SCHEMA_VERSION, type ProcessDocument, type ProcessFlow, type ProcessNode } from "./ProcessDocument";
5
+
6
+ /** The version 1 of the format, for {@link upgradeDocument}. */
7
+ export const PROCESS_SCHEMA_VERSION_V1 = "bazis.workflow/v1";
8
+
9
+ /** A condition of version 1: a tree of `all` / `any` / `not` and comparisons. */
10
+ export type ConditionV1 =
11
+ | { readonly all: readonly ConditionV1[] }
12
+ | { readonly any: readonly ConditionV1[] }
13
+ | { readonly not: ConditionV1 }
14
+ | { readonly path: string; readonly op: string; readonly value?: unknown };
15
+
16
+ export interface FormFieldV1 {
17
+ readonly code: string;
18
+ readonly type: string;
19
+ readonly title?: string;
20
+ readonly required?: boolean;
21
+ readonly values?: readonly (string | number)[];
22
+ }
23
+
24
+ export interface ProcessDocumentV1 {
25
+ readonly schemaVersion: typeof PROCESS_SCHEMA_VERSION_V1;
26
+ readonly input?: { readonly fields: readonly FormFieldV1[] };
27
+ readonly nodes: Readonly<Record<string, { readonly kind: string; readonly title: string; readonly attachedTo?: string; readonly config?: Readonly<Record<string, unknown>> }>>;
28
+ readonly flows: Readonly<Record<string, { readonly source: string; readonly target: string; readonly title?: string; readonly condition?: ConditionV1; readonly isDefault?: boolean }>>;
29
+ readonly diagram?: ProcessDocument["diagram"];
30
+ }
31
+
32
+ function isObject(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ /**
37
+ * A document of version 1 as version 2: `input` becomes `in` variables, form
38
+ * fields take `name` instead of `code`, conditions become expressions, titles
39
+ * stay as they are. A pure function. A draft may hold errors, so what is not of
40
+ * the expected shape is left as it is for the validation to report; anything
41
+ * that is not version 1 comes back unchanged.
42
+ */
43
+ export function upgradeDocument(document: ProcessDocumentV1): ProcessDocument;
44
+ export function upgradeDocument(document: unknown): unknown;
45
+ export function upgradeDocument(document: unknown): unknown {
46
+ if (!isObject(document) || document.schemaVersion !== PROCESS_SCHEMA_VERSION_V1) return document;
47
+ const { input, nodes, flows, ...rest } = document;
48
+ const fields = isObject(input) && Array.isArray(input.fields) ? input.fields : undefined;
49
+ return {
50
+ ...rest,
51
+ schemaVersion: PROCESS_SCHEMA_VERSION,
52
+ ...(fields === undefined ? {} : { variables: fields.map((field) => upgradeField(field, "in")) }),
53
+ nodes: isObject(nodes) ? Object.fromEntries(Object.entries(nodes).map(([id, node]) => [id, upgradeNode(node)])) : nodes,
54
+ flows: isObject(flows) ? Object.fromEntries(Object.entries(flows).map(([id, flow]) => [id, upgradeFlow(flow)])) : flows,
55
+ };
56
+ }
57
+
58
+ function upgradeField(field: unknown, direction?: VariableSpec["direction"]): unknown {
59
+ if (!isObject(field)) return field;
60
+ const { code, ...rest } = field;
61
+ return { name: code, ...rest, ...(direction === undefined ? {} : { direction }) };
62
+ }
63
+
64
+ function upgradeNode(node: unknown): ProcessNode | unknown {
65
+ if (!isObject(node) || !isObject(node.config) || !isObject(node.config.form) || !Array.isArray(node.config.form.fields)) return node;
66
+ return { ...node, config: { ...node.config, form: { ...node.config.form, fields: node.config.form.fields.map((field) => upgradeField(field)) } } };
67
+ }
68
+
69
+ function upgradeFlow(flow: unknown): ProcessFlow | unknown {
70
+ if (!isObject(flow) || flow.condition === undefined) return flow;
71
+ return { ...flow, condition: upgradeCondition(flow.condition) };
72
+ }
73
+
74
+ /** `all` → `and`, `any` → `or`, `not` → `not`, a comparison → a call with the path first. */
75
+ export function upgradeCondition(condition: unknown): Expr {
76
+ if (!isObject(condition)) return condition as Expr;
77
+ if (Array.isArray(condition.all)) return { op: "and", args: condition.all.map(upgradeCondition) };
78
+ if (Array.isArray(condition.any)) return { op: "or", args: condition.any.map(upgradeCondition) };
79
+ if (condition.not !== undefined) return { op: "not", args: [upgradeCondition(condition.not)] };
80
+ if (typeof condition.path !== "string" || typeof condition.op !== "string") return condition as Expr;
81
+ const path = { path: condition.path };
82
+ if (condition.value === undefined) return { op: condition.op, args: [path] } as Expr;
83
+ return { op: condition.op, args: [path, isPath(condition.value) ? { path: condition.value } : condition.value as Expr] } as Expr;
84
+ }