bulltrackers-module 1.0.364 → 1.0.366

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.
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * @fileoverview Checks availability of root data via the Root Data Index.
3
3
  * REFACTORED: Fully supports granular flags for PI, Signed-In Users, Rankings, and Verification.
4
+ * FIXED: Added safety fallback for unknown userTypes to prevent impossible runs.
5
+ * UPDATED: Added strict social data checking for Popular Investors and Signed-In Users.
4
6
  */
5
7
  const { normalizeName } = require('../utils/utils');
6
8
 
@@ -19,8 +21,14 @@ function checkRootDependencies(calcManifest, rootDataStatus) {
19
21
  else if (userType === 'popular_investor') {
20
22
  if (!rootDataStatus.piPortfolios) missing.push('piPortfolios');
21
23
  if (!rootDataStatus.piDeepPortfolios) missing.push('piDeepPortfolios');
22
- } else if (userType === 'signed_in_user' && !rootDataStatus.signedInUserPortfolio) missing.push('signedInUserPortfolio');
24
+ }
25
+ else if (userType === 'signed_in_user' && !rootDataStatus.signedInUserPortfolio) missing.push('signedInUserPortfolio');
23
26
  else if (userType === 'all' && !rootDataStatus.hasPortfolio) missing.push('portfolio');
27
+ else {
28
+ // [FIX] Safety Block: If userType doesn't match known types (e.g. typo in metadata),
29
+ // enforce the global 'hasPortfolio' check instead of silently allowing it.
30
+ if (!rootDataStatus.hasPortfolio) missing.push('portfolio (fallback)');
31
+ }
24
32
  }
25
33
  else if (dep === 'history') {
26
34
  if (userType === 'speculator' && !rootDataStatus.speculatorHistory) missing.push('speculatorHistory');
@@ -28,11 +36,31 @@ function checkRootDependencies(calcManifest, rootDataStatus) {
28
36
  else if (userType === 'popular_investor' && !rootDataStatus.piHistory) missing.push('piHistory');
29
37
  else if (userType === 'signed_in_user' && !rootDataStatus.signedInUserHistory) missing.push('signedInUserHistory');
30
38
  else if (userType === 'all' && !rootDataStatus.hasHistory) missing.push('history');
39
+ else {
40
+ // [FIX] Safety Block for History
41
+ if (!rootDataStatus.hasHistory) missing.push('history (fallback)');
42
+ }
31
43
  }
32
44
  else if (dep === 'rankings' && !rootDataStatus.piRankings) missing.push('piRankings');
33
45
  else if (dep === 'verification' && !rootDataStatus.signedInUserVerification) missing.push('signedInUserVerification');
34
46
  else if (dep === 'insights' && !rootDataStatus.hasInsights) missing.push('insights');
35
- else if (dep === 'social' && !rootDataStatus.hasSocial) missing.push('social');
47
+
48
+ // [UPDATED] Strict Social Data Checking
49
+ else if (dep === 'social') {
50
+ if (userType === 'popular_investor') {
51
+ // Strict check: Must have PI-specific social data
52
+ if (!rootDataStatus.hasPISocial) missing.push('piSocial');
53
+ }
54
+ else if (userType === 'signed_in_user') {
55
+ // Strict check: Must have Signed-In User specific social data
56
+ if (!rootDataStatus.hasSignedInSocial) missing.push('signedInSocial');
57
+ }
58
+ else {
59
+ // Default behavior: Check for generic asset/market social data
60
+ if (!rootDataStatus.hasSocial) missing.push('social');
61
+ }
62
+ }
63
+
36
64
  else if (dep === 'price' && !rootDataStatus.hasPrices) missing.push('price');
37
65
  }
38
66
  return { canRun: missing.length === 0, missing };
