@teamscala/service-boot 0.23.384 → 0.23.386
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/page-server-orpc/server-client.test.ts +44 -0
- package/src/page-server-orpc/server-client.ts +33 -0
- package/src/pg-boss-mount.ts +1 -0
- package/src/register-page-server-client.ts +3 -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
|
+
);
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
20
20
|
import {
|
|
21
21
|
getServerClient,
|
|
22
|
+
getServerClientCapabilityFault,
|
|
22
23
|
isServerClientRegistered,
|
|
24
|
+
recordServerClientCapability,
|
|
23
25
|
setServerClient,
|
|
24
26
|
} from "./server-client.ts";
|
|
25
27
|
|
|
@@ -66,3 +68,45 @@ describe("page-server server client", () => {
|
|
|
66
68
|
expect(isServerClientRegistered()).toBe(true);
|
|
67
69
|
});
|
|
68
70
|
});
|
|
71
|
+
|
|
72
|
+
describe("declared-vs-present capability probe (/health)", () => {
|
|
73
|
+
test("no fault when every declared model is servable", () => {
|
|
74
|
+
setServerClient({
|
|
75
|
+
assessments: { findFirst: async () => null },
|
|
76
|
+
assessment_responses: { findFirst: async () => null },
|
|
77
|
+
} as never);
|
|
78
|
+
recordServerClientCapability(["assessments", "assessment_responses"]);
|
|
79
|
+
expect(getServerClientCapabilityFault()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("names the models the client cannot serve", () => {
|
|
83
|
+
// THE OUTAGE, AS A HEALTH ANSWER. A client was registered and carried only
|
|
84
|
+
// the platform routers, so every process-existence signal was green while
|
|
85
|
+
// every data-backed page 500d. /health must be able to say this.
|
|
86
|
+
setServerClient({ puckPages: {}, userPreferences: {} } as never);
|
|
87
|
+
recordServerClientCapability(["assessments", "assessment_responses"]);
|
|
88
|
+
const fault = getServerClientCapabilityFault();
|
|
89
|
+
expect(fault).toContain("assessments");
|
|
90
|
+
expect(fault).toContain("assessment_responses");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a service declaring no models is never faulted", () => {
|
|
94
|
+
// Daemon/portal shells own no DB schema and are healthy with an empty client.
|
|
95
|
+
setServerClient({ puckPages: {} } as never);
|
|
96
|
+
recordServerClientCapability([]);
|
|
97
|
+
expect(getServerClientCapabilityFault()).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("an unregistered client is itself the fault", () => {
|
|
101
|
+
setServerClient(null as never);
|
|
102
|
+
recordServerClientCapability(["assessments"]);
|
|
103
|
+
expect(getServerClientCapabilityFault()).toContain("no ORPC server client registered");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a model present without findFirst still counts as missing", () => {
|
|
107
|
+
// The generated router shape is what matters, not mere key presence.
|
|
108
|
+
setServerClient({ assessments: {} } as never);
|
|
109
|
+
recordServerClientCapability(["assessments"]);
|
|
110
|
+
expect(getServerClientCapabilityFault()).toContain("assessments");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -55,3 +55,36 @@ export function setServerClient(
|
|
|
55
55
|
): void {
|
|
56
56
|
serverClient = client;
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Declared-vs-present capability, recorded once at boot by
|
|
61
|
+
* registerPageServerClient and read by /health.
|
|
62
|
+
*
|
|
63
|
+
* The outage this exists for: a client WAS registered, so every
|
|
64
|
+
* process-existence signal was green, and it carried no CRUD models. Liveness
|
|
65
|
+
* must be a capability probe, never a registration check
|
|
66
|
+
* (`liveness-by-capability-not-process-existence`).
|
|
67
|
+
*/
|
|
68
|
+
let capabilityFault: string | null = null;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Record whether the registered client can actually serve the models the
|
|
72
|
+
* service declares. Property lookups only — no I/O — so /health stays cheap.
|
|
73
|
+
*/
|
|
74
|
+
export function recordServerClientCapability(declaredModels: readonly string[]): void {
|
|
75
|
+
if (!serverClient) {
|
|
76
|
+
capabilityFault = "no ORPC server client registered";
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const client = serverClient as unknown as Record<string, { findFirst?: unknown } | undefined>;
|
|
80
|
+
const missing = declaredModels.filter((m) => typeof client[m]?.findFirst !== "function");
|
|
81
|
+
capabilityFault =
|
|
82
|
+
missing.length === 0
|
|
83
|
+
? null
|
|
84
|
+
: `generated client exposes no findFirst for ${missing.length} declared model(s): ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", …" : ""}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The capability fault, or null when the service can serve its declared models. */
|
|
88
|
+
export function getServerClientCapabilityFault(): string | null {
|
|
89
|
+
return capabilityFault;
|
|
90
|
+
}
|
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";
|
|
@@ -148,6 +148,9 @@ export async function registerPageServerClient(
|
|
|
148
148
|
} as never,
|
|
149
149
|
);
|
|
150
150
|
mod.setServerClient(client);
|
|
151
|
+
// Record declared-vs-present capability for /health. Property lookups only.
|
|
152
|
+
(mod as unknown as { recordServerClientCapability?: (m: readonly string[]) => void })
|
|
153
|
+
.recordServerClientCapability?.(declaredModels);
|
|
151
154
|
// State the model count. "registered" alone was true in the outage and told
|
|
152
155
|
// nobody anything; a zero here is now visible in the boot log.
|
|
153
156
|
log.info(
|