bulltrackers-module 1.0.661 → 1.0.662

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.
@@ -133,15 +133,70 @@ async function getStableDateSession(config, dependencies, pass, dateLimitStr, fo
133
133
  // 2. NEW SNAPSHOT HANDLER
134
134
  async function handleSnapshot(config, dependencies, reqBody) {
135
135
  const { logger } = dependencies;
136
- const date = reqBody.date;
136
+ const targetDate = reqBody.date; // Optional: if provided, only process up to this date
137
137
 
138
- if (!date) throw new Error('Snapshot action requires a "date"');
139
-
140
138
  try {
141
- logger.log('INFO', `[Dispatcher] 📸 Triggering Snapshot Service for ${date}`);
142
- // Calls the service we created earlier
143
- const result = await generateDailySnapshots(date, config, dependencies);
144
- return result;
139
+ // Get earliest available root data date
140
+ const earliestDates = await getEarliestDataDates(config, dependencies);
141
+ const earliestDate = earliestDates.absoluteEarliest;
142
+
143
+ if (!earliestDate) {
144
+ throw new Error('Could not determine earliest available root data date');
145
+ }
146
+
147
+ // Determine end date: use targetDate if provided, otherwise use today
148
+ const endDate = targetDate ? new Date(targetDate + 'T00:00:00Z') : new Date();
149
+ endDate.setUTCHours(0, 0, 0, 0);
150
+
151
+ // Generate all dates from earliest to end date
152
+ const startDate = new Date(earliestDate);
153
+ startDate.setUTCHours(0, 0, 0, 0);
154
+
155
+ const dateStrings = getExpectedDateStrings(startDate, endDate);
156
+
157
+ if (dateStrings.length === 0) {
158
+ logger.log('WARN', '[Dispatcher] No dates to process for snapshot');
159
+ return { status: 'OK', processed: 0, skipped: 0 };
160
+ }
161
+
162
+ logger.log('INFO', `[Dispatcher] 📸 Processing snapshots for ${dateStrings.length} dates from ${dateStrings[0]} to ${dateStrings[dateStrings.length - 1]}`);
163
+
164
+ // Process each date (snapshot service will skip if already exists)
165
+ const results = [];
166
+ const BATCH_SIZE = 5; // Process 5 dates in parallel to avoid overwhelming the system
167
+
168
+ for (let i = 0; i < dateStrings.length; i += BATCH_SIZE) {
169
+ const batch = dateStrings.slice(i, i + BATCH_SIZE);
170
+ const batchResults = await Promise.allSettled(
171
+ batch.map(dateStr => generateDailySnapshots(dateStr, config, dependencies))
172
+ );
173
+
174
+ batchResults.forEach((result, idx) => {
175
+ const dateStr = batch[idx];
176
+ if (result.status === 'fulfilled') {
177
+ const value = result.value;
178
+ results.push({ date: dateStr, status: value.status || 'OK' });
179
+ } else {
180
+ logger.log('ERROR', `[Dispatcher] Snapshot failed for ${dateStr}: ${result.reason?.message || result.reason}`);
181
+ results.push({ date: dateStr, status: 'ERROR', error: result.reason?.message || String(result.reason) });
182
+ }
183
+ });
184
+ }
185
+
186
+ const successful = results.filter(r => r.status === 'OK').length;
187
+ const skipped = results.filter(r => r.status === 'SKIPPED').length;
188
+ const failed = results.filter(r => r.status === 'ERROR').length;
189
+
190
+ logger.log('INFO', `[Dispatcher] 📸 Snapshot batch complete: ${successful} processed, ${skipped} skipped, ${failed} failed out of ${results.length} total`);
191
+
192
+ return {
193
+ status: failed === 0 ? 'OK' : 'PARTIAL',
194
+ processed: successful,
195
+ skipped: skipped,
196
+ failed: failed,
197
+ total: results.length,
198
+ results: results
199
+ };
145
200
  } catch (e) {
146
201
  logger.log('ERROR', `[Dispatcher] Snapshot failed: ${e.message}`);
147
202
  // Return error object so workflow can see failure
@@ -247,6 +247,8 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
247
247
 
248
248
  if (calc.manifest.class.getSchema && flushMode !== 'INTERMEDIATE') {
249
249
  const { class: _cls, ...safeMetadata } = calc.manifest;
250
+ // Ensure ttlDays is set to the resolved value (defaults to 90 if undefined)
251
+ safeMetadata.ttlDays = ttlDays;
250
252
  schemas.push({ name, category: calc.manifest.category, schema: calc.manifest.class.getSchema(), metadata: safeMetadata });
251
253
  }
252
254
  if (calc.manifest.previousCategory && calc.manifest.previousCategory !== calc.manifest.category && flushMode !== 'INTERMEDIATE') {
@@ -10,11 +10,30 @@ const dataLoader = require('../utils/data_loader');
10
10
 
11
11
  async function generateDailySnapshots(dateStr, config, deps) {
12
12
  const { logger } = deps;
13
- logger.log('INFO', `[SnapshotService] 📸 Starting Full System Snapshot for ${dateStr}`);
14
-
15
13
  const bucketName = config.gcsBucketName || 'bulltrackers';
16
14
  const bucket = storage.bucket(bucketName);
17
15
 
16
+ // Quick check: if all main snapshots exist, skip entirely
17
+ const mainFiles = [
18
+ `${dateStr}/snapshots/portfolios.json.gz`,
19
+ `${dateStr}/snapshots/social.json.gz`,
20
+ `${dateStr}/snapshots/history.jsonl.gz`,
21
+ `${dateStr}/snapshots/ratings.json.gz`,
22
+ `${dateStr}/snapshots/rankings.json.gz`
23
+ ];
24
+
25
+ if (!config.forceSnapshot) {
26
+ const existenceChecks = await Promise.all(mainFiles.map(path => bucket.file(path).exists()));
27
+ const allExist = existenceChecks.every(([exists]) => exists);
28
+
29
+ if (allExist) {
30
+ logger.log('INFO', `[SnapshotService] ⏭️ All snapshots already exist for ${dateStr}, skipping`);
31
+ return { status: 'SKIPPED', date: dateStr, reason: 'all_exist' };
32
+ }
33
+ }
34
+
35
+ logger.log('INFO', `[SnapshotService] 📸 Starting Full System Snapshot for ${dateStr}`);
36
+
18
37
  // parallelize independent fetches
19
38
  await Promise.all([
20
39
  snapshotPortfolios(dateStr, bucket, config, deps), // Heavy
@@ -26,7 +45,7 @@ async function generateDailySnapshots(dateStr, config, deps) {
26
45
  snapshotMetadata(dateStr, bucket, config, deps) // Small Docs (Insights, Alerts, Watchlist)
27
46
  ]);
28
47
 
29
- logger.log('INFO', `[SnapshotService] ✅ Full System Snapshot Complete.`);
48
+ logger.log('INFO', `[SnapshotService] ✅ Full System Snapshot Complete for ${dateStr}`);
30
49
  return { status: 'OK', date: dateStr };
31
50
  }
32
51
 
@@ -109,10 +128,10 @@ async function snapshotRankings(dateStr, bucket, config, deps) {
109
128
  async function snapshotMetadata(dateStr, bucket, config, deps) {
110
129
  // Bundle small files into one "metadata.json" or keep separate. Separate is safer for loaders.
111
130
  const ops = [
112
- { name: 'insights', fn: () => dataLoader.loadDailyInsights(config, deps, dateStr) },
113
- { name: 'page_views', fn: () => dataLoader.loadPIPageViews(config, deps, dateStr) },
114
- { name: 'watchlist', fn: () => dataLoader.loadWatchlistMembership(config, deps, dateStr) },
115
- { name: 'alerts', fn: () => dataLoader.loadPIAlertHistory(config, deps, dateStr) },
131
+ { name: 'insights', fn: () => dataLoader.loadDailyInsights(config, deps, dateStr) },
132
+ { name: 'page_views', fn: () => dataLoader.loadPIPageViews(config, deps, dateStr) },
133
+ { name: 'watchlist', fn: () => dataLoader.loadWatchlistMembership(config, deps, dateStr) },
134
+ { name: 'alerts', fn: () => dataLoader.loadPIAlertHistory(config, deps, dateStr) },
116
135
  { name: 'master_list', fn: () => dataLoader.loadPopularInvestorMasterList(config, deps) } // Not date bound usually, but good to snapshot state
117
136
  ];
118
137
 
@@ -4,6 +4,35 @@
4
4
  * UPDATED: Added schema validation to prevent silent batch failures.
5
5
  */
6
6
 
7
+ /**
8
+ * Recursively removes undefined values from an object.
9
+ * Firestore doesn't allow undefined values, so we filter them out entirely.
10
+ * @param {any} data - Data to sanitize
11
+ * @returns {any} Sanitized data with undefined values removed
12
+ */
13
+ function removeUndefinedValues(data) {
14
+ if (data === undefined) return undefined; // Will be filtered out
15
+ if (data === null) return null;
16
+ if (data instanceof Date) return data;
17
+
18
+ if (Array.isArray(data)) {
19
+ return data.map(item => removeUndefinedValues(item)).filter(item => item !== undefined);
20
+ }
21
+
22
+ if (typeof data === 'object') {
23
+ const sanitized = {};
24
+ for (const [key, value] of Object.entries(data)) {
25
+ const sanitizedValue = removeUndefinedValues(value);
26
+ if (sanitizedValue !== undefined) {
27
+ sanitized[key] = sanitizedValue;
28
+ }
29
+ }
30
+ return sanitized;
31
+ }
32
+
33
+ return data;
34
+ }
35
+
7
36
  /**
8
37
  * Validates a schema object before storage.
9
38
  * Checks for circular references and size limits.
@@ -58,13 +87,17 @@ async function batchStoreSchemas(dependencies, config, schemas) {
58
87
  const docRef = db.collection(schemaCollection).doc(item.name);
59
88
 
60
89
  // Critical: Always overwrite 'lastUpdated' to now
61
- batch.set(docRef, {
90
+ // Sanitize metadata to remove undefined values (Firestore doesn't allow undefined)
91
+ const sanitizedMetadata = item.metadata ? removeUndefinedValues(item.metadata) : {};
92
+ const docData = removeUndefinedValues({
62
93
  computationName: item.name,
63
94
  category: item.category,
64
95
  schema: item.schema,
65
- metadata: item.metadata || {},
96
+ metadata: sanitizedMetadata,
66
97
  lastUpdated: new Date()
67
- }, { merge: true });
98
+ });
99
+
100
+ batch.set(docRef, docData, { merge: true });
68
101
 
69
102
  validCount++;
70
103
 
@@ -42,14 +42,21 @@ class PubSubUtils {
42
42
 
43
43
  /**
44
44
  * [NEW] Publishes a single JSON message to a topic.
45
+ * Includes timeout to prevent hanging on network issues.
45
46
  */
46
- async publish(topicName, message) {
47
+ async publish(topicName, message, timeoutMs = 10000) {
47
48
  const { pubsub, logger } = this.dependencies;
48
49
  const topic = pubsub.topic(topicName);
49
50
  const dataBuffer = Buffer.from(JSON.stringify(message));
50
51
 
51
52
  try {
52
- await topic.publishMessage({ data: dataBuffer });
53
+ // Wrap publish in a timeout promise
54
+ const publishPromise = topic.publishMessage({ data: dataBuffer });
55
+ const timeoutPromise = new Promise((_, reject) =>
56
+ setTimeout(() => reject(new Error(`Publish timeout after ${timeoutMs}ms`)), timeoutMs)
57
+ );
58
+
59
+ await Promise.race([publishPromise, timeoutPromise]);
53
60
  } catch (error) {
54
61
  logger.log('ERROR', `[Core Utils] Failed to publish message to ${topicName}`, { error: error.message });
55
62
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.661",
3
+ "version": "1.0.662",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [