@teamscala/service-boot 0.23.384 → 0.23.385
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/handlers/messaging-send-recording-logic.test.ts +86 -0
- package/src/handlers/messaging-send-recording-logic.ts +68 -0
- package/src/handlers/messaging-send-recording-watch.test.ts +93 -0
- package/src/handlers/messaging-send-recording-watch.ts +86 -0
- package/src/pg-boss-mount.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Unit tests for the pure detection logic of the messaging send-recording
|
|
7
|
+
* watch: healthy shape (zero rows) yields null; the failure shape yields the
|
|
8
|
+
* aggregate with ordered bounds; the sample keeps the MOST RECENT rows.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
buildUnrecordedPayload,
|
|
13
|
+
detectUnrecordedSends,
|
|
14
|
+
type UnrecordedSendRow,
|
|
15
|
+
} from "./messaging-send-recording-logic.ts";
|
|
16
|
+
|
|
17
|
+
const T = (iso: string) => new Date(iso);
|
|
18
|
+
|
|
19
|
+
function row(id: string, iso: string, sendKind: string | null = null): UnrecordedSendRow {
|
|
20
|
+
return { id, created_at: T(iso), external_chat_id: "447@s.whatsapp.net", send_kind: sendKind };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("detectUnrecordedSends", () => {
|
|
24
|
+
test("zero rows is healthy — no finding, no event", () => {
|
|
25
|
+
expect(detectUnrecordedSends([], 5)).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("one row yields the finding with matching bounds", () => {
|
|
29
|
+
const finding = detectUnrecordedSends([row("a", "2026-08-20T05:54:37.715Z")], 5);
|
|
30
|
+
expect(finding).not.toBeNull();
|
|
31
|
+
expect(finding!.unrecordedCount).toBe(1);
|
|
32
|
+
expect(finding!.firstAt).toEqual(T("2026-08-20T05:54:37.715Z"));
|
|
33
|
+
expect(finding!.lastAt).toEqual(T("2026-08-20T05:54:37.715Z"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("unordered rows are ordered into first/last bounds", () => {
|
|
37
|
+
// The live pre-fix rows, newest first as a LIMIT-descending read might return.
|
|
38
|
+
const finding = detectUnrecordedSends(
|
|
39
|
+
[
|
|
40
|
+
row("c", "2026-08-20T08:37:15.667Z"),
|
|
41
|
+
row("a", "2026-08-20T05:54:37.715Z"),
|
|
42
|
+
row("b", "2026-08-20T08:36:49.149Z"),
|
|
43
|
+
],
|
|
44
|
+
5,
|
|
45
|
+
);
|
|
46
|
+
expect(finding!.unrecordedCount).toBe(3);
|
|
47
|
+
expect(finding!.firstAt).toEqual(T("2026-08-20T05:54:37.715Z"));
|
|
48
|
+
expect(finding!.lastAt).toEqual(T("2026-08-20T08:37:15.667Z"));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("the sample keeps the MOST RECENT rows up to the limit", () => {
|
|
52
|
+
const rows = [
|
|
53
|
+
row("a", "2026-08-20T05:54:37.715Z"),
|
|
54
|
+
row("b", "2026-08-20T08:36:49.149Z"),
|
|
55
|
+
row("c", "2026-08-20T08:37:15.667Z"),
|
|
56
|
+
];
|
|
57
|
+
const finding = detectUnrecordedSends(rows, 2);
|
|
58
|
+
expect(finding!.sample.map((r) => r.id)).toEqual(["b", "c"]);
|
|
59
|
+
// The count still reports every row, not just the sample.
|
|
60
|
+
expect(finding!.unrecordedCount).toBe(3);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("buildUnrecordedPayload", () => {
|
|
65
|
+
test("carries count, baseline, bounds, and ISO-string sample rows", () => {
|
|
66
|
+
const finding = detectUnrecordedSends(
|
|
67
|
+
[row("a", "2026-08-20T08:36:49.149Z", "relay-notify")],
|
|
68
|
+
5,
|
|
69
|
+
)!;
|
|
70
|
+
const payload = buildUnrecordedPayload(finding, "2026-08-20T10:19:00Z");
|
|
71
|
+
expect(payload).toEqual({
|
|
72
|
+
unrecordedCount: 1,
|
|
73
|
+
recordedSince: "2026-08-20T10:19:00Z",
|
|
74
|
+
firstAt: "2026-08-20T08:36:49.149Z",
|
|
75
|
+
lastAt: "2026-08-20T08:36:49.149Z",
|
|
76
|
+
sample: [
|
|
77
|
+
{
|
|
78
|
+
id: "a",
|
|
79
|
+
createdAt: "2026-08-20T08:36:49.149Z",
|
|
80
|
+
chatId: "447@s.whatsapp.net",
|
|
81
|
+
sendKind: "relay-notify",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Pure detection logic for the messaging send-recording watch
|
|
7
|
+
* (`messaging-send-recording-watch` handler). Split from the DB-coupled handler
|
|
8
|
+
* so the row-shape decision is unit-testable with zero database dependencies,
|
|
9
|
+
* the same shape as whatsapp-liveness-logic.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One outbound messaging_logs row that never recorded its provider message id. */
|
|
13
|
+
export interface UnrecordedSendRow {
|
|
14
|
+
id: string;
|
|
15
|
+
created_at: Date;
|
|
16
|
+
external_chat_id: string | null;
|
|
17
|
+
send_kind: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The aggregate the handler emits while the failure shape persists. */
|
|
21
|
+
export interface UnrecordedSendFinding {
|
|
22
|
+
unrecordedCount: number;
|
|
23
|
+
firstAt: Date;
|
|
24
|
+
lastAt: Date;
|
|
25
|
+
sample: UnrecordedSendRow[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Classify freshly-read unrecorded outbound rows. Zero rows is the healthy
|
|
30
|
+
* state (every send recorded its provider id) and yields null — no event,
|
|
31
|
+
* idle-costs-nothing. Any row at/after the baseline is the failure signature
|
|
32
|
+
* handed over 2026-08-20: a send whose provider id was never recorded can
|
|
33
|
+
* never correlate its delivery receipt, so "did it reach the handset" is
|
|
34
|
+
* unanswerable for exactly those sends.
|
|
35
|
+
*/
|
|
36
|
+
export function detectUnrecordedSends(
|
|
37
|
+
rows: UnrecordedSendRow[],
|
|
38
|
+
sampleLimit: number,
|
|
39
|
+
): UnrecordedSendFinding | null {
|
|
40
|
+
if (rows.length === 0) return null;
|
|
41
|
+
const sorted = [...rows].sort((a, b) => a.created_at.getTime() - b.created_at.getTime());
|
|
42
|
+
return {
|
|
43
|
+
unrecordedCount: rows.length,
|
|
44
|
+
firstAt: sorted[0]!.created_at,
|
|
45
|
+
lastAt: sorted[sorted.length - 1]!.created_at,
|
|
46
|
+
// The most recent rows are the ones a responder acts on first.
|
|
47
|
+
sample: sorted.slice(-sampleLimit),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The durable-event payload — ISO strings + the baseline it was read against. */
|
|
52
|
+
export function buildUnrecordedPayload(
|
|
53
|
+
finding: UnrecordedSendFinding,
|
|
54
|
+
recordedSince: string,
|
|
55
|
+
): Record<string, unknown> {
|
|
56
|
+
return {
|
|
57
|
+
unrecordedCount: finding.unrecordedCount,
|
|
58
|
+
recordedSince,
|
|
59
|
+
firstAt: finding.firstAt.toISOString(),
|
|
60
|
+
lastAt: finding.lastAt.toISOString(),
|
|
61
|
+
sample: finding.sample.map((row) => ({
|
|
62
|
+
id: row.id,
|
|
63
|
+
createdAt: row.created_at.toISOString(),
|
|
64
|
+
chatId: row.external_chat_id,
|
|
65
|
+
sendKind: row.send_kind,
|
|
66
|
+
})),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Handler-level integration test for `messagingSendRecordingWatch`: mocks the
|
|
7
|
+
* Prisma registry + recordEvent, drives the two observed live shapes (the
|
|
8
|
+
* three pre-fix rows measured 2026-08-20, and the healthy all-recorded state),
|
|
9
|
+
* and asserts the `messaging.send.unrecorded` durable event fires for exactly
|
|
10
|
+
* the failure shape — without touching the real database.
|
|
11
|
+
*/
|
|
12
|
+
import { beforeEach, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
const recordEventCalls: Array<{ kind: string; payload: Record<string, unknown> }> = [];
|
|
15
|
+
|
|
16
|
+
// The live unrecorded rows the query returns — mutated per test.
|
|
17
|
+
let mockRows: Array<{
|
|
18
|
+
id: string;
|
|
19
|
+
created_at: Date;
|
|
20
|
+
external_chat_id: string | null;
|
|
21
|
+
send_kind: string | null;
|
|
22
|
+
}>;
|
|
23
|
+
let queryArgs: unknown[] = [];
|
|
24
|
+
|
|
25
|
+
mock.module("@teamscala/db/prisma-registry", () => ({
|
|
26
|
+
getPrisma: () => ({
|
|
27
|
+
$queryRawUnsafe: async (_sql: string, ...args: unknown[]) => {
|
|
28
|
+
queryArgs = args;
|
|
29
|
+
return mockRows;
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
mock.module("@teamscala/event-log/record-event", () => ({
|
|
34
|
+
recordEvent: (e: { kind: string; payload: Record<string, unknown> }) => {
|
|
35
|
+
recordEventCalls.push(e);
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
// Self-contained in any suite order: another handler test file installs a
|
|
39
|
+
// partial process-global app-loggers mock that leaks into this one, so this
|
|
40
|
+
// file pins the logger shape it needs rather than depending on run order.
|
|
41
|
+
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
42
|
+
getAppLogger: () => ({
|
|
43
|
+
info: () => {},
|
|
44
|
+
warn: () => {},
|
|
45
|
+
error: () => {},
|
|
46
|
+
}),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const { messagingSendRecordingWatch } = await import("./messaging-send-recording-watch.ts");
|
|
50
|
+
|
|
51
|
+
const CONFIG = {
|
|
52
|
+
recorded_since: "2026-08-01T00:00:00Z",
|
|
53
|
+
sample_limit: 2,
|
|
54
|
+
} as never;
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
recordEventCalls.length = 0;
|
|
58
|
+
queryArgs = [];
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("unrecorded rows at/after the baseline emit one messaging.send.unrecorded event", async () => {
|
|
62
|
+
mockRows = [
|
|
63
|
+
{ id: "a", created_at: new Date("2026-08-20T05:54:37.715Z"), external_chat_id: "447@s.whatsapp.net", send_kind: null },
|
|
64
|
+
{ id: "b", created_at: new Date("2026-08-20T08:36:49.149Z"), external_chat_id: "447@s.whatsapp.net", send_kind: null },
|
|
65
|
+
{ id: "c", created_at: new Date("2026-08-20T08:37:15.667Z"), external_chat_id: "447@s.whatsapp.net", send_kind: null },
|
|
66
|
+
];
|
|
67
|
+
await messagingSendRecordingWatch({ config: CONFIG });
|
|
68
|
+
|
|
69
|
+
expect(recordEventCalls).toHaveLength(1);
|
|
70
|
+
expect(recordEventCalls[0]!.kind).toBe("messaging.send.unrecorded");
|
|
71
|
+
expect(recordEventCalls[0]!.payload).toMatchObject({
|
|
72
|
+
unrecordedCount: 3,
|
|
73
|
+
recordedSince: "2026-08-01T00:00:00Z",
|
|
74
|
+
});
|
|
75
|
+
// The query reads the baseline as a bound — the predicate's timestamp is
|
|
76
|
+
// part of the failure signature, not decoration.
|
|
77
|
+
expect(queryArgs).toEqual(["2026-08-01T00:00:00Z"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("healthy shape (every send recorded) emits nothing", async () => {
|
|
81
|
+
mockRows = [];
|
|
82
|
+
await messagingSendRecordingWatch({ config: CONFIG });
|
|
83
|
+
expect(recordEventCalls).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("missing recorded_since baseline skips the query rather than sweeping pre-fix history", async () => {
|
|
87
|
+
mockRows = [
|
|
88
|
+
{ id: "historic", created_at: new Date("2026-08-01T00:00:00Z"), external_chat_id: null, send_kind: null },
|
|
89
|
+
];
|
|
90
|
+
await messagingSendRecordingWatch({ config: {} as never });
|
|
91
|
+
expect(recordEventCalls).toEqual([]);
|
|
92
|
+
expect(queryArgs).toEqual([]);
|
|
93
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `messaging-send-recording-watch` pg-boss handler — watches the durable
|
|
7
|
+
* row shape that makes delivery acks correlatable: every outbound
|
|
8
|
+
* messaging_logs row created at/after `recorded_since` must carry the provider
|
|
9
|
+
* message id (`external_msg_id`). A row without it is a send whose
|
|
10
|
+
* `message.ack` receipt can never correlate — the regression fixed
|
|
11
|
+
* 2026-08-20T10:19Z (the single send path in messaging-service outbound.rs),
|
|
12
|
+
* whose silent return is otherwise invisible: the ack-time "cannot correlate"
|
|
13
|
+
* warning fires only when a live webhook consumer receives an ack, so a dead
|
|
14
|
+
* consumer or a recording break both read as healthy silence. This watch
|
|
15
|
+
* reads the shared platform database directly — independent of the webhook
|
|
16
|
+
* consumer, GOWA, and any scale-to-zero surface — and emits one
|
|
17
|
+
* `messaging.send.unrecorded` durable event per tick while the shape
|
|
18
|
+
* persists. Thresholding (duration + recurrence), episode dedup, and operator
|
|
19
|
+
* delivery belong to the platform-fault escalator, not here.
|
|
20
|
+
*
|
|
21
|
+
* Registered under `@teamscala/service-boot:messaging-send-recording-watch`.
|
|
22
|
+
*/
|
|
23
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
24
|
+
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
25
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
26
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
27
|
+
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
28
|
+
import {
|
|
29
|
+
buildUnrecordedPayload,
|
|
30
|
+
detectUnrecordedSends,
|
|
31
|
+
type UnrecordedSendRow,
|
|
32
|
+
} from "./messaging-send-recording-logic.ts";
|
|
33
|
+
|
|
34
|
+
const DEFAULT_SAMPLE_LIMIT = 5;
|
|
35
|
+
/** Bounds the per-tick read while a break runs for weeks; far above daily volume. */
|
|
36
|
+
const QUERY_ROW_CAP = 1000;
|
|
37
|
+
|
|
38
|
+
export async function messagingSendRecordingWatch({
|
|
39
|
+
config,
|
|
40
|
+
}: {
|
|
41
|
+
config: ScheduledJobConfig;
|
|
42
|
+
}): Promise<void> {
|
|
43
|
+
const log = getAppLogger();
|
|
44
|
+
|
|
45
|
+
// Fail explicit, not historic: without a baseline the query would sweep
|
|
46
|
+
// pre-fix history and alert on rows that were never promised an id.
|
|
47
|
+
const recordedSince = config.recorded_since;
|
|
48
|
+
if (!recordedSince) {
|
|
49
|
+
log.error(
|
|
50
|
+
"[messaging-send-recording-watch] scheduled_job row has no recorded_since baseline — skipping (a baseline-less watch would fire on pre-fix history)",
|
|
51
|
+
);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const prisma = getPrisma();
|
|
56
|
+
const rows = await prisma.$queryRawUnsafe<UnrecordedSendRow[]>(
|
|
57
|
+
`SELECT id, created_at, external_chat_id, payload->>'send_kind' AS send_kind
|
|
58
|
+
FROM messaging_logs
|
|
59
|
+
WHERE direction = 'outbound'
|
|
60
|
+
AND external_msg_id IS NULL
|
|
61
|
+
AND created_at >= $1::timestamptz
|
|
62
|
+
ORDER BY created_at ASC
|
|
63
|
+
LIMIT ${QUERY_ROW_CAP}`,
|
|
64
|
+
recordedSince,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const finding = detectUnrecordedSends(rows, config.sample_limit ?? DEFAULT_SAMPLE_LIMIT);
|
|
68
|
+
if (!finding) {
|
|
69
|
+
log.info(
|
|
70
|
+
`[messaging-send-recording-watch] poll complete — every outbound send at/after ${recordedSince} recorded its provider id`,
|
|
71
|
+
);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const payload = buildUnrecordedPayload(finding, recordedSince);
|
|
76
|
+
recordEvent({ kind: "messaging.send.unrecorded", payload });
|
|
77
|
+
log.warn(
|
|
78
|
+
`[messaging-send-recording-watch] ${finding.unrecordedCount} outbound send(s) at/after ${recordedSince} recorded no provider message id — their delivery receipts can never correlate`,
|
|
79
|
+
payload,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
pgBossHandlerRegistry.register(
|
|
84
|
+
"@teamscala/service-boot:messaging-send-recording-watch",
|
|
85
|
+
messagingSendRecordingWatch,
|
|
86
|
+
);
|
package/src/pg-boss-mount.ts
CHANGED
|
@@ -31,6 +31,7 @@ import "./handlers/sweep-event-log.ts";
|
|
|
31
31
|
// bound. No TypeScript copy may exist beside it.
|
|
32
32
|
import "./handlers/bun-stable-watch.ts";
|
|
33
33
|
import "./handlers/whatsapp-liveness.ts";
|
|
34
|
+
import "./handlers/messaging-send-recording-watch.ts";
|
|
34
35
|
import "./handlers/upstream-issue-sync.ts";
|
|
35
36
|
import "./handlers/scala-tools-currency-watch.ts";
|
|
36
37
|
import "./handlers/singleton-tools-currency-watch.ts";
|