ballrush-core 1.3.0 → 1.3.2

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,5 +1,6 @@
1
1
  export * from "./mongo/user.repository";
2
2
  export * from "./mongo/group.repository";
3
+ export * from "./mongo/group-repo.events";
3
4
  export * from "./mongo/event.repository";
4
5
  export * from "./mongo/event-template.repository";
5
6
  export * from "./mongo/stat-group.repository";
@@ -16,6 +16,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./mongo/user.repository"), exports);
18
18
  __exportStar(require("./mongo/group.repository"), exports);
19
+ __exportStar(require("./mongo/group-repo.events"), exports);
19
20
  __exportStar(require("./mongo/event.repository"), exports);
20
21
  __exportStar(require("./mongo/event-template.repository"), exports);
21
22
  __exportStar(require("./mongo/stat-group.repository"), exports);
@@ -0,0 +1,42 @@
1
+ import { EventEmitter } from "events";
2
+ export type SuspiciousGroupUpdateReason = "rating_regression" | "history_truncated";
3
+ export interface SuspiciousGroupUpdate {
4
+ groupId: number;
5
+ reason: SuspiciousGroupUpdateReason;
6
+ /** Sum of all rating points across all participants — before this update */
7
+ sumBefore: number;
8
+ /** Sum of all rating points across all participants — after this update would be applied */
9
+ sumAfter: number;
10
+ /** sumAfter - sumBefore (negative = regression) */
11
+ delta: number;
12
+ /** Total ratingHistory entries in the document — before */
13
+ historyCountBefore: number;
14
+ /** Total ratingHistory entries — after */
15
+ historyCountAfter: number;
16
+ /** Top 10 individual losses (userId, delta), sorted by largest loss first */
17
+ topLosses: Array<{
18
+ userId: number;
19
+ deltaSum: number;
20
+ deltaHistoryLen: number;
21
+ }>;
22
+ /** First N lines of the call stack pointing at the caller */
23
+ callerStack: string;
24
+ /** true if the repository refused to apply the write because of regression guard */
25
+ refused: boolean;
26
+ /** ISO timestamp of when the event fired */
27
+ timestamp: string;
28
+ }
29
+ declare class GroupRepoEvents extends EventEmitter {
30
+ emitSuspicious(ev: SuspiciousGroupUpdate): void;
31
+ }
32
+ /**
33
+ * Singleton emitter the MongoGroupsRepository fires events on whenever it
34
+ * detects something unusual (e.g. a write that would drop total rating
35
+ * significantly, or shorten history). Wire a listener in the app's bootstrap
36
+ * to forward these to Telegram, Slack, etc.
37
+ *
38
+ * import { groupRepoEvents } from "ballrush-core/repositories";
39
+ * groupRepoEvents.on("suspicious", (ev) => { ... });
40
+ */
41
+ export declare const groupRepoEvents: GroupRepoEvents;
42
+ export {};
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.groupRepoEvents = void 0;
4
+ const events_1 = require("events");
5
+ class GroupRepoEvents extends events_1.EventEmitter {
6
+ emitSuspicious(ev) {
7
+ this.emit("suspicious", ev);
8
+ }
9
+ }
10
+ /**
11
+ * Singleton emitter the MongoGroupsRepository fires events on whenever it
12
+ * detects something unusual (e.g. a write that would drop total rating
13
+ * significantly, or shorten history). Wire a listener in the app's bootstrap
14
+ * to forward these to Telegram, Slack, etc.
15
+ *
16
+ * import { groupRepoEvents } from "ballrush-core/repositories";
17
+ * groupRepoEvents.on("suspicious", (ev) => { ... });
18
+ */
19
+ exports.groupRepoEvents = new GroupRepoEvents();
@@ -3,6 +3,71 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MongoGroupsRepository = void 0;
4
4
  const group_mapper_1 = require("./group.mapper");
5
5
  const errors_1 = require("../../errors");
