bulltrackers-module 1.0.231 → 1.0.233
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,53 +1,144 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview API sub-pipes.
|
|
3
|
-
* REFACTORED:
|
|
4
|
-
*
|
|
5
|
-
* NEW: createManifestHandler filters out STALE schemas (>7 days old).
|
|
3
|
+
* REFACTORED: Fixed Category Resolution to match ManifestBuilder logic.
|
|
4
|
+
* Implements Status-Based Availability Caching and Smart Date Resolution.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
const { FieldPath } = require('@google-cloud/firestore');
|
|
9
8
|
|
|
9
|
+
// --- AVAILABILITY CACHE ---
|
|
10
|
+
class AvailabilityCache {
|
|
11
|
+
constructor(db, logger, ttlMs = 5 * 60 * 1000) { // 5 Minute TTL
|
|
12
|
+
this.db = db;
|
|
13
|
+
this.logger = logger;
|
|
14
|
+
this.ttlMs = ttlMs;
|
|
15
|
+
this.cache = null;
|
|
16
|
+
this.lastFetched = 0;
|
|
17
|
+
this.statusCollection = 'computation_status';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async getMap() {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
if (this.cache && (now - this.lastFetched < this.ttlMs)) {
|
|
23
|
+
return this.cache;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.logger.log('INFO', '[AvailabilityCache] Refreshing availability map from Firestore...');
|
|
27
|
+
|
|
28
|
+
// Fetch recent status to build the map (Limit 400 days)
|
|
29
|
+
const snapshot = await this.db.collection(this.statusCollection)
|
|
30
|
+
.orderBy(FieldPath.documentId(), 'desc')
|
|
31
|
+
.limit(400)
|
|
32
|
+
.get();
|
|
33
|
+
|
|
34
|
+
const newMap = {};
|
|
35
|
+
|
|
36
|
+
snapshot.forEach(doc => {
|
|
37
|
+
const dateStr = doc.id;
|
|
38
|
+
const statusData = doc.data();
|
|
39
|
+
|
|
40
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return;
|
|
41
|
+
|
|
42
|
+
for (const [calcName, status] of Object.entries(statusData)) {
|
|
43
|
+
if (status && status !== 'IMPOSSIBLE') {
|
|
44
|
+
if (!newMap[calcName]) newMap[calcName] = [];
|
|
45
|
+
newMap[calcName].push(dateStr);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.cache = newMap;
|
|
51
|
+
this.lastFetched = now;
|
|
52
|
+
this.logger.log('INFO', `[AvailabilityCache] Refreshed. Tracked ${Object.keys(newMap).length} computations.`);
|
|
53
|
+
return this.cache;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
10
56
|
|
|
11
57
|
/**
|
|
12
|
-
*
|
|
58
|
+
* Helper: Resolve which dates to fetch based on mode and availability.
|
|
13
59
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (!query.startDate || !/^\d{4}-\d{2}-\d{2}$/.test(query.startDate)) return "Missing or invalid 'startDate'.";
|
|
17
|
-
if (!query.endDate || !/^\d{4}-\d{2}-\d{2}$/.test(query.endDate)) return "Missing or invalid 'endDate'.";
|
|
18
|
-
|
|
19
|
-
const start = new Date(query.startDate);
|
|
20
|
-
const end = new Date(query.endDate);
|
|
21
|
-
|
|
22
|
-
if (end < start) return "'endDate' must be after 'startDate'.";
|
|
60
|
+
async function resolveTargetDates(availabilityCache, computationKeys, mode, limit) {
|
|
61
|
+
const map = await availabilityCache.getMap();
|
|
23
62
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
63
|
+
const relevantDatesSet = new Set();
|
|
64
|
+
computationKeys.forEach(key => {
|
|
65
|
+
const dates = map[key] || [];
|
|
66
|
+
dates.forEach(d => relevantDatesSet.add(d));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const sortedDates = Array.from(relevantDatesSet).sort((a, b) => b.localeCompare(a));
|
|
70
|
+
|
|
71
|
+
if (sortedDates.length === 0) return [];
|
|
72
|
+
|
|
73
|
+
if (mode === 'latest') {
|
|
74
|
+
return [sortedDates[0]];
|
|
75
|
+
}
|
|
27
76
|
|
|
28
|
-
if (
|
|
77
|
+
if (mode === 'series') {
|
|
78
|
+
return sortedDates.slice(0, limit);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sub-pipe: pipe.api.helpers.validateRequest
|
|
86
|
+
*/
|
|
87
|
+
const validateRequest = (query) => {
|
|
88
|
+
if (!query.computations) return "Missing 'computations' parameter.";
|
|
29
89
|
|
|
90
|
+
const allowedModes = ['latest', 'series'];
|
|
91
|
+
if (query.mode && !allowedModes.includes(query.mode)) {
|
|
92
|
+
return "Invalid 'mode'. Must be 'latest' or 'series'.";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (query.mode === 'series') {
|
|
96
|
+
const limit = parseInt(query.limit);
|
|
97
|
+
if (query.limit && (isNaN(limit) || limit < 1 || limit > 365)) {
|
|
98
|
+
return "Invalid 'limit'. Must be between 1 and 365.";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
30
102
|
return null;
|
|
31
103
|
};
|
|
32
104
|
|
|
33
105
|
/**
|
|
34
106
|
* Sub-pipe: pipe.api.helpers.buildCalculationMap
|
|
35
|
-
*
|
|
36
|
-
* This function now stores the class itself in the map,
|
|
37
|
-
* which is required by the /manifest/generate endpoint.
|
|
107
|
+
* FIX APPLIED: Checks class metadata for category override (mirroring ManifestBuilder).
|
|
38
108
|
*/
|
|
39
109
|
const buildCalculationMap = (unifiedCalculations) => {
|
|
40
110
|
const calcMap = {};
|
|
111
|
+
|
|
112
|
+
// Helper to resolve category exactly like ManifestBuilder.js does
|
|
113
|
+
const resolveCategory = (cls, folderName) => {
|
|
114
|
+
if (typeof cls.getMetadata === 'function') {
|
|
115
|
+
const meta = cls.getMetadata();
|
|
116
|
+
// If folder is 'core', allow metadata to override
|
|
117
|
+
if (folderName === 'core' && meta.category) {
|
|
118
|
+
return meta.category;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return folderName;
|
|
122
|
+
};
|
|
123
|
+
|
|
41
124
|
for (const category in unifiedCalculations) {
|
|
42
125
|
for (const subKey in unifiedCalculations[category]) {
|
|
43
126
|
const item = unifiedCalculations[category][subKey];
|
|
127
|
+
|
|
44
128
|
if (subKey === 'historical' && typeof item === 'object') {
|
|
45
129
|
for (const calcName in item) {
|
|
46
|
-
|
|
130
|
+
const cls = item[calcName];
|
|
131
|
+
calcMap[calcName] = {
|
|
132
|
+
category: resolveCategory(cls, category),
|
|
133
|
+
class: cls
|
|
134
|
+
};
|
|
47
135
|
}
|
|
48
136
|
} else if (typeof item === 'function') {
|
|
49
137
|
const calcName = subKey;
|
|
50
|
-
calcMap[calcName] = {
|
|
138
|
+
calcMap[calcName] = {
|
|
139
|
+
category: resolveCategory(item, category),
|
|
140
|
+
class: item
|
|
141
|
+
};
|
|
51
142
|
}
|
|
52
143
|
}
|
|
53
144
|
}
|
|
@@ -55,21 +146,7 @@ const buildCalculationMap = (unifiedCalculations) => {
|
|
|
55
146
|
};
|
|
56
147
|
|
|
57
148
|
/**
|
|
58
|
-
*
|
|
59
|
-
*/
|
|
60
|
-
const getDateStringsInRange = (startDate, endDate) => {
|
|
61
|
-
const dates = [];
|
|
62
|
-
const current = new Date(startDate + 'T00:00:00Z');
|
|
63
|
-
const end = new Date(endDate + 'T00:00:00Z');
|
|
64
|
-
while (current <= end) {
|
|
65
|
-
dates.push(current.toISOString().slice(0, 10));
|
|
66
|
-
current.setUTCDate(current.getUTCDate() + 1);
|
|
67
|
-
}
|
|
68
|
-
return dates;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Sub-pipe: pipe.api.helpers.fetchData
|
|
149
|
+
* Sub-pipe: pipe.api.helpers.fetchUnifiedData
|
|
73
150
|
*/
|
|
74
151
|
const fetchUnifiedData = async (config, dependencies, calcKeys, dateStrings, calcMap) => {
|
|
75
152
|
const { db, logger } = dependencies;
|
|
@@ -78,11 +155,13 @@ const fetchUnifiedData = async (config, dependencies, calcKeys, dateStrings, cal
|
|
|
78
155
|
const resultsSub = config.resultsSubcollection || 'results';
|
|
79
156
|
const compsSub = config.computationsSubcollection || 'computations';
|
|
80
157
|
|
|
158
|
+
if (dateStrings.length === 0) return {};
|
|
159
|
+
|
|
81
160
|
try {
|
|
161
|
+
const readPromises = [];
|
|
162
|
+
|
|
82
163
|
for (const date of dateStrings) {
|
|
83
|
-
response[date] = {};
|
|
84
|
-
const docRefs = [];
|
|
85
|
-
const keyPaths = [];
|
|
164
|
+
response[date] = {};
|
|
86
165
|
|
|
87
166
|
for (const key of calcKeys) {
|
|
88
167
|
const pathInfo = calcMap[key];
|
|
@@ -90,18 +169,27 @@ const fetchUnifiedData = async (config, dependencies, calcKeys, dateStrings, cal
|
|
|
90
169
|
const docRef = db.collection(insightsCollection).doc(date)
|
|
91
170
|
.collection(resultsSub).doc(pathInfo.category)
|
|
92
171
|
.collection(compsSub).doc(key);
|
|
93
|
-
|
|
94
|
-
|
|
172
|
+
|
|
173
|
+
readPromises.push({ date, key, ref: docRef });
|
|
95
174
|
} else {
|
|
96
|
-
|
|
175
|
+
// If no path info, we can't fetch.
|
|
176
|
+
response[date][key] = null;
|
|
97
177
|
}
|
|
98
178
|
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (readPromises.length === 0) return response;
|
|
182
|
+
|
|
183
|
+
// Batch reads
|
|
184
|
+
const CHUNK_SIZE = 100;
|
|
185
|
+
for (let i = 0; i < readPromises.length; i += CHUNK_SIZE) {
|
|
186
|
+
const chunk = readPromises.slice(i, i + CHUNK_SIZE);
|
|
187
|
+
const refs = chunk.map(item => item.ref);
|
|
99
188
|
|
|
100
|
-
|
|
189
|
+
const snapshots = await db.getAll(...refs);
|
|
101
190
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const key = keyPaths[i];
|
|
191
|
+
snapshots.forEach((doc, idx) => {
|
|
192
|
+
const { date, key } = chunk[idx];
|
|
105
193
|
if (doc.exists) {
|
|
106
194
|
response[date][key] = doc.data();
|
|
107
195
|
} else {
|
|
@@ -109,6 +197,7 @@ const fetchUnifiedData = async (config, dependencies, calcKeys, dateStrings, cal
|
|
|
109
197
|
}
|
|
110
198
|
});
|
|
111
199
|
}
|
|
200
|
+
|
|
112
201
|
} catch (error) {
|
|
113
202
|
logger.log('ERROR', 'API: Error fetching data from Firestore.', { errorMessage: error.message });
|
|
114
203
|
throw new Error('Failed to retrieve computation data.');
|
|
@@ -120,24 +209,53 @@ const fetchUnifiedData = async (config, dependencies, calcKeys, dateStrings, cal
|
|
|
120
209
|
* Factory for the main API handler.
|
|
121
210
|
*/
|
|
122
211
|
const createApiHandler = (config, dependencies, calcMap) => {
|
|
123
|
-
const { logger } = dependencies;
|
|
212
|
+
const { logger, db } = dependencies;
|
|
213
|
+
|
|
214
|
+
// Singleton Cache
|
|
215
|
+
const availabilityCache = new AvailabilityCache(db, logger);
|
|
216
|
+
|
|
124
217
|
return async (req, res) => {
|
|
125
|
-
const validationError = validateRequest(req.query
|
|
218
|
+
const validationError = validateRequest(req.query);
|
|
126
219
|
if (validationError) {
|
|
127
220
|
logger.log('WARN', 'API Bad Request', { error: validationError, query: req.query });
|
|
128
221
|
return res.status(400).send({ status: 'error', message: validationError });
|
|
129
222
|
}
|
|
223
|
+
|
|
130
224
|
try {
|
|
131
225
|
const computationKeys = req.query.computations.split(',');
|
|
132
|
-
const
|
|
226
|
+
const mode = req.query.mode || 'latest';
|
|
227
|
+
const limit = parseInt(req.query.limit) || 30;
|
|
228
|
+
|
|
229
|
+
// 1. Resolve Dates
|
|
230
|
+
const dateStrings = await resolveTargetDates(availabilityCache, computationKeys, mode, limit);
|
|
231
|
+
|
|
232
|
+
if (dateStrings.length === 0) {
|
|
233
|
+
return res.status(200).send({
|
|
234
|
+
status: 'success',
|
|
235
|
+
metadata: {
|
|
236
|
+
computations: computationKeys,
|
|
237
|
+
mode,
|
|
238
|
+
count: 0,
|
|
239
|
+
dates: []
|
|
240
|
+
},
|
|
241
|
+
data: {}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 2. Fetch Data
|
|
133
246
|
const data = await fetchUnifiedData(config, dependencies, computationKeys, dateStrings, calcMap);
|
|
247
|
+
|
|
134
248
|
res.set('Cache-Control', 'public, max-age=300, s-maxage=3600');
|
|
135
249
|
res.status(200).send({
|
|
136
250
|
status: 'success',
|
|
137
251
|
metadata: {
|
|
138
252
|
computations: computationKeys,
|
|
139
|
-
|
|
140
|
-
|
|
253
|
+
mode,
|
|
254
|
+
limit: mode === 'series' ? limit : 1,
|
|
255
|
+
dateRange: {
|
|
256
|
+
start: dateStrings[dateStrings.length - 1],
|
|
257
|
+
end: dateStrings[0]
|
|
258
|
+
}
|
|
141
259
|
},
|
|
142
260
|
data,
|
|
143
261
|
});
|
|
@@ -148,9 +266,6 @@ const createApiHandler = (config, dependencies, calcMap) => {
|
|
|
148
266
|
};
|
|
149
267
|
};
|
|
150
268
|
|
|
151
|
-
/**
|
|
152
|
-
* Internal helper for snippet generation.
|
|
153
|
-
*/
|
|
154
269
|
function createStructureSnippet(data, maxKeys = 20) {
|
|
155
270
|
if (data === null || typeof data !== 'object') {
|
|
156
271
|
if (typeof data === 'number') return 0;
|
|
@@ -184,9 +299,6 @@ function createStructureSnippet(data, maxKeys = 20) {
|
|
|
184
299
|
return newObj;
|
|
185
300
|
}
|
|
186
301
|
|
|
187
|
-
/**
|
|
188
|
-
* Sub-pipe: pipe.api.helpers.getComputationStructure
|
|
189
|
-
*/
|
|
190
302
|
async function getComputationStructure(computationName, calcMap, config, dependencies) {
|
|
191
303
|
const { db, logger } = dependencies;
|
|
192
304
|
try {
|
|
@@ -218,10 +330,6 @@ async function getComputationStructure(computationName, calcMap, config, depende
|
|
|
218
330
|
}
|
|
219
331
|
}
|
|
220
332
|
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* --- UPDATED: DYNAMIC SCHEMA GENERATION HARNESS ---
|
|
224
|
-
*/
|
|
225
333
|
async function getDynamicSchema(CalcClass, calcName) {
|
|
226
334
|
if (CalcClass && typeof CalcClass.getSchema === 'function') {
|
|
227
335
|
try {
|
|
@@ -235,47 +343,31 @@ async function getDynamicSchema(CalcClass, calcName) {
|
|
|
235
343
|
}
|
|
236
344
|
}
|
|
237
345
|
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* --- NEW: MANIFEST API HANDLER (With Filtering) ---
|
|
241
|
-
*/
|
|
242
346
|
const createManifestHandler = (config, dependencies, calcMap) => {
|
|
243
347
|
const { db, logger } = dependencies;
|
|
244
348
|
const schemaCollection = config.schemaCollection || 'computation_schemas';
|
|
245
349
|
|
|
246
350
|
return async (req, res) => {
|
|
247
351
|
try {
|
|
248
|
-
logger.log('INFO', '[API /manifest] Fetching all computation schemas...');
|
|
249
352
|
const snapshot = await db.collection(schemaCollection).get();
|
|
250
|
-
|
|
251
|
-
if (snapshot.empty) {
|
|
252
|
-
logger.log('WARN', '[API /manifest] No schemas found in collection.');
|
|
253
|
-
return res.status(404).send({ status: 'error', message: 'No computation schemas have been generated yet.' });
|
|
254
|
-
}
|
|
353
|
+
if (snapshot.empty) { return res.status(404).send({ status: 'error', message: 'No computation schemas have been generated yet.' }); }
|
|
255
354
|
|
|
256
|
-
// --- FILTERING LOGIC ---
|
|
257
355
|
const manifest = {};
|
|
258
356
|
const now = Date.now();
|
|
259
|
-
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
357
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
260
358
|
|
|
261
359
|
let activeCount = 0;
|
|
262
360
|
let staleCount = 0;
|
|
263
361
|
|
|
264
362
|
snapshot.forEach(doc => {
|
|
265
363
|
const data = doc.data();
|
|
266
|
-
|
|
267
|
-
// Safe Timestamp conversion
|
|
268
364
|
let lastUpdatedMs = 0;
|
|
269
365
|
if (data.lastUpdated && typeof data.lastUpdated.toMillis === 'function') {
|
|
270
366
|
lastUpdatedMs = data.lastUpdated.toMillis();
|
|
271
367
|
} else if (data.lastUpdated instanceof Date) {
|
|
272
368
|
lastUpdatedMs = data.lastUpdated.getTime();
|
|
273
|
-
} else {
|
|
274
|
-
// Fallback for very old records without a timestamp
|
|
275
|
-
lastUpdatedMs = 0;
|
|
276
369
|
}
|
|
277
370
|
|
|
278
|
-
// Exclude stale records
|
|
279
371
|
if ((now - lastUpdatedMs) < MAX_AGE_MS) {
|
|
280
372
|
manifest[doc.id] = {
|
|
281
373
|
category: data.category,
|
|
@@ -284,16 +376,9 @@ const createManifestHandler = (config, dependencies, calcMap) => {
|
|
|
284
376
|
lastUpdated: data.lastUpdated
|
|
285
377
|
};
|
|
286
378
|
activeCount++;
|
|
287
|
-
} else {
|
|
288
|
-
staleCount++;
|
|
289
|
-
}
|
|
379
|
+
} else { staleCount++; }
|
|
290
380
|
});
|
|
291
381
|
|
|
292
|
-
// Log filtering results
|
|
293
|
-
if (staleCount > 0) {
|
|
294
|
-
logger.log('INFO', `[API /manifest] Filtered out ${staleCount} stale schemas (older than 7 days). Returning ${activeCount} active.`);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
382
|
res.status(200).send({
|
|
298
383
|
status: 'success',
|
|
299
384
|
summary: {
|
|
@@ -301,21 +386,18 @@ const createManifestHandler = (config, dependencies, calcMap) => {
|
|
|
301
386
|
totalComputations: snapshot.size,
|
|
302
387
|
schemasAvailable: activeCount,
|
|
303
388
|
schemasFiltered: staleCount,
|
|
304
|
-
lastUpdated:
|
|
305
|
-
(m.lastUpdated && m.lastUpdated.toMillis) ? m.lastUpdated.toMillis() : 0
|
|
306
|
-
), 0)
|
|
389
|
+
lastUpdated: Date.now()
|
|
307
390
|
},
|
|
308
391
|
manifest: manifest
|
|
309
392
|
});
|
|
310
393
|
|
|
311
394
|
} catch (error) {
|
|
312
|
-
logger.log('ERROR', 'API /manifest handler failed.', { errorMessage: error.message
|
|
395
|
+
logger.log('ERROR', 'API /manifest handler failed.', { errorMessage: error.message });
|
|
313
396
|
res.status(500).send({ status: 'error', message: 'An internal error occurred.' });
|
|
314
397
|
}
|
|
315
398
|
};
|
|
316
399
|
};
|
|
317
400
|
|
|
318
|
-
|
|
319
401
|
module.exports = {
|
|
320
402
|
validateRequest,
|
|
321
403
|
buildCalculationMap,
|
|
@@ -1,142 +1,190 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Main entry point for the Generic API module.
|
|
3
|
-
*
|
|
4
|
-
* REFACTORED:
|
|
5
|
-
* REFACTORED: /manifest/generate endpoint now reads static schema from class.
|
|
6
|
-
*
|
|
7
|
-
* --- MODIFIED: Added in-memory cache wrapper for the main API handler ---
|
|
3
|
+
* Export the 'createApiApp' main pipe function.
|
|
4
|
+
* REFACTORED: API V3 - Status-Aware Data Fetching.
|
|
8
5
|
*/
|
|
9
6
|
|
|
10
7
|
const express = require('express');
|
|
11
8
|
const cors = require('cors');
|
|
12
|
-
const {
|
|
13
|
-
const { buildCalculationMap, createApiHandler, getComputationStructure,createManifestHandler, getDynamicSchema } = require('./helpers/api_helpers.js');
|
|
9
|
+
const { buildCalculationMap, createApiHandler, getComputationStructure, createManifestHandler, getDynamicSchema } = require('./helpers/api_helpers.js');
|
|
14
10
|
|
|
15
11
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* to any Express request handler.
|
|
19
|
-
* @param {function} handler - The original (req, res) handler to wrap.
|
|
20
|
-
* @param {object} dependencies - { logger }
|
|
21
|
-
* @returns {function} The new (req, res) handler with caching logic.
|
|
12
|
+
* In-Memory Cache Handler
|
|
13
|
+
* Wrapper that adds TTL cache to GET requests.
|
|
22
14
|
*/
|
|
23
15
|
const createCacheHandler = (handler, { logger }) => {
|
|
24
|
-
// 1. Cache
|
|
25
16
|
const CACHE = {};
|
|
26
|
-
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
27
|
-
|
|
28
|
-
return async (req, res) => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
17
|
+
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 Minutes
|
|
18
|
+
|
|
19
|
+
return async (req, res) => {
|
|
20
|
+
// Cache Key now includes mode and limit
|
|
21
|
+
const cacheKey = req.url;
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
|
|
24
|
+
if (CACHE[cacheKey] && (now - CACHE[cacheKey].timestamp) < CACHE_TTL_MS) {
|
|
25
|
+
logger.log('INFO', `[API] Cache HIT for ${cacheKey}`);
|
|
26
|
+
return res.status(CACHE[cacheKey].status).send(CACHE[cacheKey].data);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
logger.log('INFO', `[API] Cache MISS for ${cacheKey}`);
|
|
30
|
+
|
|
31
|
+
const originalSend = res.send;
|
|
32
|
+
const originalStatus = res.status;
|
|
33
|
+
let capturedData = null;
|
|
34
|
+
let capturedStatus = 200;
|
|
35
|
+
|
|
36
|
+
res.status = (statusCode) => {
|
|
37
|
+
capturedStatus = statusCode;
|
|
38
|
+
return originalStatus.call(res, statusCode);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
res.send = (data) => {
|
|
42
|
+
capturedData = data;
|
|
43
|
+
return originalSend.call(res, data);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
await handler(req, res);
|
|
47
|
+
|
|
48
|
+
if (capturedStatus === 200 && capturedData) {
|
|
49
|
+
logger.log('INFO', `[API] Caching new entry for ${cacheKey}`);
|
|
50
|
+
CACHE[cacheKey] = {
|
|
51
|
+
data: capturedData,
|
|
52
|
+
status: capturedStatus,
|
|
53
|
+
timestamp: now
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
};
|
|
47
57
|
};
|
|
48
58
|
|
|
49
59
|
|
|
50
60
|
/**
|
|
51
61
|
* Main pipe: pipe.api.createApiApp
|
|
52
|
-
* Creates and configures the Express app for the Generic API.
|
|
53
|
-
* @param {object} config - The Generic API V2 configuration object.
|
|
54
|
-
* @param {object} dependencies - Shared dependencies { db, logger }.
|
|
55
|
-
* @param {Object} unifiedCalculations - The calculations manifest from 'aiden-shared-calculations-unified'.
|
|
56
|
-
* @returns {express.Application} The configured Express app.
|
|
57
62
|
*/
|
|
58
63
|
function createApiApp(config, dependencies, unifiedCalculations) {
|
|
59
64
|
const app = express();
|
|
60
65
|
const { logger, db } = dependencies;
|
|
61
66
|
|
|
62
|
-
//
|
|
67
|
+
// Build Calc Map once
|
|
63
68
|
const calcMap = buildCalculationMap(unifiedCalculations);
|
|
64
69
|
|
|
65
|
-
//
|
|
70
|
+
// Middleware
|
|
66
71
|
app.use(cors({ origin: true }));
|
|
67
72
|
app.use(express.json());
|
|
68
73
|
|
|
69
|
-
// --- Main API Endpoint ---
|
|
74
|
+
// --- Main API V3 Endpoint ---
|
|
75
|
+
// createApiHandler now initializes the AvailabilityCache internally
|
|
70
76
|
const originalApiHandler = createApiHandler(config, dependencies, calcMap);
|
|
71
77
|
const cachedApiHandler = createCacheHandler(originalApiHandler, dependencies);
|
|
78
|
+
|
|
79
|
+
// This handler now supports ?mode=latest and ?mode=series&limit=X
|
|
72
80
|
app.get('/', cachedApiHandler);
|
|
73
81
|
|
|
74
|
-
//
|
|
82
|
+
// Health Check
|
|
75
83
|
app.get('/health', (req, res) => { res.status(200).send('OK'); });
|
|
76
84
|
|
|
77
|
-
//
|
|
85
|
+
// Debug: List keys
|
|
78
86
|
app.get('/list-computations', (req, res) => {
|
|
79
|
-
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
try {
|
|
88
|
+
const computationKeys = Object.keys(calcMap);
|
|
89
|
+
res.status(200).send({
|
|
90
|
+
status: 'success',
|
|
91
|
+
count: computationKeys.length,
|
|
92
|
+
computations: computationKeys.sort(),
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logger.log('ERROR', 'API /list-computations failed.', { errorMessage: error.message });
|
|
96
|
+
res.status(500).send({ status: 'error', message: 'An internal error occurred.' });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
83
99
|
|
|
84
|
-
//
|
|
100
|
+
// Structure Inspection
|
|
85
101
|
app.get('/structure/:computationName', async (req, res) => {
|
|
86
102
|
const { computationName } = req.params;
|
|
87
103
|
const result = await getComputationStructure(computationName, calcMap, config, dependencies);
|
|
88
|
-
if (result.status === 'error') {
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
if (result.status === 'error') {
|
|
105
|
+
const statusCode = result.message.includes('not found') ? 404 : 500;
|
|
106
|
+
return res.status(statusCode).send(result);
|
|
107
|
+
}
|
|
108
|
+
res.status(200).send(result);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Manifests (Schema Generation)
|
|
91
112
|
app.get('/manifest', createManifestHandler(config, dependencies, calcMap));
|
|
113
|
+
|
|
114
|
+
// Manual Schema Gen Trigger
|
|
92
115
|
app.post('/manifest/generate/:computationName', async (req, res) => {
|
|
93
116
|
const { computationName } = req.params;
|
|
94
117
|
logger.log('INFO', `Manual static schema generation requested for: ${computationName}`);
|
|
95
118
|
|
|
96
119
|
try {
|
|
97
|
-
// 1. Find the calculation class from the calcMap
|
|
98
120
|
const calcInfo = calcMap[computationName];
|
|
99
|
-
if (!calcInfo || !calcInfo.class) {
|
|
121
|
+
if (!calcInfo || !calcInfo.class) {
|
|
122
|
+
return res.status(404).send({ status: 'error', message: `Computation '${computationName}' not found.` });
|
|
123
|
+
}
|
|
100
124
|
const targetCalcClass = calcInfo.class;
|
|
101
125
|
const targetCategory = calcInfo.category;
|
|
102
126
|
|
|
103
|
-
// 2. Use the getDynamicSchema helper (which now just reads the static method)
|
|
104
127
|
const schemaStructure = await getDynamicSchema(targetCalcClass, computationName);
|
|
105
|
-
if (schemaStructure.ERROR) {
|
|
128
|
+
if (schemaStructure.ERROR) {
|
|
129
|
+
return res.status(400).send({ status: 'error', message: `Failed to get static schema: ${schemaStructure.ERROR}` });
|
|
130
|
+
}
|
|
106
131
|
|
|
107
|
-
// 3. Import the new batchStoreSchemas utility
|
|
108
132
|
const { batchStoreSchemas } = require('../computation-system/utils/schema_capture.js');
|
|
109
133
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
134
|
+
const metadata = {
|
|
135
|
+
isHistorical: !!(targetCalcClass.toString().includes('yesterdayPortfolio')),
|
|
136
|
+
dependencies: (typeof targetCalcClass.getDependencies === 'function') ? targetCalcClass.getDependencies() : [],
|
|
137
|
+
rootDataDependencies: [],
|
|
138
|
+
type: (targetCategory === 'meta' || targetCategory === 'socialPosts') ? targetCategory : 'standard',
|
|
139
|
+
note: "Manually generated via API"
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
await batchStoreSchemas(dependencies, config, [{
|
|
143
|
+
name: computationName,
|
|
144
|
+
category: targetCategory,
|
|
145
|
+
schema: schemaStructure,
|
|
146
|
+
metadata: metadata
|
|
147
|
+
}]);
|
|
148
|
+
|
|
149
|
+
res.status(200).send({
|
|
150
|
+
status: 'success',
|
|
151
|
+
message: `Static schema read and stored for ${computationName}`,
|
|
152
|
+
computation: computationName,
|
|
153
|
+
category: targetCategory,
|
|
154
|
+
schema: schemaStructure
|
|
155
|
+
});
|
|
118
156
|
|
|
119
157
|
} catch (error) {
|
|
120
|
-
logger.log('ERROR', `Failed to generate schema for ${computationName}`, { errorMessage: error.message
|
|
121
|
-
res.status(
|
|
158
|
+
logger.log('ERROR', `Failed to generate schema for ${computationName}`, { errorMessage: error.message });
|
|
159
|
+
res.status(500).send({ status: 'error', message: `Failed: ${error.message}` });
|
|
122
160
|
}
|
|
123
161
|
});
|
|
124
162
|
|
|
125
|
-
|
|
126
|
-
* This endpoint is fine as-is. It reads from the Firestore
|
|
127
|
-
* collection that the /manifest and /manifest/generate routes populate.
|
|
128
|
-
*/
|
|
163
|
+
// Single Manifest Get
|
|
129
164
|
app.get('/manifest/:computationName', async (req, res) => {
|
|
130
165
|
const { computationName } = req.params;
|
|
131
166
|
try {
|
|
132
167
|
const schemaCollection = config.schemaCollection || 'computation_schemas';
|
|
133
168
|
const schemaDoc = await db.collection(schemaCollection).doc(computationName).get();
|
|
134
|
-
if (!schemaDoc.exists) {
|
|
169
|
+
if (!schemaDoc.exists) {
|
|
170
|
+
return res.status(404).send({ status: 'error', message: `Schema not found for ${computationName}` });
|
|
171
|
+
}
|
|
135
172
|
const data = schemaDoc.data();
|
|
136
|
-
res.status(200).send({
|
|
137
|
-
|
|
173
|
+
res.status(200).send({
|
|
174
|
+
status: 'success',
|
|
175
|
+
computation: computationName,
|
|
176
|
+
category: data.category,
|
|
177
|
+
structure: data.schema,
|
|
178
|
+
metadata: data.metadata || {},
|
|
179
|
+
lastUpdated: data.lastUpdated
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
logger.log('ERROR', `Failed to fetch schema for ${computationName}`, { errorMessage: error.message });
|
|
183
|
+
res.status(500).send({ status: 'error', message: 'An internal error occurred.' });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
138
187
|
return app;
|
|
139
188
|
}
|
|
140
189
|
|
|
141
|
-
|
|
142
190
|
module.exports = { createApiApp, helpers: require('./helpers/api_helpers.js') };
|
package/package.json
CHANGED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FIXED: computation_controller.js
|
|
3
|
-
* V5.1: Exports LEGACY_MAPPING for Manifest Builder
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// Load all layers dynamically from the index
|
|
7
|
-
const mathLayer = require('../layers/index');
|
|
8
|
-
const { loadDailyInsights, loadDailySocialPostInsights, getRelevantShardRefs, getPriceShardRefs } = require('../utils/data_loader');
|
|
9
|
-
|
|
10
|
-
// Legacy Keys Mapping (Ensures backward compatibility with existing Calculations)
|
|
11
|
-
// Maps the new modular class names to the property names expected by existing code (e.g. math.extract)
|
|
12
|
-
const LEGACY_MAPPING = {
|
|
13
|
-
DataExtractor: 'extract',
|
|
14
|
-
HistoryExtractor: 'history',
|
|
15
|
-
MathPrimitives: 'compute',
|
|
16
|
-
Aggregators: 'aggregate',
|
|
17
|
-
Validators: 'validate',
|
|
18
|
-
SignalPrimitives: 'signals',
|
|
19
|
-
SCHEMAS: 'schemas',
|
|
20
|
-
DistributionAnalytics: 'distribution',
|
|
21
|
-
TimeSeries: 'TimeSeries',
|
|
22
|
-
priceExtractor: 'priceExtractor',
|
|
23
|
-
InsightsExtractor: 'insights',
|
|
24
|
-
UserClassifier: 'classifier',
|
|
25
|
-
Psychometrics: 'psychometrics',
|
|
26
|
-
CognitiveBiases: 'bias',
|
|
27
|
-
SkillAttribution: 'skill',
|
|
28
|
-
ExecutionAnalytics: 'execution',
|
|
29
|
-
AdaptiveAnalytics: 'adaptive'
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
class DataLoader {
|
|
33
|
-
constructor(config, dependencies) { this.config = config; this.deps = dependencies; this.cache = { mappings: null, insights: new Map(), social: new Map(), prices: null }; }
|
|
34
|
-
get mappings() { return this.cache.mappings; }
|
|
35
|
-
async loadMappings() { if (this.cache.mappings) return this.cache.mappings; const { calculationUtils } = this.deps; this.cache.mappings = await calculationUtils.loadInstrumentMappings(); return this.cache.mappings; }
|
|
36
|
-
async loadInsights(dateStr) { if (this.cache.insights.has(dateStr)) return this.cache.insights.get(dateStr); const insights = await loadDailyInsights(this.config, this.deps, dateStr); this.cache.insights.set(dateStr, insights); return insights; }
|
|
37
|
-
async loadSocial(dateStr) { if (this.cache.social.has(dateStr)) return this.cache.social.get(dateStr); const social = await loadDailySocialPostInsights(this.config, this.deps, dateStr); this.cache.social.set(dateStr, social); return social; }
|
|
38
|
-
async getPriceShardReferences() { return getPriceShardRefs(this.config, this.deps); }
|
|
39
|
-
async getSpecificPriceShardReferences (targetInstrumentIds) { return getRelevantShardRefs(this.config, this.deps, targetInstrumentIds); }
|
|
40
|
-
async loadPriceShard(docRef) { try { const snap = await docRef.get(); if (!snap.exists) return {}; return snap.data(); } catch (e) { console.error(`Error loading shard ${docRef.path}:`, e); return {}; } }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
class ContextBuilder {
|
|
44
|
-
static buildMathContext() {
|
|
45
|
-
const mathContext = {};
|
|
46
|
-
for (const [key, value] of Object.entries(mathLayer)) { mathContext[key] = value; const legacyKey = LEGACY_MAPPING[key]; if (legacyKey) { mathContext[legacyKey] = value; } }
|
|
47
|
-
return mathContext;
|
|
48
|
-
}
|
|
49
|
-
static buildPerUserContext(options) {
|
|
50
|
-
const { todayPortfolio, yesterdayPortfolio, todayHistory, yesterdayHistory, userId, userType, dateStr, metadata, mappings, insights, socialData, computedDependencies, previousComputedDependencies, config, deps } = options;
|
|
51
|
-
return {
|
|
52
|
-
user: { id: userId, type: userType, portfolio: { today: todayPortfolio, yesterday: yesterdayPortfolio }, history: { today: todayHistory, yesterday: yesterdayHistory } },
|
|
53
|
-
date: { today: dateStr },
|
|
54
|
-
insights: { today: insights?.today, yesterday: insights?.yesterday },
|
|
55
|
-
social: { today: socialData?.today, yesterday: socialData?.yesterday },
|
|
56
|
-
mappings: mappings || {},
|
|
57
|
-
math: ContextBuilder.buildMathContext(),
|
|
58
|
-
computed: computedDependencies || {},
|
|
59
|
-
previousComputed: previousComputedDependencies || {},
|
|
60
|
-
meta: metadata, config, deps
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
static buildMetaContext(options) {
|
|
65
|
-
const { dateStr, metadata, mappings, insights, socialData, prices, computedDependencies, previousComputedDependencies, config, deps } = options;
|
|
66
|
-
return {
|
|
67
|
-
date: { today: dateStr },
|
|
68
|
-
insights: { today: insights?.today, yesterday: insights?.yesterday },
|
|
69
|
-
social: { today: socialData?.today, yesterday: socialData?.yesterday },
|
|
70
|
-
prices: prices || {},
|
|
71
|
-
mappings: mappings || {},
|
|
72
|
-
math: ContextBuilder.buildMathContext(),
|
|
73
|
-
computed: computedDependencies || {},
|
|
74
|
-
previousComputed: previousComputedDependencies || {},
|
|
75
|
-
meta: metadata, config, deps
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
class ComputationExecutor {
|
|
81
|
-
constructor(config, dependencies, dataLoader) {
|
|
82
|
-
this.config = config;
|
|
83
|
-
this.deps = dependencies;
|
|
84
|
-
this.loader = dataLoader;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async executePerUser(calcInstance, metadata, dateStr, portfolioData, yesterdayPortfolioData, historyData, computedDeps, prevDeps) {
|
|
88
|
-
const { logger } = this.deps;
|
|
89
|
-
const targetUserType = metadata.userType;
|
|
90
|
-
const mappings = await this.loader.loadMappings();
|
|
91
|
-
const insights = metadata.rootDataDependencies?.includes('insights') ? { today: await this.loader.loadInsights(dateStr) } : null;
|
|
92
|
-
|
|
93
|
-
// Access SCHEMAS dynamically from the loaded layer
|
|
94
|
-
const SCHEMAS = mathLayer.SCHEMAS;
|
|
95
|
-
|
|
96
|
-
for (const [userId, todayPortfolio] of Object.entries(portfolioData)) {
|
|
97
|
-
const yesterdayPortfolio = yesterdayPortfolioData ? yesterdayPortfolioData[userId] : null;
|
|
98
|
-
const todayHistory = historyData ? historyData[userId] : null;
|
|
99
|
-
const actualUserType = todayPortfolio.PublicPositions ? SCHEMAS.USER_TYPES.SPECULATOR : SCHEMAS.USER_TYPES.NORMAL;
|
|
100
|
-
if (targetUserType !== 'all') {
|
|
101
|
-
const mappedTarget = (targetUserType === 'speculator') ? SCHEMAS.USER_TYPES.SPECULATOR : SCHEMAS.USER_TYPES.NORMAL;
|
|
102
|
-
if (mappedTarget !== actualUserType) continue;
|
|
103
|
-
}
|
|
104
|
-
const context = ContextBuilder.buildPerUserContext({ todayPortfolio, yesterdayPortfolio, todayHistory, userId, userType: actualUserType, dateStr, metadata, mappings, insights, computedDependencies: computedDeps, previousComputedDependencies: prevDeps, config: this.config, deps: this.deps });
|
|
105
|
-
try { await calcInstance.process(context); } catch (e) { logger.log('WARN', `Calc ${metadata.name} failed for user ${userId}: ${e.message}`); }
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async executeOncePerDay(calcInstance, metadata, dateStr, computedDeps, prevDeps) {
|
|
110
|
-
const mappings = await this.loader.loadMappings();
|
|
111
|
-
const { logger } = this.deps;
|
|
112
|
-
const insights = metadata.rootDataDependencies?.includes('insights') ? { today: await this.loader.loadInsights(dateStr) } : null;
|
|
113
|
-
const social = metadata.rootDataDependencies?.includes('social') ? { today: await this.loader.loadSocial(dateStr) } : null;
|
|
114
|
-
|
|
115
|
-
if (metadata.rootDataDependencies?.includes('price')) {
|
|
116
|
-
logger.log('INFO', `[Executor] Running Batched/Sharded Execution for ${metadata.name}`);
|
|
117
|
-
const shardRefs = await this.loader.getPriceShardReferences();
|
|
118
|
-
if (shardRefs.length === 0) { logger.log('WARN', '[Executor] No price shards found.'); return {}; }
|
|
119
|
-
let processedCount = 0;
|
|
120
|
-
for (const ref of shardRefs) {
|
|
121
|
-
const shardData = await this.loader.loadPriceShard(ref);
|
|
122
|
-
const partialContext = ContextBuilder.buildMetaContext({ dateStr, metadata, mappings, insights, socialData: social, prices: { history: shardData }, computedDependencies: computedDeps, previousComputedDependencies: prevDeps, config: this.config, deps: this.deps });
|
|
123
|
-
await calcInstance.process(partialContext);
|
|
124
|
-
partialContext.prices = null;
|
|
125
|
-
processedCount++;
|
|
126
|
-
if (processedCount % 10 === 0) { if (global.gc) { global.gc(); } }
|
|
127
|
-
}
|
|
128
|
-
logger.log('INFO', `[Executor] Finished Batched Execution for ${metadata.name} (${processedCount} shards).`);
|
|
129
|
-
return calcInstance.getResult ? await calcInstance.getResult() : {};
|
|
130
|
-
} else {
|
|
131
|
-
const context = ContextBuilder.buildMetaContext({ dateStr, metadata, mappings, insights, socialData: social, prices: {}, computedDependencies: computedDeps, previousComputedDependencies: prevDeps, config: this.config, deps: this.deps });
|
|
132
|
-
return await calcInstance.process(context);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
class ComputationController {
|
|
138
|
-
constructor(config, dependencies) {
|
|
139
|
-
this.config = config;
|
|
140
|
-
this.deps = dependencies;
|
|
141
|
-
this.loader = new DataLoader(config, dependencies);
|
|
142
|
-
this.executor = new ComputationExecutor(config, dependencies, this.loader);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
module.exports = { ComputationController, LEGACY_MAPPING };
|