@@ -84,7 +112,7 @@ async function checkRootDataAvailability(dateStr, config, dependencies, earliest
84
112
  // Global flags
85
113
  hasPortfolio: !!data.hasPortfolio,
86
114
  hasHistory: !!data.hasHistory,
87
- hasSocial: !!data.hasSocial,
115
+ hasSocial: !!data.hasSocial, // Represents generic/asset posts
88
116
  hasInsights: !!data.hasInsights,
89
117
  hasPrices: !!data.hasPrices,
90
118
 
@@ -101,7 +129,11 @@ async function checkRootDataAvailability(dateStr, config, dependencies, earliest
101
129
  piHistory: !!details.piHistory,
102
130
  signedInUserPortfolio: !!details.signedInUserPortfolio,
103
131
  signedInUserHistory: !!details.signedInUserHistory,
104
- signedInUserVerification: !!details.signedInUserVerification
132
+ signedInUserVerification: !!details.signedInUserVerification,
133
+
134
+ // [UPDATED] Granular Social Flags
135
+ hasPISocial: !!details.hasPISocial || !!data.hasPISocial,
136
+ hasSignedInSocial: !!details.hasSignedInSocial || !!data.hasSignedInSocial
105
137
  },
106
138
  portfolioRefs: null,
107
139
  historyRefs: null,
@@ -116,7 +148,8 @@ async function checkRootDataAvailability(dateStr, config, dependencies, earliest
116
148
  hasPortfolio: false, hasHistory: false, hasSocial: false, hasInsights: false, hasPrices: false,
117
149
  speculatorPortfolio: false, normalPortfolio: false, speculatorHistory: false, normalHistory: false,
118
150
  piRankings: false, piPortfolios: false, piDeepPortfolios: false, piHistory: false,
119
- signedInUserPortfolio: false, signedInUserHistory: false, signedInUserVerification: false
151
+ signedInUserPortfolio: false, signedInUserHistory: false, signedInUserVerification: false,
152
+ hasPISocial: false, hasSignedInSocial: false
120
153
  }
121
154
  };
122
155
  }
