bulltrackers-module 1.0.1100 → 1.0.1102

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.
@@ -300,15 +300,25 @@ class StorageManager {
300
300
  * these up and handle delivery.
301
301
  */
302
302
  async _writeToOutboxes(results) {
303
+ this.logger.log('INFO', `[StorageMgr] Checking for outbox payloads...`);
303
304
  const outbox = this._extractOutboxPayloads(results);
304
- if (!outbox) return;
305
+
306
+ if (!outbox) {
307
+ this.logger.log('INFO', `[StorageMgr] No outbox payloads found.`);
308
+ return;
309
+ }
310
+
311
+ this.logger.log('INFO', `[StorageMgr] Extracted outbox payloads: email=${outbox.emailPayloads?.length || 0}, sms=${outbox.smsPayloads?.length || 0}, fcm=${outbox.fcmPayloads?.length || 0}`);
305
312
 
306
313
  const allPayloads = [
307
314
  ...(outbox.emailPayloads || []).map(p => ({ collection: 'outbox_emails', data: p })),
308
315
  ...(outbox.smsPayloads || []).map(p => ({ collection: 'outbox_sms', data: p })),
309
316
  ...(outbox.fcmPayloads || []).map(p => ({ collection: 'outbox_fcm', data: p }))
310
317
  ];
311
- if (allPayloads.length === 0) return;
318
+ if (allPayloads.length === 0) {
319
+ this.logger.log('INFO', `[StorageMgr] All payload groups are empty, nothing to write.`);
320
+ return;
321
+ }
312
322
 
313
323
  const BATCH_LIMIT = 400;
314
324
  const batches = [];
@@ -24,6 +24,8 @@
24
24
  * @param {Object} [params.piMasterList] - { piCid: { username } }
25
25
  * @returns {{ notifications, outbox, totalMatched, totalEvaluated }}
26
26
  */
27
+ const GLOBAL_SINK_USER_ID = 'GLOBAL_SINK_USER_FOR_TESTING';
28
+
27
29
  function matchSubscriptions({
28
30
  parentResults = [],
29
31
  subscriptions = [],
@@ -74,6 +76,17 @@ function matchSubscriptions({
74
76
  phoneNumber: s.phone_number || null
75
77
  };
76
78
  }
79
+ // Add global sink user's preferences (always on for all channels if settings are provided)
80
+ if (GLOBAL_SINK_USER_ID && !userPrefs[GLOBAL_SINK_USER_ID]) {
81
+ const sinkUserSettings = userSettings.find(s => String(s.user_id) === GLOBAL_SINK_USER_ID);
82
+ userPrefs[GLOBAL_SINK_USER_ID] = {
83
+ email: sinkUserSettings?.channel_email === 'true' || sinkUserSettings?.channel_email === true,
84
+ sms: sinkUserSettings?.channel_sms === 'true' || sinkUserSettings?.channel_sms === true,
85
+ push: sinkUserSettings?.channel_push === 'true' || sinkUserSettings?.channel_push === true,
86
+ emailAddress: sinkUserSettings?.email_address || 'global-sink@example.com', // Default fallback
87
+ phoneNumber: sinkUserSettings?.phone_number || null
88
+ };
89
+ }
77
90
 
78
91
  // FCM tokens index: userId → [token, ...]
79
92
  const userFcmTokens = {};
@@ -95,6 +108,22 @@ function matchSubscriptions({
95
108
  const piCid = String(result.entityId || result.piCid || result.cid || result.user_id);
96
109
  const watchers = piToWatchers[piCid] || [];
97
110
 
111
+ // --- Start Global Sink Logic ---
112
+ if (GLOBAL_SINK_USER_ID) {
113
+ const sinkWatcher = {
114
+ userId: GLOBAL_SINK_USER_ID,
115
+ watchlistId: 'GLOBAL_SINK',
116
+ watchlistName: 'Global Sink'
117
+ };
118
+ // Ensure the global sink user is processed like a normal watcher
119
+ watchers.push(sinkWatcher);
120
+ // Ensure the sink user is considered "subscribed"
121
+ if (!userSubSettings[`${GLOBAL_SINK_USER_ID}::${piCid}`]) {
122
+ userSubSettings[`${GLOBAL_SINK_USER_ID}::${piCid}`] = { [configKey]: true };
123
+ }
124
+ }
125
+ // --- End Global Sink Logic ---
126
+
98
127
  for (const watcher of watchers) {
99
128
  totalEvaluated++;
100
129
 
@@ -102,7 +131,6 @@ function matchSubscriptions({
102
131
  const settings = userSubSettings[subKey];
103
132
  if (!settings) continue;
104
133
 
105
- // Boolean check: is this alert type toggled on?
106
134
  const isEnabled = settings[configKey] === true
107
135
  || (settings.alertConfig && settings.alertConfig[configKey] === true);
108
136
  if (!isEnabled) continue;
@@ -126,7 +154,7 @@ function matchSubscriptions({
126
154
  metadata: extractMetadata(result)
127
155
  });
128
156
 
129
- // Build outbox payloads based on user delivery preferences (default: FCM/push only when no settings)
157
+ // Build outbox payloads based on user delivery preferences
130
158
  const prefs = userPrefs[watcher.userId] || { email: false, sms: false, push: true };
131
159
 
132
160
  if (prefs.email && prefs.emailAddress) {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @fileoverview Example: Inbound PubSub Triggered Computation
3
+ * * This computation waits entirely on an external service. It is NOT scheduled
4
+ * by the daily planner. When the external service publishes a message to
5
+ * 'external-ml-completed', this DAG kicks off, processes data natively in WASM,
6
+ * and then optionally responds back to the external tool.
7
+ */
8
+
9
+ module.exports = {
10
+ config: {
11
+ name: 'PostProcess_ML_Results',
12
+ type: 'standard', // Runs normally in the WASM sandbox
13
+
14
+ schedule: {
15
+ frequency: 'event-driven' // Excludes it from automated daily sweeps
16
+ },
17
+
18
+ pubsubTrigger: {
19
+ topic: 'external-ml-completed', // The topic the external tool publishes TO
20
+ payloadMatch: { event: 'training_done' }, // (Optional) only trigger if payload matches this
21
+ respondTo: 'ml-orchestrator-ack' // (Optional) Topic to reply to when this computation finishes
22
+ },
23
+
24
+ requires: {
25
+ mlData: { type: 'bigquery', table: 'computation_results_v3' }
26
+ }
27
+ },
28
+
29
+ process: function (ctx) {
30
+ ctx.log.info("Processing ML results natively after being triggered by external event!");
31
+
32
+ // ... standard WASM processing logic ...
33
+
34
+ return {
35
+ status: "processed",
36
+ result: { success: true }
37
+ };
38
+ }
39
+ };
@@ -19,4 +19,4 @@ export { WasmRuntimePlugin } from './runtime/WasmRuntimePlugin';
19
19
  export { DefaultDispatchPlugin } from './dispatch/DefaultDispatchPlugin';
20
20
  export { PythonDispatchPlugin } from './dispatch/PythonDispatchPlugin';
21
21
  export { DefaultPlannerPlugin } from './planner/DefaultPlannerPlugin';
22
- export { ExecutionLogSinkPlugin } from './logging/GcpExecutionLogSinkPlugin';
22
+ export { GcpExecutionLogSinkPlugin } from './logging/GcpExecutionLogSinkPlugin';
@@ -138,7 +138,8 @@ export class ScheduleValidator {
138
138
  const schedule = entry.schedule as ScheduleLike | undefined;
139
139
  if (!schedule || typeof schedule === 'string') return errors;
140
140
 
141
- const validFreqs = ['hourly', 'daily', 'weekly', 'monthly'];
141
+ // Add 'event-driven' to the allowed list
142
+ const validFreqs = ['hourly', 'daily', 'weekly', 'monthly', 'event-driven'];
142
143
  if (schedule.frequency && !validFreqs.includes(schedule.frequency)) {
143
144
  errors.push({ computation: entry.name, message: `Invalid frequency: ${schedule.frequency}` });
144
145
  }
@@ -297,6 +297,13 @@ export class DefaultPlannerPlugin implements IPlannerPlugin {
297
297
  allStatuses: Map<string, RunStatus>,
298
298
  manifestNames: Set<string>
299
299
  ): 'schedule' | 'skip' | 'blocked-dependency' | 'blocked-attempts' {
300
+
301
+ // NEW: Ignore event-driven computations during automated planner sweeps
302
+ const scheduleFreq = (entry.recipe?.config?.schedule as any)?.frequency;
303
+ if (scheduleFreq === 'event-driven') {
304
+ return 'skip';
305
+ }
306
+
300
307
  // Already completed with correct hash
301
308
  if (current && current.status === 'completed' && current.hash === entry.hash) {
302
309
  return 'skip';
@@ -5,6 +5,7 @@ import type { IHookRegistry } from '../../types/IHook';
5
5
  import type { HookContext } from '../../types/IHookContext';
6
6
  import type { HealthStatus } from '../../types/shared';
7
7
  import type { IBillingPlugin } from '../../types/IBillingPlugin';
8
+ import type { ISchedulerPlugin } from '../../types/ISchedulerPlugin';
8
9
 
9
10
  export class DefaultPubSubPlugin implements IPlugin {
10
11
  readonly name = 'default-pubsub';
@@ -20,6 +21,9 @@ export class DefaultPubSubPlugin implements IPlugin {
20
21
  private config: any;
21
22
 
22
23
  registerHooks(hooks: IHookRegistry): void {
24
+ // -------------------------------------------------------------------------
25
+ // OUTBOUND: Bridge to External Services (from 'batch.execute')
26
+ // -------------------------------------------------------------------------
23
27
  hooks.get('batch.execute').tap({
24
28
  name: this.name,
25
29
  priority: 50, // Run before WasmRuntimePlugin
@@ -102,6 +106,37 @@ export class DefaultPubSubPlugin implements IPlugin {
102
106
  return ctx;
103
107
  }
104
108
  });
109
+
110
+ // -------------------------------------------------------------------------
111
+ // OUTBOUND ACK: Respond to External Service upon native completion
112
+ // -------------------------------------------------------------------------
113
+ hooks.get('computation.complete').tap({
114
+ name: `${this.name}-responder`,
115
+ priority: 50,
116
+ handler: async (ctx: HookContext) => {
117
+ const entry = ctx.entry as any;
118
+ const triggerCfg = entry.recipe?.config?.pubsubTrigger;
119
+
120
+ // If this computation requested we respond to an external topic upon completion
121
+ if (triggerCfg && triggerCfg.respondTo) {
122
+ try {
123
+ const topic = this.pubsubClient.topic(triggerCfg.respondTo);
124
+ await topic.publishMessage({
125
+ json: {
126
+ computation: entry.name,
127
+ date: ctx.date,
128
+ status: (ctx as any).status || 'completed',
129
+ ownerId: entry.ownerId
130
+ }
131
+ });
132
+ this.logger.log(`[PubSub] Sent completion acknowledgment to ${triggerCfg.respondTo} for ${entry.name}`);
133
+ } catch (err) {
134
+ this.logger.error(`[PubSub] Failed to send completion response: ${err}`);
135
+ }
136
+ }
137
+ return ctx;
138
+ }
139
+ });
105
140
  }
106
141
 
107
142
  async initialize(context: PluginInitContext): Promise<void> {
@@ -117,7 +152,12 @@ export class DefaultPubSubPlugin implements IPlugin {
117
152
 
118
153
  context.kernel.onEvent('kernel.manifest.built', async (payload: any) => {
119
154
  const entries = payload.manifest as any[];
155
+
156
+ // Validate rules for outbound 'type: pubsub' computations
120
157
  this.validatePubSubBoundaries(entries);
158
+
159
+ // Register background listeners for inbound 'event-driven' triggers
160
+ await this.setupInboundTriggers(entries);
121
161
  });
122
162
 
123
163
  this.logger.log(`[PubSub] Initialized (Prefix: ${this.mailboxPrefix}). Listening for Distributed Manifest Builds.`);
@@ -128,12 +168,13 @@ export class DefaultPubSubPlugin implements IPlugin {
128
168
  }
129
169
 
130
170
  async shutdown(): Promise<void> {
131
- // Optional teardown
171
+ // Optional teardown of subscriptions could go here if graceful shutdown is needed
132
172
  }
133
173
 
134
174
  /**
135
- * Phase 2: Webhook Verifier & Resumption.
136
- * Invoked externally by a web-facing function parsing inbound Task Tokens.
175
+ * Phase 2 Webhook Verifier & Resumption.
176
+ * Invoked externally by a web-facing function parsing inbound Task Tokens
177
+ * for computations that suspended themselves via waitForResponse: true.
137
178
  */
138
179
  async processCallback(taskToken: string, resultData: any): Promise<void> {
139
180
  const firestore = this.config.clients?.firestore;
@@ -155,15 +196,76 @@ export class DefaultPubSubPlugin implements IPlugin {
155
196
  });
156
197
 
157
198
  // Write final output using storage plugins and trigger the cascade
158
- const scheduler = this.kernel.getPlugin('scheduler.enqueue');
159
- if (scheduler && typeof scheduler.enqueueTask === 'function') {
160
- await scheduler.enqueueTask(scheduler.getQueuePath('standard'), {
161
- type: 'cascade-trigger',
199
+ const scheduler = this.kernel.getPluginByCapability<ISchedulerPlugin>('scheduler.enqueue');
200
+ if (scheduler && typeof scheduler.scheduleRootTrigger === 'function') {
201
+ // Here we enqueue a cascade trigger to wake up dependents
202
+ await scheduler.scheduleRootTrigger({
203
+ taskId: `cascade-${data.computation}-${data.date}-${Date.now()}`,
162
204
  computation: data.computation,
163
- date: data.date,
164
- ownerId: data.ownerId,
165
- source: 'pubsub-webhook'
166
- }, {});
205
+ name: data.computation,
206
+ targetDate: data.date,
207
+ configHash: '',
208
+ runAtSeconds: Math.floor(Date.now() / 1000),
209
+ reason: 'PUBSUB_WEBHOOK_RESUME',
210
+ ownerId: data.ownerId
211
+ });
212
+ }
213
+ }
214
+
215
+ // -------------------------------------------------------------------------
216
+ // INBOUND: Listeners for External Triggers
217
+ // -------------------------------------------------------------------------
218
+ private async setupInboundTriggers(entries: any[]): Promise<void> {
219
+ const scheduler = this.kernel.getPluginByCapability<ISchedulerPlugin>('scheduler.enqueue');
220
+
221
+ for (const entry of entries) {
222
+ const triggerCfg = entry.recipe?.config?.pubsubTrigger;
223
+
224
+ if (triggerCfg && triggerCfg.topic) {
225
+ // Create a predictable subscription name for this worker/environment
226
+ const subName = `${triggerCfg.topic}-sub-${this.config.project?.id || 'bulltrackers'}`;
227
+ const subscription = this.pubsubClient.subscription(subName);
228
+
229
+ this.logger.log(`[PubSub] Listening for external triggers on ${triggerCfg.topic} for ${entry.name}`);
230
+
231
+ subscription.on('message', async (message) => {
232
+ try {
233
+ const data = JSON.parse(message.data.toString());
234
+
235
+ // Optional: Check payload Match (e.g. { "action": "run_model" })
236
+ if (triggerCfg.payloadMatch) {
237
+ const isMatch = Object.entries(triggerCfg.payloadMatch).every(([k, v]) => data[k] === v);
238
+ if (!isMatch) {
239
+ message.ack(); // Not meant for this computation; silently ack
240
+ return;
241
+ }
242
+ }
243
+
244
+ this.logger.log(`[PubSub] Inbound trigger received for ${entry.name}. Enqueueing task.`);
245
+
246
+ // Use the date from the payload, or default to today's UTC date
247
+ const targetDate = data.date || new Date().toISOString().split('T')[0];
248
+ const taskId = `event-${entry.name}__${targetDate}__${Date.now()}`;
249
+
250
+ // Dispatch directly to the Cloud Tasks Queue via standard root trigger
251
+ await scheduler.scheduleRootTrigger({
252
+ taskId,
253
+ computation: entry.name,
254
+ name: entry.originalName || entry.name,
255
+ targetDate,
256
+ configHash: entry.hash,
257
+ runAtSeconds: Math.floor(Date.now() / 1000), // Run immediately
258
+ reason: 'PUBSUB_EVENT_TRIGGER',
259
+ ownerId: data.ownerId || entry.ownerId,
260
+ });
261
+
262
+ message.ack(); // Successfully dispatched to our internal durable queue
263
+ } catch (err) {
264
+ this.logger.error(`[PubSub] Failed to process inbound trigger for ${entry.name}: ${err}`);
265
+ message.nack(); // Retry processing the pubsub message later
266
+ }
267
+ });
268
+ }
167
269
  }
168
270
  }
169
271
 
@@ -204,4 +306,4 @@ export class DefaultPubSubPlugin implements IPlugin {
204
306
  }
205
307
  }
206
308
  }
207
- }
309
+ }
@@ -1 +1 @@
1
- const version = 43.0
1
+ const version = 44.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.1100",
3
+ "version": "1.0.1102",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -1,165 +0,0 @@
1
- 'use strict';
2
-
3
- const {
4
- matchSubscriptions,
5
- generateAlertMessage,
6
- _safeJsonParse,
7
- _extractMetadata
8
- } = require('./AlertSubscriptionMatcher');
9
-
10
- // ─── generateAlertMessage ───────────────────────────────────────────────────
11
-
12
- describe('generateAlertMessage', () => {
13
- test('resolves piUsername and change fields', () => {
14
- const config = { messageTemplate: "{piUsername}'s risk score increased by {change} points (from {previous} to {current})" };
15
- const msg = generateAlertMessage(config, 'MockGuru', { change: 4, previous: 3, current: 7 });
16
- expect(msg).toBe("MockGuru's risk score increased by 4 points (from 3 to 7)");
17
- });
18
-
19
- test('resolves title for social post', () => {
20
- const config = { messageTemplate: '{piUsername} posted a new update: {title}' };
21
- const msg = generateAlertMessage(config, 'TestTrader', { title: 'Market Analysis Q1' });
22
- expect(msg).toBe('TestTrader posted a new update: Market Analysis Q1');
23
- });
24
-
25
- test('handles missing fields gracefully', () => {
26
- const config = { messageTemplate: '{piUsername} changed {change}' };
27
- const msg = generateAlertMessage(config, undefined, {});
28
- expect(msg).toContain('Unknown');
29
- });
30
- });
31
-
32
- // ─── matchSubscriptions (MVP boolean-only) ───────────────────────────────────
33
-
34
- describe('matchSubscriptions', () => {
35
- const alertConfig = {
36
- id: 'increasedRisk',
37
- frontendName: 'Increased Risk',
38
- configKey: 'increasedRisk',
39
- severity: 'high',
40
- isDynamic: false,
41
- messageTemplate: "{piUsername}'s risk score increased"
42
- };
43
-
44
- const parentResults = [
45
- { entityId: '100', triggered: true, currentRisk: 7, previousRisk: 3, change: 4, username: 'MockGuru' },
46
- { entityId: '200', triggered: false, currentRisk: 2, previousRisk: 2, change: 0 }
47
- ];
48
-
49
- const watchlists = [
50
- { user_id: 'user1', watchlist_id: 'wl-1', name: 'My Watchlist', items: JSON.stringify([{ cid: '100' }]) },
51
- { user_id: 'user2', watchlist_id: 'wl-2', name: 'Other', items: JSON.stringify([{ cid: '200' }]) }
52
- ];
53
-
54
- const subscriptions = [
55
- { user_id: 'user1', pi_cid: '100', settings: JSON.stringify({ alertConfig: { increasedRisk: true } }) }
56
- ];
57
-
58
- test('produces notifications for triggered entities with matching subscriptions', () => {
59
- const result = matchSubscriptions({
60
- parentResults,
61
- subscriptions,
62
- watchlists,
63
- alertConfig,
64
- computationName: 'RiskScoreIncrease',
65
- date: '2026-03-07'
66
- });
67
-
68
- expect(result.notifications).toHaveLength(1);
69
- expect(result.notifications[0]).toMatchObject({
70
- userCid: 'user1',
71
- piCid: 100,
72
- alertTypeId: 'increasedRisk',
73
- computationName: 'RiskScoreIncrease',
74
- computationDate: '2026-03-07',
75
- severity: 'high',
76
- watchlistId: 'wl-1',
77
- watchlistName: 'My Watchlist'
78
- });
79
- expect(result.outbox).toBeDefined();
80
- expect(result.outbox.emailPayloads).toBeDefined();
81
- expect(result.outbox.smsPayloads).toBeDefined();
82
- expect(result.outbox.fcmPayloads).toBeDefined();
83
- expect(result.totalMatched).toBe(1);
84
- });
85
-
86
- test('accepts boolean toggle directly on subscription (no alertConfig wrapper)', () => {
87
- const subsDirect = [
88
- { user_id: 'user1', pi_cid: '100', settings: JSON.stringify({ increasedRisk: true }) }
89
- ];
90
- const result = matchSubscriptions({
91
- parentResults,
92
- subscriptions: subsDirect,
93
- watchlists,
94
- alertConfig,
95
- computationName: 'RiskScoreIncrease',
96
- date: '2026-03-07'
97
- });
98
- expect(result.notifications).toHaveLength(1);
99
- });
100
-
101
- test('skips non-triggered entities', () => {
102
- const result = matchSubscriptions({
103
- parentResults: [{ entityId: '100', triggered: false }],
104
- subscriptions,
105
- watchlists,
106
- alertConfig,
107
- computationName: 'Test',
108
- date: '2026-03-07'
109
- });
110
-
111
- expect(result.notifications).toHaveLength(0);
112
- });
113
-
114
- test('skips users without subscriptions', () => {
115
- const result = matchSubscriptions({
116
- parentResults,
117
- subscriptions: [],
118
- watchlists,
119
- alertConfig,
120
- computationName: 'Test',
121
- date: '2026-03-07'
122
- });
123
-
124
- expect(result.notifications).toHaveLength(0);
125
- });
126
-
127
- test('returns standardised output shape', () => {
128
- const result = matchSubscriptions({
129
- parentResults: [],
130
- subscriptions: [],
131
- watchlists: [],
132
- alertConfig,
133
- computationName: 'RiskScoreIncrease',
134
- date: '2026-03-07'
135
- });
136
-
137
- expect(result).toHaveProperty('notifications');
138
- expect(result).toHaveProperty('outbox');
139
- expect(result.outbox).toHaveProperty('emailPayloads');
140
- expect(result.outbox).toHaveProperty('smsPayloads');
141
- expect(result.outbox).toHaveProperty('fcmPayloads');
142
- expect(result).toHaveProperty('totalMatched');
143
- expect(result).toHaveProperty('totalEvaluated');
144
- expect(Array.isArray(result.notifications)).toBe(true);
145
- });
146
- });
147
-
148
- // ─── Helpers ────────────────────────────────────────────────────────────────
149
-
150
- describe('helpers', () => {
151
- test('_safeJsonParse returns parsed value', () => {
152
- expect(_safeJsonParse('{"a":1}', {})).toEqual({ a: 1 });
153
- });
154
-
155
- test('_safeJsonParse returns fallback on invalid JSON', () => {
156
- expect(_safeJsonParse('not-json', [])).toEqual([]);
157
- });
158
-
159
- test('_extractMetadata removes internal fields', () => {
160
- const meta = _extractMetadata({ triggered: true, entityId: '100', currentRisk: 7 });
161
- expect(meta).not.toHaveProperty('triggered');
162
- expect(meta).not.toHaveProperty('entityId');
163
- expect(meta).toHaveProperty('currentRisk', 7);
164
- });
165
- });