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
@@ -4,7 +4,7 @@ import { Authorize, Controller, Get, Post, type HttpContext } from "../../../htt
4
4
  import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
5
5
  import { WorkflowError } from "../../contracts/WorkflowError";
6
6
  import { buildListDocument } from "../../../../library/jsonapi";
7
- import { body, callerOf, filterOf, listQuery, pagingOfList, param } from "../../internal/http";
7
+ import { body, callerOf, defaultLanguage, filterOf, listQuery, pagingOfList, param, withTitleText } from "../../internal/http";
8
8
  import type { TaskStatus } from "../model/WfTask.model";
9
9
  import { IWorkflowTasks, type TaskDetails } from "../services/IWorkflowTasks.service";
10
10
 
@@ -20,7 +20,7 @@ export class TasksController {
20
20
  @Get()
21
21
  @Authorize(can("workflow.tasks.work"))
22
22
  async list(ctx: HttpContext) {
23
- const { access, actor } = await callerOf(ctx);
23
+ const { access, actor, language } = await callerOf(ctx);
24
24
  const list = listQuery(ctx, { filter: { partition: ["eq"], processKey: ["eq"], status: ["eq", "in"] }, sort: ["createdAt", "dueAt", "status", "processKey"] });
25
25
  const partition = filterOf(list, "partition");
26
26
  const status = filterOf(list, "status");
@@ -35,44 +35,45 @@ export class TasksController {
35
35
  partition,
36
36
  processKey: filterOf(list, "processKey"),
37
37
  });
38
- return buildListDocument(page.items, list, page.total, { basePath: ctx.path });
38
+ return buildListDocument(page.items.map((item) => withTitleText(item, language, defaultLanguage(ctx))), list, page.total, { basePath: ctx.path });
39
39
  }
40
40
 
41
41
  @Get(":id")
42
42
  @Authorize(can("workflow.tasks.work"))
43
43
  async get(ctx: HttpContext) {
44
- return (await this.visible(ctx)).task;
44
+ const { task, language } = await this.visible(ctx);
45
+ return withTitleText(task, language, defaultLanguage(ctx));
45
46
  }
46
47
 
47
48
  @Post(":id/claim")
48
49
  @Authorize(can("workflow.tasks.work"))
49
50
  async claim(ctx: HttpContext) {
50
- const { task, actor } = await this.visible(ctx);
51
- return await this.tasks.claim(task.id, actor);
51
+ const { task, actor, language } = await this.visible(ctx);
52
+ return withTitleText(await this.tasks.claim(task.id, actor), language, defaultLanguage(ctx));
52
53
  }
53
54
 
54
55
  @Post(":id/release")
55
56
  @Authorize(can("workflow.tasks.work"))
56
57
  async release(ctx: HttpContext) {
57
- const { task, actor } = await this.visible(ctx);
58
- return await this.tasks.release(task.id, actor);
58
+ const { task, actor, language } = await this.visible(ctx);
59
+ return withTitleText(await this.tasks.release(task.id, actor), language, defaultLanguage(ctx));
59
60
  }
60
61
 
61
62
  @Post(":id/complete")
62
63
  @Authorize(can("workflow.tasks.work"))
63
64
  async complete(ctx: HttpContext) {
64
- const { task, actor, request: from } = await this.visible(ctx);
65
+ const { task, actor, request: from, language } = await this.visible(ctx);
65
66
  const request = await body(ctx);
66
- return await this.tasks.complete(task.id, request.output ?? {}, actor, { request: from });
67
+ return withTitleText(await this.tasks.complete(task.id, request.output ?? {}, actor, { request: from, language }), language, defaultLanguage(ctx));
67
68
  }
68
69
 
69
70
  /** The task if the caller may see it: addressed to one of their principals or claimed by them. */
70
- private async visible(ctx: HttpContext): Promise<{ task: TaskDetails; actor: WorkflowActor; request: WorkflowRequest }> {
71
+ private async visible(ctx: HttpContext): Promise<{ task: TaskDetails; actor: WorkflowActor; request: WorkflowRequest; language: string }> {
71
72
  const task = await this.tasks.get(param(ctx, "id"));
72
- const { access, actor, request } = await callerOf(ctx, "workflow.tasks.work", { scope: task.partition, process: task.processKey });
73
+ const { access, actor, request, language } = await callerOf(ctx, "workflow.tasks.work", { scope: task.partition, process: task.processKey });
73
74
  const addressees = await access.principals([task.partition]);
74
75
  if (!addressees.some(({ principals }) => principals.includes(task.assignee)) && task.claimedBy !== actor.id) throw new WorkflowError("not_found", `Task ${task.id} does not exist`);
75
- return { task, actor, request };
76
+ return { task, actor, request, language };
76
77
  }
77
78
  }
