@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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.384",
9
+ "version": "0.23.385",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -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
+ );
@@ -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";