@@ -213,14 +213,14 @@ class StandardExecutor {
213
213
  const mappings = await loader.loadMappings();
214
214
  const SCHEMAS = mathLayer.SCHEMAS;
215
215
 
216
- // 1. Load Root Data Dependencies if requested by the manifest
216
+ // 1. Load Root Data
217
217
  const insights = metadata.rootDataDependencies?.includes('insights') ? { today: await loader.loadInsights(dateStr) } : null;
218
-
219
- // [FIX] Load full maps for verifications and rankings to pass as global data
220
218
  const verifications = metadata.rootDataDependencies?.includes('verification') ? await loader.loadVerifications() : null;
221
219
  const rankings = metadata.rootDataDependencies?.includes('rankings') ? await loader.loadRankings(dateStr) : null;
222
220
 
223
- const socialData = metadata.rootDataDependencies?.includes('social') ? { today: await loader.loadSocial(dateStr) } : null;
221
+ // [UPDATED] Load the Partitioned Social Data Container
222
+ // socialContainer = { generic: {}, pi: { userId: {} }, signedIn: { userId: {} } }
223
+ const socialContainer = metadata.rootDataDependencies?.includes('social') ? await loader.loadSocial(dateStr) : null;
224
224
 
225
225
  let chunkSuccess = 0;
226
226
  let chunkFailures = 0;
@@ -230,13 +230,9 @@ class StandardExecutor {
230
230
  const todayHistory = historyData ? historyData[userId] : null;
231
231
 
232
232
  // 2. Identify User Type
233
- // [FIX] Improved Fallback logic for missing _userType tags (common in raw uploads)
234
233
  let actualUserType = todayPortfolio._userType;
235
-
236
234
  if (!actualUserType) {
237
235
  if (todayPortfolio.PublicPositions) {
238
- // Could be SPECULATOR or POPULAR_INVESTOR. Check rankings existence to differentiate.
239
- // Note: 'rankings' is the array of PI objects.
240
236
  const isRanked = rankings && rankings.some(r => String(r.CustomerId) === String(userId));
241
237
  actualUserType = isRanked ? 'POPULAR_INVESTOR' : SCHEMAS.USER_TYPES.SPECULATOR;
242
238
  } else {
@@ -252,20 +248,37 @@ class StandardExecutor {
252
248
  }
253
249
  }
254
250
 
255
- // 4. Resolve specific user data for current iteration
251
+ // 4. Resolve Contextual Data
256
252
  const userVerification = verifications ? verifications[userId] : null;
257
253
  const userRanking = rankings ? (rankings.find(r => String(r.CustomerId) === String(userId)) || null) : null;
258
254
 
259
- // [FIX] Pass allRankings and allVerifications to ContextFactory
255
+ // [CRITICAL FIX] Select Specific Social Data to Prevent Skew
256
+ let effectiveSocialData = null;
257
+ if (socialContainer) {
258
+ if (actualUserType === 'POPULAR_INVESTOR') {
259
+ // PI Route: Only gets PI-specific posts for this user
260
+ // (PIs do not need generic noise usually, or you can merge it if required)
261
+ effectiveSocialData = socialContainer.pi[userId] || {};
262
+ } else if (actualUserType === 'SIGNED_IN_USER') {
263
+ // Signed-In Route: Only gets Signed-In specific posts for this user
264
+ effectiveSocialData = socialContainer.signedIn[userId] || {};
265
+ } else {
266
+ // Normal/Speculator/All Route: Gets Generic Market Posts
267
+ effectiveSocialData = socialContainer.generic || {};
268
+ }
269
+ }
270
+
260
271
  const context = ContextFactory.buildPerUserContext({
261
272
  todayPortfolio, yesterdayPortfolio, todayHistory, userId,
262
273
  userType: actualUserType, dateStr, metadata, mappings, insights,
263
- socialData,
274
+
275
+ // Inject the filtered subset
276
+ socialData: effectiveSocialData ? { today: effectiveSocialData } : null,
277
+
264
278
  computedDependencies: computedDeps, previousComputedDependencies: prevDeps,
265
279
  config, deps,
266
280
  verification: userVerification,
267
281
  rankings: userRanking,
268
- // New Global Injections
269
282
  allRankings: rankings,
270
283
  allVerifications: verifications
271
284
  });
@@ -35,34 +35,22 @@ async function filterActiveTasks(db, date, pass, tasks, logger, forceRun = false
35
35
  const data = snap.data();
36
36
  const isActive = ['PENDING', 'IN_PROGRESS'].includes(data.status);
37
37
 
38
- // 1. ZOMBIE CHECK (Recover Stale Locks)
39
38
  if (isActive) {
40
39
  const lastActivityTime = data.telemetry?.lastHeartbeat
41
40
  ? new Date(data.telemetry.lastHeartbeat).getTime()
42
41
  : (data.startedAt ? new Date(data.startedAt).getTime() : 0);
43
42
 
44
- const timeSinceActive = Date.now() - lastActivityTime;
45
-
46
- if (timeSinceActive > STALE_LOCK_THRESHOLD_MS) {
43
+ if ((Date.now() - lastActivityTime) > STALE_LOCK_THRESHOLD_MS) {
47
44
  if (logger) logger.log('WARN', `[Dispatcher] 🧟 Breaking stale lock for ${taskName}.`);
48
45
  return t;
49
46
  }
50
47
  return null;
51
48
  }
52
-
53
- // 2. COMPLETED CHECK (Ignore)
54
- if (data.status === 'COMPLETED') return null;
55
-
56
- // 3. FAILED CHECK (Pass through to Route Splitter)
57
- // We do NOT filter FAILED tasks here. We pass them to splitRoutes()
58
- // which decides if they get promoted to High-Mem or dropped forever.
59
- if (data.status === 'FAILED') {
60
- return t;
61
- }
49
+ // Note: We do NOT filter COMPLETED here anymore for Sweep.
50
+ // If the Orchestrator says it needs to run, we run it.
62
51
  }
63
52
  return t;
64
53
  });
65
-
66
54
  const results = await Promise.all(checkPromises);
67
55
  return results.filter(t => t !== null);
68
56
  }
@@ -276,7 +264,6 @@ async function handleSweepDispatch(config, dependencies, computationManifest, re
276
264
  return { dispatched: 0 };
277
265
  }
278
266
 
279
- // [CRITICAL UPDATE] FILTER FOR SWEEP:
280
267
  const validTasks = [];
281
268
  for (const task of pending) {
282
269
  const name = normalizeName(task.name);
@@ -288,26 +275,22 @@ async function handleSweepDispatch(config, dependencies, computationManifest, re
288
275
 
289
276
  // 1. ACTIVE CHECK: Don't double-dispatch if already running
290
277
  if (['PENDING', 'IN_PROGRESS'].includes(data.status)) {
291
- // Optional: Check for zombies here if needed, but usually Standard Dispatch handles that.
292
278
  logger.log('INFO', `[Sweep] ⏳ Skipping ${name} - Already IN_PROGRESS.`);
293
279
  continue;
294
280
  }
295
281
 
296
- // 2. COMPLETION CHECK: Don't re-dispatch if already done (Ghost State protection)
297
- if (data.status === 'COMPLETED') {
298
- // Only skip if the code hash matches (True Duplicate)
299
- // If hashes differ, it's a legitimate Re-Run that needs to happen.
300
- if (data.hash === task.hash) {
301
- logger.log('WARN', `[Sweep] 🛑 Skipping ${name} - Ledger says COMPLETED (Ghost State).`);
302
- continue;
303
- }
304
- }
282
+ // 2. COMPLETION CHECK (GHOST STATE FIX)
283
+ // We REMOVED the check that skips if (status === 'COMPLETED' && hash === task.hash).
284
+ // If we are here, 'analyzeDateExecution' (The Brain) decided this task is NOT done
285
+ // (likely due to a missing or outdated entry in computation_status).
286
+ // Even if the Ledger (The Log) says it finished, the system state is inconsistent.
287
+ // We MUST re-run to repair the Status Index.
305
288
 
306
289
  const stage = data.error?.stage;
307
290
 
308
291
  // 3. DETERMINISTIC FAILURE CHECK
309
292
  if (['QUALITY_CIRCUIT_BREAKER', 'SEMANTIC_GATE', 'SHARDING_LIMIT_EXCEEDED'].includes(stage)) {
310
- // [FIX] If the code hash has changed, ignore the previous failure and allow retry
293
+ // If hash matches, it's the exact same code that failed before. Don't retry in loop.
311
294
  if (data.hash === task.hash) {
312
295
  logger.log('WARN', `[Sweep] 🛑 Skipping deterministic failure for ${name} (${stage}).`);
313
296
  continue;
@@ -315,10 +298,13 @@ async function handleSweepDispatch(config, dependencies, computationManifest, re
315
298
  logger.log('INFO', `[Sweep] 🔄 Code Updated for ${name}. Retrying sweep despite previous ${stage}.`);
316
299
  }
317
300
 
318
- // 4. DEAD END CHECK
301
+ // 4. DEAD END CHECK (High Mem)
319
302
  if (data.resourceTier === 'high-mem' && data.status === 'FAILED') {
320
- logger.log('WARN', `[Sweep] 🛑 Skipping ${name} - Already failed on High-Mem.`);
321
- continue;
303
+ // If code hasn't changed, don't hammer it.
304
+ if (data.hash === task.hash) {
305
+ logger.log('WARN', `[Sweep] 🛑 Skipping ${name} - Already failed on High-Mem.`);
306
+ continue;
307
+ }
322
308
  }
323
309
  }
324
310
  validTasks.push(task);
@@ -345,11 +331,7 @@ async function handleSweepDispatch(config, dependencies, computationManifest, re
345
331
  dispatchId: currentDispatchId,
346
332
  triggerReason: 'SWEEP_RECOVERY',
347
333
  resources: 'high-mem', // FORCE
348
- traceContext: {
349
- traceId: traceId,
350
- spanId: spanId,
351
- sampled: true
352
- }
334
+ traceContext: { traceId, spanId, sampled: true }
353
335
  };
354
336
  });
355
337
 
@@ -185,85 +185,77 @@ async function loadDailyInsights(config, deps, dateString) {
185
185
  }
186
186
  }
187
187
 
188
- /** Stage 5: Load daily social post insights (Comprehensive) */
188
+ /** Stage 5: Load and Partition Social Data */
189
189
  async function loadDailySocialPostInsights(config, deps, dateString) {
190
190
  const { db, logger, calculationUtils } = deps;
191
191
  const { withRetry } = calculationUtils;
192
192
 
193
- // 1. Standard Instrument posts
194
- const collectionName = config.socialInsightsCollectionName || 'daily_social_insights';
193
+ logger.log('INFO', `Loading and partitioning social data for ${dateString}`);
195
194
 
196
- logger.log('INFO', `Loading social post insights for ${dateString}`);
197
-
198
- const postsMap = {};
199
-
200
- const fetchCollection = async (colName) => {
201
- try {
202
- const postsCollectionRef = db.collection(colName).doc(dateString).collection('posts');
203
- const querySnapshot = await withRetry(() => postsCollectionRef.get(), `getSocialPosts(${colName}/${dateString})`);
204
- if (!querySnapshot.empty) {
205
- querySnapshot.forEach(doc => {
206
- postsMap[doc.id] = tryDecompress(doc.data());
207
- });
208
- }
209
- } catch (error) {
210
- logger.log('WARN', `Failed to load social posts from ${colName}: ${error.message}`);
211
- }
195
+ // 1. Initialize Buckets
196
+ const result = {
197
+ generic: {}, // Map<PostId, Data> - For Normal/Speculator
198
+ pi: {}, // Map<UserId, Map<PostId, Data>> - For Popular Investors
199
+ signedIn: {} // Map<UserId, Map<PostId, Data>> - For Signed-In Users
212
200
  };
213
201
 
214
- // Load Instrument/General posts
215
- await fetchCollection(collectionName);
202
+ const PI_COL_NAME = config.piSocialCollectionName || 'pi_social_posts';
203
+ const SIGNED_IN_COL_NAME = config.signedInUserSocialCollection || 'signed_in_users';
216
204
 
217
- // [FIX] Load Per-User Posts (Popular Investors & Signed-In Users)
218
- // These are stored as: Collection/{userId}/posts/{postId}
219
- // We must find posts created within the target date.
220
-
221
- const fetchUserPosts = async (parentCollectionName) => {
222
- try {
223
- // Listing all users is heavy, but required if we don't have a Group Index.
224
- // Assumption: Number of PIs/Signed-in users is manageable (e.g. < 5000)
225
- // Optimization: If a 'lastPostAt' field exists on user doc, use it to skip.
226
-
227
- const userDocs = await db.collection(parentCollectionName).listDocuments();
228
- logger.log('INFO', `Scanning ${userDocs.length} users in ${parentCollectionName} for posts on ${dateString}`);
229
-
230
- const startDate = new Date(dateString + 'T00:00:00Z');
231
- const endDate = new Date(dateString + 'T23:59:59Z');
232
-
233
- // Parallelize in chunks
234
- const batchSize = 20;
235
- for (let i = 0; i < userDocs.length; i += batchSize) {
236
- const batch = userDocs.slice(i, i + batchSize);
237
- await Promise.all(batch.map(async (userDoc) => {
238
- const postsQuery = userDoc.collection('posts')
239
- .where('createdAt', '>=', startDate.toISOString())
240
- .where('createdAt', '<=', endDate.toISOString());
241
-
242
- const postsSnap = await postsQuery.get();
243
- if (!postsSnap.empty) {
244
- postsSnap.forEach(doc => {
245
- postsMap[doc.id] = tryDecompress(doc.data());
246
- });
247
- }
248
- }));
249
- }
205
+ // 2. Define Time Range (UTC Day)
206
+ const startDate = new Date(dateString + 'T00:00:00Z');
207
+ const endDate = new Date(dateString + 'T23:59:59Z');
250
208
 
251
- } catch (error) {
252
- logger.log('WARN', `Failed to scan user posts in ${parentCollectionName}: ${error.message}`);
253
- }
254
- };
209
+ try {
210
+ // 3. Fetch ALL with CollectionGroup
211
+ // NOTE: Requires Firestore Index: CollectionId 'posts', Field 'fetchedAt' (ASC/DESC)
212
+ const postsQuery = db.collectionGroup('posts')
213
+ .where('fetchedAt', '>=', startDate)
214
+ .where('fetchedAt', '<=', endDate);
255
215
 
256
- // Load from PI specific social collection
257
- const piSocialCol = config.piSocialCollectionName || 'popular_investor_social_posts'; // Updated name per review
258
- const signedInSocialCol = config.signedInUserSocialCollection || 'signed_in_users';
216
+ const querySnapshot = await withRetry(() => postsQuery.get(), `getSocialPosts(${dateString})`);
217
+
218
+ if (!querySnapshot.empty) {
219
+ querySnapshot.forEach(doc => {
220
+ const data = tryDecompress(doc.data());
221
+ const path = doc.ref.path;
222
+
223
+ // 4. Partition Logic based on Path
224
+ if (path.includes(PI_COL_NAME)) {
225
+ // Path format: .../pi_social_posts/{userId}/posts/{postId}
226
+ const parts = path.split('/');
227
+ const colIndex = parts.indexOf(PI_COL_NAME);
228
+ if (colIndex !== -1 && parts[colIndex + 1]) {
229
+ const userId = parts[colIndex + 1];
230
+ if (!result.pi[userId]) result.pi[userId] = {};
231
+ result.pi[userId][doc.id] = data;
232
+ }
233
+ }
234
+ else if (path.includes(SIGNED_IN_COL_NAME)) {
235
+ // Path format: .../signed_in_users/{userId}/posts/{postId}
236
+ const parts = path.split('/');
237
+ const colIndex = parts.indexOf(SIGNED_IN_COL_NAME);
238
+ if (colIndex !== -1 && parts[colIndex + 1]) {
239
+ const userId = parts[colIndex + 1];
240
+ if (!result.signedIn[userId]) result.signedIn[userId] = {};
241
+ result.signedIn[userId][doc.id] = data;
242
+ }
243
+ }
244
+ else {
245
+ // Default: Generic Instrument Posts
246
+ result.generic[doc.id] = data;
247
+ }
248
+ });
249
+ logger.log('INFO', `Loaded Social Data: ${Object.keys(result.generic).length} Generic, ${Object.keys(result.pi).length} PIs, ${Object.keys(result.signedIn).length} SignedIn.`);
250
+ } else {
251
+ logger.log('WARN', `No social posts found for ${dateString} via CollectionGroup.`);
252
+ }
259
253
 
260
- await Promise.all([
261
- fetchUserPosts(piSocialCol),
262
- fetchUserPosts(signedInSocialCol)
263
- ]);
254
+ } catch (error) {
255
+ logger.log('ERROR', `Failed to load social posts: ${error.message}`);
256
+ }
264
257
 
265
- logger.log('TRACE', `Loaded ${Object.keys(postsMap).length} total social post insights`);
266
- return postsMap;
258
+ return result;
267
259
  }
268
260
 
269
261
  /** Stage 6: Get history part references for a given date */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * FILENAME: computation-system/utils/utils.js
3
- * UPDATED: Removed hardcoded date logic. Earliest dates are now fully data-driven.
3
+ * UPDATED: Added undefined sanitization to prevent Firestore write errors.
4
4
  */
5
5
 
6
6
  const { FieldValue, FieldPath } = require('@google-cloud/firestore');
@@ -52,6 +52,35 @@ async function withRetry(fn, operationName, maxRetries = 3) {
52
52
  }
53
53
  }
54
54
 
55
+ /** * Recursively sanitizes data for Firestore compatibility.
56
+ * Converts 'undefined' to 'null' to prevent validation errors.
57
+ */
58
+ function sanitizeForFirestore(data) {
59
+ if (data === undefined) return null;
60
+ if (data === null) return null;
61
+ if (data instanceof Date) return data;
62
+ // Handle Firestore Types if necessary (e.g. GeoPoint, Timestamp), passed through as objects usually.
63
+
64
+ if (Array.isArray(data)) {
65
+ return data.map(item => sanitizeForFirestore(item));
66
+ }
67
+
68
+ if (typeof data === 'object') {
69
+ // Check for specific Firestore types that shouldn't be traversed like plain objects
70
+ if (data.constructor && (data.constructor.name === 'DocumentReference' || data.constructor.name === 'Timestamp')) {
71
+ return data;
72
+ }
73
+
74
+ const sanitized = {};
75
+ for (const [key, value] of Object.entries(data)) {
76
+ sanitized[key] = sanitizeForFirestore(value);
77
+ }
78
+ return sanitized;
79
+ }
80
+
81
+ return data;
82
+ }
83
+
55
84
  /** Commit a batch of writes in chunks. */
56
85
  async function commitBatchInChunks(config, deps, writes, operationName) {
57
86
  const { db, logger } = deps;
@@ -74,10 +103,17 @@ async function commitBatchInChunks(config, deps, writes, operationName) {
74
103
  if (currentOpsCount + 1 > MAX_BATCH_OPS) await commitAndReset();
75
104
  currentBatch.delete(write.ref); currentOpsCount++; continue;
76
105
  }
106
+
107
+ // [FIX] Sanitize data to remove 'undefined' values before calculating size or setting
108
+ const safeData = sanitizeForFirestore(write.data);
109
+
77
110
  let docSize = 100;
78
- try { if (write.data) docSize = JSON.stringify(write.data).length; } catch (e) { }
111
+ try { if (safeData) docSize = JSON.stringify(safeData).length; } catch (e) { }
112
+
79
113
  if ((currentOpsCount + 1 > MAX_BATCH_OPS) || (currentBytesEst + docSize > MAX_BATCH_BYTES)) await commitAndReset();
80
- currentBatch.set(write.ref, write.data, write.options || { merge: true });
114
+
115
+ // Use safeData instead of write.data
116
+ currentBatch.set(write.ref, safeData, write.options || { merge: true });
81
117
  currentOpsCount++; currentBytesEst += docSize;
82
118
  }
83
119
  await commitAndReset();
@@ -126,5 +162,5 @@ async function getEarliestDataDates(config, deps) {
126
162
  module.exports = {
127
163
  FieldValue, FieldPath, normalizeName, commitBatchInChunks,
128
164
  getExpectedDateStrings, getEarliestDataDates, generateCodeHash,
129
- generateDataHash, withRetry, DEFINITIVE_EARLIEST_DATES
165
+ generateDataHash, withRetry, DEFINITIVE_EARLIEST_DATES, sanitizeForFirestore
130
166
  };
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * @fileoverview Root Data Indexer
3
3
  * Runs daily to index exactly what data is available for every date.
4
- * UPDATED: Includes explicit checks for Signed-In User Portfolios AND History via Snapshots.
4
+ * UPDATED: Includes explicit checks for Signed-In User Portfolios, History, AND Social Data.
5
+ * FIXED: 'hasSocial' is now strictly for Generic Asset Data. PI/User data is separated.
5
6
  */
6
7
 
7
8
  const { FieldValue } = require('@google-cloud/firestore');
@@ -20,6 +21,11 @@ exports.runRootDataIndexer = async (config, dependencies) => {
20
21
  } = config;
21
22
 
22
23
  const PRICE_COLLECTION_NAME = 'asset_prices';
24
+
25
+ // Collection Names (Fail-safe defaults)
26
+ const PI_SOCIAL_COLL_NAME = collections.piSocial || 'pi_social_posts';
27
+ const SIGNED_IN_SOCIAL_COLL_NAME = collections.signedInUserSocialCollection || 'signed_in_users';
28
+
23
29
  logger.log('INFO', '[RootDataIndexer] Starting Root Data Availability Scan...');
24
30
 
25
31
  // 1. Price Availability
@@ -38,7 +44,7 @@ exports.runRootDataIndexer = async (config, dependencies) => {
38
44
  });
39
45
  }
