bazis 0.123.0 → 0.124.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 (43) hide show
  1. package/core/agent/index.ts +1 -0
  2. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  3. package/core/workflow/Workflow.module.ts +15 -0
  4. package/core/workflow/access/WorkflowAccess.ts +52 -0
  5. package/core/workflow/contracts/WorkflowError.ts +28 -0
  6. package/core/workflow/contracts/WorkflowInputValidator.ts +24 -0
  7. package/core/workflow/contracts/types.ts +30 -0
  8. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +24 -0
  9. package/core/workflow/definitions_module/http/Processes.controller.ts +92 -0
  10. package/core/workflow/definitions_module/model/Definitions.dbContext.ts +16 -0
  11. package/core/workflow/definitions_module/model/WfProcess.model.ts +52 -0
  12. package/core/workflow/definitions_module/model/WfVersion.model.ts +33 -0
  13. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +79 -0
  14. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +234 -0
  15. package/core/workflow/index.ts +43 -0
  16. package/core/workflow/internal/WorkflowTools.ts +36 -0
  17. package/core/workflow/internal/http.ts +63 -0
  18. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +53 -0
  19. package/core/workflow/runtime_module/background/WorkflowRetention.ts +66 -0
  20. package/core/workflow/runtime_module/background/WorkflowWorker.ts +92 -0
  21. package/core/workflow/runtime_module/config/Runtime.config.ts +27 -0
  22. package/core/workflow/runtime_module/events/Runtime.events.ts +36 -0
  23. package/core/workflow/runtime_module/http/Instances.controller.ts +72 -0
  24. package/core/workflow/runtime_module/internal/GraphCache.ts +37 -0
  25. package/core/workflow/runtime_module/internal/ToolStep.ts +60 -0
  26. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +356 -0
  27. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +25 -0
  28. package/core/workflow/runtime_module/model/WfHistory.model.ts +38 -0
  29. package/core/workflow/runtime_module/model/WfInstance.model.ts +70 -0
  30. package/core/workflow/runtime_module/model/WfJob.model.ts +49 -0
  31. package/core/workflow/runtime_module/model/WfSubscription.model.ts +30 -0
  32. package/core/workflow/runtime_module/model/WfToken.model.ts +39 -0
  33. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +82 -0
  34. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +167 -0
  35. package/library/workflow/ProcessDocument.ts +94 -0
  36. package/library/workflow/conditions.ts +44 -0
  37. package/library/workflow/duration.ts +10 -0
  38. package/library/workflow/formFields.ts +112 -0
  39. package/library/workflow/index.ts +24 -0
  40. package/library/workflow/nodeKinds.ts +111 -0
  41. package/library/workflow/paths.ts +39 -0
  42. package/library/workflow/validateProcess.ts +310 -0
  43. package/package.json +4 -2
