bazis 0.133.0 → 0.135.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/access/contracts/References.ts +2 -0
- package/core/agent/AgentToolExecutor.ts +5 -0
- package/core/agent/Tool.contract.ts +2 -2
- package/core/agent/metadata.ts +11 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/scripts/di-generate.ts +8 -0
- package/core/tools/StandardTools.module.ts +14 -0
- package/core/tools/generated/deps.ts +10 -0
- package/core/tools/index.ts +8 -0
- package/core/tools/orm/OrmEntity.references.ts +75 -0
- package/core/tools/orm/ormEntityTools.ts +423 -0
- package/core/workflow/contracts/WorkflowError.ts +2 -1
- package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
- package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +9 -2
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
- package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +13 -1
- package/core/workflow/definitions_module/code/inspectProcesses.ts +15 -4
- package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
- package/core/workflow/definitions_module/http/Processes.controller.ts +19 -4
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +31 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +80 -6
- package/core/workflow/generated/deps.ts +13 -4
- package/core/workflow/index.ts +5 -0
- package/core/workflow/internal/WorkflowReferences.ts +69 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +5 -0
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +1 -1
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +15 -11
- package/core/workflow/runtime_module/events/Runtime.events.ts +7 -0
- package/core/workflow/runtime_module/http/Instances.controller.ts +7 -2
- package/core/workflow/runtime_module/internal/ChildCalls.ts +1 -0
- package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
- package/core/workflow/runtime_module/internal/RunLimits.ts +16 -5
- package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
- package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
- package/core/workflow/runtime_module/internal/ToolStep.ts +16 -2
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +48 -9
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +7 -0
- package/core/workflow/runtime_module/model/WfJob.model.ts +7 -6
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +34 -1
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +40 -41
- package/core/workflow/tasks_module/events/TaskEvents.ts +17 -1
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -0
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +2 -1
- package/library/workflow/cron.ts +103 -24
- package/library/workflow/index.ts +3 -2
- package/library/workflow/nodeKinds.ts +64 -9
- package/library/workflow/paths.ts +2 -2
- package/library/workflow/references.ts +106 -0
- package/library/workflow/validateProcess.ts +13 -0
- package/package.json +2 -1
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { grantsToWhere } from "../../../access";
|
|
2
|
-
import { paginate } from "../../../orm";
|
|
3
|
-
import { inputSpecOf, type
|
|
2
|
+
import { paginate, type OrmTransaction } from "../../../orm";
|
|
3
|
+
import { inputSpecOf, type FormSpec } from "../../../../library/workflow";
|
|
4
4
|
import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
|
|
5
5
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
6
6
|
import type { ServiceProvider } from "../../../di";
|
|
7
7
|
import { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
|
|
8
8
|
import type { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
9
9
|
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
10
|
+
import type { EventDelivery, EventMessage } from "../internal/EventDelivery";
|
|
10
11
|
import type { WorkflowEngine } from "../internal/WorkflowEngine";
|
|
11
12
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
12
13
|
import type { WfHistory } from "../model/WfHistory.model";
|
|
13
14
|
import type { WfInstance } from "../model/WfInstance.model";
|
|
15
|
+
import { WfJob } from "../model/WfJob.model";
|
|
14
16
|
import type { WfToken } from "../model/WfToken.model";
|
|
15
17
|
import {
|
|
16
18
|
IWorkflowRuntime,
|
|
@@ -35,6 +37,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
35
37
|
constructor(
|
|
36
38
|
private readonly db: RuntimeDbContext,
|
|
37
39
|
private readonly engine: WorkflowEngine,
|
|
40
|
+
private readonly delivery: EventDelivery,
|
|
38
41
|
private readonly definitions: IWorkflowDefinitions,
|
|
39
42
|
/** The scope: the input validator is asked here, so that the application's own registration replaces the default. */
|
|
40
43
|
private readonly services: ServiceProvider,
|
|
@@ -52,7 +55,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
52
55
|
if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
|
|
53
56
|
const { value, issues } = await this.validator().validate(inputSpecOf(version.document), options.input, { purpose: "input", processKey, partition, language: options.language ?? this.languages.default, actor: options.actor });
|
|
54
57
|
if (issues.length > 0) throw new WorkflowError("invalid_input", "The process input is invalid", issues);
|
|
55
|
-
const { id, skipped } = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor, request: options.request ?? null });
|
|
58
|
+
const { id, skipped } = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor, request: options.request ?? null, ...(options.test === true ? { test: true } : {}) });
|
|
56
59
|
if (skipped) throw new WorkflowError("conflict", `Instance ${id} with this businessKey is not finished`);
|
|
57
60
|
return await this.instance(id);
|
|
58
61
|
}
|
|
@@ -135,45 +138,14 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
async emit(eventKey: string, payload: unknown = {}, options: EmitOptions = {}): Promise<EmitResult> {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const data = payload as Record<string, unknown>;
|
|
141
|
-
const correlation = optionalText(options.correlation, "correlation");
|
|
142
|
-
const partition = optionalText(options.partition, "partition");
|
|
143
|
-
const actor = options.actor ?? { kind: "system" as const, id: `event:${eventKey}` };
|
|
144
|
-
actorOf(actor);
|
|
145
|
-
|
|
146
|
-
let waits = this.db.subscriptions.asNoTracking().where((s) => s.eventKey.eq(eventKey));
|
|
147
|
-
waits = correlation === null ? waits.where((s) => s.correlation.isNull()) : waits.where((s) => s.correlation.isNull().or(s.correlation.eq(correlation)));
|
|
148
|
-
if (partition !== null) waits = waits.where((s) => s.partition.eq(partition));
|
|
149
|
-
const continued = new Set<string>();
|
|
150
|
-
for (const wait of await waits.orderBy((s) => s.id).toList()) {
|
|
151
|
-
const moved = await this.engine.inStep(wait.instanceId, async (step) => {
|
|
152
|
-
await this.db.subscriptions.asNoTracking().where((s) => s.id.eq(wait.id)).executeDelete();
|
|
153
|
-
return await this.engine.eventReceived(step, wait.tokenId, eventKey, data);
|
|
154
|
-
});
|
|
155
|
-
if (moved === true) continued.add(wait.instanceId);
|
|
156
|
-
}
|
|
141
|
+
return await this.delivery.emit(messageOf(eventKey, payload, options));
|
|
142
|
+
}
|
|
157
143
|
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (node.kind !== "eventStart" || node.config?.event !== eventKey) continue;
|
|
164
|
-
// An event carries more than a process needs: fields the input does not declare are dropped, not refused.
|
|
165
|
-
const { value, issues: all } = await this.validator().validate(inputSpecOf(version.document), data, { purpose: "input", processKey: version.key, partition, language: this.languages.default, actor });
|
|
166
|
-
const issues = all.filter((issue) => issue.code !== "unknown");
|
|
167
|
-
if (issues.length > 0) {
|
|
168
|
-
rejected.push({ processKey: version.key, issues });
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
const begun = await this.engine.begin(this.engine.cache(version), nodeId, { input: value, partition, businessKey: correlation, startedVia: "event", actor, request: options.request ?? null, event: { key: eventKey, payload: data } });
|
|
172
|
-
if (begun.skipped) skipped.push({ processKey: version.key, instanceId: begun.id });
|
|
173
|
-
else started.push(begun.id);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return { started, skipped, continued: [...continued], rejected };
|
|
144
|
+
async emitLater(tx: OrmTransaction, eventKey: string, payload: unknown = {}, options: EmitOptions = {}): Promise<void> {
|
|
145
|
+
const message = messageOf(eventKey, payload, options);
|
|
146
|
+
const now = this.engine.now();
|
|
147
|
+
const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "event" as const, partition: message.partition, dueAt: now, payload: message, createdAt: now });
|
|
148
|
+
await tx.use(this.db, (db) => db.jobs.insertIfAbsent(job, { conflictBy: (j) => [j.id] }));
|
|
177
149
|
}
|
|
178
150
|
|
|
179
151
|
async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
|
|
@@ -188,6 +160,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
188
160
|
if (query.businessKey !== undefined) source = source.where((i) => i.businessKey.eq(query.businessKey!));
|
|
189
161
|
if (query.startedById !== undefined) source = source.where((i) => i.startedById.eq(query.startedById!));
|
|
190
162
|
if (query.parentInstanceId !== undefined) source = source.where((i) => i.parentInstanceId.eq(query.parentInstanceId!));
|
|
163
|
+
if (query.test !== undefined || query.parentInstanceId === undefined) source = source.where((i) => i.test.eq(query.test === true));
|
|
191
164
|
const { items, total } = await paginate(source, pageQueryOf(query, { field: "id", dir: "desc" }));
|
|
192
165
|
return { items: items.map(summaryOf), total };
|
|
193
166
|
}
|
|
@@ -214,6 +187,23 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
214
187
|
}
|
|
215
188
|
}
|
|
216
189
|
|
|
190
|
+
/** An event as the delivery takes it; what cannot be delivered is refused here. */
|
|
191
|
+
function messageOf(eventKey: string, payload: unknown, options: EmitOptions): EventMessage {
|
|
192
|
+
if (typeof eventKey !== "string" || !EVENT_KEY.test(eventKey)) throw new WorkflowError("invalid_input", "eventKey must look like payments.received");
|
|
193
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new WorkflowError("invalid_input", "payload must be a JSON object");
|
|
194
|
+
const actor = options.actor ?? { kind: "system" as const, id: `event:${eventKey}` };
|
|
195
|
+
actorOf(actor);
|
|
196
|
+
return {
|
|
197
|
+
eventKey,
|
|
198
|
+
payload: payload as Record<string, unknown>,
|
|
199
|
+
correlation: optionalText(options.correlation, "correlation"),
|
|
200
|
+
partition: optionalText(options.partition, "partition"),
|
|
201
|
+
actor,
|
|
202
|
+
request: options.request ?? null,
|
|
203
|
+
cause: options.cause ?? null,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
217
207
|
function notFound(instanceId: string): WorkflowError {
|
|
218
208
|
return new WorkflowError("not_found", `Instance ${instanceId} does not exist`);
|
|
219
209
|
}
|
|
@@ -242,11 +232,20 @@ function summaryOf(row: WfInstance): InstanceSummary {
|
|
|
242
232
|
startedBy: { kind: row.startedByKind, id: row.startedById },
|
|
243
233
|
parentInstanceId: row.parentInstanceId,
|
|
244
234
|
error: row.error,
|
|
235
|
+
failedNode: row.error === null || row.error.node === "" ? null : row.error.node,
|
|
236
|
+
test: row.test,
|
|
245
237
|
createdAt: row.createdAt,
|
|
246
238
|
endedAt: row.endedAt,
|
|
239
|
+
durationMs: durationOf(row),
|
|
247
240
|
};
|
|
248
241
|
}
|
|
249
242
|
|
|
243
|
+
/** From the start by the engine's clock: `createdAt` is the clock of the database. */
|
|
244
|
+
function durationOf(row: WfInstance): number | null {
|
|
245
|
+
const startedAt = typeof row.trigger?.at === "string" ? Date.parse(row.trigger.at) : Number.NaN;
|
|
246
|
+
return row.endedAt === null || Number.isNaN(startedAt) ? null : row.endedAt.getTime() - startedAt;
|
|
247
|
+
}
|
|
248
|
+
|
|
250
249
|
function tokenOf(row: WfToken) {
|
|
251
250
|
return { id: row.id, nodeId: row.nodeId, viaFlowId: row.viaFlowId, parentTokenId: row.parentTokenId, iteration: row.iteration, status: row.status, output: row.output, createdAt: row.createdAt, endedAt: row.endedAt };
|
|
252
251
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { OnEvent } from "../../../kernel";
|
|
2
|
-
import { WORKFLOW_INSTANCES_DELETED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, type WorkflowInstancesDeleted, type WorkflowTaskRequested, type WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
|
|
2
|
+
import { WORKFLOW_INSTANCES_DELETED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, WORKFLOW_TRIAL_TASK_REQUESTED, WORKFLOW_TRIAL_TOKEN_CLOSED, type WorkflowInstancesDeleted, type WorkflowTaskRequested, type WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
|
|
3
3
|
import type { TasksDbContext } from "../model/Tasks.dbContext";
|
|
4
4
|
import { WfTask } from "../model/WfTask.model";
|
|
5
5
|
|
|
@@ -10,6 +10,16 @@ export class TaskEvents {
|
|
|
10
10
|
/** A step started waiting for a person: its task. Repeated events of one token make one task. */
|
|
11
11
|
@OnEvent(WORKFLOW_TASK_REQUESTED)
|
|
12
12
|
async requested(event: WorkflowTaskRequested): Promise<void> {
|
|
13
|
+
await this.add(event, false);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The task of a trial run: it goes to who started it, and is marked. */
|
|
17
|
+
@OnEvent(WORKFLOW_TRIAL_TASK_REQUESTED)
|
|
18
|
+
async trialRequested(event: WorkflowTaskRequested): Promise<void> {
|
|
19
|
+
await this.add(event, true);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private async add(event: WorkflowTaskRequested, test: boolean): Promise<void> {
|
|
13
23
|
const assignee = "role" in event.assignee ? `role:${event.assignee.role}` : `user:${event.assignee.user}`;
|
|
14
24
|
// Past the change tracker: the scope may go on to update tasks directly, which a context holding one would refuse.
|
|
15
25
|
await this.db.tasks.insertIfAbsent(Object.assign(new WfTask(), {
|
|
@@ -18,6 +28,7 @@ export class TaskEvents {
|
|
|
18
28
|
processKey: event.processKey,
|
|
19
29
|
nodeId: event.nodeId,
|
|
20
30
|
partition: event.partition,
|
|
31
|
+
test,
|
|
21
32
|
title: event.title,
|
|
22
33
|
assignee,
|
|
23
34
|
form: event.form,
|
|
@@ -32,6 +43,11 @@ export class TaskEvents {
|
|
|
32
43
|
await this.db.tasks.asNoTracking().where((t) => t.tokenId.eq(event.tokenId).and(t.status.in(["open", "claimed"]))).executeUpdate({ status: "cancelled" });
|
|
33
44
|
}
|
|
34
45
|
|
|
46
|
+
@OnEvent(WORKFLOW_TRIAL_TOKEN_CLOSED)
|
|
47
|
+
async trialClosed(event: WorkflowTokenClosed): Promise<void> {
|
|
48
|
+
await this.closed(event);
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
/** Retention deleted the instances: their tasks go too. */
|
|
36
52
|
@OnEvent(WORKFLOW_INSTANCES_DELETED)
|
|
37
53
|
async deleted(event: WorkflowInstancesDeleted): Promise<void> {
|
|
@@ -11,6 +11,8 @@ export interface TaskDetails {
|
|
|
11
11
|
readonly nodeId: string;
|
|
12
12
|
readonly partition: string | null;
|
|
13
13
|
readonly title: LocalizedText;
|
|
14
|
+
/** A task of a trial run. */
|
|
15
|
+
readonly test: boolean;
|
|
14
16
|
readonly assignee: string;
|
|
15
17
|
readonly form: FormSpec | null;
|
|
16
18
|
readonly dueAt: Date | null;
|
|
@@ -100,7 +100,7 @@ export class WorkflowTasks extends IWorkflowTasks {
|
|
|
100
100
|
completedAt: this.clock.now(),
|
|
101
101
|
output: typeof saved === "object" && saved !== null && !Array.isArray(saved) ? saved as Record<string, unknown> : null,
|
|
102
102
|
});
|
|
103
|
-
await this.events.publish(WORKFLOW_TASK_COMPLETED, { taskId: task.id, instanceId: task.instanceId, processKey: task.processKey, nodeId: task.nodeId, partition: task.partition, actor, request: options.request ?? null });
|
|
103
|
+
if (!task.test) await this.events.publish(WORKFLOW_TASK_COMPLETED, { taskId: task.id, instanceId: task.instanceId, processKey: task.processKey, nodeId: task.nodeId, partition: task.partition, actor, request: options.request ?? null });
|
|
104
104
|
return await this.get(task.id);
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -157,6 +157,7 @@ function detailsOf(row: WfTask): TaskDetails {
|
|
|
157
157
|
nodeId: row.nodeId,
|
|
158
158
|
partition: row.partition,
|
|
159
159
|
title: row.title,
|
|
160
|
+
test: row.test,
|
|
160
161
|
assignee: row.assignee,
|
|
161
162
|
form: row.form,
|
|
162
163
|
dueAt: row.dueAt,
|
package/library/workflow/cron.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** A parsed five-field cron expression, evaluated in UTC. */
|
|
1
|
+
/** A parsed five-field cron expression, evaluated in UTC or in a time zone. */
|
|
2
2
|
export interface CronSchedule {
|
|
3
3
|
/** Whether the expression fires at this minute. */
|
|
4
4
|
matches(date: Date): boolean;
|
|
@@ -15,14 +15,53 @@ const FIELDS = [
|
|
|
15
15
|
] as const;
|
|
16
16
|
|
|
17
17
|
const MINUTE = 60_000;
|
|
18
|
+
const DAY_MINUTES = 24 * 60;
|
|
19
|
+
/** Clock changes are never longer than this: a day is skipped up to here, the rest hour by hour. */
|
|
20
|
+
const CLOCK_CHANGE_MINUTES = 3 * 60;
|
|
21
|
+
|
|
22
|
+
const formats = new Map<string, Intl.DateTimeFormat>();
|
|
23
|
+
|
|
24
|
+
function formatOf(timezone: string): Intl.DateTimeFormat {
|
|
25
|
+
let format = formats.get(timezone);
|
|
26
|
+
if (format === undefined) {
|
|
27
|
+
format = new Intl.DateTimeFormat("en-US", { timeZone: timezone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" });
|
|
28
|
+
formats.set(timezone, format);
|
|
29
|
+
}
|
|
30
|
+
return format;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether this is an IANA time zone: `Europe/Moscow`, `UTC`. */
|
|
34
|
+
export function isTimeZone(value: unknown): value is string {
|
|
35
|
+
if (typeof value !== "string" || value === "") return false;
|
|
36
|
+
try {
|
|
37
|
+
formatOf(value);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How far the clocks of the zone are ahead of UTC at this moment, in milliseconds. */
|
|
45
|
+
function offsetOf(timezone: string | undefined, time: number): number {
|
|
46
|
+
if (timezone === undefined) return 0;
|
|
47
|
+
const parts: Record<string, number> = {};
|
|
48
|
+
for (const { type, value } of formatOf(timezone).formatToParts(time)) parts[type] = Number(value);
|
|
49
|
+
return Date.UTC(parts.year!, parts.month! - 1, parts.day!, parts.hour!, parts.minute!, parts.second!) - Math.floor(time / 1000) * 1000;
|
|
50
|
+
}
|
|
18
51
|
|
|
19
52
|
/**
|
|
20
53
|
* Parses `minute hour day-of-month month day-of-week` with `*`, numbers,
|
|
21
54
|
* ranges `a-b`, steps `/n` and lists `a,b`. Day of week 0 and 7 are Sunday.
|
|
22
55
|
* When both day fields are restricted, either one matching is enough, as in
|
|
23
|
-
* classic cron. Returns `undefined` for an invalid expression.
|
|
56
|
+
* classic cron. Returns `undefined` for an invalid expression or time zone.
|
|
57
|
+
*
|
|
58
|
+
* With a `timezone` the fields are read on its clocks. A schedule with a set
|
|
59
|
+
* hour fires once a day whatever the clocks do: a time skipped when they go
|
|
60
|
+
* forward fires right after the change, a time repeated when they go back fires
|
|
61
|
+
* the first time only. A schedule with `*` in the hour simply follows the clocks.
|
|
24
62
|
*/
|
|
25
|
-
export function parseCron(expression: string): CronSchedule | undefined {
|
|
63
|
+
export function parseCron(expression: string, timezone?: string): CronSchedule | undefined {
|
|
64
|
+
if (timezone !== undefined && !isTimeZone(timezone)) return undefined;
|
|
26
65
|
const parts = expression.trim().split(/\s+/);
|
|
27
66
|
if (parts.length !== 5) return undefined;
|
|
28
67
|
const sets: Set<number>[] = [];
|
|
@@ -35,34 +74,74 @@ export function parseCron(expression: string): CronSchedule | undefined {
|
|
|
35
74
|
if (weekdays.has(7)) weekdays.add(0);
|
|
36
75
|
const daysRestricted = parts[2] !== "*";
|
|
37
76
|
const weekdaysRestricted = parts[4] !== "*";
|
|
77
|
+
const hourSet = parts[1] !== "*";
|
|
38
78
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
79
|
+
/** The date part of a time on the clocks of the zone, kept in the UTC fields of a `Date`. */
|
|
80
|
+
const dayMatches = (clock: Date): boolean => {
|
|
81
|
+
const day = days.has(clock.getUTCDate());
|
|
82
|
+
const weekday = weekdays.has(clock.getUTCDay());
|
|
83
|
+
return months.has(clock.getUTCMonth() + 1) && (daysRestricted && weekdaysRestricted ? day || weekday : day && weekday);
|
|
43
84
|
};
|
|
44
85
|
|
|
45
|
-
const
|
|
46
|
-
|
|
86
|
+
const clockMatches = (clock: Date): boolean => minutes.has(clock.getUTCMinutes()) && hours.has(clock.getUTCHours()) && dayMatches(clock);
|
|
87
|
+
|
|
88
|
+
/** Whether the clocks show this time for the second time: they went back a moment ago. */
|
|
89
|
+
const repeated = (time: number, offset: number): boolean => {
|
|
90
|
+
const before = offsetOf(timezone, time - CLOCK_CHANGE_MINUTES * MINUTE);
|
|
91
|
+
if (before <= offset) return false;
|
|
92
|
+
const first = time - (before - offset);
|
|
93
|
+
return offsetOf(timezone, first) === before;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** When the clocks jumped forward between two moments, if a firing fell into the skipped time. */
|
|
97
|
+
const skippedFiring = (from: number, to: number): number | undefined => {
|
|
98
|
+
const before = offsetOf(timezone, from);
|
|
99
|
+
const after = offsetOf(timezone, to);
|
|
100
|
+
if (after <= before) return undefined;
|
|
101
|
+
let low = from;
|
|
102
|
+
let high = to;
|
|
103
|
+
while (high - low > MINUTE) {
|
|
104
|
+
const middle = Math.floor((low + high) / 2 / MINUTE) * MINUTE;
|
|
105
|
+
if (offsetOf(timezone, middle) === before) low = middle;
|
|
106
|
+
else high = middle;
|
|
107
|
+
}
|
|
108
|
+
for (let clock = high + before; clock < high + after; clock += MINUTE) {
|
|
109
|
+
if (clockMatches(new Date(clock))) return high;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
};
|
|
47
113
|
|
|
48
114
|
return {
|
|
49
|
-
matches
|
|
115
|
+
matches(date: Date): boolean {
|
|
116
|
+
const offset = offsetOf(timezone, date.getTime());
|
|
117
|
+
if (!clockMatches(new Date(date.getTime() + offset))) return false;
|
|
118
|
+
return !(hourSet && timezone !== undefined && repeated(Math.floor(date.getTime() / MINUTE) * MINUTE, offset));
|
|
119
|
+
},
|
|
50
120
|
next(after: Date): Date | undefined {
|
|
51
|
-
const limit = after.getTime() + 5 * 366 *
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
121
|
+
const limit = after.getTime() + 5 * 366 * DAY_MINUTES * MINUTE;
|
|
122
|
+
let time = Math.floor(after.getTime() / MINUTE) * MINUTE + MINUTE;
|
|
123
|
+
let previous = time - MINUTE;
|
|
124
|
+
while (time <= limit) {
|
|
125
|
+
if (hourSet && timezone !== undefined) {
|
|
126
|
+
const skipped = skippedFiring(previous, time);
|
|
127
|
+
if (skipped !== undefined) return new Date(skipped);
|
|
128
|
+
}
|
|
129
|
+
previous = time;
|
|
130
|
+
const offset = offsetOf(timezone, time);
|
|
131
|
+
const clock = new Date(time + offset);
|
|
132
|
+
const minutesOfDay = clock.getUTCHours() * 60 + clock.getUTCMinutes();
|
|
133
|
+
if (!dayMatches(clock)) {
|
|
134
|
+
// Close to midnight hour by hour: the day may be shorter than its clocks say.
|
|
135
|
+
const left = DAY_MINUTES - minutesOfDay;
|
|
136
|
+
time += (timezone === undefined ? left : left > CLOCK_CHANGE_MINUTES ? left - CLOCK_CHANGE_MINUTES : 60 - clock.getUTCMinutes()) * MINUTE;
|
|
137
|
+
} else if (!hours.has(clock.getUTCHours())) {
|
|
138
|
+
time += (60 - clock.getUTCMinutes()) * MINUTE;
|
|
139
|
+
} else if (!minutes.has(clock.getUTCMinutes())) {
|
|
140
|
+
time += MINUTE;
|
|
141
|
+
} else if (hourSet && timezone !== undefined && repeated(time, offset)) {
|
|
142
|
+
time += MINUTE;
|
|
64
143
|
} else {
|
|
65
|
-
return
|
|
144
|
+
return new Date(time);
|
|
66
145
|
}
|
|
67
146
|
}
|
|
68
147
|
return undefined;
|
|
@@ -20,7 +20,7 @@ export {
|
|
|
20
20
|
type ProcessNode,
|
|
21
21
|
type ProcessSettings,
|
|
22
22
|
} from "./ProcessDocument";
|
|
23
|
-
export { NODE_KINDS, nodeKindOf, ISO_DURATION, MAX_ITERATIONS, MAX_LOOP_PARALLEL, PROCESS_KEY, type JsonSchema, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
|
|
23
|
+
export { NODE_KINDS, nodeKindOf, ISO_DURATION, MAX_ITERATIONS, MAX_LOOP_PARALLEL, PROCESS_KEY, ISO_MOMENT, type JsonSchema, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
|
|
24
24
|
export { PROCESS_LIMITS, validateProcess, hasErrors, type ProcessIssue, type ProcessIssueCode, type ValidateProcessOptions } from "./validateProcess";
|
|
25
25
|
export { DATA_PATH, DATA_PATHS, isPath, readPath, resolveInput, type InstanceData } from "./paths";
|
|
26
26
|
export {
|
|
@@ -42,7 +42,8 @@ export {
|
|
|
42
42
|
} from "./expressions";
|
|
43
43
|
export { ExpressionSyntaxError, parseExpression, printExpression } from "./expressionSyntax";
|
|
44
44
|
export { durationMs } from "./duration";
|
|
45
|
-
export {
|
|
45
|
+
export { documentReferences, inputReferences, schemaReferenceKinds, type DocumentReference, type InputReference } from "./references";
|
|
46
|
+
export { isTimeZone, parseCron, type CronSchedule } from "./cron";
|
|
46
47
|
export { processToMermaid } from "./mermaid";
|
|
47
48
|
export { PERSONAL_MASK, maskByMapping, maskBySchema, masksPersonal, personalPaths, personalVariables, readsPersonal } from "./masking";
|
|
48
49
|
export { allTexts, checkLocalizedText, textOf, type LocalizedText } from "./localized";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseCron } from "./cron";
|
|
1
|
+
import { isTimeZone, parseCron } from "./cron";
|
|
2
2
|
import { FIELD_NAME, checkFormSpec } from "./formFields";
|
|
3
3
|
import { DATA_PATH } from "./paths";
|
|
4
4
|
import type { NodeKind } from "./ProcessDocument";
|
|
@@ -52,11 +52,42 @@ function first(...checks: (string | null)[]): string | null {
|
|
|
52
52
|
return checks.find((check) => check !== null) ?? null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
/** A moment with its zone: `2026-11-01T09:00:00+03:00`. */
|
|
56
|
+
export const ISO_MOMENT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
57
|
+
|
|
58
|
+
function moment(config: Record<string, unknown>, key: string): string | null {
|
|
59
|
+
const value = config[key];
|
|
60
|
+
if (value === undefined) return null;
|
|
61
|
+
return typeof value === "string" && ISO_MOMENT.test(value) && !Number.isNaN(Date.parse(value)) ? null : `config.${key} must be a moment with its zone: 2026-11-01T09:00:00+03:00`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A scheduled start: five cron fields (minute, hour, day of month, month, day of week), where and when it runs. */
|
|
65
|
+
function schedule(config: Record<string, unknown>): string | null {
|
|
66
|
+
const { cron, timezone, from, until, overlap, partitions } = config;
|
|
67
|
+
if (typeof cron !== "string") return "config.cron is required";
|
|
68
|
+
if (parseCron(cron) === undefined) return "config.cron must be five valid fields: minute hour day month weekday";
|
|
69
|
+
if (timezone !== undefined && !isTimeZone(timezone)) return "config.timezone must be an IANA time zone: Europe/Moscow";
|
|
70
|
+
const period = first(moment(config, "from"), moment(config, "until"), boolean(config, "catchUp"));
|
|
71
|
+
if (period !== null) return period;
|
|
72
|
+
if (typeof from === "string" && typeof until === "string" && Date.parse(from) >= Date.parse(until)) return "config.from must be before config.until";
|
|
73
|
+
if (overlap !== undefined && overlap !== "skip" && overlap !== "queue" && overlap !== "parallel") return "config.overlap must be skip, queue or parallel";
|
|
74
|
+
if (partitions !== undefined && partitions !== "none" && partitions !== "all") {
|
|
75
|
+
if (!Array.isArray(partitions) || partitions.length === 0 || partitions.length > 1000 || !partitions.every((item) => typeof item === "string" && item !== "" && item.length <= 200)) return "config.partitions must be none, all or a list of partitions";
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An event start; its expressions are checked with the whole document. */
|
|
81
|
+
function eventStart(config: Record<string, unknown>): string | null {
|
|
82
|
+
const { debounce } = config;
|
|
83
|
+
if (debounce !== undefined) {
|
|
84
|
+
if (typeof debounce !== "object" || debounce === null || Array.isArray(debounce)) return "config.debounce must be an object";
|
|
85
|
+
const { key } = debounce as { key?: unknown };
|
|
86
|
+
if (key === undefined) return "config.debounce.key is required";
|
|
87
|
+
const problem = string(debounce as Record<string, unknown>, "window", ISO_DURATION);
|
|
88
|
+
if (problem !== null) return problem.replace("config.", "config.debounce.");
|
|
89
|
+
}
|
|
90
|
+
return first(string(config, "event", DOTTED_KEY), string(config, "delay", ISO_DURATION, false), boolean(config, "ignoreOwn"));
|
|
60
91
|
}
|
|
61
92
|
|
|
62
93
|
/** A timer: either `duration` (ISO 8601) or `at`, a path to a date in instance data. */
|
|
@@ -158,8 +189,32 @@ const fields = { type: "object", properties: { fields: { type: "array", items: {
|
|
|
158
189
|
|
|
159
190
|
export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
|
|
160
191
|
{ kind: "manualStart", role: "start", check: none, config: object({}) },
|
|
161
|
-
{
|
|
162
|
-
|
|
192
|
+
{
|
|
193
|
+
kind: "scheduleStart",
|
|
194
|
+
role: "start",
|
|
195
|
+
check: schedule,
|
|
196
|
+
config: object({
|
|
197
|
+
cron: text,
|
|
198
|
+
timezone: { type: "string", description: "An IANA time zone; UTC by default" },
|
|
199
|
+
from: { type: "string", format: "date-time" },
|
|
200
|
+
until: { type: "string", format: "date-time" },
|
|
201
|
+
overlap: { enum: ["skip", "queue", "parallel"] },
|
|
202
|
+
catchUp: flag,
|
|
203
|
+
partitions: { description: "none, all or a list of partitions", oneOf: [{ enum: ["none", "all"] }, { type: "array", items: text }] },
|
|
204
|
+
}, ["cron"]),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
kind: "eventStart",
|
|
208
|
+
role: "start",
|
|
209
|
+
check: eventStart,
|
|
210
|
+
config: object({
|
|
211
|
+
event: text,
|
|
212
|
+
filter: expression,
|
|
213
|
+
debounce: object({ key: expression, window: duration }, ["key", "window"]),
|
|
214
|
+
delay: duration,
|
|
215
|
+
ignoreOwn: flag,
|
|
216
|
+
}, ["event"]),
|
|
217
|
+
},
|
|
163
218
|
{
|
|
164
219
|
kind: "userTask",
|
|
165
220
|
role: "activity",
|
|
@@ -218,7 +273,7 @@ export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
|
|
|
218
273
|
kind: "eventWait",
|
|
219
274
|
role: "activity",
|
|
220
275
|
check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), string(c, "saveTo", FIELD_NAME, false)),
|
|
221
|
-
config: object({ event: text, correlation: dataPath, saveTo }, ["event"]),
|
|
276
|
+
config: object({ event: text, correlation: dataPath, saveTo, until: expression }, ["event"]),
|
|
222
277
|
},
|
|
223
278
|
{ kind: "exclusiveGateway", role: "gateway", check: none, config: object({}) },
|
|
224
279
|
{ kind: "parallelGateway", role: "gateway", check: none, config: object({}) },
|
|
@@ -10,7 +10,7 @@ export interface InstanceData {
|
|
|
10
10
|
readonly nodes: Readonly<Record<string, unknown>>;
|
|
11
11
|
/** The data of the event that started or continued the process. */
|
|
12
12
|
readonly event?: unknown;
|
|
13
|
-
/** How it was started: `{ via, at, actor, eventKey?, cause? }`. */
|
|
13
|
+
/** How it was started: `{ via, at, actor, eventKey?, cause?, scheduledAt? }`. */
|
|
14
14
|
readonly trigger?: unknown;
|
|
15
15
|
/** `{ id, processKey, version, partition, test }`. */
|
|
16
16
|
readonly run?: unknown;
|
|
@@ -31,7 +31,7 @@ export const DATA_PATHS: readonly { readonly path: string; readonly description:
|
|
|
31
31
|
{ path: "$vars.<name>", description: "A variable of the process" },
|
|
32
32
|
{ path: "$nodes.<node>.<field>", description: "The result of a passed step" },
|
|
33
33
|
{ path: "$event", description: "The data of the event that started or continued the process" },
|
|
34
|
-
{ path: "$trigger", description: "How it was started: { via, at, actor, eventKey?, cause? }" },
|
|
34
|
+
{ path: "$trigger", description: "How it was started: { via, at, actor, eventKey?, cause?, scheduledAt? }" },
|
|
35
35
|
{ path: "$run", description: "This run: { id, processKey, version, partition, test }" },
|
|
36
36
|
{ path: "$now", description: "The engine's time" },
|
|
37
37
|
{ path: "$item", description: "Inside a loop body: the element of this iteration" },
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { isExpressionNode } from "./expressions";
|
|
2
|
+
import { isPath } from "./paths";
|
|
3
|
+
import type { ProcessDocument } from "./ProcessDocument";
|
|
4
|
+
|
|
5
|
+
/** One reference in a Tool input or a document: what `x-ref` and `x-ref-keys` of a schema mark. */
|
|
6
|
+
export interface InputReference {
|
|
7
|
+
readonly kind: string;
|
|
8
|
+
readonly id: string;
|
|
9
|
+
/** The area it is looked for in: `x-ref-scope`. */
|
|
10
|
+
readonly within?: string;
|
|
11
|
+
/** Where in the input: `/values/status`. */
|
|
12
|
+
readonly path: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A reference of a document, and whose it is. */
|
|
16
|
+
export interface DocumentReference extends InputReference {
|
|
17
|
+
readonly node?: string;
|
|
18
|
+
readonly variable?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Json = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
function isObject(value: unknown): value is Json {
|
|
24
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether a value is known only when the step runs. */
|
|
28
|
+
function isComputed(value: unknown): boolean {
|
|
29
|
+
return isPath(value) || isExpressionNode(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isId(value: unknown): value is string | number {
|
|
33
|
+
return (typeof value === "string" && value !== "") || typeof value === "number";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The references in a Tool input by the JSON Schema of the input. A value computed
|
|
38
|
+
* when the step runs is left out: it is checked then. `x-ref-scope` beginning
|
|
39
|
+
* with `/` is a JSON Pointer to another field of the input; otherwise it is the area
|
|
40
|
+
* itself. A reference whose area is computed is left out too.
|
|
41
|
+
*/
|
|
42
|
+
export function inputReferences(schema: unknown, input: unknown): InputReference[] {
|
|
43
|
+
const out: InputReference[] = [];
|
|
44
|
+
const within = (source: Json): { readonly known: boolean; readonly value?: string } => {
|
|
45
|
+
const scope = source["x-ref-scope"];
|
|
46
|
+
if (typeof scope !== "string") return { known: true };
|
|
47
|
+
if (!scope.startsWith("/")) return { known: true, value: scope };
|
|
48
|
+
let current: unknown = input;
|
|
49
|
+
for (const key of scope.slice(1).split("/")) {
|
|
50
|
+
current = isObject(current) && Object.hasOwn(current, key) ? current[key] : undefined;
|
|
51
|
+
}
|
|
52
|
+
return isId(current) && !isComputed(current) ? { known: true, value: String(current) } : { known: false };
|
|
53
|
+
};
|
|
54
|
+
const walk = (source: unknown, value: unknown, path: string): void => {
|
|
55
|
+
if (!isObject(source) || value === undefined || value === null || isComputed(value)) return;
|
|
56
|
+
const area = within(source);
|
|
57
|
+
const add = (kind: string, id: string, at: string) => out.push({ kind, id, ...(area.value === undefined ? {} : { within: area.value }), path: at });
|
|
58
|
+
if (typeof source["x-ref"] === "string" && isId(value) && area.known) add(source["x-ref"], String(value), path);
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
value.forEach((item, index) => walk(source.items, item, `${path}/${index}`));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!isObject(value)) return;
|
|
64
|
+
if (typeof source["x-ref-keys"] === "string" && area.known) for (const key of Object.keys(value)) add(source["x-ref-keys"], key, `${path}/${key}`);
|
|
65
|
+
if (isObject(source.properties)) {
|
|
66
|
+
for (const [key, item] of Object.entries(source.properties)) if (Object.hasOwn(value, key)) walk(item, value[key], `${path}/${key}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
walk(schema, input, "");
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The kinds a schema refers to, whatever the values: each must have an owner. */
|
|
74
|
+
export function schemaReferenceKinds(schema: unknown): string[] {
|
|
75
|
+
const kinds = new Set<string>();
|
|
76
|
+
const walk = (source: unknown): void => {
|
|
77
|
+
if (!isObject(source)) return;
|
|
78
|
+
for (const key of ["x-ref", "x-ref-keys"]) if (typeof source[key] === "string") kinds.add(source[key]);
|
|
79
|
+
if (isObject(source.properties)) Object.values(source.properties).forEach(walk);
|
|
80
|
+
walk(source.items);
|
|
81
|
+
};
|
|
82
|
+
walk(schema);
|
|
83
|
+
return [...kinds];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The references a document holds as literals (WORKFLOW-002 §6.3): in the inputs of
|
|
88
|
+
* its Tools, by their schemas; the defaults of variables of type `ref`; the processes
|
|
89
|
+
* it calls. `inputSchemaOf` gives the input schema of a Tool, if it is known.
|
|
90
|
+
*/
|
|
91
|
+
export function documentReferences(document: ProcessDocument, inputSchemaOf: (tool: string) => unknown): DocumentReference[] {
|
|
92
|
+
const out: DocumentReference[] = [];
|
|
93
|
+
for (const variable of document.variables ?? []) {
|
|
94
|
+
if (variable.type === "ref" && typeof variable.ref === "string" && isId(variable.default)) {
|
|
95
|
+
out.push({ kind: variable.ref, id: String(variable.default), path: "/default", variable: variable.name });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const handler = document.settings?.errorHandler;
|
|
99
|
+
if (handler !== undefined) out.push({ kind: "workflow.process", id: handler, path: "/settings/errorHandler" });
|
|
100
|
+
for (const [node, { kind, config }] of Object.entries(document.nodes)) {
|
|
101
|
+
if (kind === "callProcess" && typeof config?.process === "string") out.push({ kind: "workflow.process", id: config.process, path: "/process", node });
|
|
102
|
+
if (kind !== "serviceTask" || typeof config?.tool !== "string") continue;
|
|
103
|
+
for (const reference of inputReferences(inputSchemaOf(config.tool), config.input)) out.push({ ...reference, node });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
@@ -33,6 +33,7 @@ export type ProcessIssueCode =
|
|
|
33
33
|
| "config_invalid"
|
|
34
34
|
| "unknown_tool"
|
|
35
35
|
| "unknown_process"
|
|
36
|
+
| "broken_ref"
|
|
36
37
|
| "container_invalid"
|
|
37
38
|
| "flow_across_container"
|
|
38
39
|
| "loop_body"
|
|
@@ -451,6 +452,18 @@ function configExpressionProblems(kind: string, config: Json, scope: ExpressionS
|
|
|
451
452
|
const problem = checkExpression(config.over, scope);
|
|
452
453
|
if (problem) out.push(`config.over: ${problem}`);
|
|
453
454
|
}
|
|
455
|
+
if (kind === "eventWait" && config.until !== undefined) {
|
|
456
|
+
const problem = checkExpression(config.until, scope);
|
|
457
|
+
if (problem) out.push(`config.until: ${problem}`);
|
|
458
|
+
}
|
|
459
|
+
if (kind === "eventStart") {
|
|
460
|
+
// Before an instance exists there is only the event: nothing to read in the input, variables and steps.
|
|
461
|
+
const before: ExpressionScope = { input: new Set(), vars: new Set(), nodes: new Set(), loop: false };
|
|
462
|
+
const filter = config.filter === undefined ? null : checkExpression(config.filter, before);
|
|
463
|
+
if (filter) out.push(`config.filter: ${filter}`);
|
|
464
|
+
const debounceKey = isObject(config.debounce) ? checkExpression(config.debounce.key, before) : null;
|
|
465
|
+
if (debounceKey) out.push(`config.debounce.key: ${debounceKey}`);
|
|
466
|
+
}
|
|
454
467
|
for (const key of ["at", "correlation"]) {
|
|
455
468
|
if (typeof config[key] === "string") {
|
|
456
469
|
const problem = checkPath(config[key], scope);
|