@teamscala/service-boot 0.23.516 → 0.23.518
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 +232 -1127
- package/src/agreement-gate/settings.ts +8 -8
- package/src/apply-logger-config.test.ts +46 -0
- package/src/apply-logger-config.ts +31 -0
- package/src/asset-proxy.test.ts +9 -5
- package/src/auth/sign-out.ts +122 -0
- package/src/build-client-assets/hydrate-bundle.ts +14 -4
- package/src/build-client-assets/manifest.ts +5 -18
- package/src/build-client-assets/styles-build.test.ts +11 -8
- package/src/build-client-assets/styles-build.ts +14 -63
- package/src/build-client-assets.test.ts +0 -161
- package/src/build-client-assets.ts +20 -67
- package/src/content-gate/token.ts +9 -2
- package/src/install-logging.ts +10 -26
- package/src/mount-orpc-router.ts +8 -2
- package/src/mutable-app-ref.ts +0 -7
- package/src/page-server-loader-graph.test.ts +31 -0
- package/src/page-server-loader-graph.ts +21 -0
- package/src/pg-boss-mount.ts +3 -16
- package/src/readiness-gate.ts +0 -4
- package/src/register-api-routes.ts +8 -17
- package/src/register-page-server-client.ts +6 -0
- package/src/register-registry-page-route/meta-title.test.ts +50 -0
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/register-registry-page-route/page-data-shared.ts +7 -11
- package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
- package/src/register-registry-page-route.ts +14 -16
- package/src/resolve-brand-favicon.test.ts +63 -0
- package/src/resolve-theme-mode.test.ts +51 -0
- package/src/resolvers/fetch-org-brand.test.ts +48 -0
- package/src/resolvers/website-by-host-map.test.ts +63 -0
- package/src/resolvers/website-by-host-map.ts +15 -3
- package/src/run-boot-hook-list.test.ts +47 -0
- package/src/run-boot-hooks.test.ts +56 -0
- package/src/scala-tools-currency-watchdog.test.ts +43 -0
- package/src/scala-tools-currency-watchdog.ts +0 -5
- package/src/transfers/presigned-upload.ts +165 -0
- package/src/two-phase-minimal-app.test.ts +24 -0
- package/src/two-phase-readiness.test.ts +102 -0
- package/src/types.test.ts +20 -0
- package/src/ui-service.test.ts +24 -0
- package/src/ws-boot-self-test.test.ts +43 -0
- package/src/build-client-assets/input-fingerprint.ts +0 -92
- package/src/build-client-assets/input-hashing.ts +0 -206
- package/src/handlers/bun-stable-watch.ts +0 -86
- package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
- package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
- package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
- package/src/handlers/messaging-send-recording-logic.ts +0 -68
- package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
- package/src/handlers/messaging-send-recording-watch.ts +0 -244
- package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
- package/src/handlers/messaging-send-sweep-selection.ts +0 -35
- package/src/handlers/platform-fault-escalation/config.ts +0 -95
- package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
- package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
- package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
- package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
- package/src/handlers/platform-fault-escalation/probe.ts +0 -19
- package/src/handlers/platform-fault-escalation-logic.ts +0 -382
- package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
- package/src/handlers/platform-fault-escalation.test.ts +0 -497
- package/src/handlers/platform-fault-escalation.ts +0 -246
- package/src/handlers/projects-digest.ts +0 -198
- package/src/handlers/quiz-research.ts +0 -178
- package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
- package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
- package/src/handlers/scala-tools-currency-watch.ts +0 -162
- package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
- package/src/handlers/singleton-tools-currency-watch.ts +0 -380
- package/src/handlers/sweep-event-log.ts +0 -98
- package/src/handlers/upstream-issue-sync.ts +0 -220
- package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
- package/src/handlers/whatsapp-liveness-logic.ts +0 -53
- package/src/handlers/whatsapp-liveness.test.ts +0 -78
- package/src/handlers/whatsapp-liveness.ts +0 -138
- package/src/interpolate-meta.ts +0 -123
- package/src/load-service-prisma-client.ts +0 -53
- package/src/loader-errors.ts +0 -41
- package/src/read-viewport-from-request.ts +0 -36
- package/src/register-page-server-client.test.ts +0 -185
- package/src/resolve-app-base-url.ts +0 -35
- package/src/resolve-from-loader-config.test.ts +0 -429
- package/src/resolve-from-loader-config.ts +0 -316
- package/src/resolve-service-context.ts +0 -88
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system server-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Pure reconciliation logic for the outbound-dispatch coverage half of
|
|
7
|
-
* `messaging-send-recording-watch`: diff GOWA's outbound message list against
|
|
8
|
-
* the recorded provider ids and shape the backfill rows — a send dispatched
|
|
9
|
-
* outside every recording chokepoint exists only in GOWA's list (2026-08-26).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** One outbound message from GOWA `getChatMessages` with is_from_me=true. */
|
|
13
|
-
export interface GowaOutboundMessage {
|
|
14
|
-
id: string;
|
|
15
|
-
chat_jid: string;
|
|
16
|
-
content: string | null;
|
|
17
|
-
timestamp: string | null;
|
|
18
|
-
is_from_me: boolean;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** A GOWA-outbound message with no messaging_logs row — the backfill input. */
|
|
22
|
-
export interface MissingSend {
|
|
23
|
-
id: string;
|
|
24
|
-
chatJid: string;
|
|
25
|
-
content: string | null;
|
|
26
|
-
timestamp: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Diff GOWA's outbound truth against the recorded provider ids. Only
|
|
31
|
-
* `is_from_me` messages count (the bridge device's own sends — inbound arrives
|
|
32
|
-
* by webhook and records itself). Messages newer than the settle window are
|
|
33
|
-
* held back one tick so the dispatch-time recorder's own write can land.
|
|
34
|
-
*/
|
|
35
|
-
export function diffOutboundSends(
|
|
36
|
-
messages: GowaOutboundMessage[],
|
|
37
|
-
recordedIds: Set<string>,
|
|
38
|
-
now: Date,
|
|
39
|
-
settleMs: number,
|
|
40
|
-
): MissingSend[] {
|
|
41
|
-
const missing: MissingSend[] = [];
|
|
42
|
-
for (const message of messages) {
|
|
43
|
-
if (!message.is_from_me) continue;
|
|
44
|
-
if (!message.id || recordedIds.has(message.id)) continue;
|
|
45
|
-
const at = Date.parse(message.timestamp ?? "");
|
|
46
|
-
if (Number.isNaN(at)) continue;
|
|
47
|
-
if (at > now.getTime() - settleMs) continue;
|
|
48
|
-
missing.push({
|
|
49
|
-
id: message.id,
|
|
50
|
-
chatJid: message.chat_jid,
|
|
51
|
-
content: message.content,
|
|
52
|
-
timestamp: new Date(at).toISOString(),
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
return missing;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The messaging_logs row that makes a bypassed send reconstructible. */
|
|
59
|
-
export function buildBackfillRow(missing: MissingSend): {
|
|
60
|
-
id: string;
|
|
61
|
-
platform: "whatsapp";
|
|
62
|
-
direction: "outbound";
|
|
63
|
-
event_type: "sent";
|
|
64
|
-
external_chat_id: string;
|
|
65
|
-
user_phone: string;
|
|
66
|
-
external_msg_id: string;
|
|
67
|
-
content: string | null;
|
|
68
|
-
payload: Record<string, unknown>;
|
|
69
|
-
timestampIso: string;
|
|
70
|
-
} {
|
|
71
|
-
return {
|
|
72
|
-
id: crypto.randomUUID(),
|
|
73
|
-
platform: "whatsapp",
|
|
74
|
-
direction: "outbound",
|
|
75
|
-
event_type: "sent",
|
|
76
|
-
external_chat_id: missing.chatJid,
|
|
77
|
-
user_phone: missing.chatJid,
|
|
78
|
-
external_msg_id: missing.id,
|
|
79
|
-
content: missing.content,
|
|
80
|
-
payload: { send_kind: "gowa-reconcile" },
|
|
81
|
-
timestampIso: missing.timestamp,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
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
|
-
});
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
import { beforeEach, expect, mock, test } from "bun:test";
|
|
11
|
-
|
|
12
|
-
const recordEventCalls: Array<{ kind: string; payload: Record<string, unknown> }> = [];
|
|
13
|
-
|
|
14
|
-
// The live unrecorded rows the query returns — mutated per test.
|
|
15
|
-
let mockRows: Array<{
|
|
16
|
-
id: string;
|
|
17
|
-
created_at: Date;
|
|
18
|
-
external_chat_id: string | null;
|
|
19
|
-
send_kind: string | null;
|
|
20
|
-
}>;
|
|
21
|
-
let queryArgs: unknown[] = [];
|
|
22
|
-
|
|
23
|
-
mock.module("@teamscala/db/prisma-registry", () => ({
|
|
24
|
-
getPrisma: () => ({
|
|
25
|
-
$queryRawUnsafe: async (_sql: string, ...args: unknown[]) => {
|
|
26
|
-
queryArgs = args;
|
|
27
|
-
return mockRows;
|
|
28
|
-
},
|
|
29
|
-
}),
|
|
30
|
-
}));
|
|
31
|
-
mock.module("@teamscala/event-log/record-event", () => ({
|
|
32
|
-
recordEvent: (e: { kind: string; payload: Record<string, unknown> }) => {
|
|
33
|
-
recordEventCalls.push(e);
|
|
34
|
-
},
|
|
35
|
-
}));
|
|
36
|
-
// Self-contained in any suite order: another handler test file installs a
|
|
37
|
-
// partial process-global app-loggers mock that leaks into this one, so this
|
|
38
|
-
// file pins the logger shape it needs rather than depending on run order.
|
|
39
|
-
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
40
|
-
getAppLogger: () => ({
|
|
41
|
-
info: () => {},
|
|
42
|
-
warn: () => {},
|
|
43
|
-
error: () => {},
|
|
44
|
-
}),
|
|
45
|
-
}));
|
|
46
|
-
|
|
47
|
-
const { messagingSendRecordingWatch } = await import("./messaging-send-recording-watch.ts");
|
|
48
|
-
|
|
49
|
-
const CONFIG = {
|
|
50
|
-
recorded_since: "2026-08-01T00:00:00Z",
|
|
51
|
-
sample_limit: 2,
|
|
52
|
-
} as never;
|
|
53
|
-
|
|
54
|
-
beforeEach(() => {
|
|
55
|
-
recordEventCalls.length = 0;
|
|
56
|
-
queryArgs = [];
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("unrecorded rows at/after the baseline emit one messaging.send.unrecorded event", async () => {
|
|
60
|
-
mockRows = [
|
|
61
|
-
{ id: "a", created_at: new Date("2026-08-20T05:54:37.715Z"), external_chat_id: "447@s.whatsapp.net", send_kind: null },
|
|
62
|
-
{ id: "b", created_at: new Date("2026-08-20T08:36:49.149Z"), external_chat_id: "447@s.whatsapp.net", send_kind: null },
|
|
63
|
-
{ id: "c", created_at: new Date("2026-08-20T08:37:15.667Z"), external_chat_id: "447@s.whatsapp.net", send_kind: null },
|
|
64
|
-
];
|
|
65
|
-
await messagingSendRecordingWatch({ config: CONFIG });
|
|
66
|
-
|
|
67
|
-
expect(recordEventCalls).toHaveLength(1);
|
|
68
|
-
expect(recordEventCalls[0]!.kind).toBe("messaging.send.unrecorded");
|
|
69
|
-
expect(recordEventCalls[0]!.payload).toMatchObject({
|
|
70
|
-
unrecordedCount: 3,
|
|
71
|
-
recordedSince: "2026-08-01T00:00:00Z",
|
|
72
|
-
});
|
|
73
|
-
// The query reads the baseline as a bound — the predicate's timestamp is
|
|
74
|
-
// part of the failure signature, not decoration.
|
|
75
|
-
expect(queryArgs).toEqual(["2026-08-01T00:00:00Z"]);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("healthy shape (every send recorded) emits nothing", async () => {
|
|
79
|
-
mockRows = [];
|
|
80
|
-
await messagingSendRecordingWatch({ config: CONFIG });
|
|
81
|
-
expect(recordEventCalls).toEqual([]);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("missing recorded_since baseline skips the query rather than sweeping pre-fix history", async () => {
|
|
85
|
-
mockRows = [
|
|
86
|
-
{ id: "historic", created_at: new Date("2026-08-01T00:00:00Z"), external_chat_id: null, send_kind: null },
|
|
87
|
-
];
|
|
88
|
-
await messagingSendRecordingWatch({ config: {} as never });
|
|
89
|
-
expect(recordEventCalls).toEqual([]);
|
|
90
|
-
expect(queryArgs).toEqual([]);
|
|
91
|
-
});
|
|
@@ -1,244 +0,0 @@
|
|
|
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
|
-
import {
|
|
34
|
-
buildBackfillRow,
|
|
35
|
-
diffOutboundSends,
|
|
36
|
-
type GowaOutboundMessage,
|
|
37
|
-
} from "./messaging-send-reconcile-logic.ts";
|
|
38
|
-
import {
|
|
39
|
-
selectChatsToSweep,
|
|
40
|
-
type GowaChatSummary,
|
|
41
|
-
} from "./messaging-send-sweep-selection.ts";
|
|
42
|
-
import { getSystemIdentity } from "./whatsapp-liveness.ts";
|
|
43
|
-
|
|
44
|
-
const DEFAULT_SAMPLE_LIMIT = 5;
|
|
45
|
-
/** Bounds the per-tick read while a break runs for weeks; far above daily volume. */
|
|
46
|
-
const QUERY_ROW_CAP = 1000;
|
|
47
|
-
/** GOWA page size cap (the spec's maximum for both listChats and messages). */
|
|
48
|
-
const GOWA_PAGE_LIMIT = 100;
|
|
49
|
-
/** Upper bound on message pages walked per chat per tick. */
|
|
50
|
-
const MAX_MESSAGE_PAGES_PER_CHAT = 5;
|
|
51
|
-
/** A freshly-dispatched send waits this long before the reconcile sweep may
|
|
52
|
-
* claim it — the tool-dispatch recorder writes its row at dispatch, and
|
|
53
|
-
* sweeping sooner would backfill a duplicate of a row about to exist. */
|
|
54
|
-
const RECONCILE_SETTLE_MS = 120_000;
|
|
55
|
-
|
|
56
|
-
export async function messagingSendRecordingWatch({
|
|
57
|
-
config,
|
|
58
|
-
}: {
|
|
59
|
-
config: ScheduledJobConfig;
|
|
60
|
-
}): Promise<void> {
|
|
61
|
-
const log = getAppLogger();
|
|
62
|
-
|
|
63
|
-
// Fail explicit, not historic: without a baseline the query would sweep
|
|
64
|
-
// pre-fix history and alert on rows that were never promised an id.
|
|
65
|
-
const recordedSince = config.recorded_since;
|
|
66
|
-
if (!recordedSince) {
|
|
67
|
-
log.error(
|
|
68
|
-
"[messaging-send-recording-watch] scheduled_job row has no recorded_since baseline — skipping (a baseline-less watch would fire on pre-fix history)",
|
|
69
|
-
);
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const prisma = getPrisma();
|
|
74
|
-
const rows = await prisma.$queryRawUnsafe<UnrecordedSendRow[]>(
|
|
75
|
-
`SELECT id, created_at, external_chat_id, payload->>'send_kind' AS send_kind
|
|
76
|
-
FROM messaging_logs
|
|
77
|
-
WHERE direction = 'outbound'
|
|
78
|
-
AND external_msg_id IS NULL
|
|
79
|
-
AND created_at >= $1::timestamptz
|
|
80
|
-
ORDER BY created_at ASC
|
|
81
|
-
LIMIT ${QUERY_ROW_CAP}`,
|
|
82
|
-
recordedSince,
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
const finding = detectUnrecordedSends(rows, config.sample_limit ?? DEFAULT_SAMPLE_LIMIT);
|
|
86
|
-
if (!finding) {
|
|
87
|
-
log.info(
|
|
88
|
-
`[messaging-send-recording-watch] poll complete — every outbound send at/after ${recordedSince} recorded its provider id`,
|
|
89
|
-
);
|
|
90
|
-
} else {
|
|
91
|
-
const payload = buildUnrecordedPayload(finding, recordedSince);
|
|
92
|
-
recordEvent({ kind: "messaging.send.unrecorded", payload });
|
|
93
|
-
log.warn(
|
|
94
|
-
`[messaging-send-recording-watch] ${finding.unrecordedCount} outbound send(s) at/after ${recordedSince} recorded no provider message id — their delivery receipts can never correlate`,
|
|
95
|
-
payload,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Dispatch-coverage reconcile — the row-shape check above can only inspect
|
|
100
|
-
// rows that EXIST; a send dispatched to GOWA outside every recording
|
|
101
|
-
// chokepoint (measured 2026-08-26: raw multipart sends delivered with zero
|
|
102
|
-
// rows) is invisible to it. GOWA's own message list is the authoritative
|
|
103
|
-
// outbound record, so this sweep backfills what bypassed us. Failure here
|
|
104
|
-
// must never take the row-shape check down with it.
|
|
105
|
-
try {
|
|
106
|
-
const backfilled = await reconcileOutboundDispatches(recordedSince);
|
|
107
|
-
if (backfilled > 0) {
|
|
108
|
-
log.warn(
|
|
109
|
-
`[messaging-send-recording-watch] reconcile: backfilled ${backfilled} outbound send(s) GOWA delivered with no messaging_logs row (dispatched outside the recording surface)`,
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
} catch (error) {
|
|
113
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
-
log.warn(`[messaging-send-recording-watch] reconcile sweep failed: ${message}`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Dispatch one api_gowa_chats action through the central /mcp loopback — the
|
|
119
|
-
* same single sealed-credential surface whatsapp-liveness uses, as the system
|
|
120
|
-
* caller. */
|
|
121
|
-
async function dispatchGowaChats(
|
|
122
|
-
action: string,
|
|
123
|
-
params: Record<string, unknown>,
|
|
124
|
-
): Promise<unknown> {
|
|
125
|
-
const { forwardToolToCentralMcp } = await import(
|
|
126
|
-
"@teamscala/chat-completion/central-tool-forwarder"
|
|
127
|
-
);
|
|
128
|
-
const { userId, organisationId } = await getSystemIdentity();
|
|
129
|
-
return forwardToolToCentralMcp(
|
|
130
|
-
"api_gowa_chats",
|
|
131
|
-
{ action, params },
|
|
132
|
-
{ userId, organisationId } as Parameters<typeof forwardToolToCentralMcp>[2],
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
interface GowaPaged<T> {
|
|
137
|
-
results?: { data?: T[]; pagination?: { total?: number } };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* The convergent half of the watch: walk GOWA's chats newest-first for
|
|
142
|
-
* outbound activity at/after the baseline, read each qualifying chat's own
|
|
143
|
-
* sends, and backfill every provider id messaging_logs is missing. Idempotent
|
|
144
|
-
* by external_msg_id — a backfilled row can never be backfilled twice, and the
|
|
145
|
-
* settle window keeps it from racing the dispatch-time recorder.
|
|
146
|
-
*/
|
|
147
|
-
async function reconcileOutboundDispatches(recordedSince: string): Promise<number> {
|
|
148
|
-
const log = getAppLogger();
|
|
149
|
-
const horizonMs = Date.parse(recordedSince);
|
|
150
|
-
if (Number.isNaN(horizonMs)) return 0;
|
|
151
|
-
|
|
152
|
-
const chatJids: string[] = [];
|
|
153
|
-
for (let offset = 0; offset < 100 * 20; offset += GOWA_PAGE_LIMIT) {
|
|
154
|
-
const page = (await dispatchGowaChats("listChats", {
|
|
155
|
-
limit: GOWA_PAGE_LIMIT,
|
|
156
|
-
offset,
|
|
157
|
-
})) as GowaPaged<GowaChatSummary> | undefined;
|
|
158
|
-
const chats = (page?.results?.data ?? []) as GowaChatSummary[];
|
|
159
|
-
if (chats.length === 0) break;
|
|
160
|
-
const { toSweep, mayStopPaging } = selectChatsToSweep(chats, horizonMs);
|
|
161
|
-
chatJids.push(...toSweep);
|
|
162
|
-
if (mayStopPaging) break;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const outbound: GowaOutboundMessage[] = [];
|
|
166
|
-
for (const chatJid of chatJids) {
|
|
167
|
-
for (let page = 0; page < MAX_MESSAGE_PAGES_PER_CHAT; page++) {
|
|
168
|
-
const messages = (await dispatchGowaChats("getChatMessages", {
|
|
169
|
-
chat_jid: chatJid,
|
|
170
|
-
is_from_me: true,
|
|
171
|
-
start_time: recordedSince,
|
|
172
|
-
limit: GOWA_PAGE_LIMIT,
|
|
173
|
-
offset: page * GOWA_PAGE_LIMIT,
|
|
174
|
-
})) as GowaPaged<GowaOutboundMessage> | undefined;
|
|
175
|
-
const batch = ((messages?.results?.data ?? []) as GowaOutboundMessage[]).filter(
|
|
176
|
-
(m) => m && typeof m.id === "string",
|
|
177
|
-
);
|
|
178
|
-
outbound.push(...batch);
|
|
179
|
-
if (batch.length < GOWA_PAGE_LIMIT) break;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (outbound.length === 0) return 0;
|
|
183
|
-
|
|
184
|
-
const prisma = getPrisma();
|
|
185
|
-
const ids = outbound.map((m) => m.id);
|
|
186
|
-
const recorded = await prisma.$queryRawUnsafe<Array<{ external_msg_id: string }>>(
|
|
187
|
-
`SELECT external_msg_id FROM messaging_logs WHERE external_msg_id = ANY($1::text[])`,
|
|
188
|
-
ids,
|
|
189
|
-
);
|
|
190
|
-
const recordedIds = new Set(recorded.map((r) => r.external_msg_id));
|
|
191
|
-
const missing = diffOutboundSends(outbound, recordedIds, new Date(), RECONCILE_SETTLE_MS);
|
|
192
|
-
if (missing.length === 0) return 0;
|
|
193
|
-
|
|
194
|
-
for (const miss of missing) {
|
|
195
|
-
const row = buildBackfillRow(miss);
|
|
196
|
-
// createMany + skipDuplicates: the table's unique (agent_id,
|
|
197
|
-
// external_chat_id, external_msg_id) makes a double-backfill (a sweep
|
|
198
|
-
// racing the dispatch-time recorder) a no-op instead of a crash.
|
|
199
|
-
await prisma.messaging_logs.createMany({
|
|
200
|
-
skipDuplicates: true,
|
|
201
|
-
data: {
|
|
202
|
-
id: row.id,
|
|
203
|
-
organisation_id: "",
|
|
204
|
-
platform: row.platform,
|
|
205
|
-
agent_id: "",
|
|
206
|
-
external_chat_id: row.external_chat_id,
|
|
207
|
-
user_phone: row.user_phone,
|
|
208
|
-
external_msg_id: row.external_msg_id,
|
|
209
|
-
direction: row.direction,
|
|
210
|
-
event_type: row.event_type,
|
|
211
|
-
content: row.content,
|
|
212
|
-
payload: {
|
|
213
|
-
...row.payload,
|
|
214
|
-
send_at: row.timestampIso,
|
|
215
|
-
reconciled_at: new Date().toISOString(),
|
|
216
|
-
},
|
|
217
|
-
created_at: new Date(row.timestampIso),
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
recordEvent({
|
|
223
|
-
kind: "messaging.send.reconciled",
|
|
224
|
-
payload: {
|
|
225
|
-
backfilledCount: missing.length,
|
|
226
|
-
baseline: recordedSince,
|
|
227
|
-
sample: missing.slice(0, DEFAULT_SAMPLE_LIMIT).map((m) => ({
|
|
228
|
-
external_msg_id: m.id,
|
|
229
|
-
chat_id: m.chatJid,
|
|
230
|
-
content: m.content?.slice(0, 120) ?? null,
|
|
231
|
-
sent_at: m.timestamp,
|
|
232
|
-
})),
|
|
233
|
-
},
|
|
234
|
-
});
|
|
235
|
-
log.info(
|
|
236
|
-
`[messaging-send-recording-watch] reconcile: ${missing.length} unrecorded outbound send(s) backfilled from GOWA truth`,
|
|
237
|
-
);
|
|
238
|
-
return missing.length;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
pgBossHandlerRegistry.register(
|
|
242
|
-
"@teamscala/service-boot:messaging-send-recording-watch",
|
|
243
|
-
messagingSendRecordingWatch,
|
|
244
|
-
);
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
import { describe, expect, test } from "bun:test";
|
|
11
|
-
import {
|
|
12
|
-
selectChatsToSweep,
|
|
13
|
-
type GowaChatSummary,
|
|
14
|
-
} from "./messaging-send-sweep-selection.ts";
|
|
15
|
-
|
|
16
|
-
describe("selectChatsToSweep", () => {
|
|
17
|
-
test("keeps chats at/after the horizon and lets paging stop once a page is older", () => {
|
|
18
|
-
const chats: GowaChatSummary[] = [
|
|
19
|
-
{ jid: "a@s.whatsapp.net", last_message_time: "2026-08-26T02:14:00Z" },
|
|
20
|
-
{ jid: "b@s.whatsapp.net", last_message_time: "2026-08-25T16:14:13Z" },
|
|
21
|
-
{ jid: "old@s.whatsapp.net", last_message_time: "2026-08-01T00:00:00Z" },
|
|
22
|
-
];
|
|
23
|
-
const { toSweep, mayStopPaging } = selectChatsToSweep(
|
|
24
|
-
chats,
|
|
25
|
-
Date.parse("2026-08-25T10:19:00Z"),
|
|
26
|
-
);
|
|
27
|
-
expect(toSweep).toEqual(["a@s.whatsapp.net", "b@s.whatsapp.net"]);
|
|
28
|
-
expect(mayStopPaging).toBe(true);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("keeps paging while the page's newest chats still qualify", () => {
|
|
32
|
-
const chats: GowaChatSummary[] = [
|
|
33
|
-
{ jid: "a@s.whatsapp.net", last_message_time: "2026-08-26T02:14:00Z" },
|
|
34
|
-
];
|
|
35
|
-
const { mayStopPaging } = selectChatsToSweep(chats, Date.parse("2026-08-25T10:19:00Z"));
|
|
36
|
-
expect(mayStopPaging).toBe(false);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("a chat with no last_message_time never qualifies and stops paging", () => {
|
|
40
|
-
const { toSweep, mayStopPaging } = selectChatsToSweep(
|
|
41
|
-
[{ jid: "silent@s.whatsapp.net", last_message_time: null }],
|
|
42
|
-
0,
|
|
43
|
-
);
|
|
44
|
-
expect(toSweep).toEqual([]);
|
|
45
|
-
expect(mayStopPaging).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system server-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Chat-page selection for the messaging-send reconcile sweep: which paged
|
|
7
|
-
* GOWA chats (newest-first) still have activity at/after the baseline, and
|
|
8
|
-
* whether paging may stop. Pure and dependency-free, the same shape as the
|
|
9
|
-
* sibling *-logic units.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** One chat from GOWA `listChats` — only the sweep-relevant fields. */
|
|
13
|
-
export interface GowaChatSummary {
|
|
14
|
-
jid: string;
|
|
15
|
-
last_message_time: string | null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Chats worth sweeping: those with activity at/after the horizon. */
|
|
19
|
-
export function selectChatsToSweep(
|
|
20
|
-
chats: GowaChatSummary[],
|
|
21
|
-
horizonMs: number,
|
|
22
|
-
): { toSweep: string[]; mayStopPaging: boolean } {
|
|
23
|
-
const toSweep: string[] = [];
|
|
24
|
-
for (const chat of chats) {
|
|
25
|
-
const at = Date.parse(chat.last_message_time ?? "");
|
|
26
|
-
if (!Number.isNaN(at) && at >= horizonMs) toSweep.push(chat.jid);
|
|
27
|
-
}
|
|
28
|
-
const last = chats[chats.length - 1];
|
|
29
|
-
// A null/unparseable last_message_time means the chat never messaged (or
|
|
30
|
-
// GOWA answered something unexpected) — it cannot hide an outbound send, so
|
|
31
|
-
// it reads as "older than the horizon" for the paging decision.
|
|
32
|
-
const lastAt = Date.parse(last?.last_message_time ?? "");
|
|
33
|
-
const mayStopPaging = chats.length === 0 || Number.isNaN(lastAt) || lastAt < horizonMs;
|
|
34
|
-
return { toSweep, mayStopPaging };
|
|
35
|
-
}
|