Architektur: Craftvia-Domänenmodell, Verträge und Team-Schnitte
- Migration 0002_craftvia_domain: 27 Fachtabellen inkl. RLS (enable_tenant_rls) - TENANT_MODELS (db.ts, backup/topology.ts) um alle Fachmodelle ergänzt - moduleGuard liefert DB-autoritative Rechte; ServiceCtx für Domänen-Services - Verträge: Statusmaschine, Events, Nummernkreise, Sichtbarkeits-Scopes, Job-Queues + Worker, KI-Provider-Interfaces, Sync-Envelope - docs/craftvia/ARCHITEKTUR.md mit Lanes, Ownership und DoD Gate: tsc, lint, build, 22/22 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { enqueueJob, type JobPayload, type JobQueueName } from "./queues";
|
||||
import { runInline } from "./processors";
|
||||
|
||||
/**
|
||||
* Queue a background job, or run it inline when no queue is available.
|
||||
* Inline runs are awaited so dev/demo behaves deterministically.
|
||||
*/
|
||||
export async function dispatchJob(name: JobQueueName, payload: JobPayload): Promise<"queued" | "inline"> {
|
||||
try {
|
||||
if (await enqueueJob(name, payload)) return "queued";
|
||||
} catch (err) {
|
||||
console.error(`[jobs] enqueue ${name} failed, running inline:`, (err as Error).message);
|
||||
}
|
||||
await runInline(name, payload);
|
||||
return "inline";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { JobPayload, JobQueueName } from "../queues";
|
||||
|
||||
export type JobProcessor = (payload: JobPayload) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Processor registry. Each lane adds exactly ONE line for its queue, e.g.
|
||||
* [JOB_QUEUES.importExtraction]: () => import("./import-extraction").then((m) => m.process),
|
||||
* Lazy imports keep the app bundle free of worker-only dependencies (Playwright etc.).
|
||||
*/
|
||||
export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor>>> = {
|
||||
// lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process),
|
||||
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
|
||||
// lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process),
|
||||
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||
};
|
||||
|
||||
/** Inline fallback when no Redis is available (dev/demo). */
|
||||
export async function runInline(name: JobQueueName, payload: JobPayload): Promise<void> {
|
||||
const load = PROCESSORS[name];
|
||||
if (!load) throw new Error(`no processor registered for ${name}`);
|
||||
const process = await load();
|
||||
await process(payload);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Queue } from "bullmq";
|
||||
import IORedis, { type Redis } from "ioredis";
|
||||
|
||||
/**
|
||||
* Craftvia background job queues (spec §30.4). Same operating model as src/server/mail/queue.ts:
|
||||
* with REDIS_URL jobs run asynchronously in `npm run worker:craftvia`; without Redis
|
||||
* enqueueJob() returns false and callers run the processor inline (dev/demo only).
|
||||
*/
|
||||
|
||||
export const JOB_QUEUES = {
|
||||
importExtraction: "import-extraction",
|
||||
transcription: "transcription",
|
||||
reportPdf: "report-pdf",
|
||||
imageDerivatives: "image-derivatives",
|
||||
} as const;
|
||||
|
||||
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
|
||||
|
||||
/** Every job carries its tenant; processors MUST use dbForTenant(tenantId). */
|
||||
export type JobPayload = { tenantId: string; entityId: string; actorId?: string | null };
|
||||
|
||||
let producer: Redis | null = null;
|
||||
let worker: Redis | null = null;
|
||||
const queues = new Map<JobQueueName, Queue<JobPayload>>();
|
||||
|
||||
function url(): string | undefined {
|
||||
return process.env.REDIS_URL?.trim() || undefined;
|
||||
}
|
||||
|
||||
function producerConnection(): Redis | null {
|
||||
const u = url();
|
||||
if (!u) return null;
|
||||
if (!producer) {
|
||||
producer = new IORedis(u, {
|
||||
maxRetriesPerRequest: 1,
|
||||
enableReadyCheck: false,
|
||||
enableOfflineQueue: false,
|
||||
connectTimeout: 3_000,
|
||||
});
|
||||
producer.on("error", (err) => console.error("[jobs] Redis (producer) unavailable:", err.message));
|
||||
}
|
||||
return producer;
|
||||
}
|
||||
|
||||
export function workerConnection(): Redis | null {
|
||||
const u = url();
|
||||
if (!u) return null;
|
||||
if (!worker) {
|
||||
worker = new IORedis(u, { maxRetriesPerRequest: null, enableReadyCheck: false });
|
||||
worker.on("error", (err) => console.error("[jobs] Redis (worker) error:", err.message));
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
function queue(name: JobQueueName): Queue<JobPayload> | null {
|
||||
const conn = producerConnection();
|
||||
if (!conn || conn.status !== "ready") return null;
|
||||
let q = queues.get(name);
|
||||
if (!q) {
|
||||
q = new Queue<JobPayload>(name, {
|
||||
connection: conn,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 15_000 },
|
||||
removeOnComplete: { age: 7 * 24 * 3600, count: 1000 },
|
||||
removeOnFail: { age: 30 * 24 * 3600 },
|
||||
},
|
||||
});
|
||||
queues.set(name, q);
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/** Returns true if the job was queued; false means: caller must process inline. */
|
||||
export async function enqueueJob(name: JobQueueName, payload: JobPayload): Promise<boolean> {
|
||||
const q = queue(name);
|
||||
if (!q) return false;
|
||||
await q.add(name, payload, { jobId: `${name}:${payload.tenantId}:${payload.entityId}:${Date.now()}` });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function closeJobQueues(): Promise<void> {
|
||||
await Promise.all([...queues.values()].map((q) => q.close()));
|
||||
queues.clear();
|
||||
producer?.disconnect();
|
||||
producer = null;
|
||||
worker?.disconnect();
|
||||
worker = null;
|
||||
}
|
||||
Reference in New Issue
Block a user