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:
2026-09-14 11:49:21 +02:00
co-authored by Claude Opus 5
parent 1701db0a62
commit bf4456718e
21 changed files with 2670 additions and 12 deletions
+16
View File
@@ -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";
}
+23
View File
@@ -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);
}
+89
View File
@@ -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;
}