@manablox/workflows 0.2.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/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@manablox/workflows",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/index.ts",
8
+ "default": "./src/index.ts"
9
+ }
10
+ },
11
+ "main": "./src/index.ts",
12
+ "types": "./src/index.ts",
13
+ "dependencies": {
14
+ "@manablox/core": "0.2.0",
15
+ "@manablox/db": "0.2.0",
16
+ "nodemailer": "^10.0.0",
17
+ "web-push": "^3.6.7"
18
+ },
19
+ "devDependencies": {
20
+ "@manablox/config-typescript": "0.0.0",
21
+ "@manablox/fields": "0.2.0",
22
+ "@manablox/services": "0.2.0",
23
+ "@types/node": "^26.4.1",
24
+ "@types/nodemailer": "^8.0.1",
25
+ "@types/web-push": "^3.6.4",
26
+ "typescript": "^7.0.2",
27
+ "vitest": "^5.0.0"
28
+ },
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run"
32
+ }
33
+ }
@@ -0,0 +1,88 @@
1
+ import type {
2
+ WorkflowConditionRule,
3
+ WorkflowConditionStep,
4
+ WorkflowRunContext,
5
+ } from '@manablox/core';
6
+ import { render, resolvePath, stringify } from './template.js';
7
+
8
+ const isEmpty = (value: unknown): boolean =>
9
+ value === null ||
10
+ value === undefined ||
11
+ value === '' ||
12
+ (Array.isArray(value) && value.length === 0) ||
13
+ (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as object).length === 0);
14
+
15
+ const asNumber = (value: unknown): number | null => {
16
+ if (typeof value === 'number') return value;
17
+ if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) {
18
+ return Number(value);
19
+ }
20
+ return null;
21
+ };
22
+
23
+ /** Loose equality across the types a field can hold: `"true"` matches `true`, `"3"` matches `3`. */
24
+ const same = (actual: unknown, expected: string): boolean => {
25
+ if (typeof actual === 'string') return actual === expected;
26
+ if (typeof actual === 'number' || typeof actual === 'boolean') return String(actual) === expected;
27
+ if (actual === null || actual === undefined) return expected === '' || expected === 'null';
28
+ return stringify(actual) === expected;
29
+ };
30
+
31
+ /** `changed` compares the path under `content` with the same path under `previous`. */
32
+ function previousValue(context: WorkflowRunContext, field: string): unknown {
33
+ if (!field.startsWith('content')) return undefined;
34
+ const rest = field.slice('content'.length);
35
+ return resolvePath(context.previous, rest.replace(/^\./, ''));
36
+ }
37
+
38
+ export function evaluateRule(rule: WorkflowConditionRule, context: WorkflowRunContext): boolean {
39
+ const actual = resolvePath(context, rule.field);
40
+ // The expected side may itself be a template, so a rule can compare two paths.
41
+ const expected = render(rule.value ?? '', context);
42
+
43
+ switch (rule.operator) {
44
+ case 'equals':
45
+ return same(actual, expected);
46
+ case 'notEquals':
47
+ return !same(actual, expected);
48
+ case 'contains':
49
+ return Array.isArray(actual)
50
+ ? actual.some((entry) => same(entry, expected))
51
+ : stringify(actual).toLowerCase().includes(expected.toLowerCase());
52
+ case 'notContains':
53
+ return Array.isArray(actual)
54
+ ? !actual.some((entry) => same(entry, expected))
55
+ : !stringify(actual).toLowerCase().includes(expected.toLowerCase());
56
+ case 'startsWith':
57
+ return stringify(actual).toLowerCase().startsWith(expected.toLowerCase());
58
+ case 'isEmpty':
59
+ return isEmpty(actual);
60
+ case 'isNotEmpty':
61
+ return !isEmpty(actual);
62
+ case 'greaterThan': {
63
+ const a = asNumber(actual);
64
+ const b = asNumber(expected);
65
+ return a !== null && b !== null ? a > b : stringify(actual) > expected;
66
+ }
67
+ case 'lessThan': {
68
+ const a = asNumber(actual);
69
+ const b = asNumber(expected);
70
+ return a !== null && b !== null ? a < b : stringify(actual) < expected;
71
+ }
72
+ case 'changed': {
73
+ // Without a previous row every field is new, which is what "changed" means then.
74
+ if (!context.previous) return true;
75
+ return stringify(actual) !== stringify(previousValue(context, rule.field));
76
+ }
77
+ }
78
+ }
79
+
80
+ /** Whether the run may continue past this step. */
81
+ export function evaluateCondition(
82
+ step: Pick<WorkflowConditionStep, 'match' | 'rules'>,
83
+ context: WorkflowRunContext,
84
+ ): boolean {
85
+ if (step.rules.length === 0) return true;
86
+ const results = step.rules.map((rule) => evaluateRule(rule, context));
87
+ return step.match === 'any' ? results.some(Boolean) : results.every(Boolean);
88
+ }
package/src/cron.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Five-field cron — `minute hour day-of-month month day-of-week` — matched against a
3
+ * wall-clock in a named timezone. Only matching is needed: the scheduler wakes every
4
+ * minute and asks "is now a match?", so there is no next-run arithmetic to get wrong.
5
+ */
6
+
7
+ export interface CronSpec {
8
+ minute: Set<number>;
9
+ hour: Set<number>;
10
+ dayOfMonth: Set<number>;
11
+ month: Set<number>;
12
+ dayOfWeek: Set<number>;
13
+ /** `*` in the day-of-month field; decides how the two day fields combine. */
14
+ anyDayOfMonth: boolean;
15
+ anyDayOfWeek: boolean;
16
+ }
17
+
18
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
19
+ const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
20
+
21
+ interface FieldSpec {
22
+ min: number;
23
+ max: number;
24
+ names?: string[];
25
+ /** Day-of-week accepts 7 for Sunday as well as 0. */
26
+ alias?: Record<number, number>;
27
+ }
28
+
29
+ const FIELDS: FieldSpec[] = [
30
+ { min: 0, max: 59 },
31
+ { min: 0, max: 23 },
32
+ { min: 1, max: 31 },
33
+ { min: 1, max: 12, names: MONTHS },
34
+ { min: 0, max: 6, names: DAYS, alias: { 7: 0 } },
35
+ ];
36
+
37
+ function parseValue(raw: string, field: FieldSpec): number | null {
38
+ const lower = raw.toLowerCase();
39
+ if (field.names) {
40
+ const index = field.names.indexOf(lower);
41
+ if (index !== -1) return field.min + index;
42
+ }
43
+ if (!/^\d+$/.test(raw)) return null;
44
+ let value = Number(raw);
45
+ if (field.alias && value in field.alias) value = field.alias[value] as number;
46
+ if (value < field.min || value > field.max) return null;
47
+ return value;
48
+ }
49
+
50
+ /** One comma-separated part: `*`, `n`, `a-b`, `*​/s`, `a-b/s`, `a/s`. */
51
+ function parsePart(part: string, field: FieldSpec, into: Set<number>): boolean {
52
+ const [rangePart, stepPart] = part.split('/');
53
+ if (rangePart === undefined || stepPart === '') return false;
54
+
55
+ let step = 1;
56
+ if (stepPart !== undefined) {
57
+ if (!/^\d+$/.test(stepPart)) return false;
58
+ step = Number(stepPart);
59
+ if (step < 1) return false;
60
+ }
61
+
62
+ let from: number;
63
+ let to: number;
64
+ if (rangePart === '*') {
65
+ from = field.min;
66
+ to = field.max;
67
+ } else if (rangePart.includes('-')) {
68
+ const [a, b] = rangePart.split('-');
69
+ const start = a === undefined ? null : parseValue(a, field);
70
+ const end = b === undefined ? null : parseValue(b, field);
71
+ if (start === null || end === null || end < start) return false;
72
+ from = start;
73
+ to = end;
74
+ } else {
75
+ const value = parseValue(rangePart, field);
76
+ if (value === null) return false;
77
+ from = value;
78
+ // `5/10` means "from 5 on, every 10"; a bare `5` is just 5.
79
+ to = stepPart !== undefined ? field.max : value;
80
+ }
81
+
82
+ for (let value = from; value <= to; value += step) into.add(value);
83
+ return true;
84
+ }
85
+
86
+ export function parseCron(expression: string): CronSpec | null {
87
+ const fields = expression.trim().split(/\s+/);
88
+ if (fields.length !== 5) return null;
89
+
90
+ const sets: Set<number>[] = [];
91
+ for (const [index, raw] of fields.entries()) {
92
+ const field = FIELDS[index] as FieldSpec;
93
+ const set = new Set<number>();
94
+ for (const part of raw.split(',')) {
95
+ if (!part || !parsePart(part, field, set)) return null;
96
+ }
97
+ sets.push(set);
98
+ }
99
+
100
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = sets as [
101
+ Set<number>,
102
+ Set<number>,
103
+ Set<number>,
104
+ Set<number>,
105
+ Set<number>,
106
+ ];
107
+ return {
108
+ minute,
109
+ hour,
110
+ dayOfMonth,
111
+ month,
112
+ dayOfWeek,
113
+ anyDayOfMonth: fields[2] === '*',
114
+ anyDayOfWeek: fields[4] === '*',
115
+ };
116
+ }
117
+
118
+ export function isValidCron(expression: string): boolean {
119
+ return parseCron(expression) !== null;
120
+ }
121
+
122
+ export function isValidTimezone(timezone: string): boolean {
123
+ try {
124
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone });
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ export interface WallClock {
132
+ minute: number;
133
+ hour: number;
134
+ dayOfMonth: number;
135
+ month: number;
136
+ dayOfWeek: number;
137
+ }
138
+
139
+ /** The wall-clock reading of an instant in a timezone. */
140
+ export function wallClock(date: Date, timezone: string): WallClock {
141
+ const parts = new Intl.DateTimeFormat('en-US', {
142
+ timeZone: timezone,
143
+ hourCycle: 'h23',
144
+ minute: 'numeric',
145
+ hour: 'numeric',
146
+ day: 'numeric',
147
+ month: 'numeric',
148
+ weekday: 'short',
149
+ }).formatToParts(date);
150
+ const read = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
151
+ return {
152
+ minute: Number(read('minute')),
153
+ hour: Number(read('hour')) % 24,
154
+ dayOfMonth: Number(read('day')),
155
+ month: Number(read('month')),
156
+ dayOfWeek: DAYS.indexOf(read('weekday').toLowerCase().slice(0, 3)),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Whether the minute containing `date` matches. As in Vixie cron, when both day fields
162
+ * are restricted a match on either is enough.
163
+ */
164
+ export function cronMatches(spec: CronSpec, date: Date, timezone: string): boolean {
165
+ const clock = wallClock(date, timezone);
166
+ if (!spec.minute.has(clock.minute)) return false;
167
+ if (!spec.hour.has(clock.hour)) return false;
168
+ if (!spec.month.has(clock.month)) return false;
169
+
170
+ const domMatch = spec.dayOfMonth.has(clock.dayOfMonth);
171
+ const dowMatch = spec.dayOfWeek.has(clock.dayOfWeek);
172
+ if (spec.anyDayOfMonth && spec.anyDayOfWeek) return true;
173
+ if (spec.anyDayOfMonth) return dowMatch;
174
+ if (spec.anyDayOfWeek) return domMatch;
175
+ return domMatch || dowMatch;
176
+ }
177
+
178
+ /** The start of the minute an instant falls in — the unit the scheduler claims. */
179
+ export function floorToMinute(date: Date): Date {
180
+ return new Date(Math.floor(date.getTime() / 60_000) * 60_000);
181
+ }