40
46
  } catch (e) {
41
- logger.log('ERROR', `[RootDataIndexer] Failed to load price shard.`, { error: e.message });
47
+ logger.log('ERROR', '[RootDataIndexer] Failed to load price shard.', { error: e.message });
42
48
  }
43
49
 
44
50
  // 2. Determine Date Range
@@ -55,6 +61,11 @@ exports.runRootDataIndexer = async (config, dependencies) => {
55
61
 
56
62
  const promises = datesToScan.map(dateStr => limit(async () => {
57
63
  try {
64
+ // Define Time Range for Social Query (Full Day UTC)
65
+ const dayStart = new Date(dateStr);
66
+ const dayEnd = new Date(dateStr);
67
+ dayEnd.setHours(23, 59, 59, 999);
68
+
58
69
  const availability = {
59
70
  date: dateStr,
60
71
  lastUpdated: FieldValue.serverTimestamp(),
@@ -72,21 +83,24 @@ exports.runRootDataIndexer = async (config, dependencies) => {
72
83
  piPortfolios: false,
73
84
  piDeepPortfolios: false,
74
85
  piHistory: false,
75
- piSocial: false,
86
+ piSocial: false, // Specific Flag for PI
76
87
  signedInUserPortfolio: false,
77
- signedInUserHistory: false, // [NEW]
78
- signedInUserVerification: false
88
+ signedInUserHistory: false,
89
+ signedInUserVerification: false,
90
+ signedInSocial: false // Specific Flag for Signed-In
79
91
  }
80
92
  };
81
93
 
82
94
  // --- Define Refs ---
83
95
 
84
- // 1. Standard Retail
96
+ // 1. Standard Retail (Date-based)
85
97
  const normPortRef = db.collection(collections.normalPortfolios).doc(CANARY_BLOCK_ID).collection('snapshots').doc(dateStr).collection('parts').doc(CANARY_PART_ID);
86
98
  const specPortRef = db.collection(collections.speculatorPortfolios).doc(CANARY_BLOCK_ID).collection('snapshots').doc(dateStr).collection('parts').doc(CANARY_PART_ID);
87
99
  const normHistRef = db.collection(collections.normalHistory).doc(CANARY_BLOCK_ID).collection('snapshots').doc(dateStr).collection('parts').doc(CANARY_PART_ID);
88
100
  const specHistRef = db.collection(collections.speculatorHistory).doc(CANARY_BLOCK_ID).collection('snapshots').doc(dateStr).collection('parts').doc(CANARY_PART_ID);
89
101
  const insightsRef = db.collection(collections.insights).doc(dateStr);
102
+
103
+ // Generic Asset Posts
90
104
  const socialPostsRef = db.collection(collections.social).doc(dateStr).collection('posts');
91
105
 
92
106
  // 2. Popular Investors
@@ -95,8 +109,7 @@ exports.runRootDataIndexer = async (config, dependencies) => {
95
109
  const piDeepRef = db.collection(collections.piDeepPortfolios || 'pi_portfolios_deep').doc(dateStr).collection('portfolios');
96
110
  const piHistoryRef = db.collection(collections.piHistory || 'pi_trade_history').doc(dateStr).collection('history');
97
111
 
98
- // 3. Signed-In Users (New Snapshots Structure)
99
- // We verify existence by checking the '19M' block parts which BatchManager writes to.
112
+ // 3. Signed-In Users
100
113
  const signedInPortRef = db.collection(collections.signedInUsers || 'signed_in_user_portfolios')
101
114
  .doc(CANARY_BLOCK_ID).collection('snapshots').doc(dateStr).collection('parts').doc(CANARY_PART_ID);
102
115
 
@@ -105,13 +118,20 @@ exports.runRootDataIndexer = async (config, dependencies) => {
105
118
 
106
119
  const verificationsRef = db.collection(collections.verifications || 'user_verifications');
107
120
 
121
+ // 4. Universal Social Check via Collection Group
122
+ const universalSocialQuery = db.collectionGroup('posts')
123
+ .where('fetchedAt', '>=', dayStart)
124
+ .where('fetchedAt', '<=', dayEnd)
125
+ .limit(50);
126
+
108
127
  // --- Execute Checks ---
109
128
  const [
110
129
  normPortSnap, specPortSnap,
111
130
  normHistSnap, specHistSnap,
112
131
  insightsSnap, socialQuerySnap,
113
132
  piRankingsSnap, piPortQuery, piDeepQuery, piHistQuery,
114
- signedInPortSnap, signedInHistSnap, verificationsQuery
133
+ signedInPortSnap, signedInHistSnap, verificationsQuery,
134
+ universalSocialSnap
115
135
  ] = await Promise.all([
116
136
  normPortRef.get(), specPortRef.get(),
117
137
  normHistRef.get(), specHistRef.get(),
@@ -122,10 +142,23 @@ exports.runRootDataIndexer = async (config, dependencies) => {
122
142
  piHistoryRef.limit(1).get(),
123
143
  signedInPortRef.get(),
124
144
  signedInHistRef.get(),
125
- verificationsRef.limit(1).get()
145
+ verificationsRef.limit(1).get(),
146
+ universalSocialQuery.get()
126
147
  ]);
127
148
 
128
- // --- Evaluate & Assign ---
149
+ // --- Evaluate Social Sources ---
150
+ let foundPISocial = false;
151
+ let foundSignedInSocial = false;
152
+
153
+ if (!universalSocialSnap.empty) {
154
+ universalSocialSnap.docs.forEach(doc => {
155
+ const path = doc.ref.path;
156
+ if (path.startsWith(PI_SOCIAL_COLL_NAME)) foundPISocial = true;
157
+ if (path.startsWith(SIGNED_IN_SOCIAL_COLL_NAME)) foundSignedInSocial = true;
158
+ });
159
+ }
160
+
161
+ // --- Assign to Availability ---
129
162
  availability.details.normalPortfolio = normPortSnap.exists;
130
163
  availability.details.speculatorPortfolio = specPortSnap.exists;
131
164
  availability.details.normalHistory = normHistSnap.exists;
@@ -135,7 +168,13 @@ exports.runRootDataIndexer = async (config, dependencies) => {
135
168
  availability.details.piDeepPortfolios = !piDeepQuery.empty;
136
169
  availability.details.piHistory = !piHistQuery.empty;
137
170
 
138
- // [NEW] Signed-In Flags
171
+ // PI & Signed-In Social Flags (Strict)
172
+ availability.details.piSocial = foundPISocial;
173
+ availability.details.hasPISocial = foundPISocial;
174
+ availability.details.signedInSocial = foundSignedInSocial;
175
+ availability.details.hasSignedInSocial = foundSignedInSocial;
176
+
177
+ // Signed-In Flags
139
178
  availability.details.signedInUserPortfolio = signedInPortSnap.exists;
140
179
  availability.details.signedInUserHistory = signedInHistSnap.exists;
141
180
  availability.details.signedInUserVerification = !verificationsQuery.empty;
@@ -144,7 +183,12 @@ exports.runRootDataIndexer = async (config, dependencies) => {
144
183
  availability.hasPortfolio = normPortSnap.exists || specPortSnap.exists || !piPortQuery.empty || signedInPortSnap.exists;
145
184
  availability.hasHistory = normHistSnap.exists || specHistSnap.exists || !piHistQuery.empty || signedInHistSnap.exists;
146
185
  availability.hasInsights = insightsSnap.exists;
186
+
187
+ // [CRITICAL FIX]
188
+ // hasSocial strictly refers to GENERIC/ASSET social data.
189
+ // It does NOT include PI or SignedIn data (they have their own flags).
147
190
  availability.hasSocial = !socialQuerySnap.empty;
191
+
148
192
  availability.hasPrices = priceAvailabilitySet.has(dateStr);
149
193
 
150
194
  await db.collection(availabilityCollection).doc(dateStr).set(availability);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.364",
3
+ "version": "1.0.366",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [