journal: typed job seam + Komoot credentials through the manager

Two related fixes from the architecture review:

Typed job seam. Job payloads were `unknown` end-to-end: every handler
opened with `job.data as SomePayload`, every enqueue site passed a bare
string queue name and an unchecked object, and a typo meant a runtime
failure in a background worker. packages/jobs gains defineJob(), which
keeps the payload typed inside the handler and performs the
contravariance cast once inside the package. The journal declares all
14 queues and their payload shapes in app/jobs/payloads.ts; enqueue()/
enqueueOptional() and defineJournalJob() key off that map, so enqueue
sites and handlers cannot drift and queue names are compile-checked.

Komoot credential bypass. The bulk-import route enqueued the raw
credentials JSONB in the pg-boss payload, skipping withFreshCredentials
entirely: credentials sat at rest in the job table, were never
refreshed if stale, and markNeedsRelink never fired. The payload now
carries only the serviceId; the handler resolves fresh credentials
through the ConnectedServiceManager at execution time, and marks the
import batch failed (instead of leaving it pending forever) when
credential resolution itself fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-10 02:05:24 +02:00
parent e94e7ac19a
commit 855244747c
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
24 changed files with 327 additions and 100 deletions

View file

@ -1,3 +1,4 @@
export { createBoss } from "./boss.ts";
export { startWorker } from "./worker.ts";
export type { JobDefinition } from "./types.ts";
export { defineJob } from "./types.ts";
export type { JobDefinition, TypedJobDefinition } from "./types.ts";

View file

@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import type { Job } from "pg-boss";
import { defineJob } from "./types.ts";
describe("defineJob", () => {
it("returns the definition unchanged (the cast is type-level only)", async () => {
const seen: string[] = [];
const def = defineJob<{ widgetId: string }>({
name: "widget-job",
retryLimit: 2,
async handler(jobs) {
for (const job of jobs) {
// job.data is typed { widgetId: string } — no cast needed
seen.push(job.data.widgetId);
}
return null;
},
});
expect(def.name).toBe("widget-job");
expect(def.retryLimit).toBe(2);
// The returned JobDefinition's handler is the same function and
// receives whatever pg-boss hands it.
await def.handler([{ data: { widgetId: "w1" } } as Job<unknown>]);
expect(seen).toEqual(["w1"]);
});
});

View file

@ -1,12 +1,15 @@
import type { Job } from "pg-boss";
/**
* A pg-boss job definition. The payload type is intentionally
* `unknown` at this boundary: a heterogeneous `JobDefinition[]` array
* (e.g. the one in `apps/journal/server.ts`) was previously forced to
* cast each typed job to `any` because of contravariance a handler
* taking `Job<SpecificPayload>` is not assignable to one taking
* `Job<object>`. Handlers narrow internally instead.
* A pg-boss job definition. The payload type is `unknown` at this
* boundary: a heterogeneous `JobDefinition[]` array (e.g. the one in
* `apps/journal/server.ts`) would otherwise be impossible because of
* contravariance a handler taking `Job<SpecificPayload>` is not
* assignable to one taking `Job<object>`.
*
* Don't write handlers against this type directly; author them with
* `defineJob`, which keeps the payload typed inside the handler and
* performs the contravariance cast once, here in the package.
*/
export interface JobDefinition {
name: string;
@ -15,3 +18,23 @@ export interface JobDefinition {
retryLimit?: number;
expireInSeconds?: number;
}
/** A JobDefinition whose handler sees the payload type it was enqueued with. */
export interface TypedJobDefinition<TPayload> {
name: string;
handler: (jobs: Job<TPayload>[]) => Promise<unknown>;
cron?: string;
retryLimit?: number;
expireInSeconds?: number;
}
/**
* Author a job with a typed payload. The handler receives
* `Job<TPayload>[]` no `job.data as ...` casts at the call sites.
* This is the single place the contravariance cast happens.
*/
export function defineJob<TPayload = void>(
definition: TypedJobDefinition<TPayload>,
): JobDefinition {
return definition as unknown as JobDefinition;
}