@@ -0,0 +1,49 @@
1
+ import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
2
+ import { WfInstance } from "./WfInstance.model";
3
+
4
+ /** `tool` calls a service task's Tool; `timer`, `retry` and `schedule` come with their stages. */
5
+ export type JobKind = "tool" | "timer" | "retry" | "schedule";
6
+
7
+ /**
8
+ * Deferred work of the worker, created in the same transaction as the waiting
9
+ * token. Application instances claim it with `FOR UPDATE SKIP LOCKED`.
10
+ */
11
+ @ForeignKey(() => WfInstance, { properties: ["instanceId"], onDelete: "cascade" })
12
+ @Entity({ table: "bazis_wf_run_jobs" })
13
+ export class WfJob {
14
+ @UUID({ version: "v7" })
15
+ id = "";
16
+
17
+ @Column({ type: "text" })
18
+ kind: JobKind = "tool";
19
+
20
+ /** `null` for a `schedule` job: its instance does not exist yet. */
21
+ @Index()
22
+ @Column({ type: "uuid", nullable: true })
23
+ instanceId: string | null = null;
24
+
25
+ @Index()
26
+ @Column({ type: "uuid", nullable: true })
27
+ tokenId: string | null = null;
28
+
29
+ /** The version a `schedule` job starts. */
30
+ @Column({ type: "uuid", nullable: true })
31
+ versionId: string | null = null;
32
+
33
+ @Index()
34
+ @Column({ type: "datetime" })
35
+ dueAt = new Date(0);
36
+
37
+ @Column({ type: "integer" })
38
+ attempts = 0;
39
+
40
+ /** The worker holds the job until then; if it dies, the job becomes free again. */
41
+ @Column({ type: "datetime", nullable: true })
42
+ lockedUntil: Date | null = null;
43
+
44
+ @Column({ type: "text", nullable: true })
45
+ lastError: string | null = null;
46
+
47
+ @Column({ type: "createdAt" })
48
+ createdAt = new Date(0);
49
+ }
@@ -0,0 +1,30 @@
1
+ import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
2
+ import { WfInstance } from "./WfInstance.model";
3
+
4
+ /** A token waiting for an event: `eventWait` or `boundaryEvent`. Matched by key and correlation. */
5
+ @ForeignKey(() => WfInstance, { properties: ["instanceId"], onDelete: "cascade" })
6
+ @Index(["eventKey", "correlation"])
7
+ @Entity({ table: "bazis_wf_run_subscriptions" })
8
+ export class WfSubscription {
9
+ @UUID({ version: "v7" })
10
+ id = "";
11
+
12
+ @Column({ type: "uuid" })
13
+ instanceId = "";
14
+
15
+ @Index()
16
+ @Column({ type: "uuid" })
17
+ tokenId = "";
18
+
19
+ @Column({ type: "text" })
20
+ eventKey = "";
21
+
22
+ @Column({ type: "text", nullable: true })
23
+ correlation: string | null = null;
24
+
25
+ @Column({ type: "text", nullable: true })
26
+ partition: string | null = null;
27
+
28
+ @Column({ type: "createdAt" })
29
+ createdAt = new Date(0);
30
+ }
@@ -0,0 +1,39 @@
1
+ import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
2
+ import { WfInstance } from "./WfInstance.model";
3
+
4
+ /**
5
+ * `active` passes a node, `waiting` waits for a Tool, a task or a join, `done`
6
+ * moved on, `cancelled` was closed by a cancel, `failed` waits for a retry.
7
+ */
8
+ export type TokenStatus = "active" | "waiting" | "done" | "cancelled" | "failed";
9
+
10
+ /** Where execution is. After a parallel gateway an instance has several. Its id is the idempotency key of a Tool call. */
11
+ @ForeignKey(() => WfInstance, { properties: ["instanceId"], onDelete: "cascade" })
12
+ @Index(["instanceId", "status"])
13
+ @Entity({ table: "bazis_wf_run_tokens" })
14
+ export class WfToken {
15
+ @UUID({ version: "v7" })
16
+ id = "";
17
+
18
+ @Column({ type: "uuid" })
19
+ instanceId = "";
20
+
21
+ @Column({ type: "text" })
22
+ nodeId = "";
23
+
24
+ /** The flow the token came by; `null` on a start node. */
25
+ @Column({ type: "text", nullable: true })
26
+ viaFlowId: string | null = null;
27
+
28
+ @Column({ type: "text" })
29
+ status: TokenStatus = "active";
30
+
31
+ @Column({ type: "json", nullable: true })
32
+ output: unknown = null;
33
+
34
+ @Column({ type: "createdAt" })
35
+ createdAt = new Date(0);
36
+
37
+ @Column({ type: "datetime", nullable: true })
38
+ endedAt: Date | null = null;
39
+ }
@@ -0,0 +1,82 @@
1
+ import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
2
+ import type { HistoryType } from "../model/WfHistory.model";
3
+ import type { InstanceStatus, StartedVia } from "../model/WfInstance.model";
4
+ import type { TokenStatus } from "../model/WfToken.model";
5
+
6
+ export interface StartInstance {
7
+ readonly input?: unknown;
8
+ /** The application's scope: a space, an organization. */
9
+ readonly partition?: string | null;
10
+ /** The application's own key of what the instance is about: `order:42`. */
11
+ readonly businessKey?: string | null;
12
+ /** On whose behalf the steps run. */
13
+ readonly actor: WorkflowActor;
14
+ }
15
+
16
+ export interface InstanceSummary {
17
+ readonly id: string;
18
+ readonly processKey: string;
19
+ readonly version: number;
20
+ readonly partition: string | null;
21
+ readonly status: InstanceStatus;
22
+ readonly businessKey: string | null;
23
+ readonly startedVia: StartedVia;
24
+ readonly startedBy: WorkflowActor;
25
+ readonly error: { readonly node: string; readonly code: string; readonly message: string } | null;
26
+ readonly createdAt: Date;
27
+ readonly endedAt: Date | null;
28
+ }
29
+
30
+ export interface TokenDetails {
31
+ readonly id: string;
32
+ readonly nodeId: string;
33
+ readonly viaFlowId: string | null;
34
+ readonly status: TokenStatus;
35
+ readonly output: unknown;
36
+ readonly createdAt: Date;
37
+ readonly endedAt: Date | null;
38
+ }
39
+
40
+ export interface InstanceDetails extends InstanceSummary {
41
+ readonly input: Record<string, unknown>;
42
+ readonly vars: Record<string, unknown>;
43
+ readonly nodes: Record<string, unknown>;
44
+ /** Every token, oldest first. */
45
+ readonly tokens: readonly TokenDetails[];
46
+ }
47
+
48
+ export interface HistoryEntry {
49
+ readonly id: string;
50
+ readonly tokenId: string | null;
51
+ readonly nodeId: string | null;
52
+ readonly type: HistoryType;
53
+ readonly data: Record<string, unknown> | null;
54
+ readonly actor: WorkflowActor | null;
55
+ readonly at: Date;
56
+ }
57
+
58
+ export interface InstanceListQuery extends WorkflowPaging {
59
+ readonly partition?: string;
60
+ readonly status?: InstanceStatus;
61
+ readonly processKey?: string;
62
+ readonly businessKey?: string;
63
+ readonly startedById?: string;
64
+ }
65
+
66
+ /**
67
+ * Runs published versions (WORKFLOW-001 §4). Errors are {@link WorkflowError}:
68
+ * `not_found`, `archived`, `invalid_input` with the issues, `wrong_status`.
69
+ */
70
+ export abstract class IWorkflowRuntime {
71
+ /** Starts the latest version of a process from its manual start. */
72
+ abstract start(processKey: string, options: StartInstance): Promise<InstanceDetails>;
73
+ abstract cancel(instanceId: string, options: { readonly reason?: string | null; readonly actor: WorkflowActor }): Promise<InstanceDetails>;
74
+ /** Retries the failed steps of a `failed` instance. */
75
+ abstract retry(instanceId: string, options: { readonly actor: WorkflowActor }): Promise<InstanceDetails>;
76
+ /** Finishes a user task's wait with the form output and moves on; `wrong_status` when the token no longer waits. */
77
+ abstract completeToken(tokenId: string, output: unknown, actor: WorkflowActor): Promise<InstanceDetails>;
78
+ /** Instances, newest first. */
79
+ abstract instances(query?: InstanceListQuery): Promise<WorkflowPage<InstanceSummary>>;
80
+ abstract instance(instanceId: string): Promise<InstanceDetails>;
81
+ abstract history(instanceId: string): Promise<readonly HistoryEntry[]>;
82
+ }
@@ -0,0 +1,167 @@
1
+ import type { FormSpec } from "../../../../library/workflow";
2
+ import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
3
+ import { WorkflowError } from "../../contracts/WorkflowError";
4
+ import type { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
5
+ import type { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
6
+ import type { WorkflowEngine } from "../internal/WorkflowEngine";
7
+ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
8
+ import type { WfHistory } from "../model/WfHistory.model";
9
+ import type { WfInstance } from "../model/WfInstance.model";
10
+ import type { WfToken } from "../model/WfToken.model";
11
+ import {
12
+ IWorkflowRuntime,
13
+ type HistoryEntry,
14
+ type InstanceDetails,
15
+ type InstanceListQuery,
16
+ type InstanceSummary,
17
+ type StartInstance,
18
+ } from "./IWorkflowRuntime.service";
19
+
20
+ const STATUSES = new Set(["running", "completed", "cancelled", "failed"]);
21
+
22
+ export class WorkflowRuntime extends IWorkflowRuntime {
23
+ constructor(
24
+ private readonly db: RuntimeDbContext,
25
+ private readonly engine: WorkflowEngine,
26
+ private readonly definitions: IWorkflowDefinitions,
27
+ private readonly validator: WorkflowInputValidator,
28
+ ) {
29
+ super();
30
+ }
31
+
32
+ async start(processKey: string, options: StartInstance): Promise<InstanceDetails> {
33
+ actorOf(options.actor);
34
+ const partition = optionalText(options.partition, "partition");
35
+ const businessKey = optionalText(options.businessKey, "businessKey");
36
+ const version = await this.definitions.latest(processKey);
37
+ const start = Object.entries(version.document.nodes).find(([, node]) => node.kind === "manualStart");
38
+ if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
39
+ const { value, issues } = await this.validator.validate(version.document.input, options.input, { purpose: "input", processKey, partition });
40
+ if (issues.length > 0) throw new WorkflowError("invalid_input", "The process input is invalid", issues);
41
+ const id = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor });
42
+ return await this.instance(id);
43
+ }
44
+
45
+ async cancel(instanceId: string, options: { readonly reason?: string | null; readonly actor: WorkflowActor }): Promise<InstanceDetails> {
46
+ actorOf(options.actor);
47
+ const reason = optionalText(options.reason, "reason", 1000);
48
+ if (!isUuid(instanceId)) throw notFound(instanceId);
49
+ const done = await this.engine.inStep(instanceId, async (step) => {
50
+ if (step.instance.status !== "running" && step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Instance is ${step.instance.status}`);
51
+ await this.engine.cancel(step, reason, options.actor);
52
+ return true;
53
+ });
54
+ if (done === null) throw notFound(instanceId);
55
+ return await this.instance(instanceId);
56
+ }
57
+
58
+ async retry(instanceId: string, options: { readonly actor: WorkflowActor }): Promise<InstanceDetails> {
59
+ actorOf(options.actor);
60
+ if (!isUuid(instanceId)) throw notFound(instanceId);
61
+ const done = await this.engine.inStep(instanceId, async (step) => {
62
+ if (step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Only a failed instance is retried; this one is ${step.instance.status}`);
63
+ await this.engine.retry(step, options.actor);
64
+ return true;
65
+ });
66
+ if (done === null) throw notFound(instanceId);
67
+ return await this.instance(instanceId);
68
+ }
69
+
70
+ async completeToken(tokenId: string, output: unknown, actor: WorkflowActor): Promise<InstanceDetails> {
71
+ actorOf(actor);
72
+ if (!isUuid(tokenId)) throw new WorkflowError("not_found", `Token ${tokenId} does not exist`);
73
+ const token = await this.db.tokens.asNoTracking().firstOrDefault((t) => t.id.eq(tokenId));
74
+ if (!token) throw new WorkflowError("not_found", `Token ${tokenId} does not exist`);
75
+ const done = await this.engine.inStep(token.instanceId, async (step) => {
76
+ const node = step.graph.document.nodes[token.nodeId];
77
+ const current = step.tokens.find((t) => t.id === tokenId);
78
+ if (node?.kind !== "userTask" || !current || current.status !== "waiting" || step.instance.status !== "running") {
79
+ throw new WorkflowError("wrong_status", "The token does not wait for a task");
80
+ }
81
+ const { value, issues } = await this.validator.validate(node.config?.form as FormSpec | undefined, output, { purpose: "task", processKey: step.instance.processKey, partition: step.instance.partition });
82
+ if (issues.length > 0) throw new WorkflowError("invalid_input", "The task form is invalid", issues);
83
+ await this.engine.complete(step, tokenId, value, actor);
84
+ return true;
85
+ });
86
+ if (done === null) throw notFound(token.instanceId);
87
+ return await this.instance(token.instanceId);
88
+ }
89
+
90
+ async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
91
+ const { limit, offset } = pagingOf(query);
92
+ if (query.status !== undefined && !STATUSES.has(query.status)) throw new WorkflowError("invalid_input", "status must be running, completed, cancelled or failed");
93
+ let source = this.db.instances.asNoTracking();
94
+ if (query.partition !== undefined) source = source.where((i) => i.partition.eq(query.partition!));
95
+ if (query.status !== undefined) source = source.where((i) => i.status.eq(query.status!));
96
+ if (query.processKey !== undefined) source = source.where((i) => i.processKey.eq(query.processKey!));
97
+ if (query.businessKey !== undefined) source = source.where((i) => i.businessKey.eq(query.businessKey!));
98
+ if (query.startedById !== undefined) source = source.where((i) => i.startedById.eq(query.startedById!));
99
+ const total = await source.count();
100
+ const rows = await source.orderByDescending((i) => i.id).skip(offset).take(limit).toList();
101
+ return { items: rows.map(summaryOf), total };
102
+ }
103
+
104
+ async instance(instanceId: string): Promise<InstanceDetails> {
105
+ if (!isUuid(instanceId)) throw notFound(instanceId);
106
+ const row = await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(instanceId));
107
+ if (!row) throw notFound(instanceId);
108
+ const tokens = await this.db.tokens.asNoTracking().where((t) => t.instanceId.eq(instanceId)).orderBy((t) => t.id).toList();
109
+ return { ...summaryOf(row), input: row.input, vars: row.vars, nodes: row.nodes, tokens: tokens.map(tokenOf) };
110
+ }
111
+
112
+ async history(instanceId: string): Promise<readonly HistoryEntry[]> {
113
+ if (!isUuid(instanceId)) throw notFound(instanceId);
114
+ const exists = await this.db.instances.asNoTracking().where((i) => i.id.eq(instanceId)).count();
115
+ if (exists === 0) throw notFound(instanceId);
116
+ const rows = await this.db.history.asNoTracking().where((h) => h.instanceId.eq(instanceId)).orderBy((h) => h.id).toList();
117
+ return rows.map(historyOf);
118
+ }
119
+ }
120
+
121
+ function notFound(instanceId: string): WorkflowError {
122
+ return new WorkflowError("not_found", `Instance ${instanceId} does not exist`);
123
+ }
124
+
125
+ function actorOf(actor: WorkflowActor): void {
126
+ if (typeof actor !== "object" || actor === null || (actor.kind !== "user" && actor.kind !== "system") || typeof actor.id !== "string" || actor.id === "" || actor.id.length > 200) {
127
+ throw new WorkflowError("invalid_input", "actor must be { kind: \"user\" | \"system\", id }");
128
+ }
129
+ }
130
+
131
+ function optionalText(value: unknown, name: string, max = 200): string | null {
132
+ if (value === undefined || value === null) return null;
133
+ if (typeof value !== "string" || value.length > max) throw new WorkflowError("invalid_input", `${name} must be a string up to ${max} characters`);
134
+ return value;
135
+ }
136
+
137
+ function summaryOf(row: WfInstance): InstanceSummary {
138
+ return {
139
+ id: row.id,
140
+ processKey: row.processKey,
141
+ version: row.version,
142
+ partition: row.partition,
143
+ status: row.status,
144
+ businessKey: row.businessKey,
145
+ startedVia: row.startedVia,
146
+ startedBy: { kind: row.startedByKind, id: row.startedById },
147
+ error: row.error,
148
+ createdAt: row.createdAt,
149
+ endedAt: row.endedAt,
150
+ };
151
+ }
152
+
153
+ function tokenOf(row: WfToken) {
154
+ return { id: row.id, nodeId: row.nodeId, viaFlowId: row.viaFlowId, status: row.status, output: row.output, createdAt: row.createdAt, endedAt: row.endedAt };
155
+ }
156
+
157
+ function historyOf(row: WfHistory): HistoryEntry {
158
+ return {
159
+ id: row.id,
160
+ tokenId: row.tokenId,
161
+ nodeId: row.nodeId,
162
+ type: row.type,
163
+ data: row.data,
164
+ actor: row.actorId === null ? null : { kind: row.actorKind === "system" ? "system" : "user", id: row.actorId },
165
+ at: row.at,
166
+ };
167
+ }
@@ -0,0 +1,94 @@
1
+ import type { FormSpec } from "./formFields";
2
+
3
+ /**
4
+ * 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
+ */
8
+ export const PROCESS_SCHEMA_VERSION = "bazis.workflow/v1";
9
+
10
+ /** The node kinds of version 1; closed: a new kind comes with a new document version. */
11
+ export type NodeKind =
12
+ | "manualStart"
13
+ | "scheduleStart"
14
+ | "eventStart"
15
+ | "userTask"
16
+ | "serviceTask"
17
+ | "timerCatch"
18
+ | "eventWait"
19
+ | "exclusiveGateway"
20
+ | "parallelGateway"
21
+ | "boundaryError"
22
+ | "boundaryTimer"
23
+ | "boundaryEvent"
24
+ | "end";
25
+
26
+ export interface ProcessNode {
27
+ readonly kind: NodeKind;
28
+ readonly title: string;
29
+ /** Boundary nodes only: the step the node is attached to. */
30
+ readonly attachedTo?: string;
31
+ readonly config?: Readonly<Record<string, unknown>>;
32
+ }
33
+
34
+ export interface ProcessFlow {
35
+ readonly source: string;
36
+ readonly target: string;
37
+ readonly title?: string;
38
+ /** Condition of a flow that leaves an exclusive gateway. */
39
+ readonly condition?: Condition;
40
+ /** The flow an exclusive gateway takes when no condition holds. */
41
+ readonly isDefault?: boolean;
42
+ }
43
+
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
+ export interface Bounds {
58
+ readonly x: number;
59
+ readonly y: number;
60
+ readonly width: number;
61
+ readonly height: number;
62
+ }
63
+
64
+ export interface Point {
65
+ readonly x: number;
66
+ readonly y: number;
67
+ }
68
+
69
+ /** Canvas layout: stored and returned, never read by the engine. */
70
+ export interface ProcessDiagram {
71
+ readonly shapes: Readonly<Record<string, Bounds>>;
72
+ readonly edges: Readonly<Record<string, { readonly waypoints: readonly Point[] }>>;
73
+ }
74
+
75
+ export interface ProcessDocument {
76
+ readonly schemaVersion: typeof PROCESS_SCHEMA_VERSION;
77
+ /** Fields the process starts with; without them it starts with no data. */
78
+ readonly input?: FormSpec;
79
+ readonly nodes: Readonly<Record<string, ProcessNode>>;
80
+ readonly flows: Readonly<Record<string, ProcessFlow>>;
81
+ readonly diagram?: ProcessDiagram;
82
+ }
83
+
84
+ /** A new process: a manual start leading straight to the end. It already passes validation. */
85
+ export function emptyProcess(): ProcessDocument {
86
+ return {
87
+ schemaVersion: PROCESS_SCHEMA_VERSION,
88
+ nodes: {
89
+ Start: { kind: "manualStart", title: "Start" },
90
+ End: { kind: "end", title: "End" },
91
+ },
92
+ flows: { StartToEnd: { source: "Start", target: "End" } },
93
+ };
94
+ }
@@ -0,0 +1,44 @@
1
+ import type { Condition } from "./ProcessDocument";
2
+ import { isPath, readPath, type InstanceData } from "./paths";
3
+
4
+ /** Evaluates a flow condition. Pure: no calls, no side effects. */
5
+ export function evaluateCondition(condition: Condition, data: InstanceData): boolean {
6
+ if ("all" in condition) return condition.all.every((item) => evaluateCondition(item, data));
7
+ if ("any" in condition) return condition.any.some((item) => evaluateCondition(item, data));
8
+ if ("not" in condition) return !evaluateCondition(condition.not, data);
9
+ const actual = readPath(data, condition.path);
10
+ const expected = isPath(condition.value) ? readPath(data, condition.value) : condition.value;
11
+ switch (condition.op) {
12
+ case "eq": return same(actual, expected);
13
+ case "ne": return !same(actual, expected);
14
+ case "gt": return compare(actual, expected, (a, b) => a > b);
15
+ case "gte": return compare(actual, expected, (a, b) => a >= b);
16
+ case "lt": return compare(actual, expected, (a, b) => a < b);
17
+ case "lte": return compare(actual, expected, (a, b) => a <= b);
18
+ case "in": return Array.isArray(expected) && expected.some((item) => same(actual, item));
19
+ case "contains":
20
+ if (typeof actual === "string") return typeof expected === "string" && actual.includes(expected);
21
+ return Array.isArray(actual) && actual.some((item) => same(item, expected));
22
+ case "empty": return isEmpty(actual);
23
+ case "notEmpty": return !isEmpty(actual);
24
+ }
25
+ }
26
+
27
+ function same(a: unknown, b: unknown): boolean {
28
+ if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return Number(a) === Number(b);
29
+ return JSON.stringify(a) === JSON.stringify(b);
30
+ }
31
+
32
+ /** Numbers compare as numbers (a decimal arrives as a string), other strings as strings (ISO dates compare right). */
33
+ function compare(a: unknown, b: unknown, test: (a: number | string, b: number | string) => boolean): boolean {
34
+ if (a === null || a === undefined || b === null || b === undefined) return false;
35
+ const na = Number(a), nb = Number(b);
36
+ if (typeof a !== "boolean" && typeof b !== "boolean" && a !== "" && b !== "" && Number.isFinite(na) && Number.isFinite(nb)) return test(na, nb);
37
+ return typeof a === "string" && typeof b === "string" ? test(a, b) : false;
38
+ }
39
+
40
+ function isEmpty(value: unknown): boolean {
41
+ if (value === null || value === undefined || value === "") return true;
42
+ if (Array.isArray(value)) return value.length === 0;
43
+ return typeof value === "object" && Object.keys(value).length === 0;
44
+ }
@@ -0,0 +1,10 @@
1
+ import { ISO_DURATION } from "./nodeKinds";
2
+
3
+ /** Milliseconds of an ISO 8601 duration without months and years (`P2D`, `PT1H30M`); `undefined` if it is not one. */
4
+ export function durationMs(value: string): number | undefined {
5
+ const match = ISO_DURATION.exec(value);
6
+ if (!match) return undefined;
7
+ const number = (part: string | undefined) => (part === undefined ? 0 : Number.parseInt(part, 10));
8
+ const weeks = number(match[1]), days = number(match[2]), hours = number(match[4]), minutes = number(match[5]), seconds = number(match[6]);
9
+ return ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
10
+ }
@@ -0,0 +1,112 @@
1
+ /** Field types of a process input and of a task form. */
2
+ export type FormFieldType = "string" | "text" | "number" | "integer" | "boolean" | "date" | "datetime" | "enum";
3
+
4
+ export interface FormField {
5
+ /** The key in the value: `amount`, `reason`. */
6
+ readonly code: string;
7
+ readonly type: FormFieldType;
8
+ readonly title?: string;
9
+ readonly required?: boolean;
10
+ /** Allowed values of an `enum` field. */
11
+ readonly values?: readonly (string | number)[];
12
+ }
13
+
14
+ /** The fields of a process input or a task form. */
15
+ export interface FormSpec {
16
+ readonly fields: readonly FormField[];
17
+ }
18
+
19
+ /** One problem of a value: which field and why. */
20
+ export interface FormIssue {
21
+ readonly field: string;
22
+ readonly code: "required" | "type" | "enum" | "unknown";
23
+ readonly message: string;
24
+ }
25
+
26
+ const TYPES = new Set<FormFieldType>(["string", "text", "number", "integer", "boolean", "date", "datetime", "enum"]);
27
+ const CODE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
28
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
29
+ const MAX_FIELDS = 200;
30
+
31
+ function isObject(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ /** Checks the shape of a form spec; returns a problem or `null`. `at` names it in the text. */
36
+ export function checkFormSpec(value: unknown, at: string): string | null {
37
+ if (!isObject(value) || !Array.isArray(value.fields)) return `${at} must be an object with a fields list`;
38
+ if (value.fields.length > MAX_FIELDS) return `${at} has more than ${MAX_FIELDS} fields`;
39
+ const seen = new Set<string>();
40
+ for (const [index, field] of value.fields.entries()) {
41
+ const where = `${at}.fields[${index}]`;
42
+ if (!isObject(field)) return `${where} must be an object`;
43
+ if (typeof field.code !== "string" || !CODE.test(field.code) || field.code === "__proto__") return `${where}.code is invalid`;
44
+ if (seen.has(field.code)) return `${where}.code ${field.code} is repeated`;
45
+ seen.add(field.code);
46
+ if (typeof field.type !== "string" || !TYPES.has(field.type as FormFieldType)) return `${where}.type must be one of ${[...TYPES].join(", ")}`;
47
+ if (field.title !== undefined && (typeof field.title !== "string" || field.title.length > 200)) return `${where}.title must be up to 200 characters`;
48
+ if (field.required !== undefined && typeof field.required !== "boolean") return `${where}.required must be a boolean`;
49
+ if (field.type === "enum") {
50
+ if (!Array.isArray(field.values) || field.values.length === 0 || !field.values.every((item) => typeof item === "string" || typeof item === "number")) {
51
+ return `${where}.values must be a non-empty list of strings or numbers`;
52
+ }
53
+ } else if (field.values !== undefined) {
54
+ return `${where}.values is only for enum fields`;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /** The field codes of a form spec. */
61
+ export function formFieldCodes(spec: FormSpec): string[] {
62
+ return spec.fields.map((field) => field.code);
63
+ }
64
+
65
+ /**
66
+ * Checks a value against a form spec: required fields, types, enum values and
67
+ * unknown fields. Returns the value with only the declared fields and every issue.
68
+ * Without a spec the form takes no data: any field is unknown.
69
+ */
70
+ export function validateForm(spec: FormSpec | undefined, value: unknown): { readonly value: Record<string, unknown>; readonly issues: readonly FormIssue[] } {
71
+ const issues: FormIssue[] = [];
72
+ const source = value === undefined || value === null ? {} : value;
73
+ if (!isObject(source)) return { value: {}, issues: [{ field: "", code: "type", message: "value must be an object" }] };
74
+ const fields = spec?.fields ?? [];
75
+ const declared = new Set(fields.map((field) => field.code));
76
+ for (const key of Object.keys(source)) {
77
+ if (!declared.has(key)) issues.push({ field: key, code: "unknown", message: `field ${key} is not declared` });
78
+ }
79
+ const out: Record<string, unknown> = {};
80
+ for (const field of fields) {
81
+ const item = Object.hasOwn(source, field.code) ? source[field.code] : undefined;
82
+ if (item === undefined || item === null || item === "") {
83
+ if (field.required === true) issues.push({ field: field.code, code: "required", message: `field ${field.code} is required` });
84
+ continue;
85
+ }
86
+ const problem = checkType(field, item);
87
+ if (problem) issues.push({ field: field.code, ...problem });
88
+ else out[field.code] = item;
89
+ }
90
+ return { value: out, issues };
91
+ }
92
+
93
+ function checkType(field: FormField, value: unknown): { code: "type" | "enum"; message: string } | null {
94
+ const wrong = (expected: string) => ({ code: "type" as const, message: `field ${field.code} must be ${expected}` });
95
+ switch (field.type) {
96
+ case "string":
97
+ case "text":
98
+ return typeof value === "string" ? null : wrong("a string");
99
+ case "number":
100
+ return typeof value === "number" && Number.isFinite(value) ? null : wrong("a number");
101
+ case "integer":
102
+ return Number.isSafeInteger(value) ? null : wrong("an integer");
103
+ case "boolean":
104
+ return typeof value === "boolean" ? null : wrong("true or false");
105
+ case "date":
106
+ return typeof value === "string" && DATE.test(value) && !Number.isNaN(Date.parse(value)) ? null : wrong("a date YYYY-MM-DD");
107
+ case "datetime":
108
+ return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? null : wrong("an ISO 8601 date and time");
109
+ case "enum":
110
+ return (field.values ?? []).includes(value as string | number) ? null : { code: "enum", message: `field ${field.code} must be one of ${(field.values ?? []).join(", ")}` };
111
+ }
112
+ }
@@ -0,0 +1,24 @@
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.
5
+ */
6
+ export {
7
+ PROCESS_SCHEMA_VERSION,
8
+ emptyProcess,
9
+ type Bounds,
10
+ type Condition,
11
+ type ConditionOp,
12
+ type NodeKind,
13
+ type Point,
14
+ type ProcessDiagram,
15
+ type ProcessDocument,
16
+ type ProcessFlow,
17
+ type ProcessNode,
18
+ } from "./ProcessDocument";
19
+ export { NODE_KINDS, nodeKindOf, ISO_DURATION, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
20
+ 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";
23
+ export { durationMs } from "./duration";
24
+ export { checkFormSpec, formFieldCodes, validateForm, type FormField, type FormFieldType, type FormIssue, type FormSpec } from "./formFields";