psdev-task-manager 1.0.2 → 1.0.3

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.
@@ -0,0 +1,22 @@
1
+ const { items, collections } = require('@wix/data');
2
+ const { auth } = require('@wix/essentials');
3
+
4
+ // @wix/data does not support suppressAuth currently, so we need to elevate it
5
+ const wixData = {
6
+ insert: auth.elevate(items.insert),
7
+ update: auth.elevate(items.update),
8
+ bulkInsert: auth.elevate(items.bulkInsert),
9
+ query: auth.elevate(items.query),
10
+ save: auth.elevate(items.save),
11
+ remove: auth.elevate(items.remove),
12
+ get: auth.elevate(items.get),
13
+ //TODO: add other methods here as needed
14
+ };
15
+
16
+ const wixCollections = {
17
+ getDataCollection: auth.elevate(collections.getDataCollection),
18
+ createDataCollection: auth.elevate(collections.createDataCollection),
19
+ updateDataCollection: auth.elevate(collections.updateDataCollection),
20
+ };
21
+
22
+ module.exports = { wixData, wixCollections };
@@ -0,0 +1,4 @@
1
+ module.exports = {
2
+ ...require('./tasks'),
3
+ ...require('./elevated-modules'),
4
+ };
@@ -0,0 +1,25 @@
1
+ const TASK_STATUS = {
2
+ PENDING: 'pending',
3
+ IN_PROGRESS: 'in_progress',
4
+ SUCCESS: 'success',
5
+ FAILED: 'failed',
6
+ SKIPPED: 'skipped',
7
+ };
8
+
9
+ const TASK_TYPE = {
10
+ SCHEDULED: 'scheduled',
11
+ EVENT: 'event',
12
+ };
13
+
14
+ const TASKS_CONCURRENCY_LIMIT = 1;
15
+ const TASK_MAX_TRIES = 3;
16
+
17
+ const CRON_JOB_MAX_DURATION_SEC = 240; // 4 minutes is the velo limit for a cron job run @see https://wix.slack.com/archives/C02T75TDG2C/p1747750017241869
18
+
19
+ module.exports = {
20
+ TASK_STATUS,
21
+ TASK_TYPE,
22
+ TASKS_CONCURRENCY_LIMIT,
23
+ TASK_MAX_TRIES,
24
+ CRON_JOB_MAX_DURATION_SEC,
25
+ };
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Utility functions for detecting field types based on values
3
+ */
4
+
5
+ /**
6
+ * Checks if a string is a valid URL
7
+ * @param {string} str - The string to check
8
+ * @returns {boolean} - Whether the string is a valid URL
9
+ */
10
+ function isValidUrl(str) {
11
+ if (typeof str !== 'string') return false;
12
+
13
+ try {
14
+ const url = new URL(str);
15
+ return ['http:', 'https:', 'ftp:', 'ftps:'].includes(url.protocol);
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Checks if a string represents a date/datetime
23
+ * @param {string} str - The string to check
24
+ * @returns {boolean} - Whether the string represents a valid date
25
+ */
26
+ function isDateString(str) {
27
+ if (typeof str !== 'string') return false;
28
+
29
+ // Common date patterns
30
+ const datePatterns = [
31
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/, // ISO 8601: 2025-06-24T11:26:00.000Z
32
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/, // ISO with timezone: 2025-06-24T11:26:00+01:00
33
+ /^\d{4}-\d{2}-\d{2}$/, // Date only: 2025-06-24
34
+ /^\d{2}\/\d{2}\/\d{4}$/, // US format: 06/24/2025
35
+ /^\d{2}-\d{2}-\d{4}$/, // Alternative format: 24-06-2025
36
+ ];
37
+
38
+ if (!datePatterns.some(pattern => pattern.test(str))) {
39
+ return false;
40
+ }
41
+
42
+ // Validate that it's actually a valid date
43
+ const date = new Date(str);
44
+ return !isNaN(date.getTime());
45
+ }
46
+
47
+ /**
48
+ * Checks if a string represents an email address
49
+ * @param {string} str - The string to check
50
+ * @returns {boolean} - Whether the string is a valid email
51
+ */
52
+ function isEmail(str) {
53
+ if (typeof str !== 'string') return false;
54
+
55
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
56
+ return emailPattern.test(str);
57
+ }
58
+
59
+ /**
60
+ * Checks if a string represents a phone number
61
+ * @param {string} str - The string to check
62
+ * @returns {boolean} - Whether the string looks like a phone number
63
+ */
64
+ function isPhoneNumber(str) {
65
+ if (typeof str !== 'string') return false;
66
+
67
+ // Remove all non-alphanumeric characters for pattern matching
68
+ const cleanStr = str.replace(/[\s().-]/g, '');
69
+
70
+ // Basic phone number patterns (international and common formats)
71
+ const phonePatterns = [
72
+ /^\+\d{1,3}\d{6,14}$/, // International: +1234567890
73
+ /^\d{10}$/, // Simple 10 digits: 1234567890
74
+ /^\d{11}$/, // 11 digits with country code: 11234567890
75
+ ];
76
+
77
+ // Also check original string for formatted patterns
78
+ const formattedPatterns = [
79
+ /^\+\d{1,3}\s?\(\d{3}\)\s?\d{3}[\s-]?\d{4}$/, // +1 (555) 123-4567
80
+ /^\(\d{3}\)\s?\d{3}[\s-]?\d{4}$/, // (555) 123-4567
81
+ /^\d{3}[\s-]?\d{3}[\s-]?\d{4}$/, // 555-123-4567 or 555 123 4567
82
+ /^\+\d{1,3}[\s-]?\d{6,14}$/, // +1-1234567890 or +1 1234567890
83
+ ];
84
+
85
+ return (
86
+ phonePatterns.some(pattern => pattern.test(cleanStr)) ||
87
+ formattedPatterns.some(pattern => pattern.test(str))
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Detects the appropriate Wix Data field type based on a value
93
+ * @param {*} value - The value to analyze
94
+ * @returns {string} - The Wix Data field type
95
+ */
96
+ function detectFieldType(value) {
97
+ // Handle null/undefined
98
+ if (value === null || value === undefined) {
99
+ return 'TEXT'; // Default to TEXT for null values
100
+ }
101
+
102
+ // Handle primitive types first
103
+ if (typeof value === 'boolean') {
104
+ return 'BOOLEAN';
105
+ }
106
+
107
+ if (typeof value === 'number') {
108
+ return 'NUMBER';
109
+ }
110
+
111
+ if (value instanceof Date) {
112
+ return 'DATETIME';
113
+ }
114
+
115
+ // Handle arrays
116
+ if (Array.isArray(value)) {
117
+ return 'ARRAY';
118
+ }
119
+
120
+ // Handle objects (but not arrays or dates)
121
+ if (typeof value === 'object') {
122
+ return 'OBJECT';
123
+ }
124
+
125
+ // Handle strings with special patterns
126
+ if (typeof value === 'string') {
127
+ // Check for empty strings
128
+ if (value.trim() === '') {
129
+ return 'TEXT';
130
+ }
131
+
132
+ // Check for date strings first (most specific)
133
+ if (isDateString(value)) {
134
+ return 'DATETIME';
135
+ }
136
+
137
+ // Check for URLs
138
+ if (isValidUrl(value)) {
139
+ return 'URL';
140
+ }
141
+
142
+ // Check for email addresses
143
+ if (isEmail(value)) {
144
+ return 'EMAIL';
145
+ }
146
+
147
+ // Check for phone numbers
148
+ if (isPhoneNumber(value)) {
149
+ return 'PHONE';
150
+ }
151
+
152
+ // Check for very long text (might be better as rich text)
153
+ if (value.length > 500) {
154
+ return 'RICHTEXT';
155
+ }
156
+
157
+ // Default to TEXT for other strings
158
+ return 'TEXT';
159
+ }
160
+
161
+ // Fallback for unknown types
162
+ return 'TEXT';
163
+ }
164
+
165
+ /**
166
+ * Analyzes multiple values to determine the best field type
167
+ * Useful when you have an array of values and want to determine the most appropriate type
168
+ * @param {Array} values - Array of values to analyze
169
+ * @returns {string} - The most appropriate Wix Data field type
170
+ */
171
+ function detectFieldTypeFromMultipleValues(values) {
172
+ if (!Array.isArray(values) || values.length === 0) {
173
+ return 'TEXT';
174
+ }
175
+
176
+ // Filter out null/undefined values for analysis
177
+ const validValues = values.filter(v => v !== null && v !== undefined);
178
+
179
+ if (validValues.length === 0) {
180
+ return 'TEXT';
181
+ }
182
+
183
+ // Get types for all valid values
184
+ const types = validValues.map(detectFieldType);
185
+ const uniqueTypes = [...new Set(types)];
186
+
187
+ // If all values have the same type, use that type
188
+ if (uniqueTypes.length === 1) {
189
+ return uniqueTypes[0];
190
+ }
191
+
192
+ // Handle mixed types - prioritize more specific types
193
+ const typePriority = {
194
+ BOOLEAN: 8,
195
+ NUMBER: 7,
196
+ DATETIME: 6,
197
+ URL: 5,
198
+ EMAIL: 4,
199
+ PHONE: 3,
200
+ ARRAY: 2,
201
+ OBJECT: 2,
202
+ RICHTEXT: 1,
203
+ TEXT: 0,
204
+ };
205
+
206
+ // Find the highest priority type that appears
207
+ const sortedTypes = uniqueTypes.sort((a, b) => (typePriority[b] || 0) - (typePriority[a] || 0));
208
+
209
+ // If we have mixed primitive types with TEXT, prefer the more specific type
210
+ // unless it's a very small percentage of the data
211
+ const primaryType = sortedTypes[0];
212
+ const primaryTypeCount = types.filter(t => t === primaryType).length;
213
+ const primaryTypePercentage = primaryTypeCount / types.length;
214
+
215
+ // If the primary type represents at least 70% of the data, use it
216
+ if (primaryTypePercentage >= 0.7) {
217
+ return primaryType;
218
+ }
219
+
220
+ // Otherwise, default to TEXT for mixed data
221
+ return 'TEXT';
222
+ }
223
+
224
+ module.exports = {
225
+ isValidUrl,
226
+ isDateString,
227
+ isEmail,
228
+ isPhoneNumber,
229
+ detectFieldType,
230
+ detectFieldTypeFromMultipleValues,
231
+ };
@@ -0,0 +1,10 @@
1
+ module.exports = {
2
+ ...require('../tasksCollections'),
3
+ ...require('./parentChildTasks'),
4
+
5
+ ...require('./taskManager'),
6
+ ...require('./consts'),
7
+ ...require('./resultsSaver'),
8
+ ...require('./utils'),
9
+ ...require('./fieldTypeDetector'),
10
+ };
@@ -0,0 +1,66 @@
1
+ const { insertNewTask, bulkInsertTasks } = require('../utils');
2
+
3
+ const { createChildTaskPayload } = require('./utils');
4
+
5
+ const scheduleChildren = ({
6
+ parentTaskId,
7
+ parentTaskData,
8
+ expectedChildTasks,
9
+ scheduledChildren,
10
+ }) => {
11
+ const scheduleSingleChild = ({ taskName, taskData, parentTaskId }) => {
12
+ const childTask = createChildTaskPayload({
13
+ taskName,
14
+ taskData,
15
+ parentTaskId,
16
+ });
17
+ return insertNewTask(childTask);
18
+ };
19
+
20
+ const scheduleChildrenInBulk = ({
21
+ parentTaskId,
22
+ parentTaskData: { previousTaskOutput: _previousTaskOutput, ...restParentData },
23
+ childTasks,
24
+ }) => {
25
+ const tasksToInsert = childTasks.map(({ name, data }) =>
26
+ createChildTaskPayload({
27
+ taskName: name,
28
+ taskData: { ...restParentData, ...data },
29
+ parentTaskId,
30
+ })
31
+ );
32
+ //Host needs to keep in mind Velo limitations of bulkInsert 1000 items max and 500kb max item size
33
+ //Ref: https://dev.wix.com/docs/velo/apis/wix-data/bulk-insert
34
+ return bulkInsertTasks(tasksToInsert);
35
+ };
36
+
37
+ const getPreviousChildOutput = childIndex => {
38
+ const isFirstChild = childIndex === 0 || !scheduledChildren?.length;
39
+ if (isFirstChild) {
40
+ return undefined;
41
+ }
42
+ const lastScheduledChild = scheduledChildren[scheduledChildren.length - 1];
43
+ return lastScheduledChild?.data?.output;
44
+ };
45
+
46
+ return {
47
+ sequentially: childIndex =>
48
+ scheduleSingleChild({
49
+ taskName: expectedChildTasks[childIndex].name,
50
+ taskData: {
51
+ ...parentTaskData,
52
+ ...expectedChildTasks[childIndex].data,
53
+ previousTaskOutput: getPreviousChildOutput(childIndex),
54
+ },
55
+ parentTaskId,
56
+ }),
57
+ inBulk: () =>
58
+ scheduleChildrenInBulk({
59
+ parentTaskId,
60
+ parentTaskData,
61
+ childTasks: expectedChildTasks,
62
+ }),
63
+ };
64
+ };
65
+
66
+ module.exports = { scheduleChildren };
@@ -0,0 +1,77 @@
1
+ const { getTaskConfig, getTaskScheduledChildren, getExpectedChildTasks } = require('../utils');
2
+
3
+ const { scheduleChildren } = require('./childScheduler');
4
+ const {
5
+ hasFailedChildAndMarkedParent,
6
+ hasUnfinishedChild,
7
+ areChildrenSucceededAndMarkedParent,
8
+ } = require('./parentStatusManager');
9
+ const { shouldScheduleNextChild: _shouldScheduleNextChild } = require('./utils');
10
+ const scheduleChildTasksAndUpdateParent = async (task, tasksConfig) => {
11
+ const { _id: parentTaskId, name: parentTaskName, data: parentTaskData } = task;
12
+ const parentTaskConfig = getTaskConfig(parentTaskName, tasksConfig);
13
+ const expectedChildTasks = getExpectedChildTasks(task, parentTaskConfig);
14
+ const scheduledChildren = await getTaskScheduledChildren(parentTaskId);
15
+ const childrenScheduler = scheduleChildren({
16
+ parentTaskId,
17
+ parentTaskData,
18
+ expectedChildTasks,
19
+ scheduledChildren,
20
+ });
21
+ if (!scheduledChildren.length) {
22
+ console.log(
23
+ `No scheduled children found for task with id ${parentTaskId}, started scheduling children`
24
+ );
25
+ if (parentTaskConfig.scheduleChildrenSequentially) {
26
+ console.log('scheduling children sequentially');
27
+ return childrenScheduler.sequentially(0);
28
+ } else {
29
+ console.log('scheduling children in bulk');
30
+ return childrenScheduler.inBulk();
31
+ }
32
+ }
33
+
34
+ if (hasUnfinishedChild(scheduledChildren)) {
35
+ console.log(
36
+ `[Children Unfinished]: Found unfinished children for task with id ${parentTaskId}, quitting parent processing`
37
+ );
38
+ return;
39
+ }
40
+
41
+ if (await hasFailedChildAndMarkedParent(task, scheduledChildren)) {
42
+ console.log(
43
+ `[Children Failed]: Found Failed children for task with id ${parentTaskId}, quitting parent processing`
44
+ );
45
+ return;
46
+ }
47
+
48
+ if (
49
+ await areChildrenSucceededAndMarkedParent({
50
+ parent: task,
51
+ scheduledChildren: scheduledChildren,
52
+ expectedChildrenCount: expectedChildTasks.length,
53
+ })
54
+ ) {
55
+ console.log(
56
+ `[Children Completed Successfully]: All children for task with id ${parentTaskId} completed Successfully, quitting parent processing.`
57
+ );
58
+ return;
59
+ }
60
+ const shouldScheduleNextChild = _shouldScheduleNextChild({
61
+ parentTaskConfig,
62
+ scheduledChildren,
63
+ expectedChildTasks,
64
+ });
65
+ if (shouldScheduleNextChild) {
66
+ const toScheduleChildIndex = scheduledChildren.length;
67
+ console.log(
68
+ `[Sequential Scheduling]: Scheduling child with index ${toScheduleChildIndex} for task with id ${parentTaskId}`
69
+ );
70
+ return childrenScheduler.sequentially(toScheduleChildIndex);
71
+ }
72
+ throw new Error(
73
+ `[scheduleChildTasksAndUpdateParent] - Something went wrong with task: ${task.name} this line should not be reached`
74
+ );
75
+ };
76
+
77
+ module.exports = { scheduleChildTasksAndUpdateParent };
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ ...require('./handler'),
3
+ };
@@ -0,0 +1,41 @@
1
+ const { TASK_STATUS } = require('../consts');
2
+ const { markTask } = require('../utils');
3
+
4
+ const { isPermanentlyFailed, isCompletedSuccessfully, isUnfinishedTask } = require('./utils');
5
+
6
+ const hasFailedChildAndMarkedParent = async (parent, children) => {
7
+ const failedChild = children.find(child => isPermanentlyFailed(child));
8
+ if (failedChild) {
9
+ console.log('Child task failed, so marking parent as failed!!');
10
+ await markTask({
11
+ task: parent,
12
+ status: TASK_STATUS.FAILED,
13
+ error: failedChild.error,
14
+ });
15
+ return true;
16
+ }
17
+ return false;
18
+ };
19
+
20
+ const hasUnfinishedChild = children => children.some(child => isUnfinishedTask(child));
21
+
22
+ const areChildrenSucceededAndMarkedParent = async ({
23
+ parent,
24
+ scheduledChildren,
25
+ expectedChildrenCount,
26
+ }) => {
27
+ const allChildrenSucceeded =
28
+ scheduledChildren.length === expectedChildrenCount &&
29
+ scheduledChildren.every(isCompletedSuccessfully);
30
+ if (allChildrenSucceeded) {
31
+ await markTask({ task: parent, status: TASK_STATUS.SUCCESS });
32
+ return true;
33
+ }
34
+ return false;
35
+ };
36
+
37
+ module.exports = {
38
+ hasFailedChildAndMarkedParent,
39
+ hasUnfinishedChild,
40
+ areChildrenSucceededAndMarkedParent,
41
+ };
@@ -0,0 +1,28 @@
1
+ const { TASK_TYPE, TASK_STATUS, TASK_MAX_TRIES } = require('../consts');
2
+
3
+ const isCompletedSuccessfully = task =>
4
+ [TASK_STATUS.SUCCESS, TASK_STATUS.SKIPPED].includes(task.status);
5
+
6
+ const isPermanentlyFailed = task =>
7
+ task.status === TASK_STATUS.FAILED && task.amountOfRetries === TASK_MAX_TRIES;
8
+ const isFinishedTask = task => isCompletedSuccessfully(task) || isPermanentlyFailed(task);
9
+
10
+ const isUnfinishedTask = task => !isCompletedSuccessfully(task) && !isPermanentlyFailed(task);
11
+ const createChildTaskPayload = ({ taskName, taskData, parentTaskId }) => ({
12
+ name: taskName,
13
+ data: taskData,
14
+ type: TASK_TYPE.SCHEDULED,
15
+ parentTaskId,
16
+ });
17
+ const shouldScheduleNextChild = ({ parentTaskConfig, scheduledChildren, expectedChildTasks }) =>
18
+ parentTaskConfig.scheduleChildrenSequentially &&
19
+ scheduledChildren.length < expectedChildTasks.length;
20
+
21
+ module.exports = {
22
+ createChildTaskPayload,
23
+ isPermanentlyFailed,
24
+ isCompletedSuccessfully,
25
+ isFinishedTask,
26
+ isUnfinishedTask,
27
+ shouldScheduleNextChild,
28
+ };
@@ -0,0 +1,124 @@
1
+ const { wixData, wixCollections } = require('../elevated-modules');
2
+
3
+ const { detectFieldType } = require('./fieldTypeDetector');
4
+ const { createCollectionIfMissing } = require('./utils');
5
+
6
+ /**
7
+ * Creates a results collection for a specific task if it doesn't exist
8
+ * @param {string} taskName - The name of the task
9
+ * @returns {Promise<string>} - The collection ID
10
+ */
11
+ function createResultsCollectionIfMissing(taskName) {
12
+ const collectionId = `${taskName}_results`;
13
+
14
+ const resultsFields = [
15
+ { key: 'taskId', type: 'TEXT' },
16
+ { key: 'taskName', type: 'TEXT' },
17
+ { key: 'status', type: 'TEXT' },
18
+ { key: 'result', type: 'OBJECT' },
19
+ ];
20
+
21
+ return createCollectionIfMissing(collectionId, resultsFields, null, 'Results');
22
+ }
23
+
24
+ /**
25
+ * Adds fields dynamically to a results collection based on the result object structure
26
+ * @param {string} collectionId - The collection ID
27
+ * @param {Object} result - The result object to analyze for fields
28
+ */
29
+ async function addDynamicFieldsToCollection(collectionId, result) {
30
+ if (!result || typeof result !== 'object') {
31
+ console.log(`[addDynamicFieldsToCollection] No valid result object to analyze for fields`);
32
+ return;
33
+ }
34
+
35
+ try {
36
+ const collection = await wixCollections.getDataCollection(collectionId);
37
+ const existingFields = new Set(collection.fields.map(field => field.key));
38
+ const newFields = [];
39
+
40
+ Object.keys(result).forEach(key => {
41
+ if (!existingFields.has(key)) {
42
+ const value = result[key];
43
+ const fieldType = detectFieldType(value);
44
+ newFields.push({ key, type: fieldType });
45
+ }
46
+ });
47
+
48
+ if (newFields.length > 0) {
49
+ console.log(
50
+ `[addDynamicFieldsToCollection] Adding ${newFields.length} new fields to collection ${collectionId}`
51
+ );
52
+
53
+ const updatedFields = [...collection.fields, ...newFields];
54
+ await wixCollections.updateDataCollection({
55
+ _id: collectionId,
56
+ revision: collection.revision,
57
+ fields: updatedFields,
58
+ });
59
+
60
+ console.log(
61
+ `[addDynamicFieldsToCollection] Successfully added new fields: ${newFields.map(f => f.key).join(', ')}`
62
+ );
63
+ }
64
+ } catch (error) {
65
+ // If updating fields fails, we'll continue anyway - the result will still be saved in the 'result' OBJECT field
66
+ console.error(
67
+ `[addDynamicFieldsToCollection] Failed to add dynamic fields to collection ${collectionId}: ${error.message}`
68
+ );
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Saves task results to a dedicated collection
74
+ * @param {Object} task - The task object
75
+ * @param {string} status - The task status
76
+ * @returns {Promise<string>} - The inserted result ID
77
+ */
78
+ async function saveTaskResultToAdditionalCollection(task, status, result) {
79
+ try {
80
+ const { _id: taskId, name: taskName } = task;
81
+
82
+ console.log(`[saveTaskResult] Saving result for task ${taskName}`);
83
+
84
+ const collectionId = await createResultsCollectionIfMissing(taskName);
85
+
86
+ await addDynamicFieldsToCollection(collectionId, result);
87
+
88
+ const dataToInsert = {
89
+ taskId,
90
+ taskName,
91
+ status,
92
+ result,
93
+ // Add individual fields from result for easier querying
94
+ ...(result && typeof result === 'object' ? result : {}),
95
+ };
96
+
97
+ const insertedResult = await wixData.insert(collectionId, dataToInsert);
98
+
99
+ console.log(
100
+ `[saveTaskResult] Successfully saved result for task ${taskName} with ID ${insertedResult._id}`
101
+ );
102
+ return insertedResult._id;
103
+ } catch (error) {
104
+ const errMsg = `[saveTaskResult] Failed to save result for task ${task.name}: ${error.message}`;
105
+ console.error(errMsg);
106
+ throw new Error(errMsg);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Checks if a task configuration has results saving enabled
112
+ * @param {Object} taskConfig - The task configuration
113
+ * @returns {boolean} - Whether results saving is enabled
114
+ */
115
+ function shouldSaveResultsToAdditionalCollection(taskConfig) {
116
+ return taskConfig && taskConfig.saveResults === true;
117
+ }
118
+
119
+ module.exports = {
120
+ createResultsCollectionIfMissing,
121
+ addDynamicFieldsToCollection,
122
+ saveTaskResultToAdditionalCollection,
123
+ shouldSaveResultsToAdditionalCollection,
124
+ };
@@ -0,0 +1,141 @@
1
+ const { withDuration } = require('../../public/performance/duration'); //relative path to needed file, so we won't break site due to frontend modules imports inside backend
2
+
3
+ const { TASK_STATUS, CRON_JOB_MAX_DURATION_SEC } = require('./consts');
4
+ const { scheduleChildTasksAndUpdateParent } = require('./parentChildTasks/handler');
5
+ const {
6
+ saveTaskResultToAdditionalCollection,
7
+ shouldSaveResultsToAdditionalCollection,
8
+ } = require('./resultsSaver');
9
+ const {
10
+ getTaskConfig,
11
+ markTask,
12
+ insertNewTask,
13
+ getScheduledTasks,
14
+ isParentTask,
15
+ getTasksToProcess,
16
+ bulkInsertTasks,
17
+ } = require('./utils');
18
+
19
+ function taskManager() {
20
+ const processTask = (task, tasksConfig) =>
21
+ withDuration(
22
+ 'processTask',
23
+ async () => {
24
+ const taskName = task.name;
25
+ const didFailBefore = task.status === TASK_STATUS.FAILED;
26
+ let saveResults = false;
27
+ try {
28
+ console.log(`[processTask] for ${taskName} Started`);
29
+ const taskConfig = getTaskConfig(taskName, tasksConfig);
30
+ saveResults = shouldSaveResultsToAdditionalCollection(taskConfig);
31
+ await markTask({ task, status: TASK_STATUS.IN_PROGRESS });
32
+ if (isParentTask(task, taskConfig)) {
33
+ await scheduleChildTasksAndUpdateParent(task, tasksConfig);
34
+ //TODO: handle collecting children outputs and use it, e.g csv generation
35
+ return task; // we return here, as we let upper line handle parent task status update;
36
+ }
37
+ const identifier = await taskConfig.getIdentifier(task);
38
+ if (taskConfig.shouldSkipCheck(identifier)) {
39
+ console.log(`[${taskName}] Task for identifier ${identifier} will be skipped`);
40
+ await markTask({
41
+ task,
42
+ status: TASK_STATUS.SKIPPED,
43
+ error: `${taskName} skipped`,
44
+ });
45
+ console.log(`[${taskName}] Task for identifier ${identifier} is skipped`);
46
+ return task;
47
+ }
48
+
49
+ const result = await taskConfig.process(identifier);
50
+ //setting the processing output to the task data, so it can be used by the parent task if needed
51
+ task.data.output = result;
52
+
53
+ await markTask({ task, status: TASK_STATUS.SUCCESS });
54
+
55
+ if (saveResults) {
56
+ await saveTaskResultToAdditionalCollection(task, TASK_STATUS.SUCCESS, result);
57
+ }
58
+
59
+ console.log(
60
+ `[processTask] for ${taskName} Task with identifier ${identifier} completed successfully`
61
+ );
62
+ return task;
63
+ } catch (error) {
64
+ const errMsg = `[processTask] for ${taskName} Task failed with error: ${error}`;
65
+ console.error(errMsg);
66
+ const amountOfRetries = didFailBefore ? task.amountOfRetries + 1 : task.amountOfRetries; // this is the first time it fails
67
+ await markTask({
68
+ task,
69
+ error,
70
+ status: TASK_STATUS.FAILED,
71
+ amountOfRetries,
72
+ });
73
+ if (saveResults) {
74
+ await saveTaskResultToAdditionalCollection(task, TASK_STATUS.FAILED, { error });
75
+ }
76
+ throw new Error(errMsg);
77
+ }
78
+ },
79
+ console
80
+ );
81
+ /**
82
+ * @description Processing tasks based on how many the cron job tick can handle and running them sequentially to be as safe as possible
83
+ * */
84
+ const processTasksBasedOnVeloLimit = async (scheduledTasks, tasksConfig) => {
85
+ console.log(
86
+ `processTasksBasedOnVeloLimit: ${JSON.stringify(scheduledTasks)} ${JSON.stringify(tasksConfig)}`
87
+ );
88
+ const toProcessTasks = getTasksToProcess({
89
+ scheduledTasks,
90
+ tasksConfig,
91
+ maxDuration: CRON_JOB_MAX_DURATION_SEC,
92
+ });
93
+ console.log(`processTasksBasedOnVeloLimit: toProcessTasks: ${JSON.stringify(toProcessTasks)}`);
94
+ console.log(
95
+ `[processTasksBasedOnVeloLimit] started processing: ${toProcessTasks.length} tasks`
96
+ );
97
+ for (const task of toProcessTasks) {
98
+ // want to run tasks sequentially, but still don't want if one fails to stop others from running to gain the most from each job tick
99
+ try {
100
+ await processTask(task, tasksConfig);
101
+ } catch (error) {
102
+ console.error(
103
+ `[processTasksBasedOnVeloLimit] failed to process task ${JSON.stringify(
104
+ task
105
+ )} with error: ${error}`
106
+ );
107
+ }
108
+ }
109
+ console.log(
110
+ `[processTasksBasedOnVeloLimit] finished processing: ${toProcessTasks.length} tasks`
111
+ );
112
+ };
113
+ const runScheduledTasks = async tasksConfig => {
114
+ try {
115
+ const scheduledTasks = await getScheduledTasks();
116
+ console.log(`runScheduledTasks: scheduledTasks: ${JSON.stringify(scheduledTasks)}`);
117
+ console.log(`runScheduledTasks: tasksConfig: ${JSON.stringify(tasksConfig)}`);
118
+ if (scheduledTasks.length) {
119
+ await processTasksBasedOnVeloLimit(scheduledTasks, tasksConfig);
120
+ } else {
121
+ console.log(
122
+ '[runScheduledTasks] No Tasks to process',
123
+ JSON.stringify({ scheduledTasksCount: scheduledTasks.length })
124
+ );
125
+ }
126
+ } catch (error) {
127
+ console.error(`[runScheduledTasks] failed with error: ${error}`);
128
+ throw error;
129
+ }
130
+ };
131
+
132
+ return {
133
+ schedule: insertNewTask,
134
+ scheduleInBulk: bulkInsertTasks,
135
+ processTask,
136
+ runScheduledTasks: (...args) =>
137
+ withDuration('runScheduledTasks', () => runScheduledTasks(...args), console),
138
+ };
139
+ }
140
+
141
+ module.exports = { taskManager };
@@ -0,0 +1,262 @@
1
+ const { COLLECTIONS, COLLECTIONS_FIELDS } = require('../../public/consts'); // need relative import to not break site due to frontend modules imports inside backend
2
+ const { wixData, wixCollections } = require('../elevated-modules');
3
+
4
+ const { TASK_STATUS, TASK_TYPE, TASK_MAX_TRIES } = require('./consts');
5
+
6
+ /**
7
+ * Generic function to create a collection if it doesn't exist
8
+ * @param {string} collectionId - The ID of the collection to create
9
+ * @param {Array} fields - Array of field definitions for the collection
10
+ * @param {Object} permissions - Permissions object for the collection
11
+ * @param {string} collectionType - Description of the collection type for logging
12
+ * @returns {Promise<string>} - The collection ID
13
+ */
14
+ async function createCollectionIfMissing(
15
+ collectionId,
16
+ fields,
17
+ permissions = null,
18
+ collectionType = 'Collection'
19
+ ) {
20
+ console.log(`[createCollectionIfMissing] Checking for collection with ID: ${collectionId}`);
21
+
22
+ const defaultPermissions = { insert: 'ADMIN', update: 'ADMIN', remove: 'ADMIN', read: 'ADMIN' };
23
+ const collectionPermissions = permissions || defaultPermissions;
24
+
25
+ try {
26
+ // try to get the collection by id, if it doesn't exist, this will throw an error
27
+ await wixCollections.getDataCollection(collectionId);
28
+ console.log(
29
+ `[createCollectionIfMissing] ${collectionType} collection ${collectionId} already exists`
30
+ );
31
+ return collectionId;
32
+ } catch (e) {
33
+ if (e.message.includes('WDE0025')) {
34
+ // an error that indicates that the collection does not exist.
35
+ console.log(
36
+ `[createCollectionIfMissing] ${collectionType} collection ${collectionId} does not exist. Creating...`
37
+ );
38
+ const createDataCollectionOptions = {
39
+ id: collectionId,
40
+ fields,
41
+ permissions: collectionPermissions,
42
+ };
43
+
44
+ if (collectionType === 'singleItem') {
45
+ createDataCollectionOptions.plugins = [
46
+ {
47
+ type: "SINGLE_ITEM",
48
+ singleItemOptions: {},
49
+ },
50
+ ];
51
+ }
52
+ await wixCollections.createDataCollection(createDataCollectionOptions);
53
+ console.log(
54
+ `[createCollectionIfMissing] ${collectionType} collection ${collectionId} created successfully`
55
+ );
56
+ return collectionId;
57
+ } else {
58
+ // unexpected error
59
+ console.error(
60
+ `[createCollectionIfMissing] While trying to get collection with id: ${collectionId} an unexpected error occurred: ${e}`
61
+ );
62
+ throw e;
63
+ }
64
+ }
65
+ }
66
+
67
+ const getTaskConfig = (taskName, tasksConfig) => {
68
+ const taskConfig = Object.values(tasksConfig).find(t => t.name === taskName);
69
+ if (!taskConfig) {
70
+ throw new Error(`unknown task name ${taskName}`);
71
+ }
72
+ return taskConfig;
73
+ };
74
+
75
+ const updateTask = async task => {
76
+ try {
77
+ await wixData.update(COLLECTIONS.TASKS, task);
78
+ } catch (err) {
79
+ const errMsg = `[updateTask] for ${JSON.stringify(task)} failed with error: ${err.message}`;
80
+ console.error(errMsg);
81
+ throw new Error(errMsg);
82
+ }
83
+ };
84
+ const markTask = async ({ task, status, error, amountOfRetries }) => {
85
+ const toMarkTask = {
86
+ ...task,
87
+ ...(status && { status }),
88
+ ...(error && { error: error.message ?? error }),
89
+ ...(amountOfRetries !== undefined && { amountOfRetries }),
90
+ };
91
+ await updateTask(toMarkTask);
92
+ };
93
+
94
+ const validateTask = (task, methodName) => {
95
+ if (
96
+ typeof task.name !== 'string' ||
97
+ !task.name ||
98
+ typeof task.data !== 'object' ||
99
+ !task.data ||
100
+ typeof task.type !== 'string' ||
101
+ !task.type ||
102
+ (task.parentTaskId !== undefined &&
103
+ (typeof task.parentTaskId !== 'string' || !task.parentTaskId))
104
+ ) {
105
+ throw new Error(`[${methodName}] Invalid params: ${JSON.stringify(task)}`);
106
+ }
107
+ };
108
+
109
+ const insertNewTask = async ({ name, data, type, parentTaskId }) => {
110
+ await createCollectionIfMissing(COLLECTIONS.TASKS, COLLECTIONS_FIELDS.TASKS);
111
+ try {
112
+ validateTask({ name, data, type, parentTaskId }, 'insertNewTask');
113
+ console.log('inserting pending task', name);
114
+ const insertedTask = await wixData.insert(COLLECTIONS.TASKS, {
115
+ name,
116
+ parentTaskId,
117
+ data,
118
+ status: TASK_STATUS.PENDING,
119
+ type,
120
+ amountOfRetries: 0,
121
+ });
122
+ console.log(`Task ${name} inserted successfully`);
123
+ return insertedTask._id;
124
+ } catch (err) {
125
+ const errMsg = `[insertNewTask] Failed for Task ${name} with error ${err.message}`;
126
+ console.error(errMsg);
127
+ throw new Error(errMsg);
128
+ }
129
+ };
130
+ const bulkInsertTasks = async tasks => {
131
+ await createCollectionIfMissing(COLLECTIONS.TASKS, COLLECTIONS_FIELDS.TASKS);
132
+
133
+ // Validate tasks before attempting database operations
134
+ const toInsertTasks = tasks.map(task => {
135
+ validateTask(task, 'bulkInsertTasks');
136
+ return {
137
+ ...task,
138
+ status: TASK_STATUS.PENDING,
139
+ amountOfRetries: 0,
140
+ };
141
+ });
142
+
143
+ try {
144
+ const insertedTasks = await wixData.bulkInsert(COLLECTIONS.TASKS, toInsertTasks);
145
+ return insertedTasks.insertedItemIds;
146
+ } catch (err) {
147
+ const errMsg = `[bulkInsertTasks] Failed for Tasks with error ${err.message}`;
148
+ console.error(errMsg);
149
+ throw new Error(errMsg);
150
+ }
151
+ };
152
+
153
+ const getExpectedChildTasks = (task, taskConfig) => {
154
+ const childTasks =
155
+ typeof taskConfig.childTasks === 'function'
156
+ ? taskConfig.childTasks(task)
157
+ : taskConfig.childTasks;
158
+ return childTasks;
159
+ };
160
+ const isParentTask = (task, taskConfig) => {
161
+ if (!('childTasks' in taskConfig)) {
162
+ return false;
163
+ }
164
+ const childTasks = getExpectedChildTasks(task, taskConfig);
165
+ if (!Array.isArray(childTasks) || childTasks.length === 0) {
166
+ throw new Error('taskConfig.childTasks must be a non-empty array if defined');
167
+ }
168
+ return true;
169
+ };
170
+ const filterScheduledTasksByStatus = (tasks, tasksConfig) => {
171
+ console.log(
172
+ `filterScheduledTasksByStatus: ${JSON.stringify(tasks)} ${JSON.stringify(tasksConfig)}`
173
+ );
174
+ const filtered = tasks.filter(task => {
175
+ if (task.status === TASK_STATUS.IN_PROGRESS) {
176
+ //Only include parent tasks that are in progress
177
+ console.log(`filterScheduledTasksByStatus: task: ${JSON.stringify(task)}`);
178
+ console.log(`filterScheduledTasksByStatus: tasksConfig: ${JSON.stringify(tasksConfig)}`);
179
+ return isParentTask(task, tasksConfig[task.name]);
180
+ }
181
+ return [TASK_STATUS.PENDING, TASK_STATUS.FAILED].includes(task.status);
182
+ });
183
+ console.log(`filterScheduledTasksByStatus: filtered: ${JSON.stringify(filtered)}`);
184
+ return filtered;
185
+ };
186
+ const getScheduledTasks = async () => {
187
+ try {
188
+ const baseQuery = wixData.query(COLLECTIONS.TASKS);
189
+ const scheduledTasksQuery = baseQuery
190
+ .eq('type', TASK_TYPE.SCHEDULED)
191
+ .hasSome('status', [TASK_STATUS.PENDING, TASK_STATUS.FAILED, TASK_STATUS.IN_PROGRESS]);
192
+ const eventBasedTasksQuery = baseQuery
193
+ .eq('type', TASK_TYPE.EVENT)
194
+ .eq('status', TASK_STATUS.FAILED);
195
+ return await scheduledTasksQuery
196
+ .or(eventBasedTasksQuery)
197
+ .lt('amountOfRetries', TASK_MAX_TRIES)
198
+ .ascending('_updatedDate')
199
+ .limit(1000)
200
+ .find()
201
+ .then(result => result.items);
202
+ } catch (error) {
203
+ throw new Error(`[getScheduledTasks] failed with error: ${error.message}`);
204
+ }
205
+ };
206
+ const getTaskScheduledChildren = async parentTaskId => {
207
+ try {
208
+ return await wixData
209
+ .query(COLLECTIONS.TASKS)
210
+ .eq('parentTaskId', parentTaskId)
211
+ .ascending('_createdDate')
212
+ .limit(1000)
213
+ .find()
214
+ .then(result => result.items);
215
+ } catch (error) {
216
+ console.error(
217
+ `[getTaskScheduledChildren] failed for task [${parentTaskId}] with error: ${error.message}`
218
+ );
219
+ throw error;
220
+ }
221
+ };
222
+
223
+ const getTasksToProcess = ({ scheduledTasks, tasksConfig, maxDuration }) => {
224
+ console.log('getTasksToProcess:', { scheduledTasks, tasksConfig, maxDuration });
225
+ const tasksToProcess = [];
226
+ let totalDuration = 0;
227
+ console.log(`tasksConfig is : ${JSON.stringify(tasksConfig)}`);
228
+ const filteredScheduledTasks = filterScheduledTasksByStatus(scheduledTasks, tasksConfig);
229
+ console.log(`filteredScheduledTasks is : ${JSON.stringify(filteredScheduledTasks)}`);
230
+ for (const task of filteredScheduledTasks) {
231
+ console.log(`task is : ${JSON.stringify(task)}`);
232
+ const taskConfig = getTaskConfig(task.name, tasksConfig);
233
+ console.log(`taskConfig is : ${JSON.stringify(taskConfig)}`);
234
+ const estimated = taskConfig?.estimatedDurationSec;
235
+ if (!estimated) {
236
+ const errMsg = `[getTasksToProcess] estimatedDurationSec is not defined for task ${task.name}`;
237
+ console.error(errMsg);
238
+ throw new Error(errMsg);
239
+ }
240
+ const buffered = estimated * 1.5; // 1.5x buffer to be safe
241
+
242
+ if (totalDuration + buffered > maxDuration) break;
243
+ totalDuration += buffered;
244
+ tasksToProcess.push(task);
245
+ }
246
+ console.log('getTasksToProcess: tasksToProcess:', tasksToProcess);
247
+ return tasksToProcess;
248
+ };
249
+
250
+ module.exports = {
251
+ createCollectionIfMissing,
252
+ getTaskConfig,
253
+ updateTask,
254
+ markTask,
255
+ insertNewTask,
256
+ bulkInsertTasks,
257
+ getScheduledTasks,
258
+ getTaskScheduledChildren,
259
+ isParentTask,
260
+ getTasksToProcess,
261
+ getExpectedChildTasks,
262
+ };
package/index.js CHANGED
@@ -1,7 +1,4 @@
1
- // Main entry point for psdev-task-manager
2
- const script = require('./script');
3
-
4
- // Export all functions from script.js
5
1
  module.exports = {
6
- ...script,
2
+ ...require('./backend'),
3
+ ...require('./public')
7
4
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "psdev-task-manager",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Task manager library",
5
5
  "keywords": [
6
6
  "task-manager"
@@ -19,5 +19,9 @@
19
19
  "main": "index.js",
20
20
  "scripts": {
21
21
  "test": "jest"
22
+ },
23
+ "dependencies": {
24
+ "@wix/data": "^1.0.273",
25
+ "@wix/essentials": "^0.1.27"
22
26
  }
23
27
  }
@@ -0,0 +1,29 @@
1
+ const COLLECTIONS = {
2
+ TASKS: 'Tasks',
3
+ };
4
+
5
+ const COLLECTIONS_FIELDS = {
6
+ TASKS: [
7
+ { key: 'name', type: 'TEXT' },
8
+ { key: 'data', type: 'OBJECT' },
9
+ { key: 'type', type: 'TEXT', enum: ['event', 'scheduled'] },
10
+ {
11
+ key: 'status',
12
+ type: 'TEXT',
13
+ enum: ['pending', 'in_progress', 'success', 'skipped', 'failed'],
14
+ },
15
+ { key: 'error', type: 'TEXT' },
16
+ { key: 'amountOfRetries', type: 'NUMBER' },
17
+ {
18
+ key: 'parentTaskId',
19
+ type: 'REFERENCE',
20
+ typeMetadata: { reference: { referencedCollectionId: 'Tasks' } },
21
+ },
22
+ { key: 'Task Result', type: 'TEXT' },
23
+ ],
24
+ };
25
+
26
+ module.exports = {
27
+ COLLECTIONS,
28
+ COLLECTIONS_FIELDS,
29
+ };
@@ -0,0 +1,6 @@
1
+ // Export all functions from script.js
2
+ module.exports = {
3
+ ...require('./performance'),
4
+ ...require('./consts'),
5
+
6
+ };
@@ -0,0 +1,14 @@
1
+ async function withDuration(functionName, func, logger) {
2
+ const start = Date.now();
3
+ const result = await func();
4
+ logger.log(
5
+ `${functionName} duration: ${
6
+ Date.now() - start
7
+ }ms and in human readble format: ${(Date.now() - start) / 1000} s`
8
+ );
9
+ return result;
10
+ }
11
+
12
+ module.exports = {
13
+ withDuration,
14
+ };
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ ...require('./duration'),
3
+ };
package/script.js DELETED
@@ -1,7 +0,0 @@
1
- const myFunction = () => {
2
- console.log("Hello World");
3
- };
4
-
5
- module.exports = {
6
- myFunction,
7
- };