@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.
@@ -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 connectionString = captured?.connectionString ?? "";
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(captured?.useListenNotify).toBe(false);
97
+ expect(capturedOpts?.useListenNotify).toBe(false);
97
98
  // pg-boss stays a single sibling connection (pool sizing doctrine).
98
- expect(captured?.max).toBe(1);
99
+ expect(capturedOpts?.max).toBe(1);
99
100
  });
100
101
  });
@@ -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
- try {
198
- config = validateRow("scheduled_job", entry.config) as never as ScheduledJobConfig;
199
- } catch (err) {
200
- const reason = err instanceof Error ? err.message : String(err);
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`,