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.
- package/functions/computation-system/data/AvailabilityChecker.js +38 -5
- package/functions/computation-system/executors/StandardExecutor.js +25 -12
- package/functions/computation-system/helpers/computation_dispatcher.js +17 -35
- package/functions/computation-system/utils/data_loader.js +59 -67
- package/functions/computation-system/utils/utils.js +40 -4
- package/functions/root-data-indexer/index.js +56 -12
- package/package.json +1 -1
|
@@ -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
|
-
}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
321
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
const collectionName = config.socialInsightsCollectionName || 'daily_social_insights';
|
|
193
|
+
logger.log('INFO', `Loading and partitioning social data for ${dateString}`);
|
|
195
194
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
215
|
-
|
|
202
|
+
const PI_COL_NAME = config.piSocialCollectionName || 'pi_social_posts';
|
|
203
|
+
const SIGNED_IN_COL_NAME = config.signedInUserSocialCollection || 'signed_in_users';
|
|
216
204
|
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
]);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
logger.log('ERROR', `Failed to load social posts: ${error.message}`);
|
|
256
|
+
}
|
|
264
257
|
|
|
265
|
-
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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',
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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);
|