78
79
  contextOnlyController(TasksController);
@@ -8,7 +8,7 @@ export class TasksDbContext extends DbContext {
8
8
  /** The table of the Tasks part: bazis creates and checks it in the application database. */
9
9
  export const tasksStore = defineOrmOwnedStoreV1({
10
10
  contract: "bazis.orm-owned-store/v1",
11
- storeKey: "bazis.workflow.tasks",
11
+ storeKey: "bazis.workflow.tasks.v2",
12
12
  formatVersion: 1,
13
- ownedScope: { schema: "public", tablePrefix: "bazis_wf_task_" },
13
+ ownedScope: { schema: "public", tablePrefix: "bazis_wf2_task_" },
14
14
  });
@@ -1,5 +1,5 @@
1
1
  import { Column, Entity, Index, UUID } from "../../../orm";
2
- import type { FormSpec } from "../../../../library/workflow";
2
+ import type { FormSpec, LocalizedText } from "../../../../library/workflow";
3
3
 
4
4
  /** `open` waits for someone, `claimed` is taken by one person, `completed` and `cancelled` are done. */
5
5
  export type TaskStatus = "open" | "claimed" | "completed" | "cancelled";
@@ -10,7 +10,7 @@ export type TaskStatus = "open" | "claimed" | "completed" | "cancelled";
10
10
  */
11
11
  @Index(["assignee", "status"])
12
12
  @Index(["partition", "status"])
13
- @Entity({ table: "bazis_wf_task_items" })
13
+ @Entity({ table: "bazis_wf2_task_items" })
14
14
  export class WfTask {
15
15
  @UUID({ version: "v7" })
16
16
  id = "";
@@ -33,8 +33,12 @@ export class WfTask {
33
33
  @Column({ type: "text", nullable: true })
34
34
  partition: string | null = null;
35
35
 
36
- @Column({ type: "text" })
37
- title = "";
36
+ @Column({ type: "json" })
37
+ title: LocalizedText = "";
38
+
39
+ /** The task of a trial run (WORKFLOW-002 §5.4, stage 5). */
40
+ @Column({ type: "boolean" })
41
+ test = false;
38
42
 
39
43
  /** `role:<code>` or `user:<id>`. */
40
44
  @Column({ type: "text" })
@@ -1,4 +1,4 @@
1
- import type { FormSpec } from "../../../../library/workflow";
1
+ import type { FormSpec, LocalizedText } from "../../../../library/workflow";
2
2
  import type { AccessFilter, ScopePrincipals } from "../../../access";
3
3
  import type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging } from "../../contracts/types";
4
4
  import type { TaskStatus } from "../model/WfTask.model";
@@ -10,7 +10,7 @@ export interface TaskDetails {
10
10
  readonly processKey: string;
11
11
  readonly nodeId: string;
12
12
  readonly partition: string | null;
13
- readonly title: string;
13
+ readonly title: LocalizedText;
14
14
  readonly assignee: string;
15
15
  readonly form: FormSpec | null;
16
16
  readonly dueAt: Date | null;
@@ -53,5 +53,5 @@ export abstract class IWorkflowTasks {
53
53
  /** Gives a claimed task back. */
54
54
  abstract release(taskId: string, actor: WorkflowActor): Promise<TaskDetails>;
55
55
  /** Fills the form: the process moves on. */
56
- abstract complete(taskId: string, output: unknown, actor: WorkflowActor, options?: WorkflowAuditOptions): Promise<TaskDetails>;
56
+ abstract complete(taskId: string, output: unknown, actor: WorkflowActor, options?: WorkflowAuditOptions & { readonly language?: string }): Promise<TaskDetails>;
57
57
  }
@@ -47,7 +47,7 @@ export class WorkflowTasks extends IWorkflowTasks {
47
47
 
48
48
  async partitions(query: { readonly status?: readonly TaskStatus[] } = {}): Promise<readonly (string | null)[]> {
49
49
  const statuses = statusesOf(query.status);
50
- const rows = await this.db.database.querySqlRaw(`SELECT DISTINCT "partition" FROM "bazis_wf_task_items" WHERE "status" IN (${statuses.map((_, index) => `{${index}}`).join(", ")})`, ...statuses);
50
+ const rows = await this.db.database.querySqlRaw(`SELECT DISTINCT "partition" FROM "bazis_wf2_task_items" WHERE "status" IN (${statuses.map((_, index) => `{${index}}`).join(", ")})`, ...statuses);
51
51
  return rows.map((row) => row.partition as string | null);
52
52
  }
53
53
 
@@ -81,19 +81,19 @@ export class WorkflowTasks extends IWorkflowTasks {
81
81
  });
82
82
  }
83
83
 
84
- async complete(taskId: string, output: unknown, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<TaskDetails> {
84
+ async complete(taskId: string, output: unknown, actor: WorkflowActor, options: WorkflowAuditOptions & { readonly language?: string } = {}): Promise<TaskDetails> {
85
85
  const task = await this.task(taskId, false);
86
86
  if (task.status === "claimed" && task.claimedById !== actor.id) throw new WorkflowError("conflict", "The task is taken by someone else");
87
87
  if (task.status !== "open" && task.status !== "claimed") throw new WorkflowError("wrong_status", `The task is ${task.status}`);
88
88
  let instance;
89
89
  try {
90
90
  // The form is checked and the process moves on first; the task follows.
91
- instance = await this.runtime.completeToken(task.tokenId, output, actor);
91
+ instance = await this.runtime.completeToken(task.tokenId, output, actor, options.language === undefined ? {} : { language: options.language });
92
92
  } catch (error) {
93
93
  if (error instanceof WorkflowError && error.code === "wrong_status") await this.close(task.tokenId, "cancelled");
94
94
  throw error;
95
95
  }
96
- const saved = instance.nodes[task.nodeId];
96
+ const saved = instance.tokens.find((t) => t.id === task.tokenId)?.output;
97
97
  await this.db.tasks.asNoTracking().where((t) => t.id.eq(task.id)).executeUpdate({
98
98
  status: "completed",
99
99
  completedById: actor.id,
@@ -695,6 +695,9 @@ function applyValidatorOptions(schema: OpenApiCodegenSchema, options: Record<str
695
695
  if (typeof options.refScope === "string") {
696
696
  out["x-ref-scope"] = options.refScope;
697
697
  }
698
+ if (options.personal === true) {
699
+ out["x-personal"] = true;
700
+ }
698
701
  return out;
699
702
  }
700
703
 
@@ -160,4 +160,6 @@ export interface ValidatorOptions {
160
160
  refKeys?: string;
161
161
  /** JSON Pointer to the sibling input that sets their area (`x-ref-scope`): `/table`. */
162
162
  refScope?: `/${string}`;
163
+ /** Personal data (`x-personal`): a process masks the value in its history. */
164
+ personal?: boolean;
163
165
  }
@@ -1,19 +1,25 @@
1
- import type { FormSpec } from "./formFields";
1
+ import type { Expr } from "./expressions";
2
+ import type { VariableSpec } from "./formFields";
3
+ import type { LocalizedText } from "./localized";
2
4
 
3
5
  /**
4
6
  * A process document: the framework's own JSON, not BPMN XML (a designer may
5
- * render BPMN from it). One object holds the process input (`input`), the graph
6
- * (`nodes`, `flows`) and the canvas layout (`diagram`).
7
+ * render BPMN from it). One object holds the variables, the settings, the graph
8
+ * (`nodes`, `flows`), the canvas layout (`diagram`) and the application's data
9
+ * (`extensions`).
7
10
  */
8
- export const PROCESS_SCHEMA_VERSION = "bazis.workflow/v1";
11
+ export const PROCESS_SCHEMA_VERSION = "bazis.workflow/v2";
9
12
 
10
- /** The node kinds of version 1; closed: a new kind comes with a new document version. */
13
+ /** The node kinds of version 2; closed: a new kind comes with a new document version. */
11
14
  export type NodeKind =
12
15
  | "manualStart"
13
16
  | "scheduleStart"
14
17
  | "eventStart"
15
18
  | "userTask"
16
19
  | "serviceTask"
20
+ | "assign"
21
+ | "loop"
22
+ | "callProcess"
17
23
  | "timerCatch"
18
24
  | "eventWait"
19
25
  | "exclusiveGateway"
@@ -25,35 +31,26 @@ export type NodeKind =
25
31
 
26
32
  export interface ProcessNode {
27
33
  readonly kind: NodeKind;
28
- readonly title: string;
34
+ readonly title: LocalizedText;
29
35
  /** Boundary nodes only: the step the node is attached to. */
30
36
  readonly attachedTo?: string;
37
+ /** The `loop` whose body the node belongs to. */
38
+ readonly parent?: string;
31
39
  readonly config?: Readonly<Record<string, unknown>>;
40
+ /** The application's data by namespace; the engine does not read it. */
41
+ readonly extensions?: Readonly<Record<string, unknown>>;
32
42
  }
33
43
 
34
44
  export interface ProcessFlow {
35
45
  readonly source: string;
36
46
  readonly target: string;
37
- readonly title?: string;
47
+ readonly title?: LocalizedText;
38
48
  /** Condition of a flow that leaves an exclusive gateway. */
39
- readonly condition?: Condition;
49
+ readonly condition?: Expr;
40
50
  /** The flow an exclusive gateway takes when no condition holds. */
41
51
  readonly isDefault?: boolean;
42
52
  }
43
53
 
44
- /** Comparison operators of conditions. */
45
- export type ConditionOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in" | "contains" | "empty" | "notEmpty";
46
-
47
- /**
48
- * A condition: a tree of `all` / `any` / `not` and comparisons. `path` addresses
49
- * instance data: `$input.<field>`, `$vars.<name>`, `$nodes.<node>.<field>`.
50
- */
51
- export type Condition =
52
- | { readonly all: readonly Condition[] }
53
- | { readonly any: readonly Condition[] }
54
- | { readonly not: Condition }
55
- | { readonly path: string; readonly op: ConditionOp; readonly value?: unknown };
56
-
57
54
  export interface Bounds {
58
55
  readonly x: number;
59
56
  readonly y: number;
@@ -72,13 +69,43 @@ export interface ProcessDiagram {
72
69
  readonly edges: Readonly<Record<string, { readonly waypoints: readonly Point[] }>>;
73
70
  }
74
71
 
72
+ /** What goes to the history of an instance. */
73
+ export interface ProcessLogSettings {
74
+ /** The values of variables an `assign` step sets; `false` by default. */
75
+ readonly variables?: boolean;
76
+ /** The input and output of Tools; `false` by default. */
77
+ readonly toolPayloads?: boolean;
78
+ /** Personal data shows as `***`; `true` by default. */
79
+ readonly maskPersonal?: boolean;
80
+ }
81
+
82
+ /** Settings of a process: they are part of the version. */
83
+ export interface ProcessSettings {
84
+ /** On whose behalf steps run: who started the instance (the default) or the process's own system actor. */
85
+ readonly runAs?: "starter" | "process";
86
+ /** How long the whole run may take, waits included: an ISO duration. */
87
+ readonly runTimeout?: string;
88
+ /** The default `timeout` of steps with a Tool: an ISO duration. */
89
+ readonly stepTimeout?: string;
90
+ /** How many instances of the process run at once; the rest wait as `pending`. */
91
+ readonly maxConcurrent?: number;
92
+ /** A new start with the `businessKey` of an unfinished instance: run as well, do not start, or wait for it. */
93
+ readonly sameKey?: "allow" | "skip" | "queue";
94
+ /** The key of the process to start when an instance fails or times out. */
95
+ readonly errorHandler?: string;
96
+ readonly log?: ProcessLogSettings;
97
+ }
98
+
75
99
  export interface ProcessDocument {
76
100
  readonly schemaVersion: typeof PROCESS_SCHEMA_VERSION;
77
- /** Fields the process starts with; without them it starts with no data. */
78
- readonly input?: FormSpec;
101
+ /** `in` — the input of a start, `local` — inside the process, `out` — what it gives back. */
102
+ readonly variables?: readonly VariableSpec[];
103
+ readonly settings?: ProcessSettings;
79
104
  readonly nodes: Readonly<Record<string, ProcessNode>>;
80
105
  readonly flows: Readonly<Record<string, ProcessFlow>>;
81
106
  readonly diagram?: ProcessDiagram;
107
+ /** The application's data by namespace; the engine does not read it. */
108
+ readonly extensions?: Readonly<Record<string, unknown>>;
82
109
  }
83
110
 
84
111
  /** A new process: a manual start leading straight to the end. It already passes validation. */
@@ -92,3 +119,26 @@ export function emptyProcess(): ProcessDocument {
92
119
  flows: { StartToEnd: { source: "Start", target: "End" } },
93
120
  };
94
121
  }
122
+
123
+ /** The fields a start takes: the `in` variables. */
124
+ export function inputSpecOf(document: Pick<ProcessDocument, "variables">): { readonly fields: readonly VariableSpec[] } {
125
+ return { fields: (document.variables ?? []).filter((variable) => variable.direction === "in") };
126
+ }
127
+
128
+ /** What `$vars` holds at the start: the `local` and `out` variables with a default. */
129
+ export function initialVarsOf(document: Pick<ProcessDocument, "variables">): Record<string, unknown> {
130
+ const vars: Record<string, unknown> = {};
131
+ for (const variable of document.variables ?? []) {
132
+ if (variable.direction !== "in" && variable.default !== undefined) vars[variable.name] = variable.default;
133
+ }
134
+ return vars;
135
+ }
136
+
137
+ /** What a finished process gives back: its `out` variables. */
138
+ export function outputOf(document: Pick<ProcessDocument, "variables">, vars: Readonly<Record<string, unknown>>): Record<string, unknown> {
139
+ const output: Record<string, unknown> = {};
140
+ for (const variable of document.variables ?? []) {
141
+ if (variable.direction === "out") output[variable.name] = Object.hasOwn(vars, variable.name) ? vars[variable.name] : null;
142
+ }
143
+ return output;
144
+ }
@@ -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
+ }