@yellowpanther/shared 1.2.6 → 1.2.9

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.
@@ -2,18 +2,18 @@
2
2
  // One-off *publish* scheduler for image items (BullMQ).
3
3
  // Strong upsert: purge any prior job for the same image before adding a new delayed job.
4
4
 
5
- const { Queue } = require('bullmq');
6
- const redisClient = require('../redis/redisClient');
5
+ const { Queue } = require("bullmq");
6
+ const redisClient = require("../redis/redisClient");
7
7
  // const logger = require('../logger'); // uncomment if you have a shared logger
8
8
 
9
- const DEBUG = String(process.env.DEBUG_LOGGER || '').trim() === '1';
9
+ const DEBUG = String(process.env.DEBUG_LOGGER || "").trim() === "1";
10
10
  const DRIFT_MS = Math.max(0, Number(process.env.SCHEDULE_DRIFT_MS || 250));
11
11
 
12
- const imagePublishQueue = new Queue('imagePublishQueue', {
12
+ const imagePublishQueue = new Queue("imagePublishQueue", {
13
13
  connection: redisClient,
14
14
  defaultJobOptions: {
15
15
  attempts: 5,
16
- backoff: { type: 'exponential', delay: 2000 },
16
+ backoff: { type: "exponential", delay: 2000 },
17
17
  removeOnComplete: 500,
18
18
  removeOnFail: 500,
19
19
  },
@@ -21,11 +21,19 @@ const imagePublishQueue = new Queue('imagePublishQueue', {
21
21
 
22
22
  // ── ID helpers ─────────────────────────────────────────────────────────────────
23
23
 
24
+ /** Replace invalid chars for BullMQ IDs */
25
+ function sanitizeId(str) {
26
+ return str.replace(/[:.]/g, "-");
27
+ }
28
+
29
+ /** Stable id (latest schedule) */
24
30
  function stableJobId(image_id) {
25
- return `image:publish:${image_id}`;
31
+ return sanitizeId(`image:publish:${image_id}`);
26
32
  }
33
+
34
+ /** Versioned id (keeps history distinct) */
27
35
  function versionedJobId(image_id, runAtIso) {
28
- return `image:publish:${image_id}:${runAtIso}`;
36
+ return sanitizeId(`image:publish:${image_id}:${runAtIso}`);
29
37
  }
30
38
 
31
39
  // ── Time helpers ───────────────────────────────────────────────────────────────
@@ -33,19 +41,21 @@ function versionedJobId(image_id, runAtIso) {
33
41
  /** Parse anything into a UTC Date. Strings without timezone are treated as UTC. */
34
42
  function normalizeToUtcDate(input) {
35
43
  if (input instanceof Date) return new Date(input.getTime());
36
- if (typeof input === 'number') return new Date(input); // epoch ms
44
+ if (typeof input === "number") return new Date(input); // epoch ms
37
45
 
38
- if (typeof input === 'string') {
46
+ if (typeof input === "string") {
39
47
  // Has timezone (Z or ±hh:mm)
40
48
  if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(input)) {
41
49
  const d = new Date(input);
42
- if (Number.isNaN(d.getTime())) throw new Error(`Invalid ISO datetime: ${input}`);
50
+ if (Number.isNaN(d.getTime()))
51
+ throw new Error(`Invalid ISO datetime: ${input}`);
43
52
  return d;
44
53
  }
45
54
  // No timezone => treat as UTC
46
- const s = input.trim().replace(' ', 'T');
55
+ const s = input.trim().replace(" ", "T");
47
56
  const d = new Date(`${s}Z`);
48
- if (Number.isNaN(d.getTime())) throw new Error(`Invalid datetime (no tz): ${input}`);
57
+ if (Number.isNaN(d.getTime()))
58
+ throw new Error(`Invalid datetime (no tz): ${input}`);
49
59
  return d;
50
60
  }
51
61
 
@@ -55,10 +65,10 @@ function normalizeToUtcDate(input) {
55
65
  // ── Purge helpers ─────────────────────────────────────────────────────────────
56
66
 
57
67
  async function findPendingImageJobs(image_id) {
58
- const states = ['delayed', 'waiting', 'waiting-children', 'active'];
68
+ const states = ["delayed", "waiting", "waiting-children", "active"];
59
69
  const jobs = await imagePublishQueue.getJobs(states);
60
70
  return jobs.filter(
61
- (j) => j?.name === 'image:publish' && j?.data?.image_id === String(image_id)
71
+ (j) => j?.name === "image:publish" && j?.data?.image_id === String(image_id)
62
72
  );
63
73
  }
64
74
 
@@ -72,7 +82,10 @@ async function purgeExistingForImage(image_id) {
72
82
  await j.remove();
73
83
  removed++;
74
84
  } catch (e) {
75
- if (DEBUG) console.warn(`[imagePublishQueue] failed to remove pending job ${j.id}: ${e.message}`);
85
+ if (DEBUG)
86
+ console.warn(
87
+ `[imagePublishQueue] failed to remove pending job ${j.id}: ${e.message}`
88
+ );
76
89
  }
77
90
  }
78
91
 
@@ -83,12 +96,15 @@ async function purgeExistingForImage(image_id) {
83
96
  await last.remove();
84
97
  removed++;
85
98
  } catch (e) {
86
- if (DEBUG) console.warn(`[imagePublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
99
+ if (DEBUG)
100
+ console.warn(
101
+ `[imagePublishQueue] failed to remove stable job ${last.id}: ${e.message}`
102
+ );
87
103
  }
88
104
  }
89
105
 
90
106
  if (DEBUG) {
91
- console.info('[imagePublishQueue] purgeExistingForImage', {
107
+ console.info("[imagePublishQueue] purgeExistingForImage", {
92
108
  image_id: String(image_id),
93
109
  removed,
94
110
  });
@@ -109,9 +125,14 @@ async function purgeExistingForImage(image_id) {
109
125
  * @param {object} [params.extra]
110
126
  * @param {boolean} [params.useStableId=false] - if true, uses stable jobId
111
127
  */
112
- async function addImagePublishJob({ image_id, runAtUtc, extra = {}, useStableId = false }) {
128
+ async function addImagePublishJob({
129
+ image_id,
130
+ runAtUtc,
131
+ extra = {},
132
+ useStableId = false,
133
+ }) {
113
134
  if (!image_id || !runAtUtc) {
114
- throw new Error('addImagePublishJob: image_id and runAtUtc are required');
135
+ throw new Error("addImagePublishJob: image_id and runAtUtc are required");
115
136
  }
116
137
 
117
138
  const runAt = normalizeToUtcDate(runAtUtc);
@@ -126,16 +147,18 @@ async function addImagePublishJob({ image_id, runAtUtc, extra = {}, useStableId
126
147
  await purgeExistingForImage(image_id);
127
148
 
128
149
  // Choose jobId strategy
129
- const jobId = useStableId ? stableJobId(image_id) : versionedJobId(image_id, runAtIso);
150
+ const jobId = useStableId
151
+ ? stableJobId(image_id)
152
+ : versionedJobId(image_id, runAtIso);
130
153
 
131
154
  const job = await imagePublishQueue.add(
132
- 'image:publish',
155
+ "image:publish",
133
156
  { image_id: String(image_id), runAtUtc: runAtIso, extra },
134
157
  { jobId, delay: delayMs }
135
158
  );
136
159
 
137
160
  if (DEBUG) {
138
- console.info('[imagePublishQueue] upsert', {
161
+ console.info("[imagePublishQueue] upsert", {
139
162
  image_id: String(image_id),
140
163
  jobId,
141
164
  runAtIso,
@@ -161,4 +184,4 @@ module.exports = {
161
184
  stableJobId,
162
185
  versionedJobId,
163
186
  normalizeToUtcDate,
164
- };
187
+ };
@@ -2,18 +2,18 @@
2
2
  // One-off *publish* scheduler for news items (BullMQ).
3
3
  // Strong upsert: purge any prior job for the same news before adding a new delayed job.
4
4
 
5
- const { Queue } = require('bullmq');
6
- const redisClient = require('../redis/redisClient');
5
+ const { Queue } = require("bullmq");
6
+ const redisClient = require("../redis/redisClient");
7
7
  // const logger = require('../logger'); // optional
8
8
 
9
- const DEBUG = String(process.env.DEBUG_LOGGER || '').trim() === '1';
9
+ const DEBUG = String(process.env.DEBUG_LOGGER || "").trim() === "1";
10
10
  const DRIFT_MS = Math.max(0, Number(process.env.SCHEDULE_DRIFT_MS || 250));
11
11
 
12
- const newsPublishQueue = new Queue('newsPublishQueue', {
12
+ const newsPublishQueue = new Queue("newsPublishQueue", {
13
13
  connection: redisClient,
14
14
  defaultJobOptions: {
15
15
  attempts: 5,
16
- backoff: { type: 'exponential', delay: 2000 },
16
+ backoff: { type: "exponential", delay: 2000 },
17
17
  removeOnComplete: 500,
18
18
  removeOnFail: 500,
19
19
  },
@@ -21,11 +21,19 @@ const newsPublishQueue = new Queue('newsPublishQueue', {
21
21
 
22
22
  // ── ID helpers ────────────────────────────────────────────────────────────────
23
23
 
24
+ /** Replace invalid chars for BullMQ IDs */
25
+ function sanitizeId(str) {
26
+ return str.replace(/[:.]/g, "-");
27
+ }
28
+
29
+ /** Stable id (latest schedule) */
24
30
  function stableJobId(news_id) {
25
- return `news:publish:${news_id}`;
31
+ return sanitizeId(`news:publish:${news_id}`);
26
32
  }
33
+
34
+ /** Versioned id (keeps history distinct) */
27
35
  function versionedJobId(news_id, runAtIso) {
28
- return `news:publish:${news_id}:${runAtIso}`;
36
+ return sanitizeId(`news:publish:${news_id}:${runAtIso}`);
29
37
  }
30
38
 
31
39
  // ── Time helpers ──────────────────────────────────────────────────────────────
@@ -33,19 +41,21 @@ function versionedJobId(news_id, runAtIso) {
33
41
  /** Parse anything into a UTC Date. Strings without timezone are treated as UTC. */
34
42
  function normalizeToUtcDate(input) {
35
43
  if (input instanceof Date) return new Date(input.getTime());
36
- if (typeof input === 'number') return new Date(input); // epoch ms
44
+ if (typeof input === "number") return new Date(input); // epoch ms
37
45
 
38
- if (typeof input === 'string') {
46
+ if (typeof input === "string") {
39
47
  // Has timezone (Z or ±hh:mm)
40
48
  if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(input)) {
41
49
  const d = new Date(input);
42
- if (Number.isNaN(d.getTime())) throw new Error(`Invalid ISO datetime: ${input}`);
50
+ if (Number.isNaN(d.getTime()))
51
+ throw new Error(`Invalid ISO datetime: ${input}`);
43
52
  return d;
44
53
  }
45
54
  // No timezone => treat as UTC
46
- const s = input.trim().replace(' ', 'T');
55
+ const s = input.trim().replace(" ", "T");
47
56
  const d = new Date(`${s}Z`);
48
- if (Number.isNaN(d.getTime())) throw new Error(`Invalid datetime (no tz): ${input}`);
57
+ if (Number.isNaN(d.getTime()))
58
+ throw new Error(`Invalid datetime (no tz): ${input}`);
49
59
  return d;
50
60
  }
51
61
 
@@ -55,10 +65,10 @@ function normalizeToUtcDate(input) {
55
65
  // ── Purge helpers ────────────────────────────────────────────────────────────
56
66
 
57
67
  async function findPendingNewsJobs(news_id) {
58
- const states = ['delayed', 'waiting', 'waiting-children', 'active'];
68
+ const states = ["delayed", "waiting", "waiting-children", "active"];
59
69
  const jobs = await newsPublishQueue.getJobs(states);
60
70
  return jobs.filter(
61
- (j) => j?.name === 'news:publish' && j?.data?.news_id === String(news_id)
71
+ (j) => j?.name === "news:publish" && j?.data?.news_id === String(news_id)
62
72
  );
63
73
  }
64
74
 
@@ -72,7 +82,10 @@ async function purgeExistingForNews(news_id) {
72
82
  await j.remove();
73
83
  removed++;
74
84
  } catch (e) {
75
- if (DEBUG) console.warn(`[newsPublishQueue] failed to remove pending job ${j.id}: ${e.message}`);
85
+ if (DEBUG)
86
+ console.warn(
87
+ `[newsPublishQueue] failed to remove pending job ${j.id}: ${e.message}`
88
+ );
76
89
  }
77
90
  }
78
91
 
@@ -83,12 +96,15 @@ async function purgeExistingForNews(news_id) {
83
96
  await last.remove();
84
97
  removed++;
85
98
  } catch (e) {
86
- if (DEBUG) console.warn(`[newsPublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
99
+ if (DEBUG)
100
+ console.warn(
101
+ `[newsPublishQueue] failed to remove stable job ${last.id}: ${e.message}`
102
+ );
87
103
  }
88
104
  }
89
105
 
90
106
  if (DEBUG) {
91
- console.info('[newsPublishQueue] purgeExistingForNews', {
107
+ console.info("[newsPublishQueue] purgeExistingForNews", {
92
108
  news_id: String(news_id),
93
109
  removed,
94
110
  });
@@ -109,9 +125,14 @@ async function purgeExistingForNews(news_id) {
109
125
  * @param {object} [params.extra]
110
126
  * @param {boolean} [params.useStableId=false] - if true, uses stable jobId
111
127
  */
112
- async function addNewsPublishJob({ news_id, runAtUtc, extra = {}, useStableId = false }) {
128
+ async function addNewsPublishJob({
129
+ news_id,
130
+ runAtUtc,
131
+ extra = {},
132
+ useStableId = false,
133
+ }) {
113
134
  if (!news_id || !runAtUtc) {
114
- throw new Error('addNewsPublishJob: news_id and runAtUtc are required');
135
+ throw new Error("addNewsPublishJob: news_id and runAtUtc are required");
115
136
  }
116
137
 
117
138
  const runAt = normalizeToUtcDate(runAtUtc);
@@ -126,16 +147,18 @@ async function addNewsPublishJob({ news_id, runAtUtc, extra = {}, useStableId =
126
147
  await purgeExistingForNews(news_id);
127
148
 
128
149
  // Choose jobId strategy
129
- const jobId = useStableId ? stableJobId(news_id) : versionedJobId(news_id, runAtIso);
150
+ const jobId = useStableId
151
+ ? stableJobId(news_id)
152
+ : versionedJobId(news_id, runAtIso);
130
153
 
131
154
  const job = await newsPublishQueue.add(
132
- 'news:publish',
155
+ "news:publish",
133
156
  { news_id: String(news_id), runAtUtc: runAtIso, extra },
134
157
  { jobId, delay: delayMs }
135
158
  );
136
159
 
137
160
  if (DEBUG) {
138
- console.info('[newsPublishQueue] upsert', {
161
+ console.info("[newsPublishQueue] upsert", {
139
162
  news_id: String(news_id),
140
163
  jobId,
141
164
  runAtIso,
@@ -161,4 +184,4 @@ module.exports = {
161
184
  stableJobId,
162
185
  versionedJobId,
163
186
  normalizeToUtcDate,
164
- };
187
+ };
@@ -2,18 +2,18 @@
2
2
  // One-off *publish* scheduler for page items (BullMQ).
3
3
  // Strong upsert: purge any prior job for the same page before adding a new delayed job.
4
4
 
5
- const { Queue } = require('bullmq');
6
- const redisClient = require('../redis/redisClient');
5
+ const { Queue } = require("bullmq");
6
+ const redisClient = require("../redis/redisClient");
7
7
  // const logger = require('../logger'); // uncomment if you have a shared logger
8
8
 
9
- const DEBUG = String(process.env.DEBUG_LOGGER || '').trim() === '1';
9
+ const DEBUG = String(process.env.DEBUG_LOGGER || "").trim() === "1";
10
10
  const DRIFT_MS = Math.max(0, Number(process.env.SCHEDULE_DRIFT_MS || 250));
11
11
 
12
- const pagePublishQueue = new Queue('pagePublishQueue', {
12
+ const pagePublishQueue = new Queue("pagePublishQueue", {
13
13
  connection: redisClient,
14
14
  defaultJobOptions: {
15
15
  attempts: 5,
16
- backoff: { type: 'exponential', delay: 2000 },
16
+ backoff: { type: "exponential", delay: 2000 },
17
17
  removeOnComplete: 500,
18
18
  removeOnFail: 500,
19
19
  },
@@ -21,11 +21,19 @@ const pagePublishQueue = new Queue('pagePublishQueue', {
21
21
 
22
22
  // ── ID helpers ─────────────────────────────────────────────────────────────────
23
23
 
24
+ /** Replace invalid chars for BullMQ IDs */
25
+ function sanitizeId(str) {
26
+ return str.replace(/[:.]/g, "-");
27
+ }
28
+
29
+ /** Stable id (latest schedule) */
24
30
  function stableJobId(page_id) {
25
- return `page:publish:${page_id}`;
31
+ return sanitizeId(`page:publish:${page_id}`);
26
32
  }
33
+
34
+ /** Versioned id (keeps history distinct) */
27
35
  function versionedJobId(page_id, runAtIso) {
28
- return `page:publish:${page_id}:${runAtIso}`;
36
+ return sanitizeId(`page:publish:${page_id}:${runAtIso}`);
29
37
  }
30
38
 
31
39
  // ── Time helpers ───────────────────────────────────────────────────────────────
@@ -33,19 +41,21 @@ function versionedJobId(page_id, runAtIso) {
33
41
  /** Parse anything into a UTC Date. Strings without timezone are treated as UTC. */
34
42
  function normalizeToUtcDate(input) {
35
43
  if (input instanceof Date) return new Date(input.getTime());
36
- if (typeof input === 'number') return new Date(input); // epoch ms
44
+ if (typeof input === "number") return new Date(input); // epoch ms
37
45
 
38
- if (typeof input === 'string') {
46
+ if (typeof input === "string") {
39
47
  // Has timezone (Z or ±hh:mm)
40
48
  if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(input)) {
41
49
  const d = new Date(input);
42
- if (Number.isNaN(d.getTime())) throw new Error(`Invalid ISO datetime: ${input}`);
50
+ if (Number.isNaN(d.getTime()))
51
+ throw new Error(`Invalid ISO datetime: ${input}`);
43
52
  return d;
44
53
  }
45
54
  // No timezone => treat as UTC
46
- const s = input.trim().replace(' ', 'T');
55
+ const s = input.trim().replace(" ", "T");
47
56
  const d = new Date(`${s}Z`);
48
- if (Number.isNaN(d.getTime())) throw new Error(`Invalid datetime (no tz): ${input}`);
57
+ if (Number.isNaN(d.getTime()))
58
+ throw new Error(`Invalid datetime (no tz): ${input}`);
49
59
  return d;
50
60
  }
51
61
 
@@ -55,10 +65,10 @@ function normalizeToUtcDate(input) {
55
65
  // ── Purge helpers ─────────────────────────────────────────────────────────────
56
66
 
57
67
  async function findPendingPageJobs(page_id) {
58
- const states = ['delayed', 'waiting', 'waiting-children', 'active'];
68
+ const states = ["delayed", "waiting", "waiting-children", "active"];
59
69
  const jobs = await pagePublishQueue.getJobs(states);
60
70
  return jobs.filter(
61
- (j) => j?.name === 'page:publish' && j?.data?.page_id === String(page_id)
71
+ (j) => j?.name === "page:publish" && j?.data?.page_id === String(page_id)
62
72
  );
63
73
  }
64
74
 
@@ -72,7 +82,10 @@ async function purgeExistingForPage(page_id) {
72
82
  await j.remove();
73
83
  removed++;
74
84
  } catch (e) {
75
- if (DEBUG) console.warn(`[pagePublishQueue] failed to remove pending job ${j.id}: ${e.message}`);
85
+ if (DEBUG)
86
+ console.warn(
87
+ `[pagePublishQueue] failed to remove pending job ${j.id}: ${e.message}`
88
+ );
76
89
  }
77
90
  }
78
91
 
@@ -83,12 +96,15 @@ async function purgeExistingForPage(page_id) {
83
96
  await last.remove();
84
97
  removed++;
85
98
  } catch (e) {
86
- if (DEBUG) console.warn(`[pagePublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
99
+ if (DEBUG)
100
+ console.warn(
101
+ `[pagePublishQueue] failed to remove stable job ${last.id}: ${e.message}`
102
+ );
87
103
  }
88
104
  }
89
105
 
90
106
  if (DEBUG) {
91
- console.info('[pagePublishQueue] purgeExistingForPage', {
107
+ console.info("[pagePublishQueue] purgeExistingForPage", {
92
108
  page_id: String(page_id),
93
109
  removed,
94
110
  });
@@ -109,9 +125,14 @@ async function purgeExistingForPage(page_id) {
109
125
  * @param {object} [params.extra]
110
126
  * @param {boolean} [params.useStableId=false] - if true, uses stable jobId
111
127
  */
112
- async function addPagePublishJob({ page_id, runAtUtc, extra = {}, useStableId = false }) {
128
+ async function addPagePublishJob({
129
+ page_id,
130
+ runAtUtc,
131
+ extra = {},
132
+ useStableId = false,
133
+ }) {
113
134
  if (!page_id || !runAtUtc) {
114
- throw new Error('addPagePublishJob: page_id and runAtUtc are required');
135
+ throw new Error("addPagePublishJob: page_id and runAtUtc are required");
115
136
  }
116
137
 
117
138
  const runAt = normalizeToUtcDate(runAtUtc);
@@ -126,16 +147,18 @@ async function addPagePublishJob({ page_id, runAtUtc, extra = {}, useStableId =
126
147
  await purgeExistingForPage(page_id);
127
148
 
128
149
  // Choose jobId strategy
129
- const jobId = useStableId ? stableJobId(page_id) : versionedJobId(page_id, runAtIso);
150
+ const jobId = useStableId
151
+ ? stableJobId(page_id)
152
+ : versionedJobId(page_id, runAtIso);
130
153
 
131
154
  const job = await pagePublishQueue.add(
132
- 'page:publish',
155
+ "page:publish",
133
156
  { page_id: String(page_id), runAtUtc: runAtIso, extra },
134
157
  { jobId, delay: delayMs }
135
158
  );
136
159
 
137
160
  if (DEBUG) {
138
- console.info('[pagePublishQueue] upsert', {
161
+ console.info("[pagePublishQueue] upsert", {
139
162
  page_id: String(page_id),
140
163
  jobId,
141
164
  runAtIso,
@@ -161,4 +184,4 @@ module.exports = {
161
184
  stableJobId,
162
185
  versionedJobId,
163
186
  normalizeToUtcDate,
164
- };
187
+ };
@@ -2,18 +2,18 @@
2
2
  // One-off *publish* scheduler for prediction items (BullMQ).
3
3
  // Strong upsert: purge any prior job for the same prediction before adding a new delayed job.
4
4
 
5
- const { Queue } = require('bullmq');
6
- const redisClient = require('../redis/redisClient');
5
+ const { Queue } = require("bullmq");
6
+ const redisClient = require("../redis/redisClient");
7
7
  // const logger = require('../logger'); // optional
8
8
 
9
- const DEBUG = String(process.env.DEBUG_LOGGER || '').trim() === '1';
9
+ const DEBUG = String(process.env.DEBUG_LOGGER || "").trim() === "1";
10
10
  const DRIFT_MS = Math.max(0, Number(process.env.SCHEDULE_DRIFT_MS || 250));
11
11
 
12
- const predictionPublishQueue = new Queue('predictionPublishQueue', {
12
+ const predictionPublishQueue = new Queue("predictionPublishQueue", {
13
13
  connection: redisClient,
14
14
  defaultJobOptions: {
15
15
  attempts: 5,
16
- backoff: { type: 'exponential', delay: 2000 },
16
+ backoff: { type: "exponential", delay: 2000 },
17
17
  removeOnComplete: 500,
18
18
  removeOnFail: 500,
19
19
  },
@@ -21,11 +21,19 @@ const predictionPublishQueue = new Queue('predictionPublishQueue', {
21
21
 
22
22
  // ── ID helpers ─────────────────────────────────────────────────────────────────
23
23
 
24
+ /** Replace invalid chars for BullMQ IDs */
25
+ function sanitizeId(str) {
26
+ return str.replace(/[:.]/g, "-");
27
+ }
28
+
29
+ /** Stable id (latest schedule) */
24
30
  function stableJobId(prediction_id) {
25
- return `prediction:publish:${prediction_id}`;
31
+ return sanitizeId(`prediction:publish:${prediction_id}`);
26
32
  }
33
+
34
+ /** Versioned id (keeps history distinct) */
27
35
  function versionedJobId(prediction_id, runAtIso) {
28
- return `prediction:publish:${prediction_id}:${runAtIso}`;
36
+ return sanitizeId(`prediction:publish:${prediction_id}:${runAtIso}`);
29
37
  }
30
38
 
31
39
  // ── Time helpers ───────────────────────────────────────────────────────────────
@@ -33,19 +41,21 @@ function versionedJobId(prediction_id, runAtIso) {
33
41
  /** Parse anything into a UTC Date. Strings without timezone are treated as UTC. */
34
42
  function normalizeToUtcDate(input) {
35
43
  if (input instanceof Date) return new Date(input.getTime());
36
- if (typeof input === 'number') return new Date(input); // epoch ms
44
+ if (typeof input === "number") return new Date(input); // epoch ms
37
45
 
38
- if (typeof input === 'string') {
46
+ if (typeof input === "string") {
39
47
  // Has timezone (Z or ±hh:mm)
40
48
  if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(input)) {
41
49
  const d = new Date(input);
42
- if (Number.isNaN(d.getTime())) throw new Error(`Invalid ISO datetime: ${input}`);
50
+ if (Number.isNaN(d.getTime()))
51
+ throw new Error(`Invalid ISO datetime: ${input}`);
43
52
  return d;
44
53
  }
45
54
  // No timezone => treat as UTC
46
- const s = input.trim().replace(' ', 'T');
55
+ const s = input.trim().replace(" ", "T");
47
56
  const d = new Date(`${s}Z`);
48
- if (Number.isNaN(d.getTime())) throw new Error(`Invalid datetime (no tz): ${input}`);
57
+ if (Number.isNaN(d.getTime()))
58
+ throw new Error(`Invalid datetime (no tz): ${input}`);
49
59
  return d;
50
60
  }
51
61
 
@@ -55,10 +65,12 @@ function normalizeToUtcDate(input) {
55
65
  // ── Purge helpers ─────────────────────────────────────────────────────────────
56
66
 
57
67
  async function findPendingPredictionJobs(prediction_id) {
58
- const states = ['delayed', 'waiting', 'waiting-children', 'active'];
68
+ const states = ["delayed", "waiting", "waiting-children", "active"];
59
69
  const jobs = await predictionPublishQueue.getJobs(states);
60
70
  return jobs.filter(
61
- (j) => j?.name === 'prediction:publish' && j?.data?.prediction_id === String(prediction_id)
71
+ (j) =>
72
+ j?.name === "prediction:publish" &&
73
+ j?.data?.prediction_id === String(prediction_id)
62
74
  );
63
75
  }
64
76
 
@@ -72,7 +84,10 @@ async function purgeExistingForPrediction(prediction_id) {
72
84
  await j.remove();
73
85
  removed++;
74
86
  } catch (e) {
75
- if (DEBUG) console.warn(`[predictionPublishQueue] failed to remove pending job ${j.id}: ${e.message}`);
87
+ if (DEBUG)
88
+ console.warn(
89
+ `[predictionPublishQueue] failed to remove pending job ${j.id}: ${e.message}`
90
+ );
76
91
  }
77
92
  }
78
93
 
@@ -83,12 +98,15 @@ async function purgeExistingForPrediction(prediction_id) {
83
98
  await last.remove();
84
99
  removed++;
85
100
  } catch (e) {
86
- if (DEBUG) console.warn(`[predictionPublishQueue] failed to remove stable job ${last.id}: ${e.message}`);
101
+ if (DEBUG)
102
+ console.warn(
103
+ `[predictionPublishQueue] failed to remove stable job ${last.id}: ${e.message}`
104
+ );
87
105
  }
88
106
  }
89
107
 
90
108
  if (DEBUG) {
91
- console.info('[predictionPublishQueue] purgeExistingForPrediction', {
109
+ console.info("[predictionPublishQueue] purgeExistingForPrediction", {
92
110
  prediction_id: String(prediction_id),
93
111
  removed,
94
112
  });
@@ -109,9 +127,16 @@ async function purgeExistingForPrediction(prediction_id) {
109
127
  * @param {object} [params.extra]
110
128
  * @param {boolean} [params.useStableId=false] - if true, uses stable jobId
111
129
  */
112
- async function addPredictionPublishJob({ prediction_id, runAtUtc, extra = {}, useStableId = false }) {
130
+ async function addPredictionPublishJob({
131
+ prediction_id,
132
+ runAtUtc,
133
+ extra = {},
134
+ useStableId = false,
135
+ }) {
113
136
  if (!prediction_id || !runAtUtc) {
114
- throw new Error('addPredictionPublishJob: prediction_id and runAtUtc are required');
137
+ throw new Error(
138
+ "addPredictionPublishJob: prediction_id and runAtUtc are required"
139
+ );
115
140
  }
116
141
 
117
142
  const runAt = normalizeToUtcDate(runAtUtc);
@@ -126,16 +151,18 @@ async function addPredictionPublishJob({ prediction_id, runAtUtc, extra = {}, us
126
151
  await purgeExistingForPrediction(prediction_id);
127
152
 
128
153
  // Choose jobId strategy
129
- const jobId = useStableId ? stableJobId(prediction_id) : versionedJobId(prediction_id, runAtIso);
154
+ const jobId = useStableId
155
+ ? stableJobId(prediction_id)
156
+ : versionedJobId(prediction_id, runAtIso);
130
157
 
131
158
  const job = await predictionPublishQueue.add(
132
- 'prediction:publish',
159
+ "prediction:publish",
133
160
  { prediction_id: String(prediction_id), runAtUtc: runAtIso, extra },
134
161
  { jobId, delay: delayMs }
135
162
  );
136
163
 
137
164
  if (DEBUG) {
138
- console.info('[predictionPublishQueue] upsert', {
165
+ console.info("[predictionPublishQueue] upsert", {
139
166
  prediction_id: String(prediction_id),
140
167
  jobId,
141
168
  runAtIso,
@@ -161,4 +188,4 @@ module.exports = {
161
188
  stableJobId,
162
189
  versionedJobId,
163
190
  normalizeToUtcDate,
164
- };
191
+ };