@yellowpanther/shared 1.1.6 → 1.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.
@@ -0,0 +1,164 @@
1
+ // src/queue/pagePublishQueue.js
2
+ // One-off *publish* scheduler for page items (BullMQ).
3
+ // Strong upsert: purge any prior job for the same page before adding a new delayed job.
4
+
5
+ const { Queue } = require('bullmq');
6
+ const redisClient = require('../redis/redisClient');
7
+ // const logger = require('../logger'); // uncomment if you have a shared logger
8
+
9
+ const DEBUG = String(process.env.DEBUG_LOGGER || '').trim() === '1';
10
+ const DRIFT_MS = Math.max(0, Number(process.env.SCHEDULE_DRIFT_MS || 250));
11
+
12
+ const pagePublishQueue = new Queue('pagePublishQueue', {
13
+ connection: redisClient,
14
+ defaultJobOptions: {
15
+ attempts: 5,
16
+ backoff: { type: 'exponential', delay: 2000 },
17
+ removeOnComplete: 500,
18
+ removeOnFail: 500,
19
+ },
20
+ });
21
+
22
+ // ── ID helpers ─────────────────────────────────────────────────────────────────
23
+
24
+ function stableJobId(page_id) {
25
+ return `page:publish:${page_id}`;
26
+ }
27
+ function versionedJobId(page_id, runAtIso) {
28
+ return `page:publish:${page_id}:${runAtIso}`;
29
+ }
30
+
31
+ // ── Time helpers ───────────────────────────────────────────────────────────────
32
+
33
+ /** Parse anything into a UTC Date. Strings without timezone are treated as UTC. */
34
+ function normalizeToUtcDate(input) {
35
+ if (input instanceof Date) return new Date(input.getTime());
36
+ if (typeof input === 'number') return new Date(input); // epoch ms
37
+
38
+ if (typeof input === 'string') {
39
+ // Has timezone (Z or ±hh:mm)
40
+ if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(input)) {
41
+ const d = new Date(input);
42
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid ISO datetime: ${input}`);
43
+ return d;
44
+ }
45
+ // No timezone => treat as UTC
46
+ const s = input.trim().replace(' ', 'T');
47
+ const d = new Date(`${s}Z`);
48
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid datetime (no tz): ${input}`);
49
+ return d;
50
+ }
51
+
52
+ throw new Error(`Unsupported runAtUtc type: ${typeof input}`);
53
+ }
54
+
55
+ // ── Purge helpers ─────────────────────────────────────────────────────────────
56
+
57
+ async function findPendingPageJobs(page_id) {
58
+ const states = ['delayed', 'waiting', 'waiting-children', 'active'];
59
+ const jobs = await pagePublishQueue.getJobs(states);
60
+ return jobs.filter(
61
+ (j) => j?.name === 'page:publish' && j?.data?.page_id === String(page_id)
62
+ );
63
+ }
64
+
65
+ async function purgeExistingForPage(page_id) {
66
+ let removed = 0;
67
+
68
+ // Remove any pending/waiting/active jobs for this page
69
+ const pendings = await findPendingPageJobs(page_id);
70
+ for (const j of pendings) {
71
+ try {
72
+ await j.remove();
73
+ removed++;
74
+ } catch (e) {
75
+ if (DEBUG) console.warn(`[pagePublishQueue] failed to remove pending job ${j.id}: ${e.message}`);
76
+ }
77
+ }
78
+
79
+ // Also remove a leftover stable job if present
80
+ const last = await pagePublishQueue.getJob(stableJobId(page_id));
81
+ if (last) {
82
+ try {
83
+ await last.remove();
84
+ removed++;
85
+ } catch (e) {
86
+ if (DEBUG) console.warn(`[pagePublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
87
+ }
88
+ }
89
+
90
+ if (DEBUG) {
91
+ console.info('[pagePublishQueue] purgeExistingForPage', {
92
+ page_id: String(page_id),
93
+ removed,
94
+ });
95
+ }
96
+ return removed;
97
+ }
98
+
99
+ // ── Public API ────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Upsert a one-off publish job for a page.
103
+ * - Purges any previous jobs for this page
104
+ * - Uses a versioned jobId by default (or stable if useStableId=true)
105
+ *
106
+ * @param {Object} params
107
+ * @param {string|number} params.page_id
108
+ * @param {string|number|Date} params.runAtUtc - ISO string, epoch ms, or Date (UTC)
109
+ * @param {object} [params.extra]
110
+ * @param {boolean} [params.useStableId=false] - if true, uses stable jobId
111
+ */
112
+ async function addPagePublishJob({ page_id, runAtUtc, extra = {}, useStableId = false }) {
113
+ if (!page_id || !runAtUtc) {
114
+ throw new Error('addPagePublishJob: page_id and runAtUtc are required');
115
+ }
116
+
117
+ const runAt = normalizeToUtcDate(runAtUtc);
118
+ const runAtIso = runAt.toISOString();
119
+
120
+ // Drift guard to avoid immediate fire due to ms skew
121
+ const now = Date.now();
122
+ let delayMs = runAt.getTime() - now;
123
+ if (delayMs < DRIFT_MS) delayMs = Math.max(0, DRIFT_MS);
124
+
125
+ // Purge any prior jobs for this page (across states)
126
+ await purgeExistingForPage(page_id);
127
+
128
+ // Choose jobId strategy
129
+ const jobId = useStableId ? stableJobId(page_id) : versionedJobId(page_id, runAtIso);
130
+
131
+ const job = await pagePublishQueue.add(
132
+ 'page:publish',
133
+ { page_id: String(page_id), runAtUtc: runAtIso, extra },
134
+ { jobId, delay: delayMs }
135
+ );
136
+
137
+ if (DEBUG) {
138
+ console.info('[pagePublishQueue] upsert', {
139
+ page_id: String(page_id),
140
+ jobId,
141
+ runAtIso,
142
+ delayMs,
143
+ nowIso: new Date(now).toISOString(),
144
+ });
145
+ }
146
+
147
+ return job;
148
+ }
149
+
150
+ /** Cancel any scheduled publish for a given page (if present). */
151
+ async function cancelPagePublishJob(page_id) {
152
+ const removed = await purgeExistingForPage(page_id);
153
+ return removed > 0;
154
+ }
155
+
156
+ module.exports = {
157
+ pagePublishQueue,
158
+ addPagePublishJob,
159
+ cancelPagePublishJob,
160
+ // helpers for tooling/tests
161
+ stableJobId,
162
+ versionedJobId,
163
+ normalizeToUtcDate,
164
+ };
@@ -0,0 +1,164 @@
1
+ // src/queue/predictionPublishQueue.js
2
+ // One-off *publish* scheduler for prediction items (BullMQ).
3
+ // Strong upsert: purge any prior job for the same prediction before adding a new delayed job.
4
+
5
+ const { Queue } = require('bullmq');
6
+ const redisClient = require('../redis/redisClient');
7
+ // const logger = require('../logger'); // optional
8
+
9
+ const DEBUG = String(process.env.DEBUG_LOGGER || '').trim() === '1';
10
+ const DRIFT_MS = Math.max(0, Number(process.env.SCHEDULE_DRIFT_MS || 250));
11
+
12
+ const predictionPublishQueue = new Queue('predictionPublishQueue', {
13
+ connection: redisClient,
14
+ defaultJobOptions: {
15
+ attempts: 5,
16
+ backoff: { type: 'exponential', delay: 2000 },
17
+ removeOnComplete: 500,
18
+ removeOnFail: 500,
19
+ },
20
+ });
21
+
22
+ // ── ID helpers ─────────────────────────────────────────────────────────────────
23
+
24
+ function stableJobId(prediction_id) {
25
+ return `prediction:publish:${prediction_id}`;
26
+ }
27
+ function versionedJobId(prediction_id, runAtIso) {
28
+ return `prediction:publish:${prediction_id}:${runAtIso}`;
29
+ }
30
+
31
+ // ── Time helpers ───────────────────────────────────────────────────────────────
32
+
33
+ /** Parse anything into a UTC Date. Strings without timezone are treated as UTC. */
34
+ function normalizeToUtcDate(input) {
35
+ if (input instanceof Date) return new Date(input.getTime());
36
+ if (typeof input === 'number') return new Date(input); // epoch ms
37
+
38
+ if (typeof input === 'string') {
39
+ // Has timezone (Z or ±hh:mm)
40
+ if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(input)) {
41
+ const d = new Date(input);
42
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid ISO datetime: ${input}`);
43
+ return d;
44
+ }
45
+ // No timezone => treat as UTC
46
+ const s = input.trim().replace(' ', 'T');
47
+ const d = new Date(`${s}Z`);
48
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid datetime (no tz): ${input}`);
49
+ return d;
50
+ }
51
+
52
+ throw new Error(`Unsupported runAtUtc type: ${typeof input}`);
53
+ }
54
+
55
+ // ── Purge helpers ─────────────────────────────────────────────────────────────
56
+
57
+ async function findPendingPredictionJobs(prediction_id) {
58
+ const states = ['delayed', 'waiting', 'waiting-children', 'active'];
59
+ const jobs = await predictionPublishQueue.getJobs(states);
60
+ return jobs.filter(
61
+ (j) => j?.name === 'prediction:publish' && j?.data?.prediction_id === String(prediction_id)
62
+ );
63
+ }
64
+
65
+ async function purgeExistingForPrediction(prediction_id) {
66
+ let removed = 0;
67
+
68
+ // Remove any pending/waiting/active jobs for this prediction
69
+ const pendings = await findPendingPredictionJobs(prediction_id);
70
+ for (const j of pendings) {
71
+ try {
72
+ await j.remove();
73
+ removed++;
74
+ } catch (e) {
75
+ if (DEBUG) console.warn(`[predictionPublishQueue] failed to remove pending job ${j.id}: ${e.message}`);
76
+ }
77
+ }
78
+
79
+ // Also remove a leftover stable job if present
80
+ const last = await predictionPublishQueue.getJob(stableJobId(prediction_id));
81
+ if (last) {
82
+ try {
83
+ await last.remove();
84
+ removed++;
85
+ } catch (e) {
86
+ if (DEBUG) console.warn(`[predictionPublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
87
+ }
88
+ }
89
+
90
+ if (DEBUG) {
91
+ console.info('[predictionPublishQueue] purgeExistingForPrediction', {
92
+ prediction_id: String(prediction_id),
93
+ removed,
94
+ });
95
+ }
96
+ return removed;
97
+ }
98
+
99
+ // ── Public API ────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Upsert a one-off publish job for a prediction.
103
+ * - Purges any previous jobs for this prediction
104
+ * - Uses a versioned jobId by default (or stable if useStableId=true)
105
+ *
106
+ * @param {Object} params
107
+ * @param {string|number} params.prediction_id
108
+ * @param {string|number|Date} params.runAtUtc - ISO string, epoch ms, or Date (UTC)
109
+ * @param {object} [params.extra]
110
+ * @param {boolean} [params.useStableId=false] - if true, uses stable jobId
111
+ */
112
+ async function addPredictionPublishJob({ prediction_id, runAtUtc, extra = {}, useStableId = false }) {
113
+ if (!prediction_id || !runAtUtc) {
114
+ throw new Error('addPredictionPublishJob: prediction_id and runAtUtc are required');
115
+ }
116
+
117
+ const runAt = normalizeToUtcDate(runAtUtc);
118
+ const runAtIso = runAt.toISOString();
119
+
120
+ // Drift guard to avoid immediate fire due to ms skew
121
+ const now = Date.now();
122
+ let delayMs = runAt.getTime() - now;
123
+ if (delayMs < DRIFT_MS) delayMs = Math.max(0, DRIFT_MS);
124
+
125
+ // Purge any prior jobs for this prediction (across states)
126
+ await purgeExistingForPrediction(prediction_id);
127
+
128
+ // Choose jobId strategy
129
+ const jobId = useStableId ? stableJobId(prediction_id) : versionedJobId(prediction_id, runAtIso);
130
+
131
+ const job = await predictionPublishQueue.add(
132
+ 'prediction:publish',
133
+ { prediction_id: String(prediction_id), runAtUtc: runAtIso, extra },
134
+ { jobId, delay: delayMs }
135
+ );
136
+
137
+ if (DEBUG) {
138
+ console.info('[predictionPublishQueue] upsert', {
139
+ prediction_id: String(prediction_id),
140
+ jobId,
141
+ runAtIso,
142
+ delayMs,
143
+ nowIso: new Date(now).toISOString(),
144
+ });
145
+ }
146
+
147
+ return job;
148
+ }
149
+
150
+ /** Cancel any scheduled publish for a given prediction (if present). */
151
+ async function cancelPredictionPublishJob(prediction_id) {
152
+ const removed = await purgeExistingForPrediction(prediction_id);
153
+ return removed > 0;
154
+ }
155
+
156
+ module.exports = {
157
+ predictionPublishQueue,
158
+ addPredictionPublishJob,
159
+ cancelPredictionPublishJob,
160
+ // helpers for tooling/tests
161
+ stableJobId,
162
+ versionedJobId,
163
+ normalizeToUtcDate,
164
+ };
@@ -0,0 +1,164 @@
1
+ // src/queue/productPublishQueue.js
2
+ // One-off *publish* scheduler for product items (BullMQ).
3
+ // Strong upsert: purge any prior job for the same product before adding a new delayed job.
4
+
5
+ const { Queue } = require('bullmq');
6
+ const redisClient = require('../redis/redisClient');
7
+ // const logger = require('../logger'); // uncomment if you have a shared logger
8
+
9
+ const DEBUG = String(process.env.DEBUG_LOGGER || '').trim() === '1';
10
+ const DRIFT_MS = Math.max(0, Number(process.env.SCHEDULE_DRIFT_MS || 250));
11
+
12
+ const productPublishQueue = new Queue('productPublishQueue', {
13
+ connection: redisClient,
14
+ defaultJobOptions: {
15
+ attempts: 5,
16
+ backoff: { type: 'exponential', delay: 2000 },
17
+ removeOnComplete: 500,
18
+ removeOnFail: 500,
19
+ },
20
+ });
21
+
22
+ // ── ID helpers ─────────────────────────────────────────────────────────────────
23
+
24
+ function stableJobId(product_id) {
25
+ return `product:publish:${product_id}`;
26
+ }
27
+ function versionedJobId(product_id, runAtIso) {
28
+ return `product:publish:${product_id}:${runAtIso}`;
29
+ }
30
+
31
+ // ── Time helpers ───────────────────────────────────────────────────────────────
32
+
33
+ /** Parse anything into a UTC Date. Strings without timezone are treated as UTC. */
34
+ function normalizeToUtcDate(input) {
35
+ if (input instanceof Date) return new Date(input.getTime());
36
+ if (typeof input === 'number') return new Date(input); // epoch ms
37
+
38
+ if (typeof input === 'string') {
39
+ // Has timezone (Z or ±hh:mm)
40
+ if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(input)) {
41
+ const d = new Date(input);
42
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid ISO datetime: ${input}`);
43
+ return d;
44
+ }
45
+ // No timezone => treat as UTC
46
+ const s = input.trim().replace(' ', 'T');
47
+ const d = new Date(`${s}Z`);
48
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid datetime (no tz): ${input}`);
49
+ return d;
50
+ }
51
+
52
+ throw new Error(`Unsupported runAtUtc type: ${typeof input}`);
53
+ }
54
+
55
+ // ── Purge helpers ─────────────────────────────────────────────────────────────
56
+
57
+ async function findPendingProductJobs(product_id) {
58
+ const states = ['delayed', 'waiting', 'waiting-children', 'active'];
59
+ const jobs = await productPublishQueue.getJobs(states);
60
+ return jobs.filter(
61
+ (j) => j?.name === 'product:publish' && j?.data?.product_id === String(product_id)
62
+ );
63
+ }
64
+
65
+ async function purgeExistingForProduct(product_id) {
66
+ let removed = 0;
67
+
68
+ // Remove any pending/waiting/active jobs for this product
69
+ const pendings = await findPendingProductJobs(product_id);
70
+ for (const j of pendings) {
71
+ try {
72
+ await j.remove();
73
+ removed++;
74
+ } catch (e) {
75
+ if (DEBUG) console.warn(`[productPublishQueue] failed to remove pending job ${j.id}: ${e.message}`);
76
+ }
77
+ }
78
+
79
+ // Also remove a leftover stable job if present
80
+ const last = await productPublishQueue.getJob(stableJobId(product_id));
81
+ if (last) {
82
+ try {
83
+ await last.remove();
84
+ removed++;
85
+ } catch (e) {
86
+ if (DEBUG) console.warn(`[productPublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
87
+ }
88
+ }
89
+
90
+ if (DEBUG) {
91
+ console.info('[productPublishQueue] purgeExistingForProduct', {
92
+ product_id: String(product_id),
93
+ removed,
94
+ });
95
+ }
96
+ return removed;
97
+ }
98
+
99
+ // ── Public API ────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Upsert a one-off publish job for a product.
103
+ * - Purges any previous jobs for this product
104
+ * - Uses a versioned jobId by default (or stable if useStableId=true)
105
+ *
106
+ * @param {Object} params
107
+ * @param {string|number} params.product_id
108
+ * @param {string|number|Date} params.runAtUtc - ISO string, epoch ms, or Date (UTC)
109
+ * @param {object} [params.extra]
110
+ * @param {boolean} [params.useStableId=false] - if true, uses stable jobId
111
+ */
112
+ async function addProductPublishJob({ product_id, runAtUtc, extra = {}, useStableId = false }) {
113
+ if (!product_id || !runAtUtc) {
114
+ throw new Error('addProductPublishJob: product_id and runAtUtc are required');
115
+ }
116
+
117
+ const runAt = normalizeToUtcDate(runAtUtc);
118
+ const runAtIso = runAt.toISOString();
119
+
120
+ // Drift guard to avoid immediate fire due to ms skew
121
+ const now = Date.now();
122
+ let delayMs = runAt.getTime() - now;
123
+ if (delayMs < DRIFT_MS) delayMs = Math.max(0, DRIFT_MS);
124
+
125
+ // Purge any prior jobs for this product (across states)
126
+ await purgeExistingForProduct(product_id);
127
+
128
+ // Choose jobId strategy
129
+ const jobId = useStableId ? stableJobId(product_id) : versionedJobId(product_id, runAtIso);
130
+
131
+ const job = await productPublishQueue.add(
132
+ 'product:publish',
133
+ { product_id: String(product_id), runAtUtc: runAtIso, extra },
134
+ { jobId, delay: delayMs }
135
+ );
136
+
137
+ if (DEBUG) {
138
+ console.info('[productPublishQueue] upsert', {
139
+ product_id: String(product_id),
140
+ jobId,
141
+ runAtIso,
142
+ delayMs,
143
+ nowIso: new Date(now).toISOString(),
144
+ });
145
+ }
146
+
147
+ return job;
148
+ }
149
+
150
+ /** Cancel any scheduled publish for a given product (if present). */
151
+ async function cancelProductPublishJob(product_id) {
152
+ const removed = await purgeExistingForProduct(product_id);
153
+ return removed > 0;
154
+ }
155
+
156
+ module.exports = {
157
+ productPublishQueue,
158
+ addProductPublishJob,
159
+ cancelProductPublishJob,
160
+ // helpers for tooling/tests
161
+ stableJobId,
162
+ versionedJobId,
163
+ normalizeToUtcDate,
164
+ };
@@ -1,5 +1,13 @@
1
1
  const { compressionQueue } = require('../queue/compressionQueue');
2
2
  const { newsTranslationQueue } = require('./newsTranslateQueue');
3
+ const {newsPublishQueue} = require('./newsPublishQueue');
4
+ const { articlePublishQueue } = require('./articlePublishQueue');
5
+ const {videoPublishQueue} = require('./videoPublishQueue');
6
+ const {imaagePublishQueue} = require('./imagePublishQueue');
7
+ const {pagePublishQueue} = require('./pagePublishQueue');
8
+ const {productPublishQueue} = require('./productPublishQueue');
9
+ const {quizPublishQueue} = require('./quizPublishQueue');
10
+ const {predictionPublishQueue} = require('./predictionPublishQueue');
3
11
  // You can register more queues here like translationQueue, videoProcessingQueue, etc.
4
12
 
5
13
  module.exports = [
@@ -10,6 +18,38 @@ module.exports = [
10
18
  {
11
19
  name: 'newsTranslationQueue',
12
20
  instance: newsTranslationQueue
21
+ },
22
+ {
23
+ name: 'newsPublishQueue',
24
+ instance: newsPublishQueue
25
+ },
26
+ {
27
+ name: 'articlePublishQueue',
28
+ instance: articlePublishQueue
29
+ },
30
+ {
31
+ name: 'videoPublishQueue',
32
+ instance: videoPublishQueue
33
+ },
34
+ {
35
+ name: 'imaagePublishQueue',
36
+ instance: imaagePublishQueue
37
+ },
38
+ {
39
+ name: 'pagePublishQueue',
40
+ instance: pagePublishQueue
41
+ },
42
+ {
43
+ name: 'productPublishQueue',
44
+ instance: productPublishQueue
45
+ },
46
+ {
47
+ name: 'quizPublishQueue',
48
+ instance: quizPublishQueue
49
+ },
50
+ {
51
+ name: 'predictionPublishQueue',
52
+ instance: predictionPublishQueue
13
53
  }
14
54
  // Add more { name, instance } here
15
55
  ];