6
+ const group_repo_events_1 = require("./group-repo.events");
7
+ // Regression guard thresholds. Tunable via env:
8
+ // GROUPS_REGRESSION_GUARD=off — disable refusal (still logs + emits event)
9
+ // GROUPS_REGRESSION_ABS_DROP — absolute sum drop that counts as suspicious (default 50)
10
+ // GROUPS_REGRESSION_PCT_DROP — fractional drop (0..1) that counts as suspicious (default 0.05)
11
+ function getRegressionGuardConfig() {
12
+ const enabled = (process.env.GROUPS_REGRESSION_GUARD ?? "on") !== "off";
13
+ const absDrop = Number(process.env.GROUPS_REGRESSION_ABS_DROP ?? 50);
14
+ const pctDrop = Number(process.env.GROUPS_REGRESSION_PCT_DROP ?? 0.05);
15
+ return { enabled, absDrop, pctDrop };
16
+ }
17
+ function sumOfHistory(participants) {
18
+ if (!participants)
19
+ return 0;
20
+ let total = 0;
21
+ for (const p of participants) {
22
+ for (const r of p.ratingHistory ?? [])
23
+ total += r.value ?? 0;
24
+ }
25
+ return total;
26
+ }
27
+ function countHistory(participants) {
28
+ if (!participants)
29
+ return 0;
30
+ let total = 0;
31
+ for (const p of participants)
32
+ total += (p.ratingHistory ?? []).length;
33
+ return total;
34
+ }
35
+ function computeTopLosses(before, after, limit = 10) {
36
+ const beforeBy = new Map();
37
+ for (const p of before)
38
+ beforeBy.set(p.userId, p);
39
+ const losses = [];
40
+ for (const a of after) {
41
+ const b = beforeBy.get(a.userId);
42
+ if (!b)
43
+ continue;
44
+ const bSum = (b.ratingHistory ?? []).reduce((s, r) => s + (r.value ?? 0), 0);
45
+ const aSum = (a.ratingHistory ?? []).reduce((s, r) => s + (r.value ?? 0), 0);
46
+ const deltaSum = aSum - bSum;
47
+ const deltaHistoryLen = (a.ratingHistory ?? []).length - (b.ratingHistory ?? []).length;
48
+ if (deltaSum < 0 || deltaHistoryLen < 0) {
49
+ losses.push({ userId: a.userId, deltaSum: Math.round(deltaSum * 100) / 100, deltaHistoryLen });
50
+ }
51
+ }
52
+ // also detect participants that disappeared entirely
53
+ const afterIds = new Set(after.map(p => p.userId));
54
+ for (const b of before) {
55
+ if (!afterIds.has(b.userId)) {
56
+ const bSum = (b.ratingHistory ?? []).reduce((s, r) => s + (r.value ?? 0), 0);
57
+ losses.push({
58
+ userId: b.userId,
59
+ deltaSum: Math.round(-bSum * 100) / 100,
60
+ deltaHistoryLen: -(b.ratingHistory ?? []).length,
61
+ });
62
+ }
63
+ }
64
+ losses.sort((x, y) => x.deltaSum - y.deltaSum);
65
+ return losses.slice(0, limit);
66
+ }
67
+ function shortStack(depth = 8) {
68
+ const raw = new Error().stack ?? "";
69
+ return raw.split("\n").slice(2, 2 + depth).join("\n");
70
+ }
6
71
  class MongoGroupsRepository {
7
72
  constructor(GroupModel) {
8
73
  this.GroupModel = GroupModel;
@@ -58,6 +123,59 @@ class MongoGroupsRepository {
58
123
  throw new errors_1.ValidationError('Group', 'updates', updates, 'updates object is required');
59
124
  }
60
125
  const write = (0, group_mapper_1.toDoc)(updates);
126
+ // --- Observability: log every full-document write of a group ---
127
+ // We log enough context to find the culprit after the fact if the
128
+ // historical Pattaya-style regression ever happens again.
129
+ const callerStack = shortStack();
130
+ const sumAfter = sumOfHistory(write.participants);
131
+ const historyCountAfter = countHistory(write.participants);
132
+ // Read current state ONCE for regression guard + alert payload.
133
+ const currentDoc = await this.GroupModel.findOne({ groupId }).lean();
134
+ const sumBefore = sumOfHistory(currentDoc?.participants ?? []);
135
+ const historyCountBefore = countHistory(currentDoc?.participants ?? []);
136
+ const delta = sumAfter - sumBefore;
137
+ const historyDelta = historyCountAfter - historyCountBefore;
138
+ console.log(`[GROUPS_UPDATE] groupId=${groupId} participants=${write.participants.length} ` +
139
+ `sumBefore=${sumBefore.toFixed(2)} sumAfter=${sumAfter.toFixed(2)} delta=${delta.toFixed(2)} ` +
140
+ `histBefore=${historyCountBefore} histAfter=${historyCountAfter} histDelta=${historyDelta}\n` +
141
+ `[GROUPS_UPDATE_STACK]\n${callerStack}`);
142
+ // --- Regression guard ---
143
+ const guard = getRegressionGuardConfig();
144
+ const absThreshold = Math.max(guard.absDrop, Math.abs(sumBefore) * guard.pctDrop);
145
+ const isRegression = currentDoc !== null && (delta < -absThreshold || // big rating drop
146
+ historyDelta < 0 // history shrank
147
+ );
148
+ if (isRegression) {
149
+ const topLosses = computeTopLosses((currentDoc?.participants ?? []), write.participants);
150
+ const event = {
151
+ groupId,
152
+ reason: (delta < -absThreshold ? "rating_regression" : "history_truncated"),
153
+ sumBefore: Math.round(sumBefore * 100) / 100,
154
+ sumAfter: Math.round(sumAfter * 100) / 100,
155
+ delta: Math.round(delta * 100) / 100,
156
+ historyCountBefore,
157
+ historyCountAfter,
158
+ topLosses,
159
+ callerStack,
160
+ refused: guard.enabled,
161
+ timestamp: new Date().toISOString(),
162
+ };
163
+ console.error(`[GROUPS_REGRESSION] groupId=${groupId} reason=${event.reason} ` +
164
+ `delta=${event.delta} histDelta=${historyDelta} refused=${event.refused}\n` +
165
+ `topLosses=${JSON.stringify(event.topLosses)}\n` +
166
+ `[GROUPS_REGRESSION_STACK]\n${callerStack}`);
167
+ // Fire-and-forget event for any listener (e.g. Telegram alerter in the bot).
168
+ try {
169
+ group_repo_events_1.groupRepoEvents.emitSuspicious(event);
170
+ }
171
+ catch { /* never fail update because of a listener */ }
172
+ if (guard.enabled) {
173
+ // Refuse the write — return null so callers can detect failure.
174
+ // To bypass intentionally (e.g. legit reset by admin), run with:
175
+ // GROUPS_REGRESSION_GUARD=off
176
+ return null;
177
+ }
178
+ }
61
179
  const doc = await this.GroupModel.findOneAndUpdate({ groupId }, { $set: write }, { new: true }).lean();
62
180
  return doc ? (0, group_mapper_1.toDomain)(doc) : null;
63
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ballrush-core",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",