@teamscala/service-boot 0.23.508 → 0.23.512
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 +626 -569
- package/src/handlers/quiz-research.ts +178 -0
- package/src/pg-boss-mount.test.ts +4 -3
- package/src/pg-boss-mount.ts +12 -4
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `quiz-research` pg-boss handler — the trigger + execution surface for the
|
|
7
|
+
* quiz research pass (assessment_research). Two job shapes on one queue:
|
|
8
|
+
*
|
|
9
|
+
* - cron tick (jobData null, from the scheduled_job row's cron): the SWEEP —
|
|
10
|
+
* bounded batch of eligible responses (identity anchor present, within the
|
|
11
|
+
* window, no live pass, fewer than the attempt cap of superseded
|
|
12
|
+
* failure-tombstones) → one singleton-deduped one-shot job per response on
|
|
13
|
+
* this same queue. The submit path is never touched; research starts within
|
|
14
|
+
* one tick of the submit.
|
|
15
|
+
* - `{ responseId }`: the RUN — one research pass via
|
|
16
|
+
* @teamscala/assessment's run unit; a throw lands in pg-boss native retry
|
|
17
|
+
* (retryLimit from the scheduled_job row).
|
|
18
|
+
*
|
|
19
|
+
* Tiering: this thin handler is server-tier (imports service-boot's own
|
|
20
|
+
* getPgBoss + the adapters-tier assessment package — downward only); all
|
|
21
|
+
* pipeline logic lives in the capability package.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
25
|
+
import { prisma as databasePrisma } from "@teamscala/db/database/client";
|
|
26
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
27
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
28
|
+
import {
|
|
29
|
+
QuizResearchConfigSchema,
|
|
30
|
+
makeProductionDeps,
|
|
31
|
+
recordResearchFailure,
|
|
32
|
+
runQuizResearch,
|
|
33
|
+
type QuizResearchConfig,
|
|
34
|
+
} from "@teamscala/assessment/assessment-research-run";
|
|
35
|
+
import { normaliseLinkedinIdentity } from "@teamscala/assessment/linkedin-identity";
|
|
36
|
+
import { getPgBoss } from "../pg-boss-mount.ts";
|
|
37
|
+
import { registerScheduledJobHandler } from "../pg-boss-handler-registry.ts";
|
|
38
|
+
|
|
39
|
+
const ATTEMPT_CAP = 2;
|
|
40
|
+
const ELIGIBLE_WINDOW_DAYS = 7;
|
|
41
|
+
/** Per-tick dedup window for the one-shot send (singletonKey = responseId). */
|
|
42
|
+
const SEND_DEDUP_SECONDS = 600;
|
|
43
|
+
|
|
44
|
+
interface QuizResearchJobData {
|
|
45
|
+
responseId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function sweepAndDispatch(
|
|
49
|
+
config: QuizResearchConfig,
|
|
50
|
+
queueName: string,
|
|
51
|
+
): Promise<number> {
|
|
52
|
+
const log = getAppLogger();
|
|
53
|
+
const boss = getPgBoss();
|
|
54
|
+
if (!boss) {
|
|
55
|
+
log.warn("[quiz-research] sweep fired but no pg-boss instance is mounted — skipping tick");
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
const windowStart = new Date(Date.now() - ELIGIBLE_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
59
|
+
const candidates = (await databasePrisma.assessment_responses.findMany({
|
|
60
|
+
where: { linkedin_url: { not: null }, completed_at: { gt: windowStart } },
|
|
61
|
+
select: {
|
|
62
|
+
id: true,
|
|
63
|
+
assessment_id: true,
|
|
64
|
+
linkedin_url: true,
|
|
65
|
+
completed_at: true,
|
|
66
|
+
},
|
|
67
|
+
orderBy: { completed_at: "desc" },
|
|
68
|
+
take: 200,
|
|
69
|
+
})) as unknown as Array<{
|
|
70
|
+
id: string;
|
|
71
|
+
assessment_id: string;
|
|
72
|
+
linkedin_url: string;
|
|
73
|
+
}>;
|
|
74
|
+
|
|
75
|
+
let dispatched = 0;
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
if (dispatched >= (config.batch_size ?? 3)) break;
|
|
78
|
+
const identityKey = normaliseLinkedinIdentity(candidate.linkedin_url);
|
|
79
|
+
if (!identityKey) continue;
|
|
80
|
+
const researchRows = (await databasePrisma.assessment_research.findMany({
|
|
81
|
+
where: { assessment_id: candidate.assessment_id, identity_key: identityKey },
|
|
82
|
+
select: { superseded_at: true },
|
|
83
|
+
orderBy: { researched_at: "desc" },
|
|
84
|
+
})) as unknown as Array<{ superseded_at: Date | string | null }>;
|
|
85
|
+
if (researchRows.some((row) => row.superseded_at == null)) continue; // live pass exists
|
|
86
|
+
const tombstones = researchRows.filter((row) => row.superseded_at != null).length;
|
|
87
|
+
if (tombstones >= ATTEMPT_CAP) continue; // failed too many times — leave it
|
|
88
|
+
const sent = await boss.send(
|
|
89
|
+
queueName,
|
|
90
|
+
{ responseId: candidate.id } satisfies QuizResearchJobData,
|
|
91
|
+
{ singletonKey: candidate.id, singletonSeconds: SEND_DEDUP_SECONDS },
|
|
92
|
+
);
|
|
93
|
+
if (sent) dispatched++;
|
|
94
|
+
}
|
|
95
|
+
return dispatched;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function quizResearchHandler(ctx: {
|
|
99
|
+
config: ScheduledJobConfig;
|
|
100
|
+
jobData?: unknown;
|
|
101
|
+
}): Promise<unknown> {
|
|
102
|
+
const log = getAppLogger();
|
|
103
|
+
// "quiz-research" is not yet in the generated ConfigKey union: the registry-keys
|
|
104
|
+
// codegen output is refusing to regenerate (shrink-guard refusal, 2026-09-16) —
|
|
105
|
+
// surfaced to the orchestrator. The cast is the same boundary shape the sibling
|
|
106
|
+
// units use for dynamic slugs; drop it when the union regen lands.
|
|
107
|
+
const config = await loadRegistryConfig("config", "quiz-research" as never, QuizResearchConfigSchema) as unknown as QuizResearchConfig;
|
|
108
|
+
const queueName = ctx.config.queue_name;
|
|
109
|
+
const jobData = (ctx.jobData ?? {}) as QuizResearchJobData;
|
|
110
|
+
|
|
111
|
+
if (!jobData.responseId) {
|
|
112
|
+
const dispatched = await sweepAndDispatch(config, queueName);
|
|
113
|
+
return { mode: "sweep", dispatched };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const responseId = jobData.responseId;
|
|
117
|
+
try {
|
|
118
|
+
// The generated client does not structurally spell the simplified delegate shape
|
|
119
|
+
// ResearchPrisma declares (its method types are deeply generic); the boundary cast
|
|
120
|
+
// is the contained admission — the unit documents the exact calls it makes.
|
|
121
|
+
const result = await runQuizResearch(databasePrisma as unknown as never as Parameters<typeof runQuizResearch>[0], responseId, makeProductionDeps(config));
|
|
122
|
+
return { mode: "run", responseId, ...result };
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
125
|
+
// Attempt tombstone: a superseded, findings-less research row that the
|
|
126
|
+
// report can never render (only superseded_at IS NULL rows load) but the
|
|
127
|
+
// sweep can count — ATTEMPT_CAP failures stop further dispatch. Written
|
|
128
|
+
// on every failed invocation so pg-boss retries consume the cap too.
|
|
129
|
+
try {
|
|
130
|
+
const response = (await databasePrisma.assessment_responses.findUnique({
|
|
131
|
+
where: { id: responseId },
|
|
132
|
+
select: {
|
|
133
|
+
assessment_id: true,
|
|
134
|
+
organisation_id: true,
|
|
135
|
+
linkedin_url: true,
|
|
136
|
+
first_name: true,
|
|
137
|
+
last_name: true,
|
|
138
|
+
},
|
|
139
|
+
})) as unknown as {
|
|
140
|
+
assessment_id: string;
|
|
141
|
+
organisation_id: string | null;
|
|
142
|
+
linkedin_url: string | null;
|
|
143
|
+
first_name: string | null;
|
|
144
|
+
last_name: string | null;
|
|
145
|
+
} | null;
|
|
146
|
+
const identityKey = response?.linkedin_url
|
|
147
|
+
? normaliseLinkedinIdentity(response.linkedin_url)
|
|
148
|
+
: null;
|
|
149
|
+
if (response && identityKey) {
|
|
150
|
+
await databasePrisma.assessment_research.create({
|
|
151
|
+
data: {
|
|
152
|
+
assessment_id: response.assessment_id,
|
|
153
|
+
organisation_id: response.organisation_id,
|
|
154
|
+
display_name: [response.first_name, response.last_name]
|
|
155
|
+
.filter((p): p is string => typeof p === "string" && p.trim().length > 0)
|
|
156
|
+
.join(" ")
|
|
157
|
+
.trim() || "unresolved",
|
|
158
|
+
identity_key: identityKey,
|
|
159
|
+
linkedin_url: response.linkedin_url as string,
|
|
160
|
+
validation_note: `failed pass: ${reason.slice(0, 400)}`,
|
|
161
|
+
superseded_at: new Date(),
|
|
162
|
+
findings: {},
|
|
163
|
+
},
|
|
164
|
+
select: { id: true },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
} catch (tombstoneErr) {
|
|
168
|
+
log.error(
|
|
169
|
+
`[quiz-research] tombstone write failed for ${responseId}: ${tombstoneErr instanceof Error ? tombstoneErr.message : String(tombstoneErr)}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
recordResearchFailure(responseId, err);
|
|
173
|
+
log.error(`[quiz-research] pass failed for ${responseId}: ${reason}`);
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
registerScheduledJobHandler("@teamscala/service-boot:quiz-research", quizResearchHandler);
|
|
@@ -82,7 +82,8 @@ describe("mountPgBoss poll-only contract", () => {
|
|
|
82
82
|
packages: ["@teamscala/sync-worker"],
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
const
|
|
85
|
+
const capturedOpts = captured as unknown as CapturedOpts | undefined;
|
|
86
|
+
const connectionString = capturedOpts?.connectionString ?? "";
|
|
86
87
|
// The transaction pooler, exactly as injected — the mount adds no
|
|
87
88
|
// endpoint derivation.
|
|
88
89
|
expect(connectionString).toContain(":6543");
|
|
@@ -93,8 +94,8 @@ describe("mountPgBoss poll-only contract", () => {
|
|
|
93
94
|
// The pg sslmode dialect rewrite still applies.
|
|
94
95
|
expect(connectionString).toContain("sslmode=no-verify");
|
|
95
96
|
// Poll-only, spelled: no LISTEN dependency, now and after a bump.
|
|
96
|
-
expect(
|
|
97
|
+
expect(capturedOpts?.useListenNotify).toBe(false);
|
|
97
98
|
// pg-boss stays a single sibling connection (pool sizing doctrine).
|
|
98
|
-
expect(
|
|
99
|
+
expect(capturedOpts?.max).toBe(1);
|
|
99
100
|
});
|
|
100
101
|
});
|
package/src/pg-boss-mount.ts
CHANGED
|
@@ -36,6 +36,8 @@ import "./handlers/scala-tools-currency-watch.ts";
|
|
|
36
36
|
import "./handlers/singleton-tools-currency-watch.ts";
|
|
37
37
|
import "./handlers/registry-stale-inactive-sweep.ts";
|
|
38
38
|
import "./handlers/platform-fault-escalation.ts";
|
|
39
|
+
import "./handlers/quiz-research.ts";
|
|
40
|
+
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Module-level reference to the mounted PgBoss instance — set by mountPgBoss on
|
|
@@ -194,10 +196,15 @@ export async function mountPgBoss(
|
|
|
194
196
|
// unavailable" (logged + event-log row), NEVER a boot-crash. A bare
|
|
195
197
|
// pg-boss assert reaching boot is the violation.
|
|
196
198
|
let config: ScheduledJobConfig;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
199
|
+
// validateRow returns a RESULT WRAPPER ({ok:true,value}|{ok:false,issues}),
|
|
200
|
+
// never the config itself — measured 2026-09-16: assigning the wrapper as
|
|
201
|
+
// the config left queue_name/handler_slug undefined and silently skipped
|
|
202
|
+
// EVERY row on services holding db-validation 0.1.289+.
|
|
203
|
+
const validated = validateRow("scheduled_job", entry.config);
|
|
204
|
+
if (!validated.ok) {
|
|
205
|
+
const reason = validated.issues
|
|
206
|
+
.map((i) => `${String(i.path?.map((p) => String(p.key)).join(".") ?? "")}: ${i.message}`)
|
|
207
|
+
.join("; ");
|
|
201
208
|
log.error(
|
|
202
209
|
`[pg-boss-mount:${ctx.serviceSlug}] scheduled_job "${entry.slug}" failed schema validation — skipping`,
|
|
203
210
|
{ reason },
|
|
@@ -208,6 +215,7 @@ export async function mountPgBoss(
|
|
|
208
215
|
});
|
|
209
216
|
continue;
|
|
210
217
|
}
|
|
218
|
+
config = validated.value as ScheduledJobConfig;
|
|
211
219
|
if (!config.queue_name || !config.handler_slug) {
|
|
212
220
|
log.error(
|
|
213
221
|
`[pg-boss-mount:${ctx.serviceSlug}] scheduled_job "${entry.slug}" missing queue_name or handler_slug — skipping`,
|