bazis 0.133.0 → 0.134.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 (38) hide show
  1. package/core/agent/AgentToolExecutor.ts +5 -0
  2. package/core/agent/Tool.contract.ts +2 -2
  3. package/core/agent/metadata.ts +11 -0
  4. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  5. package/core/scripts/di-generate.ts +8 -0
  6. package/core/tools/StandardTools.module.ts +14 -0
  7. package/core/tools/generated/deps.ts +10 -0
  8. package/core/tools/index.ts +8 -0
  9. package/core/tools/orm/OrmEntity.references.ts +71 -0
  10. package/core/tools/orm/ormEntityTools.ts +423 -0
  11. package/core/workflow/contracts/WorkflowError.ts +2 -1
  12. package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
  13. package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
  14. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +7 -2
  15. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
  16. package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
  17. package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
  18. package/core/workflow/definitions_module/http/Processes.controller.ts +19 -4
  19. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +8 -0
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +24 -0
  21. package/core/workflow/generated/deps.ts +8 -2
  22. package/core/workflow/index.ts +5 -0
  23. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +5 -0
  24. package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
  25. package/core/workflow/runtime_module/internal/RunLimits.ts +13 -3
  26. package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
  27. package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
  28. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +29 -3
  29. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +7 -0
  30. package/core/workflow/runtime_module/model/WfJob.model.ts +7 -6
  31. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +21 -1
  32. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +29 -40
  33. package/library/workflow/cron.ts +103 -24
  34. package/library/workflow/index.ts +2 -2
  35. package/library/workflow/nodeKinds.ts +64 -9
  36. package/library/workflow/paths.ts +2 -2
  37. package/library/workflow/validateProcess.ts +12 -0
  38. package/package.json +2 -1
@@ -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
- const dayMatches = (date: Date): boolean => {
40
- const day = days.has(date.getUTCDate());
41
- const weekday = weekdays.has(date.getUTCDay());
42
- return daysRestricted && weekdaysRestricted ? day || weekday : day && weekday;
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 matches = (date: Date): boolean =>
46
- minutes.has(date.getUTCMinutes()) && hours.has(date.getUTCHours()) && months.has(date.getUTCMonth() + 1) && dayMatches(date);
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 * 24 * 60 * MINUTE;
52
- const date = new Date(Math.floor(after.getTime() / MINUTE) * MINUTE + MINUTE);
53
- while (date.getTime() <= limit) {
54
- if (!months.has(date.getUTCMonth() + 1)) {
55
- date.setUTCMonth(date.getUTCMonth() + 1, 1);
56
- date.setUTCHours(0, 0, 0, 0);
57
- } else if (!dayMatches(date)) {
58
- date.setUTCDate(date.getUTCDate() + 1);
59
- date.setUTCHours(0, 0, 0, 0);
60
- } else if (!hours.has(date.getUTCHours())) {
61
- date.setUTCHours(date.getUTCHours() + 1, 0, 0, 0);
62
- } else if (!minutes.has(date.getUTCMinutes())) {
63
- date.setTime(date.getTime() + MINUTE);
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 date;
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,7 @@ export {
42
42
  } from "./expressions";
43
43
  export { ExpressionSyntaxError, parseExpression, printExpression } from "./expressionSyntax";
44
44
  export { durationMs } from "./duration";
45
- export { parseCron, type CronSchedule } from "./cron";
45
+ export { isTimeZone, parseCron, type CronSchedule } from "./cron";
46
46
  export { processToMermaid } from "./mermaid";
47
47
  export { PERSONAL_MASK, maskByMapping, maskBySchema, masksPersonal, personalPaths, personalVariables, readsPersonal } from "./masking";
48
48
  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
- /** Five cron fields: minute, hour, day of month, month, day of week. */
56
- function cron(config: Record<string, unknown>): string | null {
57
- const value = config.cron;
58
- if (typeof value !== "string") return "config.cron is required";
59
- return parseCron(value) === undefined ? "config.cron must be five valid fields: minute hour day month weekday (UTC)" : null;
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
- { kind: "scheduleStart", role: "start", check: cron, config: object({ cron: text }, ["cron"]) },
162
- { kind: "eventStart", role: "start", check: (c) => string(c, "event", DOTTED_KEY), config: object({ event: text }, ["event"]) },
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" },
@@ -451,6 +451,18 @@ function configExpressionProblems(kind: string, config: Json, scope: ExpressionS
451
451
  const problem = checkExpression(config.over, scope);
452
452
  if (problem) out.push(`config.over: ${problem}`);
453
453
  }
454
+ if (kind === "eventWait" && config.until !== undefined) {
455
+ const problem = checkExpression(config.until, scope);
456
+ if (problem) out.push(`config.until: ${problem}`);
457
+ }
458
+ if (kind === "eventStart") {
459
+ // Before an instance exists there is only the event: nothing to read in the input, variables and steps.
460
+ const before: ExpressionScope = { input: new Set(), vars: new Set(), nodes: new Set(), loop: false };
461
+ const filter = config.filter === undefined ? null : checkExpression(config.filter, before);
462
+ if (filter) out.push(`config.filter: ${filter}`);
463
+ const debounceKey = isObject(config.debounce) ? checkExpression(config.debounce.key, before) : null;
464
+ if (debounceKey) out.push(`config.debounce.key: ${debounceKey}`);
465
+ }
454
466
  for (const key of ["at", "correlation"]) {
455
467
  if (typeof config[key] === "string") {
456
468
  const problem = checkPath(config[key], scope);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.133.0",
3
+ "version": "0.134.0",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",
@@ -44,6 +44,7 @@
44
44
  "./core/kernel": "./core/kernel/index.ts",
45
45
  "./core/orm": "./core/orm/index.ts",
46
46
  "./core/testing": "./core/testing/index.ts",
47
+ "./core/tools": "./core/tools/index.ts",
47
48
  "./core/websocket": "./core/websocket/index.ts",
48
49
  "./core/workflow": "./core/workflow/index.ts",
49
50
  "./core/workflow/testing": "./core/workflow/testing/index.ts",