bulltrackers-module 1.0.264 → 1.0.265

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,6 @@
1
1
  /**
2
2
  * @fileoverview Main Orchestrator. Coordinates the topological execution.
3
- * UPDATED: Uses centralized AvailabilityChecker for strict UserType validation.
3
+ * UPDATED: Implements Smart Audit logic to detect WHY a hash mismatch occurred.
4
4
  */
5
5
  const { normalizeName, DEFINITIVE_EARLIEST_DATES } = require('./utils/utils');
6
6
  const { checkRootDataAvailability, checkRootDependencies } = require('./data/AvailabilityChecker');
@@ -10,13 +10,13 @@ const { StandardExecutor } = require('./executor
10
10
  const { MetaExecutor } = require('./executors/MetaExecutor');
11
11
  const { generateProcessId, PROCESS_TYPES } = require('./logger/logger');
12
12
 
13
- // [FIX] Split IMPOSSIBLE into semantic categories
14
13
  const STATUS_IMPOSSIBLE_PREFIX = 'IMPOSSIBLE';
15
14
 
16
15
  function groupByPass(manifest) { return manifest.reduce((acc, calc) => { (acc[calc.pass] = acc[calc.pass] || []).push(calc); return acc; }, {}); }
17
16
 
18
17
  /**
19
18
  * Analyzes whether calculations should run, be skipped, or are blocked.
19
+ * Now performs Deep Hash Analysis to explain Re-Runs.
20
20
  */
21
21
  function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus, manifestMap, prevDailyStatus = null) {
22
22
  const report = { runnable: [], blocked: [], impossible: [], failedDependency: [], reRuns: [], skipped: [] };
@@ -28,7 +28,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
28
28
  const stored = currentStatusMap[norm];
29
29
  const depManifest = manifestMap.get(norm);
30
30
  if (!stored) return false;
31
- // [FIX] Check for any IMPOSSIBLE variant
32
31
  if (typeof stored.hash === 'string' && stored.hash.startsWith(STATUS_IMPOSSIBLE_PREFIX)) return false;
33
32
  if (!depManifest) return false;
34
33
  if (stored.hash !== depManifest.hash) return false;
@@ -42,7 +41,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
42
41
  const storedCategory = stored ? stored.category : null;
43
42
  const currentHash = calc.hash;
44
43
 
45
- // [FIX] Granular impossible marking
46
44
  const markImpossible = (reason, type = 'GENERIC') => {
47
45
  report.impossible.push({ name: cName, reason });
48
46
  const statusHash = `${STATUS_IMPOSSIBLE_PREFIX}:${type}`;
@@ -58,7 +56,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
58
56
  let migrationOldCategory = null;
59
57
  if (storedCategory && storedCategory !== calc.category) { migrationOldCategory = storedCategory; }
60
58
 
61
- // [FIX] Check for any IMPOSSIBLE variant in storage
62
59
  if (typeof storedHash === 'string' && storedHash.startsWith(STATUS_IMPOSSIBLE_PREFIX)) {
63
60
  report.skipped.push({ name: cName, reason: `Permanently Impossible (${storedHash})` });
64
61
  continue;
@@ -69,7 +66,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
69
66
  if (!rootCheck.canRun) {
70
67
  const missingStr = rootCheck.missing.join(', ');
71
68
  if (!isTargetToday) {
72
- // [FIX] Mark specifically as NO_DATA
73
69
  markImpossible(`Missing Root Data: ${missingStr} (Historical)`, 'NO_DATA');
74
70
  } else {
75
71
  report.blocked.push({ name: cName, reason: `Missing Root Data: ${missingStr} (Waiting)` });
@@ -83,7 +79,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
83
79
  for (const dep of calc.dependencies) {
84
80
  const normDep = normalizeName(dep);
85
81
  const depStored = simulationStatus[normDep];
86
- // [FIX] Check for any IMPOSSIBLE variant in dependencies
87
82
  if (depStored && typeof depStored.hash === 'string' && depStored.hash.startsWith(STATUS_IMPOSSIBLE_PREFIX)) {
88
83
  dependencyIsImpossible = true;
89
84
  break;
@@ -93,7 +88,6 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
93
88
  }
94
89
 
95
90
  if (dependencyIsImpossible) {
96
- // [FIX] Mark specifically as UPSTREAM failure
97
91
  markImpossible('Dependency is Impossible', 'UPSTREAM');
98
92
  continue;
99
93
  }
@@ -111,44 +105,88 @@ function analyzeDateExecution(dateStr, calcsInPass, rootDataStatus, dailyStatus,
111
105
  }
112
106
  }
113
107
 
114
- if (!storedHash) { markRunnable(); }
115
- else if (storedHash !== currentHash) { markRunnable(true, { name: cName, oldHash: storedHash, newHash: currentHash, previousCategory: migrationOldCategory }); }
116
- else if (migrationOldCategory) { markRunnable(true, { name: cName, reason: 'Category Migration', previousCategory: migrationOldCategory, newCategory: calc.category }); }
117
- else { report.skipped.push({ name: cName }); simulationStatus[cName] = { hash: currentHash, category: calc.category }; }
108
+ // --- HASH CHECK LOGIC ---
109
+ if (!storedHash) {
110
+ markRunnable(false); // New Calculation
111
+ }
112
+ else if (storedHash !== currentHash) {
113
+ // Smart Logic: Why did it change?
114
+ let changeReason = "Hash Mismatch (Unknown)";
115
+ const oldComp = stored.composition;
116
+ const newComp = calc.composition;
117
+
118
+ if (oldComp && newComp) {
119
+ // 1. Check Code
120
+ if (oldComp.code !== newComp.code) {
121
+ changeReason = "Code Changed";
122
+ }
123
+ // 2. Check Layers
124
+ else if (JSON.stringify(oldComp.layers) !== JSON.stringify(newComp.layers)) {
125
+ // Find specific layer
126
+ const changedLayers = [];
127
+ for(const lKey in newComp.layers) {
128
+ if (newComp.layers[lKey] !== oldComp.layers[lKey]) changedLayers.push(lKey);
129
+ }
130
+ changeReason = `Layer Update: [${changedLayers.join(', ')}]`;
131
+ }
132
+ // 3. Check Dependencies
133
+ else if (JSON.stringify(oldComp.deps) !== JSON.stringify(newComp.deps)) {
134
+ // Find specific dep
135
+ const changedDeps = [];
136
+ for(const dKey in newComp.deps) {
137
+ if (newComp.deps[dKey] !== oldComp.deps[dKey]) changedDeps.push(dKey);
138
+ }
139
+ changeReason = `Upstream Change: [${changedDeps.join(', ')}]`;
140
+ }
141
+ else {
142
+ changeReason = "Logic/Epoch Change";
143
+ }
144
+ } else {
145
+ changeReason = "Hash Mismatch (No prior composition)";
146
+ }
147
+
148
+ markRunnable(true, {
149
+ name: cName,
150
+ oldHash: storedHash,
151
+ newHash: currentHash,
152
+ previousCategory: migrationOldCategory,
153
+ reason: changeReason // <--- Passed to Reporter
154
+ });
155
+ }
156
+ else if (migrationOldCategory) {
157
+ markRunnable(true, { name: cName, reason: 'Category Migration', previousCategory: migrationOldCategory, newCategory: calc.category });
158
+ }
159
+ else {
160
+ report.skipped.push({ name: cName });
161
+ simulationStatus[cName] = { hash: currentHash, category: calc.category, composition: calc.composition };
162
+ }
118
163
  }
119
164
  return report;
120
165
  }
121
166
 
122
167
  /**
123
168
  * DIRECT EXECUTION PIPELINE (For Workers)
124
- * Skips analysis. Assumes the calculation is valid and runnable.
125
- * [UPDATED] Accepted previousCategory argument to handle migrations.
126
169
  */
127
170
  async function executeDispatchTask(dateStr, pass, targetComputation, config, dependencies, computationManifest, previousCategory = null) {
128
171
  const { logger } = dependencies;
129
172
  const pid = generateProcessId(PROCESS_TYPES.EXECUTOR, targetComputation, dateStr);
130
173
 
131
- // 1. Get Calculation Manifest
132
174
  const manifestMap = new Map(computationManifest.map(c => [normalizeName(c.name), c]));
133
175
  const calcManifest = manifestMap.get(normalizeName(targetComputation));
134
176
 
135
177
  if (!calcManifest) { throw new Error(`Calculation '${targetComputation}' not found in manifest.`); }
136
178
 
137
- // [UPDATED] Attach migration context if present
138
179
  if (previousCategory) {
139
180
  calcManifest.previousCategory = previousCategory;
140
181
  logger.log('INFO', `[Executor] Migration detected for ${calcManifest.name}. Old data will be cleaned from: ${previousCategory}`);
141
182
  }
142
183
 
143
- // 2. Fetch Root Data Availability
144
184
  const rootData = await checkRootDataAvailability(dateStr, config, dependencies, DEFINITIVE_EARLIEST_DATES);
145
-
146
185
  if (!rootData) {
147
- logger.log('ERROR', `[Executor] FATAL: Root data check failed for ${targetComputation} on ${dateStr}. Index might be missing.`);
186
+ logger.log('ERROR', `[Executor] FATAL: Root data check failed for ${targetComputation} on ${dateStr}.`);
148
187
  return;
149
188
  }
150
189
 
151
- // 3. Fetch Dependencies
152
190
  const calcsToRun = [calcManifest];
153
191
  const existingResults = await fetchExistingResults(dateStr, calcsToRun, computationManifest, config, dependencies, false);
154
192
 
@@ -160,7 +198,6 @@ async function executeDispatchTask(dateStr, pass, targetComputation, config, dep
160
198
  previousResults = await fetchExistingResults(prevDateStr, calcsToRun, computationManifest, config, dependencies, true);
161
199
  }
162
200
 
163
- // 4. Execute
164
201
  logger.log('INFO', `[Executor] Running ${calcManifest.name} for ${dateStr}`, { processId: pid });
165
202
  let resultUpdates = {};
166
203
 
@@ -176,5 +213,4 @@ async function executeDispatchTask(dateStr, pass, targetComputation, config, dep
176
213
  }
177
214
  }
178
215
 
179
-
180
216
  module.exports = { executeDispatchTask, groupByPass, analyzeDateExecution };
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * @fileoverview Dynamic Manifest Builder - Handles Topological Sort and Auto-Discovery.
3
+ * UPDATED: Generates Granular Hash Composition for Audit Trails.
3
4
  */
4
5
  const { generateCodeHash, LEGACY_MAPPING } = require('../topology/HashManager.js');
5
6
  const { normalizeName } = require('../utils/utils');
@@ -106,9 +107,13 @@ function buildManifest(productLinesToRun = [], calculations) {
106
107
  const metadata = Class.getMetadata();
107
108
  const dependencies = Class.getDependencies().map(normalizeName);
108
109
  const codeStr = Class.toString();
110
+ const selfCodeHash = generateCodeHash(codeStr);
111
+
112
+ let compositeHashString = selfCodeHash + `|EPOCH:${SYSTEM_EPOCH}`;
109
113
 
110
- let compositeHashString = generateCodeHash(codeStr) + `|EPOCH:${SYSTEM_EPOCH}`; // Here we build the hash
111
114
  const usedDeps = [];
115
+ // Track layer hashes for composition analysis
116
+ const usedLayerHashes = {};
112
117
 
113
118
  for (const [layerName, exportsMap] of Object.entries(LAYER_TRIGGERS)) {
114
119
  const layerHashes = LAYER_HASHES[layerName];
@@ -118,19 +123,30 @@ function buildManifest(productLinesToRun = [], calculations) {
118
123
  if (exportHash) {
119
124
  compositeHashString += exportHash;
120
125
  usedDeps.push(`${layerName}.${exportName}`);
126
+
127
+ // Group hashes by layer for the composition report
128
+ if (!usedLayerHashes[layerName]) usedLayerHashes[layerName] = '';
129
+ usedLayerHashes[layerName] += exportHash;
121
130
  }
122
131
  }
123
132
  }
124
133
  }
125
134
 
135
+ // Simplify layer hashes to one hash per layer for the report
136
+ const layerComposition = {};
137
+ for(const [lName, lStr] of Object.entries(usedLayerHashes)) {
138
+ layerComposition[lName] = generateCodeHash(lStr);
139
+ }
140
+
126
141
  // Safe Mode Fallback
127
142
  let isSafeMode = false;
128
143
  if (usedDeps.length === 0) {
129
144
  isSafeMode = true;
130
145
  Object.values(LAYER_HASHES).forEach(layerObj => { Object.values(layerObj).forEach(h => compositeHashString += h); });
146
+ layerComposition['ALL_SAFE_MODE'] = 'ALL';
131
147
  }
132
148
 
133
- const baseHash = generateCodeHash(compositeHashString);
149
+ const intrinsicHash = generateCodeHash(compositeHashString);
134
150
 
135
151
  const manifestEntry = {
136
152
  name: normalizedName,
@@ -143,7 +159,16 @@ function buildManifest(productLinesToRun = [], calculations) {
143
159
  userType: metadata.userType,
144
160
  dependencies: dependencies,
145
161
  pass: 0,
146
- hash: baseHash,
162
+ hash: intrinsicHash, // Will be updated with deps
163
+
164
+ // [NEW] Composition Object for Audit
165
+ composition: {
166
+ epoch: SYSTEM_EPOCH,
167
+ code: selfCodeHash,
168
+ layers: layerComposition,
169
+ deps: {} // Will be populated after topo sort
170
+ },
171
+
147
172
  debugUsedLayers: isSafeMode ? ['ALL (Safe Mode)'] : usedDeps
148
173
  };
149
174
 
@@ -174,8 +199,6 @@ function buildManifest(productLinesToRun = [], calculations) {
174
199
  }
175
200
 
176
201
  const productLineEndpoints = [];
177
-
178
- // [UPDATE] Check if we should run ALL product lines (if empty or wildcard)
179
202
  const runAll = !productLinesToRun || productLinesToRun.length === 0 || productLinesToRun.includes('*');
180
203
 
181
204
  for (const [name, entry] of manifestMap.entries()) {
@@ -187,7 +210,6 @@ function buildManifest(productLinesToRun = [], calculations) {
187
210
  const requiredCalcs = getDependencySet(productLineEndpoints, adjacency);
188
211
  log.info(`Filtered down to ${requiredCalcs.size} active calculations.`);
189
212
 
190
- // [LOG VERIFICATION] Final Proof of Active Lines
191
213
  const activePackages = new Set();
192
214
  requiredCalcs.forEach(name => {
193
215
  const entry = manifestMap.get(name);
@@ -240,11 +262,17 @@ function buildManifest(productLinesToRun = [], calculations) {
240
262
 
241
263
  // --- Cascading Hash (Phase 2) ---
242
264
  for (const entry of sortedManifest) {
243
- let dependencySignature = entry.hash;
265
+ let dependencySignature = entry.hash; // Start with intrinsic
266
+
244
267
  if (entry.dependencies && entry.dependencies.length > 0) {
245
268
  const depHashes = entry.dependencies.map(depName => {
246
- const depEntry = filteredManifestMap.get(depName);
247
- return depEntry ? depEntry.hash : '';
269
+ const depEntry = filteredManifestMap.get(depName);
270
+ if (depEntry) {
271
+ // Populate Composition
272
+ entry.composition.deps[depName] = depEntry.hash;
273
+ return depEntry.hash;
274
+ }
275
+ return '';
248
276
  }).join('|');
249
277
  dependencySignature += `|DEPS:${depHashes}`;
250
278
  }
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * @fileoverview Handles saving computation results with observability and Smart Cleanup.
3
- * UPDATED: Returns detailed failure reports AND metrics for the Audit Logger.
4
- * UPDATED: Stops retrying on non-transient errors.
5
- * UPDATED: Supports Multi-Date Fan-Out (Time Machine Mode) with CONCURRENCY THROTTLING.
3
+ * UPDATED: Stores Hash Composition in status for audit trail.
6
4
  */
7
5
  const { commitBatchInChunks } = require('./FirestoreUtils');
8
6
  const { updateComputationStatus } = require('./StatusRepository');
@@ -10,13 +8,10 @@ const { batchStoreSchemas } = require('../utils/schema_capture');
10
8
  const { generateProcessId, PROCESS_TYPES } = require('../logger/logger');
11
9
  const { HeuristicValidator } = require('./ResultsValidator');
12
10
  const validationOverrides = require('../config/validation_overrides');
13
- const pLimit = require('p-limit'); // <--- CRITICAL IMPORT
11
+ const pLimit = require('p-limit');
14
12
 
15
13
  const NON_RETRYABLE_ERRORS = [
16
- 'INVALID_ARGUMENT', // Schema/Type mismatch
17
- 'PERMISSION_DENIED', // Auth issue
18
- 'DATA_LOSS', // Firestore corruption
19
- 'FAILED_PRECONDITION' // Transaction requirements not met
14
+ 'INVALID_ARGUMENT', 'PERMISSION_DENIED', 'DATA_LOSS', 'FAILED_PRECONDITION'
20
15
  ];
21
16
 
22
17
  async function commitResults(stateObj, dStr, passName, config, deps, skipStatusWrite = false) {
@@ -27,13 +22,11 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
27
22
  const { logger, db } = deps;
28
23
  const pid = generateProcessId(PROCESS_TYPES.STORAGE, passName, dStr);
29
24
 
30
- // SAFETY LIMIT: Only allow 10 concurrent daily writes to prevent network saturation during Fan-Out
31
25
  const fanOutLimit = pLimit(10);
32
26
 
33
27
  for (const name in stateObj) {
34
28
  const calc = stateObj[name];
35
29
 
36
- // Prep metrics container
37
30
  const runMetrics = {
38
31
  storage: { sizeBytes: 0, isSharded: false, shardCount: 1, keys: 0 },
39
32
  validation: { isValid: true, anomalies: [] }
@@ -41,7 +34,6 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
41
34
 
42
35
  try {
43
36
  const result = await calc.getResult();
44
-
45
37
  const overrides = validationOverrides[calc.manifest.name] || {};
46
38
  const healthCheck = HeuristicValidator.analyze(calc.manifest.name, result, overrides);
47
39
 
@@ -54,52 +46,52 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
54
46
  const isEmpty = !result || (typeof result === 'object' && Object.keys(result).length === 0) || (typeof result === 'number' && result === 0);
55
47
  if (isEmpty) {
56
48
  if (calc.manifest.hash) {
57
- successUpdates[name] = { hash: false, category: calc.manifest.category, metrics: runMetrics };
49
+ successUpdates[name] = {
50
+ hash: calc.manifest.hash,
51
+ category: calc.manifest.category,
52
+ composition: calc.manifest.composition, // <--- Added Composition
53
+ metrics: runMetrics
54
+ };
58
55
  }
59
56
  continue;
60
57
  }
61
58
 
62
59
  if (typeof result === 'object') runMetrics.storage.keys = Object.keys(result).length;
63
60
 
64
- // --- MULTI-DATE FAN-OUT DETECTION ---
65
- // If the result keys are ALL date strings (YYYY-MM-DD), we split the writes.
61
+ // ... (Fan-out logic remains same) ...
66
62
  const resultKeys = Object.keys(result || {});
67
63
  const isMultiDate = resultKeys.length > 0 && resultKeys.every(k => /^\d{4}-\d{2}-\d{2}$/.test(k));
68
64
 
69
65
  if (isMultiDate) {
70
66
  logger.log('INFO', `[ResultCommitter] 🕰️ Multi-Date Output detected for ${name} (${resultKeys.length} days). Throttled Fan-Out...`);
71
67
 
72
- // Group updates by DATE. result is { "2024-01-01": { user1: ... }, "2024-01-02": { user1: ... } }
73
- // We execute a fan-out commit for each date using p-limit.
74
-
75
68
  const datePromises = resultKeys.map((historicalDate) => fanOutLimit(async () => {
76
69
  const dailyData = result[historicalDate];
77
70
  if (!dailyData || Object.keys(dailyData).length === 0) return;
78
71
 
79
72
  const historicalDocRef = db.collection(config.resultsCollection)
80
- .doc(historicalDate) // Use the HISTORICAL date, not dStr
73
+ .doc(historicalDate)
81
74
  .collection(config.resultsSubcollection)
82
75
  .doc(calc.manifest.category)
83
76
  .collection(config.computationsSubcollection)
84
77
  .doc(name);
85
78
 
86
- // Re-use the existing sharding logic for this specific date payload
87
79
  await writeSingleResult(dailyData, historicalDocRef, name, historicalDate, logger, config, deps);
88
80
  }));
89
81
 
90
82
  await Promise.all(datePromises);
91
83
 
92
- // Mark success for the Target Date (dStr) so the workflow continues
93
84
  if (calc.manifest.hash) {
94
85
  successUpdates[name] = {
95
86
  hash: calc.manifest.hash,
96
87
  category: calc.manifest.category,
97
- metrics: runMetrics // Pass metrics up
88
+ composition: calc.manifest.composition, // <--- Added Composition
89
+ metrics: runMetrics
98
90
  };
99
91
  }
100
92
 
101
93
  } else {
102
- // --- STANDARD MODE (Single Date) ---
94
+ // --- STANDARD MODE ---
103
95
  const mainDocRef = db.collection(config.resultsCollection)
104
96
  .doc(dStr)
105
97
  .collection(config.resultsSubcollection)
@@ -107,30 +99,27 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
107
99
  .collection(config.computationsSubcollection)
108
100
  .doc(name);
109
101
 
110
- // Use the encapsulated write function
111
102
  const writeStats = await writeSingleResult(result, mainDocRef, name, dStr, logger, config, deps);
112
103
 
113
104
  runMetrics.storage.sizeBytes = writeStats.totalSize;
114
105
  runMetrics.storage.isSharded = writeStats.isSharded;
115
106
  runMetrics.storage.shardCount = writeStats.shardCount;
116
107
 
117
- // Mark Success & Pass Metrics
118
108
  if (calc.manifest.hash) {
119
109
  successUpdates[name] = {
120
110
  hash: calc.manifest.hash,
121
111
  category: calc.manifest.category,
112
+ composition: calc.manifest.composition, // <--- Added Composition
122
113
  metrics: runMetrics
123
114
  };
124
115
  }
125
116
  }
126
117
 
127
- // Capture Schema
128
118
  if (calc.manifest.class.getSchema) {
129
119
  const { class: _cls, ...safeMetadata } = calc.manifest;
130
120
  schemas.push({ name, category: calc.manifest.category, schema: calc.manifest.class.getSchema(), metadata: safeMetadata });
131
121
  }
132
122
 
133
- // Cleanup Migration
134
123
  if (calc.manifest.previousCategory && calc.manifest.previousCategory !== calc.manifest.category) {
135
124
  cleanupTasks.push(deleteOldCalculationData(dStr, calc.manifest.previousCategory, name, config, deps));
136
125
  }
@@ -144,7 +133,7 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
144
133
  failureReport.push({
145
134
  name,
146
135
  error: { message: msg, stack: e.stack, stage },
147
- metrics: runMetrics // Pass incomplete metrics for debugging
136
+ metrics: runMetrics
148
137
  });
149
138
  }
150
139
  }
@@ -156,181 +145,79 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
156
145
  return { successUpdates, failureReport };
157
146
  }
158
147
 
159
- /**
160
- * Encapsulated write logic for reuse in Fan-Out.
161
- * Handles sharding strategy and retries.
162
- */
163
148
  async function writeSingleResult(result, docRef, name, dateContext, logger, config, deps) {
164
- // Strategy: 1=Normal, 2=Safe (Halved), 3=Aggressive (Quartered + Key Limit)
165
- const strategies = [
166
- { bytes: 900 * 1024, keys: null }, // Attempt 1: Standard
167
- { bytes: 450 * 1024, keys: 10000 }, // Attempt 2: High Index usage
168
- { bytes: 200 * 1024, keys: 2000 } // Attempt 3: Extreme fragmentation
169
- ];
170
-
171
- let committed = false;
172
- let lastError = null;
173
- let finalStats = { totalSize: 0, isSharded: false, shardCount: 1 };
149
+ const strategies = [ { bytes: 900 * 1024, keys: null }, { bytes: 450 * 1024, keys: 10000 }, { bytes: 200 * 1024, keys: 2000 } ];
150
+ let committed = false; let lastError = null; let finalStats = { totalSize: 0, isSharded: false, shardCount: 1 };
174
151
 
175
152
  for (let attempt = 0; attempt < strategies.length; attempt++) {
176
153
  if (committed) break;
177
-
178
154
  const constraints = strategies[attempt];
179
-
180
155
  try {
181
- // 1. Prepare Shards with current constraints
182
156
  const updates = await prepareAutoShardedWrites(result, docRef, logger, constraints.bytes, constraints.keys);
183
-
184
- // Stats
185
157
  const pointer = updates.find(u => u.data._completed === true);
186
158
  finalStats.isSharded = pointer && pointer.data._sharded === true;
187
159
  finalStats.shardCount = finalStats.isSharded ? (pointer.data._shardCount || 1) : 1;
188
160
  finalStats.totalSize = updates.reduce((acc, u) => acc + (u.data ? JSON.stringify(u.data).length : 0), 0);
189
-
190
- // 2. Attempt Commit
191
161
  await commitBatchInChunks(config, deps, updates, `${name}::${dateContext} (Att ${attempt+1})`);
192
-
193
- // Log Success
194
- if (logger && logger.logStorage) {
195
- logger.logStorage(null, name, dateContext, docRef.path, finalStats.totalSize, finalStats.isSharded);
196
- }
197
-
198
- committed = true; // Exit loop
199
-
162
+ if (logger && logger.logStorage) { logger.logStorage(null, name, dateContext, docRef.path, finalStats.totalSize, finalStats.isSharded); }
163
+ committed = true;
200
164
  } catch (commitErr) {
201
165
  lastError = commitErr;
202
166
  const msg = commitErr.message || '';
203
-
204
- const isNonRetryable = NON_RETRYABLE_ERRORS.includes(commitErr.code);
205
- if (isNonRetryable) {
206
- logger.log('ERROR', `[SelfHealing] ${name} encountered FATAL error (Attempt ${attempt + 1}): ${msg}. Aborting.`);
207
- throw commitErr;
208
- }
209
-
210
- const isSizeError = msg.includes('Transaction too big') || msg.includes('payload is too large');
211
- const isIndexError = msg.includes('too many index entries') || msg.includes('INVALID_ARGUMENT');
212
-
213
- if (isSizeError || isIndexError) {
214
- logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} failed write attempt ${attempt + 1}. Retrying with tighter constraints...`, { error: msg });
215
- continue; // Try next strategy
216
- } else {
217
- logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} unknown error (Attempt ${attempt + 1}). Retrying...`, { error: msg });
218
- }
167
+ if (NON_RETRYABLE_ERRORS.includes(commitErr.code)) { logger.log('ERROR', `[SelfHealing] ${name} FATAL error: ${msg}.`); throw commitErr; }
168
+ if (msg.includes('Transaction too big') || msg.includes('payload is too large') || msg.includes('too many index entries')) { logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} failed attempt ${attempt+1}. Retrying...`, { error: msg }); continue; }
169
+ else { logger.log('WARN', `[SelfHealing] ${name} on ${dateContext} unknown error. Retrying...`, { error: msg }); }
219
170
  }
220
171
  }
221
-
222
- if (!committed) {
223
- throw {
224
- message: `Exhausted sharding strategies for ${name} on ${dateContext}. Last error: ${lastError?.message}`,
225
- stack: lastError?.stack,
226
- stage: 'SHARDING_LIMIT_EXCEEDED'
227
- };
228
- }
229
-
172
+ if (!committed) { throw { message: `Exhausted sharding strategies for ${name}. Last error: ${lastError?.message}`, stack: lastError?.stack, stage: 'SHARDING_LIMIT_EXCEEDED' }; }
230
173
  return finalStats;
231
174
  }
232
175
 
233
- /**
234
- * Deletes result documents from a previous category location.
235
- */
236
176
  async function deleteOldCalculationData(dateStr, oldCategory, calcName, config, deps) {
237
177
  const { db, logger, calculationUtils } = deps;
238
178
  const { withRetry } = calculationUtils || { withRetry: (fn) => fn() };
239
-
240
179
  try {
241
- const oldDocRef = db.collection(config.resultsCollection)
242
- .doc(dateStr)
243
- .collection(config.resultsSubcollection)
244
- .doc(oldCategory)
245
- .collection(config.computationsSubcollection)
246
- .doc(calcName);
247
-
248
- const shardsCol = oldDocRef.collection('_shards');
180
+ const oldDocRef = db.collection(config.resultsCollection).doc(dateStr).collection(config.resultsSubcollection).doc(oldCategory).collection(config.computationsSubcollection).doc(calcName);
181
+ const shardsCol = oldDocRef.collection('_shards');
249
182
  const shardsSnap = await withRetry(() => shardsCol.listDocuments(), 'ListOldShards');
250
- const batch = db.batch();
251
- let ops = 0;
252
-
183
+ const batch = db.batch(); let ops = 0;
253
184
  for (const shardDoc of shardsSnap) { batch.delete(shardDoc); ops++; }
254
- batch.delete(oldDocRef);
255
- ops++;
256
-
185
+ batch.delete(oldDocRef); ops++;
257
186
  await withRetry(() => batch.commit(), 'CleanupOldCategory');
258
- logger.log('INFO', `[Migration] Cleaned up ${ops} docs for ${calcName} in old category '${oldCategory}'`);
259
-
260
- } catch (e) {
261
- logger.log('WARN', `[Migration] Failed to clean up old data for ${calcName}: ${e.message}`);
262
- }
187
+ logger.log('INFO', `[Migration] Cleaned up ${ops} docs for ${calcName} in '${oldCategory}'`);
188
+ } catch (e) { logger.log('WARN', `[Migration] Failed to clean up ${calcName}: ${e.message}`); }
263
189
  }
264
190
 
265
191
  function calculateFirestoreBytes(value) {
266
- if (value === null) return 1;
267
- if (value === undefined) return 0;
268
- if (typeof value === 'boolean') return 1;
269
- if (typeof value === 'number') return 8;
270
- if (typeof value === 'string') return Buffer.byteLength(value, 'utf8') + 1;
271
- if (value instanceof Date) return 8;
272
- if (value.constructor && value.constructor.name === 'DocumentReference') { return Buffer.byteLength(value.path, 'utf8') + 16; }
192
+ if (value === null) return 1; if (value === undefined) return 0; if (typeof value === 'boolean') return 1; if (typeof value === 'number') return 8; if (typeof value === 'string') return Buffer.byteLength(value, 'utf8') + 1; if (value instanceof Date) return 8; if (value.constructor && value.constructor.name === 'DocumentReference') { return Buffer.byteLength(value.path, 'utf8') + 16; }
273
193
  if (Array.isArray(value)) { let sum = 0; for (const item of value) sum += calculateFirestoreBytes(item); return sum; }
274
- if (typeof value === 'object') { let sum = 0; for (const k in value) { if (Object.prototype.hasOwnProperty.call(value, k)) { sum += (Buffer.byteLength(k, 'utf8') + 1) + calculateFirestoreBytes(value[k]); } } return sum; }
275
- return 0;
194
+ if (typeof value === 'object') { let sum = 0; for (const k in value) { if (Object.prototype.hasOwnProperty.call(value, k)) { sum += (Buffer.byteLength(k, 'utf8') + 1) + calculateFirestoreBytes(value[k]); } } return sum; } return 0;
276
195
  }
277
196
 
278
197
  async function prepareAutoShardedWrites(result, docRef, logger, maxBytes = 900 * 1024, maxKeys = null) {
279
- const OVERHEAD_ALLOWANCE = 20 * 1024;
280
- const CHUNK_LIMIT = maxBytes - OVERHEAD_ALLOWANCE;
281
-
282
- const totalSize = calculateFirestoreBytes(result);
283
- const docPathSize = Buffer.byteLength(docRef.path, 'utf8') + 16;
284
-
285
- const writes = [];
286
- const shardCollection = docRef.collection('_shards');
287
- let currentChunk = {};
288
- let currentChunkSize = 0;
289
- let currentKeyCount = 0;
290
- let shardIndex = 0;
198
+ const OVERHEAD_ALLOWANCE = 20 * 1024; const CHUNK_LIMIT = maxBytes - OVERHEAD_ALLOWANCE;
199
+ const totalSize = calculateFirestoreBytes(result); const docPathSize = Buffer.byteLength(docRef.path, 'utf8') + 16;
200
+ const writes = []; const shardCollection = docRef.collection('_shards');
201
+ let currentChunk = {}; let currentChunkSize = 0; let currentKeyCount = 0; let shardIndex = 0;
291
202
 
292
- // Fast path: If small enough AND keys are safe
293
203
  if (!maxKeys && (totalSize + docPathSize) < CHUNK_LIMIT) {
294
- const data = {
295
- ...result,
296
- _completed: true,
297
- _sharded: false,
298
- _lastUpdated: new Date().toISOString()
299
- };
204
+ const data = { ...result, _completed: true, _sharded: false, _lastUpdated: new Date().toISOString() };
300
205
  return [{ ref: docRef, data, options: { merge: true } }];
301
206
  }
302
207
 
303
208
  for (const [key, value] of Object.entries(result)) {
304
209
  if (key.startsWith('_')) continue;
305
- const keySize = Buffer.byteLength(key, 'utf8') + 1;
306
- const valueSize = calculateFirestoreBytes(value);
307
- const itemSize = keySize + valueSize;
308
-
309
- const byteLimitReached = (currentChunkSize + itemSize > CHUNK_LIMIT);
310
- const keyLimitReached = (maxKeys && currentKeyCount + 1 >= maxKeys);
311
-
210
+ const keySize = Buffer.byteLength(key, 'utf8') + 1; const valueSize = calculateFirestoreBytes(value); const itemSize = keySize + valueSize;
211
+ const byteLimitReached = (currentChunkSize + itemSize > CHUNK_LIMIT); const keyLimitReached = (maxKeys && currentKeyCount + 1 >= maxKeys);
312
212
  if (byteLimitReached || keyLimitReached) {
313
213
  writes.push({ ref: shardCollection.doc(`shard_${shardIndex}`), data: currentChunk, options: { merge: false } });
314
- shardIndex++;
315
- currentChunk = {};
316
- currentChunkSize = 0;
317
- currentKeyCount = 0;
214
+ shardIndex++; currentChunk = {}; currentChunkSize = 0; currentKeyCount = 0;
318
215
  }
319
- currentChunk[key] = value;
320
- currentChunkSize += itemSize;
321
- currentKeyCount++;
322
- }
323
-
324
- if (Object.keys(currentChunk).length > 0) {
325
- writes.push({ ref: shardCollection.doc(`shard_${shardIndex}`), data: currentChunk, options: { merge: false } });
216
+ currentChunk[key] = value; currentChunkSize += itemSize; currentKeyCount++;
326
217
  }
218
+ if (Object.keys(currentChunk).length > 0) { writes.push({ ref: shardCollection.doc(`shard_${shardIndex}`), data: currentChunk, options: { merge: false } }); }
327
219
 
328
- const pointerData = {
329
- _completed: true,
330
- _sharded: true,
331
- _shardCount: shardIndex + 1,
332
- _lastUpdated: new Date().toISOString()
333
- };
220
+ const pointerData = { _completed: true, _sharded: true, _shardCount: shardIndex + 1, _lastUpdated: new Date().toISOString() };
334
221
  writes.push({ ref: docRef, data: pointerData, options: { merge: false } });
335
222
  return writes;
336
223
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @fileoverview Manages computation status tracking in Firestore.
3
- * UPDATED: Supports Schema V2 (Object with Category) for smart migrations.
3
+ * UPDATED: Supports Schema V2 (Object with Category & Composition) for deep auditing.
4
4
  */
5
5
 
6
6
  async function fetchComputationStatus(dateStr, config, { db }) {
@@ -14,8 +14,11 @@ async function fetchComputationStatus(dateStr, config, { db }) {
14
14
 
15
15
  // Normalize V1 (String) to V2 (Object)
16
16
  for (const [name, value] of Object.entries(rawData)) {
17
- if (typeof value === 'string') { normalized[name] = { hash: value, category: null }; // Legacy entry
18
- } else { normalized[name] = value; }
17
+ if (typeof value === 'string') {
18
+ normalized[name] = { hash: value, category: null, composition: null }; // Legacy entry
19
+ } else {
20
+ normalized[name] = value;
21
+ }
19
22
  }
20
23
 
21
24
  return normalized;
@@ -30,8 +33,16 @@ async function updateComputationStatus(dateStr, updates, config, { db }) {
30
33
 
31
34
  const safeUpdates = {};
32
35
  for (const [key, val] of Object.entries(updates)) {
33
- if (typeof val === 'string') { safeUpdates[key] = { hash: val, category: 'unknown', lastUpdated: new Date() };
34
- } else { safeUpdates[key] = { ...val, lastUpdated: new Date() }; }
36
+ if (typeof val === 'string') {
37
+ // Legacy Call Fallback
38
+ safeUpdates[key] = { hash: val, category: 'unknown', lastUpdated: new Date() };
39
+ } else {
40
+ // V2 Call: val should contain { hash, category, composition }
41
+ safeUpdates[key] = {
42
+ ...val,
43
+ lastUpdated: new Date()
44
+ };
45
+ }
35
46
  }
36
47
 
37
48
  await docRef.set(safeUpdates, { merge: true });
@@ -111,8 +111,9 @@ async function generateBuildReport(config, dependencies, manifest, daysBack = 90
111
111
  // E. Format Findings
112
112
  const dateSummary = { willRun: [], willReRun: [], blocked: [], impossible: [] };
113
113
 
114
+ // Pass the generated "Reason" string through to the report
114
115
  analysis.runnable.forEach (item => dateSummary.willRun.push ({ name: item.name, reason: "New / No Previous Record" }));
115
- analysis.reRuns.forEach (item => dateSummary.willReRun.push ({ name: item.name, reason: item.previousCategory ? "Migration" : "Hash Mismatch" }));
116
+ analysis.reRuns.forEach (item => dateSummary.willReRun.push ({ name: item.name, reason: item.reason || "Hash Mismatch" }));
116
117
  analysis.impossible.forEach (item => dateSummary.impossible.push ({ name: item.name, reason: item.reason }));
117
118
  [...analysis.blocked, ...analysis.failedDependency].forEach(item => dateSummary.blocked.push({ name: item.name, reason: item.reason || 'Dependency' }));
118
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.264",
3
+ "version": "1.0.265",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [