Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# @toolctrl/queue
BullMQ-oriented queue contracts and producer helpers for web/API enqueue paths and the opt-in worker runtime.
This package defines stable queue names, typed payloads, job descriptors, producer helpers and small BullMQ factory helpers. Processors live in `apps/worker`; queues only run when the worker is enabled and the related runtime flags/services are configured.
## Queues
| Queue | Purpose |
| ----------------------------- | --------------------------------------------------------------- |
| `toolctrl.email` | Transactional email and webhook delivery jobs. |
| `toolctrl.notifications` | User notification delivery jobs. |
| `toolctrl.maintenance` | Background cleanup and retention jobs. |
| `toolctrl.ai-chatbot` | Contract for admin-managed AI chat completion jobs. |
| `toolctrl.browser-automation` | Contract for browser session run, cleanup and usage flush jobs. |
| `toolctrl.file-scan` | Storage file scan jobs. |
| `toolctrl.exports` | Export job execution. |
| `toolctrl.imports` | Import job execution. |
| `toolctrl.plugin-invoke` | Isolated third-party plugin invocation jobs. |
## AI/Browser Jobs
Stable job names:
- `chat-completion`
- `browser-session-run`
- `browser-session-cleanup`
- `usage-flush`
- `scan-file`
- `run-export`
- `run-import`
These are typed contracts and descriptors. Concrete processing is owned by `apps/worker` handlers and may still be disabled until the matching service flag or provider configuration is present.
## Redis
Queue workers resolve Redis from `BULLMQ_REDIS_URL` first. `REDIS_URL` is fallback only when `BULLMQ_REDIS_URL` is absent, so new deployments should prefer the BullMQ-specific variable and keep generic platform Redis bindings optional.
Supported URL schemes:
- `redis://localhost:6379/0`
- `rediss://user:password@example.redis:6379/0`
## Usage
```ts
import { createQueue, queueNames, resolveRedisConnectionOptions } from "@toolctrl/queue";
const emailQueue = createQueue(queueNames.email, {
connection: resolveRedisConnectionOptions({ required: true }),
});
await emailQueue.add("send-email", {
kind: "mail.transactional",
recipientEmail: "user@example.com",
templateId: "welcome",
});
```
@@ -0,0 +1,23 @@
{
"name": "@toolctrl/queue",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./runtime": "./src/runtime.cjs"
},
"scripts": {
"build": "pnpm run typecheck",
"test": "vitest run src test",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"bullmq": "^5.0.0"
},
"devDependencies": {
"@types/node": "^25.0.0",
"typescript": "^5.3.2"
}
}
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { parseRedisUrl, resolveRedisConnectionOptions, resolveRedisConnectionOptionsFromProcessEnv } from "./connection.js";
const PASSWORD_REDIS_URL = "redis://user:super-secret-password@redis.example.com:6380/2";
function expectRedacted(error: unknown) {
const serialized = JSON.stringify(error, Object.getOwnPropertyNames(error));
expect(serialized).not.toContain(PASSWORD_REDIS_URL);
expect(serialized).not.toContain("super-secret-password");
}
describe("resolveRedisConnectionOptions", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("prefers BULLMQ_REDIS_URL over REDIS_URL", () => {
expect(
resolveRedisConnectionOptions({
env: {
BULLMQ_REDIS_URL: "rediss://bullmq.example.com:6380/1",
REDIS_URL: "redis://redis.example.com:6379/0",
},
})
).toMatchObject({
host: "bullmq.example.com",
port: 6380,
db: 1,
tls: {},
maxRetriesPerRequest: null,
});
});
test("falls back to REDIS_URL when BULLMQ_REDIS_URL is absent", () => {
expect(resolveRedisConnectionOptions({ env: { REDIS_URL: "redis://redis.example.com:6381/3" } })).toMatchObject({
host: "redis.example.com",
port: 6381,
db: 3,
maxRetriesPerRequest: null,
});
});
test("returns undefined for missing optional Redis configuration", () => {
expect(resolveRedisConnectionOptions({ env: {} })).toBeUndefined();
});
test("throws a key-only error when required Redis configuration is missing", () => {
expect(() => resolveRedisConnectionOptions({ env: {}, required: true })).toThrowError(
"Set BULLMQ_REDIS_URL or REDIS_URL before starting queue workers."
);
});
test("rejects invalid Redis URLs and protocols without leaking URL values", () => {
let invalidUrlError: unknown;
try {
resolveRedisConnectionOptions({ env: { BULLMQ_REDIS_URL: "redis://:super-secret-password@" }, required: true });
} catch (error) {
invalidUrlError = error;
}
expect(invalidUrlError).toMatchObject({ message: "BULLMQ_REDIS_URL must be a valid Redis URL." });
expectRedacted(invalidUrlError);
let invalidProtocolError: unknown;
try {
resolveRedisConnectionOptions({ env: { BULLMQ_REDIS_URL: PASSWORD_REDIS_URL.replace("redis://", "https://") }, required: true });
} catch (error) {
invalidProtocolError = error;
}
expect(invalidProtocolError).toMatchObject({ message: "BULLMQ_REDIS_URL must use redis:// or rediss://." });
expectRedacted(invalidProtocolError);
});
test("does not implicitly fall back to process.env", () => {
vi.stubEnv("BULLMQ_REDIS_URL", "redis://process.example.com:6379/0");
expect(resolveRedisConnectionOptions()).toBeUndefined();
expect(resolveRedisConnectionOptions({ env: {} })).toBeUndefined();
});
test("keeps process.env access behind an explicit entrypoint helper", () => {
vi.stubEnv("BULLMQ_REDIS_URL", "redis://process.example.com:6382/4");
expect(resolveRedisConnectionOptionsFromProcessEnv()).toMatchObject({
host: "process.example.com",
port: 6382,
db: 4,
});
});
test("parses credentials without leaking password-bearing URLs in database errors", () => {
expect(parseRedisUrl(PASSWORD_REDIS_URL)).toMatchObject({
host: "redis.example.com",
username: "user",
password: "super-secret-password",
port: 6380,
db: 2,
});
let invalidDatabaseError: unknown;
try {
parseRedisUrl("BULLMQ_REDIS_URL", PASSWORD_REDIS_URL.replace("/2", "/nope"));
} catch (error) {
invalidDatabaseError = error;
}
expect(invalidDatabaseError).toMatchObject({ message: "BULLMQ_REDIS_URL database index must be a non-negative integer." });
expectRedacted(invalidDatabaseError);
});
});
@@ -0,0 +1,103 @@
export type ConnectionOptions = {
db?: number;
host: string;
maxRetriesPerRequest: null;
password?: string;
port: number;
tls?: Record<string, never>;
username?: string;
};
export type RedisConnectionEnv = {
REDIS_URL?: string;
BULLMQ_REDIS_URL?: string;
};
export type ResolveRedisConnectionOptions = {
env?: RedisConnectionEnv;
required?: boolean;
};
export function resolveRedisConnectionOptions(options: ResolveRedisConnectionOptions & { required: true }): ConnectionOptions;
export function resolveRedisConnectionOptions(options?: ResolveRedisConnectionOptions): ConnectionOptions | undefined;
export function resolveRedisConnectionOptions(options: ResolveRedisConnectionOptions = {}): ConnectionOptions | undefined {
const redisUrl = selectRedisUrl(options.env);
if (!redisUrl) {
if (options.required) {
throw new Error("Set BULLMQ_REDIS_URL or REDIS_URL before starting queue workers.");
}
return undefined;
}
return parseRedisUrl(redisUrl.name, redisUrl.value);
}
export function resolveRedisConnectionOptionsFromProcessEnv(options: Omit<ResolveRedisConnectionOptions, "env"> & { required: true }): ConnectionOptions;
export function resolveRedisConnectionOptionsFromProcessEnv(options?: Omit<ResolveRedisConnectionOptions, "env">): ConnectionOptions | undefined;
export function resolveRedisConnectionOptionsFromProcessEnv(options: Omit<ResolveRedisConnectionOptions, "env"> = {}): ConnectionOptions | undefined {
return resolveRedisConnectionOptions({ ...options, env: process.env as RedisConnectionEnv });
}
export function parseRedisUrl(redisUrl: string): ConnectionOptions;
export function parseRedisUrl(name: keyof RedisConnectionEnv, redisUrl: string): ConnectionOptions;
export function parseRedisUrl(nameOrRedisUrl: keyof RedisConnectionEnv | string, redisUrl?: string): ConnectionOptions {
const name = redisUrl === undefined ? "BULLMQ_REDIS_URL" : nameOrRedisUrl;
const value = redisUrl ?? nameOrRedisUrl;
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${name} must be a valid Redis URL.`);
}
if (url.protocol !== "redis:" && url.protocol !== "rediss:") {
throw new Error(`${name} must use redis:// or rediss://.`);
}
return {
db: parseRedisDatabase(name, url),
host: url.hostname,
maxRetriesPerRequest: null,
password: url.password ? decodeURIComponent(url.password) : undefined,
port: url.port ? Number(url.port) : 6379,
tls: url.protocol === "rediss:" ? {} : undefined,
username: url.username ? decodeURIComponent(url.username) : undefined,
};
}
function selectRedisUrl(env: RedisConnectionEnv | undefined): { name: keyof RedisConnectionEnv; value: string } | undefined {
const bullmqUrl = normalizeRedisUrl(env?.BULLMQ_REDIS_URL);
if (bullmqUrl) {
return { name: "BULLMQ_REDIS_URL", value: bullmqUrl };
}
const redisUrl = normalizeRedisUrl(env?.REDIS_URL);
if (redisUrl) {
return { name: "REDIS_URL", value: redisUrl };
}
return undefined;
}
function normalizeRedisUrl(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function parseRedisDatabase(name: keyof RedisConnectionEnv | string, url: URL): number | undefined {
const database = url.pathname.replace(/^\//, "");
if (!database) {
return undefined;
}
if (!/^\d+$/.test(database)) {
throw new Error(`${name} database index must be a non-negative integer.`);
}
return Number(database);
}
@@ -0,0 +1,82 @@
import { describe, expect, test } from "vitest";
import { queueNames as directQueueNames } from "./names.js";
import { buildExportJob, buildIdempotentJobId } from "./producers.js";
import { queueNames as publicQueueNames } from "./index.js";
import type { ExportJob, QueueJobDescriptor, QueueJobPayloads } from "./payloads.js";
function assertExportDescriptor(descriptor: QueueJobDescriptor<typeof directQueueNames.exports>) {
return descriptor;
}
describe("export queue contract", () => {
test("exports a stable exports queue name through direct and public surfaces", () => {
expect(directQueueNames.exports).toBe("toolctrl.exports");
expect(publicQueueNames.exports).toBe(directQueueNames.exports);
});
test("builds an export descriptor matching the typed queue payload map", () => {
const descriptor = assertExportDescriptor(
buildExportJob({
exportJobId: "export_job_contract_1",
tenantId: "tenant_1",
requestedByUserId: "user_1",
exportType: "members",
format: "json",
requestId: "req_export_contract_1",
complianceGate: {
state: "paused",
reason: "user-privacy-pause",
requestId: "req_pause_contract_1",
},
metadata: { requestId: "req_export_contract_1" },
})
);
const payload: ExportJob = descriptor.payload;
const mappedPayload: QueueJobPayloads[typeof directQueueNames.exports] = payload;
expect(descriptor.queueName).toBe(directQueueNames.exports);
expect(descriptor.jobName).toBe("run-export");
expect(mappedPayload).toMatchObject({
kind: "export.run",
exportJobId: "export_job_contract_1",
requestId: "req_export_contract_1",
complianceGate: {
state: "paused",
reason: "user-privacy-pause",
requestId: "req_pause_contract_1",
},
});
expect(buildIdempotentJobId(descriptor)).toBe("export_job_contract_1");
});
test("keeps export idempotency tied to exportJobId when compliance gate requestIds change", () => {
const activeDescriptor = buildExportJob({
exportJobId: "export_job_contract_idempotent_1",
tenantId: "tenant_1",
requestedByUserId: "user_1",
exportType: "members",
format: "json",
complianceGate: {
state: "active",
resumedAt: "2026-01-01T00:00:00.000Z",
requestId: "req_resume_contract_1",
},
});
const pausedDescriptor = buildExportJob({
exportJobId: "export_job_contract_idempotent_1",
tenantId: "tenant_1",
requestedByUserId: "user_1",
exportType: "members",
format: "json",
complianceGate: {
state: "paused",
reason: "workspace-retention-pause",
requestId: "req_pause_contract_2",
},
});
expect(buildIdempotentJobId(activeDescriptor)).toBe("export_job_contract_idempotent_1");
expect(buildIdempotentJobId(pausedDescriptor)).toBe("export_job_contract_idempotent_1");
expect(buildIdempotentJobId(pausedDescriptor)).not.toBe(pausedDescriptor.payload.complianceGate?.requestId);
});
});
@@ -0,0 +1,2 @@
export { createQueue, createQueueEvents, createWorker } from "./runtime.js";
export type { TypedProcessor, TypedQueue, TypedQueueEvents, TypedWorker } from "./types.js";
@@ -0,0 +1,87 @@
import { describe, expect, test } from "vitest";
import { queueNames, type QueueName } from "./names.js";
import {
WORKER_QUEUE_GROUPS,
WORKER_QUEUE_GROUP_NAMES,
getGroupForQueue,
getQueueNamesForGroup,
isWorkerQueueGroup,
} from "./groups.js";
describe("WORKER_QUEUE_GROUPS exhaustiveness", () => {
test("every queue declared in queueNames is assigned to exactly one group", () => {
const allDeclared = new Set<QueueName>(Object.values(queueNames));
const mapped = new Set<QueueName>();
const duplicates: QueueName[] = [];
for (const group of WORKER_QUEUE_GROUP_NAMES) {
for (const queue of WORKER_QUEUE_GROUPS[group]) {
if (mapped.has(queue)) {
duplicates.push(queue);
}
mapped.add(queue);
}
}
expect(duplicates).toEqual([]);
const missing = [...allDeclared].filter((q) => !mapped.has(q));
const extra = [...mapped].filter((q) => !allDeclared.has(q));
expect(missing).toEqual([]);
expect(extra).toEqual([]);
});
test("group names are stable", () => {
expect(WORKER_QUEUE_GROUP_NAMES.slice().sort()).toEqual([
"general",
"import",
"mail",
"plugin",
"webhook",
]);
});
});
describe("plugin group", () => {
test("contains the plugin-invoke queue", () => {
expect(getQueueNamesForGroup("plugin")).toEqual([queueNames.pluginInvoke]);
});
test("plugin queue maps back to the plugin group", () => {
expect(getGroupForQueue(queueNames.pluginInvoke)).toBe("plugin");
});
});
describe("getQueueNamesForGroup", () => {
test("returns mail queue for the mail group", () => {
expect(getQueueNamesForGroup("mail")).toEqual([queueNames.email]);
});
test("returns import + export queues for the import group", () => {
expect(getQueueNamesForGroup("import")).toEqual([queueNames.imports, queueNames.exports]);
});
});
describe("getGroupForQueue", () => {
test("maps every queue back to its group", () => {
for (const queue of Object.values(queueNames)) {
const group = getGroupForQueue(queue);
expect(WORKER_QUEUE_GROUPS[group]).toContain(queue);
}
});
});
describe("isWorkerQueueGroup", () => {
test("accepts every defined group", () => {
for (const group of WORKER_QUEUE_GROUP_NAMES) {
expect(isWorkerQueueGroup(group)).toBe(true);
}
});
test("rejects unknown values", () => {
expect(isWorkerQueueGroup("all")).toBe(false);
expect(isWorkerQueueGroup("")).toBe(false);
expect(isWorkerQueueGroup("MAIL")).toBe(false);
});
});
@@ -0,0 +1,75 @@
import { queueNames, type QueueName } from "./names.js";
/**
* Worker queue groups — single source of truth for which BullMQ queues a
* given worker process should subscribe to.
*
* The five groups exist to break head-of-line-blocking between unrelated
* workloads and to allow independent horizontal scaling per workload type
* in docker-compose / Kubernetes.
*
* - `general` — short, mixed I/O jobs (notifications, retention, scan,
* cleanup, outbox dispatcher cron). Concurrency ~10.
* - `mail` — outbound mail and webhook delivery (SMTP/HTTP throughput-
* bound). Concurrency ~5; replicate horizontally for volume.
* - `import` — long-running CPU/memory-bound imports/exports. Concurrency
* 1 — head-of-line-blocking is acceptable inside this group
* because all jobs are heavy; we isolate them so they don't
* stall the rest of the system.
* - `webhook` — external API integrations (AI chat, browser automation)
* that are I/O-bound and benefit from high parallelism.
* - `plugin` — third-party WebAssembly plugin invocations (Phase 06
* #21). Each job spins up a fresh WASM sandbox with hard
* memory + fuel limits; isolation from other groups is
* the whole point. Concurrency ~5, replicate ~2.
*
* Mapping is exhaustive: every queue name declared in `names.ts` is
* assigned to exactly one group. The exhaustiveness check lives in
* `groups.test.ts`.
*/
export const WORKER_QUEUE_GROUPS = {
general: [queueNames.notifications, queueNames.maintenance, queueNames.fileScan, queueNames.filePreview],
mail: [queueNames.email],
import: [queueNames.imports, queueNames.exports],
webhook: [queueNames.aiChatbot, queueNames.browserAutomation],
plugin: [queueNames.pluginInvoke],
} as const satisfies Record<string, readonly QueueName[]>;
export type WorkerQueueGroup = keyof typeof WORKER_QUEUE_GROUPS;
export const WORKER_QUEUE_GROUP_NAMES = Object.keys(WORKER_QUEUE_GROUPS) as readonly WorkerQueueGroup[];
const QUEUE_TO_GROUP: Readonly<Record<QueueName, WorkerQueueGroup>> = (() => {
const map = {} as Record<QueueName, WorkerQueueGroup>;
for (const group of WORKER_QUEUE_GROUP_NAMES) {
for (const queue of WORKER_QUEUE_GROUPS[group]) {
map[queue] = group;
}
}
return map;
})();
/**
* Returns the queues that belong to `group`. The result is a frozen
* readonly tuple — callers must not mutate it.
*/
export function getQueueNamesForGroup(group: WorkerQueueGroup): readonly QueueName[] {
return WORKER_QUEUE_GROUPS[group];
}
/**
* Reverse lookup: which group does this queue belong to? Useful for
* tagging metrics and Sentry events with the worker group when the
* producer side already knows the queue name.
*/
export function getGroupForQueue(queue: QueueName): WorkerQueueGroup {
return QUEUE_TO_GROUP[queue];
}
/**
* Type guard — does this string identify a known worker queue group?
* Used by the runtime entry point to validate `WORKER_QUEUE_GROUP` env.
*/
export function isWorkerQueueGroup(value: string): value is WorkerQueueGroup {
return (WORKER_QUEUE_GROUP_NAMES as readonly string[]).includes(value);
}
+108
View File
@@ -0,0 +1,108 @@
export { defaultJobOptions } from "./types.js";
export type { TypedProcessor, TypedQueue, TypedQueueEvents, TypedWorker } from "./types.js";
export {
buildAiBrowserTaskJob,
buildAiChatCompletionJob,
buildAiEmbeddingJob,
buildBrowserSessionCleanupJob,
buildBrowserSessionRunJob,
buildBrowserUsageFlushJob,
buildCleanupJob,
buildExportJob,
buildFilePreviewJob,
buildFileScanJob,
buildImportJob,
buildNotificationJob,
buildPluginInvokeJob,
buildRetryJobOptions,
buildIdempotentJobId,
buildTransactionalMailJob,
buildWebhookDeliveryJob,
buildWorkflowExecutionContinueRequestedOutboxEvent,
buildWorkflowExecutionRequestedOutboxEvent,
buildWorkflowTriggerOutboxEvent,
enqueueJob,
getEnqueueObserver,
setEnqueueObserver,
} from "./producers.js";
export type { EnqueueObserver } from "./producers.js";
export type {
AiBrowserTaskJobDescriptor,
AiBrowserTaskJobInput,
AiChatCompletionJobDescriptor,
AiChatCompletionJobInput,
AiEmbeddingJobDescriptor,
AiEmbeddingJobInput,
AiProducerJobDescriptor,
BrowserAutomationProducerJobDescriptor,
BrowserSessionCleanupJobDescriptor,
BrowserSessionCleanupJobInput,
BrowserSessionRunJobDescriptor,
BrowserSessionRunJobInput,
BrowserUsageFlushJobDescriptor,
BrowserUsageFlushJobInput,
CleanupJobDescriptor,
CleanupJobInput,
EmailProducerJobDescriptor,
ExportJobDescriptor,
ExportJobInput,
FilePreviewJobDescriptor,
FilePreviewJobInput,
FileScanJobDescriptor,
FileScanJobInput,
NotificationJobDescriptor,
NotificationJobInput,
PluginInvokeJobDescriptor,
PluginInvokeJobInput,
ProducerJobDescriptor,
QueueEnqueueOptions,
TransactionalMailJobDescriptor,
TransactionalMailJobInput,
WebhookDeliveryJobDescriptor,
WebhookDeliveryJobInput,
WorkflowTriggerOutboxEventInput,
WorkflowExecutionContinueRequestedOutboxEventInput,
WorkflowExecutionRequestedOutboxEventInput,
} from "./producers.js";
export { isQueueName, queueNames } from "./names.js";
export type { QueueKey, QueueName } from "./names.js";
export {
WORKER_QUEUE_GROUPS,
WORKER_QUEUE_GROUP_NAMES,
getGroupForQueue,
getQueueNamesForGroup,
isWorkerQueueGroup,
} from "./groups.js";
export type { WorkerQueueGroup } from "./groups.js";
export {
JOB_PRIORITY_CRITICAL,
JOB_PRIORITY_HIGH,
JOB_PRIORITY_LOW,
JOB_PRIORITY_NORMAL,
getTenantPlanLookup,
priorityFromOverride,
priorityFromPlan,
priorityLabel,
resolveJobPriorityForTenant,
setTenantPlanLookup,
} from "./priority.js";
export type { PriorityOverride, TenantPlanIdentifier, TenantPlanLookup } from "./priority.js";
export { parseRedisUrl, resolveRedisConnectionOptions, resolveRedisConnectionOptionsFromProcessEnv } from "./connection.js";
export type { ConnectionOptions, RedisConnectionEnv, ResolveRedisConnectionOptions } from "./connection.js";
export type {
ExportJob,
FilePreviewJob,
FileScanJob,
ImportJob,
PluginInvokeJob,
QueueJsonValue,
QueueJobDescriptor,
QueueJobName,
QueueJobNames,
QueueJobPayloads,
QueuePayload,
WorkflowExecutionRequestedOutboxEvent,
WorkflowExecutionRequestedOutboxPayload,
WorkflowTriggerOutboxEvent,
WorkflowTriggerOutboxPayload,
} from "./payloads.js";
@@ -0,0 +1,21 @@
export const queueNames = {
email: "toolctrl.email",
notifications: "toolctrl.notifications",
maintenance: "toolctrl.maintenance",
aiChatbot: "toolctrl.ai-chatbot",
browserAutomation: "toolctrl.browser-automation",
fileScan: "toolctrl.file-scan",
filePreview: "toolctrl.file-preview",
exports: "toolctrl.exports",
imports: "toolctrl.imports",
pluginInvoke: "toolctrl.plugin-invoke",
} as const;
export type QueueKey = keyof typeof queueNames;
export type QueueName = (typeof queueNames)[QueueKey];
const queueNameValues = new Set<string>(Object.values(queueNames));
export function isQueueName(value: string): value is QueueName {
return queueNameValues.has(value);
}
@@ -0,0 +1,269 @@
import { queueNames, type QueueName } from "./names.js";
export type MailAddressPayload = string | { email: string; name?: string };
export type MailAttachmentPayload = {
content: string;
filename: string;
disposition?: "attachment" | "inline";
id?: string;
};
export type MailHeaderPayload = {
name: string;
value: string;
};
export type MailTrackingSettingsPayload = {
trackClicks?: boolean;
trackOpens?: boolean;
trackContent?: boolean;
};
export type TransactionalMailJob = {
kind: "mail.transactional";
from?: MailAddressPayload;
to: readonly MailAddressPayload[];
cc?: readonly MailAddressPayload[];
bcc?: readonly MailAddressPayload[];
replyTo?: MailAddressPayload;
subject?: string;
text?: string;
html?: string;
templateId?: string;
attachments?: readonly MailAttachmentPayload[];
tags?: readonly string[];
precedenceBulk?: boolean;
sendAt?: number;
inReplyTo?: string;
references?: readonly string[];
settings?: MailTrackingSettingsPayload;
headers?: readonly MailHeaderPayload[];
listUnsubscribe?: string;
templateData?: Record<string, unknown>;
tenantId?: string;
userId?: string;
metadata?: Record<string, unknown>;
};
export type WebhookDeliveryJob = {
kind: "webhook.deliver";
url: string;
method?: "POST" | "PUT" | "PATCH";
headers?: Record<string, string>;
body?: unknown;
tenantId?: string;
eventName: string;
metadata?: Record<string, unknown>;
};
export type NotificationJob = {
kind: "notification.send";
userId: string;
channel: "email" | "in-app";
messageKey: string;
tenantId: string;
category?: string;
email?: string;
subject?: string;
text?: string;
html?: string;
templateId?: string;
templateData?: Record<string, unknown>;
metadata?: Record<string, unknown>;
sourceKey?: string;
};
export type CleanupJob = {
kind: "cleanup.run";
task:
| "expired-sessions"
| "anonymous-chat"
| "audit-log-retention"
| "temporary-files"
| "expired-export-artifacts"
| "dsgvo-grace-period-scan"
| "ai-cost-stripe-report"
| "outbox-done-retention"
| "workflow-execution-stalled-reconcile"
| "task-archive-retention"
| "task-reminder-sweep";
olderThanHours?: number;
olderThanMinutes?: number;
take?: number;
requestedBy?: string;
metadata?: Record<string, unknown>;
};
export type AiJob = {
kind: "ai.chat-completion" | "ai.embedding" | "ai.browser-task";
tenantId?: string;
userId?: string;
conversationId?: string;
model?: string;
messages?: readonly {
role: "system" | "user" | "assistant" | "tool";
content: string;
}[];
task?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export type BrowserAutomationJob = {
kind: "browser.session-run" | "browser.session-cleanup" | "usage.flush";
tenantId?: string;
userId?: string;
sessionId?: string;
policyId?: string;
allowedOrigins?: readonly string[];
task?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export type FileScanJob = {
kind: "scan.file";
bucket: string;
key: string;
tenantId?: string;
uploadId?: string;
contentType?: string;
// AK-P4a §5 — the File row this object belongs to. The worker writes the
// scan verdict (clean|infected) back onto File.scanStatus so the fail-closed
// media proxy can serve it. Optional for legacy import-path scans that have
// no File row yet.
fileId?: string;
metadata?: Record<string, unknown>;
};
export type FilePreviewJob = {
kind: "preview.file";
fileId: string;
tenantId: string;
bucket: string;
storageKey: string;
mimeType: string;
metadata?: Record<string, unknown>;
};
export type ExportJob = {
kind: "export.run";
exportJobId: string;
tenantId: string;
requestedByUserId: string;
exportType: string;
format: "csv" | "json";
requestId?: string;
complianceGate?: {
state: "active" | "paused";
reason?: string;
resumedAt?: string;
requestId?: string;
};
metadata?: Record<string, unknown>;
filters?: Record<string, unknown>;
};
export type ImportJob = {
kind: "import.run";
importJobId: string;
tenantId: string;
requestedByUserId: string;
importType: string;
format: "csv" | "json" | "xlsx";
sourceBucket: string;
sourceKey: string;
requestId?: string;
fileName?: string;
contentType?: string;
mappings?: Record<string, string>;
metadata?: Record<string, unknown>;
};
/**
* Plugin invocation payload — Phase 06 #21 foundation. Carries the
* minimal identifying tuple a worker needs to load the right Plugin
* row + invoke the right hook against a fresh WASM sandbox. Structured
* payloads (typed per hook) are deferred to #22's hook lifecycle work.
*/
export type PluginInvokeJob = {
kind: "plugin.invoke";
tenantId: string;
pluginId: string;
version: string;
hookName: string;
/** Free-form JSON-serialisable payload; passed through to the plugin. */
input?: unknown;
metadata?: Record<string, unknown>;
};
export type QueueJsonPrimitive = string | number | boolean | null;
export type QueueJsonValue = QueueJsonPrimitive | { [key: string]: QueueJsonValue } | QueueJsonValue[];
export type WorkflowTriggerOutboxPayload = {
event: string;
data: QueueJsonValue;
userId: string | null;
};
export type WorkflowTriggerOutboxEvent = {
tenantId: string | null;
type: "workflow.trigger.event";
payload: WorkflowTriggerOutboxPayload;
dispatchKey: `workflow-trigger:${string}`;
};
export type WorkflowExecutionRequestedOutboxPayload = {
workflowId: string;
executionId: string;
type: "manual" | "api";
input: QueueJsonValue;
userId: string | null;
};
export type WorkflowExecutionRequestedOutboxEvent = {
tenantId: string | null;
type: "workflow.execution.requested";
payload: WorkflowExecutionRequestedOutboxPayload;
dispatchKey: `workflow-execution:${string}`;
};
export type WorkflowExecutionContinueRequestedOutboxPayload = WorkflowExecutionRequestedOutboxPayload;
export type WorkflowExecutionContinueRequestedOutboxEvent = {
tenantId: string | null;
type: "workflow.execution.continue.requested";
payload: WorkflowExecutionContinueRequestedOutboxPayload;
dispatchKey: `workflow-continuation:${string}`;
};
export type QueueJobPayloads = {
[queueNames.email]: TransactionalMailJob | WebhookDeliveryJob;
[queueNames.notifications]: NotificationJob;
[queueNames.maintenance]: CleanupJob;
[queueNames.aiChatbot]: AiJob;
[queueNames.browserAutomation]: BrowserAutomationJob;
[queueNames.fileScan]: FileScanJob;
[queueNames.filePreview]: FilePreviewJob;
[queueNames.exports]: ExportJob;
[queueNames.imports]: ImportJob;
[queueNames.pluginInvoke]: PluginInvokeJob;
};
export type QueueJobNames = {
[queueNames.email]: "send-transactional-email" | "deliver-webhook";
[queueNames.notifications]: "send-notification";
[queueNames.maintenance]: "run-cleanup" | "dsgvo-grace-period-scan";
[queueNames.aiChatbot]: "chat-completion" | "create-embedding" | "browser-task";
[queueNames.browserAutomation]: "browser-session-run" | "browser-session-cleanup" | "usage-flush";
[queueNames.fileScan]: "scan-file";
[queueNames.filePreview]: "generate-file-preview";
[queueNames.exports]: "run-export";
[queueNames.imports]: "run-import";
[queueNames.pluginInvoke]: "invoke-plugin";
};
export type QueuePayload<Name extends QueueName = QueueName> = QueueJobPayloads[Name];
export type QueueJobName<Name extends QueueName = QueueName> = QueueJobNames[Name];
export type QueueJobDescriptor<Name extends QueueName = QueueName> = {
queueName: Name;
jobName: QueueJobName<Name>;
payload: QueuePayload<Name>;
};
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
JOB_PRIORITY_CRITICAL,
JOB_PRIORITY_HIGH,
JOB_PRIORITY_LOW,
JOB_PRIORITY_NORMAL,
getTenantPlanLookup,
priorityFromOverride,
priorityFromPlan,
priorityLabel,
resolveJobPriorityForTenant,
setTenantPlanLookup,
type TenantPlanLookup,
} from "./priority.js";
describe("priorityFromOverride", () => {
test.each([
["critical", JOB_PRIORITY_CRITICAL],
["high", JOB_PRIORITY_HIGH],
["normal", JOB_PRIORITY_NORMAL],
["low", JOB_PRIORITY_LOW],
] as const)("maps %s to numeric priority", (label, expected) => {
expect(priorityFromOverride(label)).toBe(expected);
});
test("throws on unsupported override", () => {
expect(() => priorityFromOverride("urgent" as unknown as "critical")).toThrow(/Unsupported priority override/);
});
});
describe("priorityFromPlan", () => {
test("maps enterprise to CRITICAL", () => {
expect(priorityFromPlan("enterprise")).toBe(JOB_PRIORITY_CRITICAL);
});
test("maps pro to NORMAL", () => {
expect(priorityFromPlan("pro")).toBe(JOB_PRIORITY_NORMAL);
});
test("maps free to LOW", () => {
expect(priorityFromPlan("free")).toBe(JOB_PRIORITY_LOW);
});
test("maps unknown to NORMAL", () => {
expect(priorityFromPlan("trial")).toBe(JOB_PRIORITY_NORMAL);
expect(priorityFromPlan(null)).toBe(JOB_PRIORITY_NORMAL);
expect(priorityFromPlan(undefined)).toBe(JOB_PRIORITY_NORMAL);
});
});
describe("priorityLabel", () => {
test("round-trips known priorities", () => {
expect(priorityLabel(JOB_PRIORITY_CRITICAL)).toBe("critical");
expect(priorityLabel(JOB_PRIORITY_HIGH)).toBe("high");
expect(priorityLabel(JOB_PRIORITY_NORMAL)).toBe("normal");
expect(priorityLabel(JOB_PRIORITY_LOW)).toBe("low");
});
test("labels unknown numeric priorities as 'other'", () => {
expect(priorityLabel(99)).toBe("other");
});
});
describe("resolveJobPriorityForTenant", () => {
const originalFlag = process.env.JOB_PRIORITY_ENABLED;
beforeEach(() => {
setTenantPlanLookup(null);
delete process.env.JOB_PRIORITY_ENABLED;
});
afterEach(() => {
setTenantPlanLookup(null);
if (originalFlag === undefined) {
delete process.env.JOB_PRIORITY_ENABLED;
} else {
process.env.JOB_PRIORITY_ENABLED = originalFlag;
}
});
test("returns CRITICAL for null tenantId (internal job)", async () => {
await expect(resolveJobPriorityForTenant(null)).resolves.toBe(JOB_PRIORITY_CRITICAL);
await expect(resolveJobPriorityForTenant(undefined)).resolves.toBe(JOB_PRIORITY_CRITICAL);
await expect(resolveJobPriorityForTenant("")).resolves.toBe(JOB_PRIORITY_CRITICAL);
});
test("returns NORMAL when no lookup is registered", async () => {
await expect(resolveJobPriorityForTenant("tenant_1")).resolves.toBe(JOB_PRIORITY_NORMAL);
});
test("uses registered lookup to resolve plan-based priority", async () => {
const lookup = vi.fn<TenantPlanLookup>(async (tenantId) => {
if (tenantId === "tenant_ent") return "enterprise";
if (tenantId === "tenant_pro") return "pro";
if (tenantId === "tenant_free") return "free";
return null;
});
setTenantPlanLookup(lookup);
await expect(resolveJobPriorityForTenant("tenant_ent")).resolves.toBe(JOB_PRIORITY_CRITICAL);
await expect(resolveJobPriorityForTenant("tenant_pro")).resolves.toBe(JOB_PRIORITY_NORMAL);
await expect(resolveJobPriorityForTenant("tenant_free")).resolves.toBe(JOB_PRIORITY_LOW);
await expect(resolveJobPriorityForTenant("tenant_unknown")).resolves.toBe(JOB_PRIORITY_NORMAL);
expect(lookup).toHaveBeenCalledTimes(4);
});
test("inline lookup override beats registered lookup", async () => {
setTenantPlanLookup(async () => "free");
const inline: TenantPlanLookup = async () => "enterprise";
await expect(resolveJobPriorityForTenant("tenant_1", { lookup: inline })).resolves.toBe(JOB_PRIORITY_CRITICAL);
});
test("explicit null lookup disables registered lookup", async () => {
setTenantPlanLookup(async () => "enterprise");
await expect(resolveJobPriorityForTenant("tenant_1", { lookup: null })).resolves.toBe(JOB_PRIORITY_NORMAL);
});
test("falls back to NORMAL when lookup throws and logs a warning", async () => {
const warn = vi.fn();
setTenantPlanLookup(async () => {
throw new Error("redis down");
});
await expect(resolveJobPriorityForTenant("tenant_1", { logger: { warn } })).resolves.toBe(JOB_PRIORITY_NORMAL);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toMatchObject({ tenantId: "tenant_1" });
});
test("feature-flag JOB_PRIORITY_ENABLED=false neutralises plan-based priority", async () => {
setTenantPlanLookup(async () => "enterprise");
process.env.JOB_PRIORITY_ENABLED = "false";
await expect(resolveJobPriorityForTenant("tenant_ent")).resolves.toBe(JOB_PRIORITY_NORMAL);
});
test("feature-flag still preserves CRITICAL for null tenantId", async () => {
process.env.JOB_PRIORITY_ENABLED = "false";
await expect(resolveJobPriorityForTenant(null)).resolves.toBe(JOB_PRIORITY_CRITICAL);
});
test("setTenantPlanLookup / getTenantPlanLookup round-trip", () => {
expect(getTenantPlanLookup()).toBeNull();
const lookup: TenantPlanLookup = async () => "pro";
setTenantPlanLookup(lookup);
expect(getTenantPlanLookup()).toBe(lookup);
setTenantPlanLookup(null);
expect(getTenantPlanLookup()).toBeNull();
});
});
@@ -0,0 +1,159 @@
/**
* Plan-based job-priority resolver.
*
* BullMQ accepts `priority: 1-2_097_152` where `1 = highest`. We use a
* fixed 4-level scale so producers, dashboards and ops runbooks stay
* legible:
*
* - `CRITICAL=1` internal jobs, password-reset / verification mails,
* cron retention / cleanup
* - `HIGH=3` system-triggered workflows
* - `NORMAL=5` default for tenant work without a plan override
* - `LOW=10` free-tier work, low-priority batches
*
* Plan → priority mapping:
* - `enterprise` → CRITICAL (1)
* - `pro` → NORMAL (5)
* - `free` → LOW (10)
* - everything else / null tenantId → CRITICAL (internal job)
*
* The resolver supports a pluggable plan lookup (so workers and api don't
* need to depend on apps/web's subscription cache). When no lookup is
* registered, `resolveJobPriorityForTenant` falls back to `NORMAL` for
* tenant-scoped jobs and `CRITICAL` for null tenantId. Callers can register
* a lookup via `setTenantPlanLookup` at process boot.
*
* Feature-flag: `JOB_PRIORITY_ENABLED=false` forces every resolver call to
* return `NORMAL` so the system behaves like the pre-priority queue. Default
* is enabled.
*/
export const JOB_PRIORITY_CRITICAL = 1;
export const JOB_PRIORITY_HIGH = 3;
export const JOB_PRIORITY_NORMAL = 5;
export const JOB_PRIORITY_LOW = 10;
export type PriorityOverride = "critical" | "high" | "normal" | "low";
export type TenantPlanIdentifier = "enterprise" | "pro" | "free" | string;
/**
* Pluggable plan lookup. Implementations may read from a two-tier cache
* (apps/web wires this up via the subscription cache) or query the database
* directly. Returning `null` is treated as "unknown plan → NORMAL".
*/
export type TenantPlanLookup = (tenantId: string) => Promise<TenantPlanIdentifier | null>;
let registeredLookup: TenantPlanLookup | null = null;
/**
* Register a plan-lookup. Idempotent: passing the same lookup twice is a
* no-op. Passing `null` clears the registration (used by tests).
*/
export function setTenantPlanLookup(lookup: TenantPlanLookup | null): void {
registeredLookup = lookup;
}
export function getTenantPlanLookup(): TenantPlanLookup | null {
return registeredLookup;
}
function jobPriorityEnabled(): boolean {
const raw = process.env.JOB_PRIORITY_ENABLED;
if (raw === undefined || raw === null || raw === "") return true;
return raw.toLowerCase() !== "false" && raw !== "0";
}
export function priorityFromOverride(override: PriorityOverride): number {
switch (override) {
case "critical":
return JOB_PRIORITY_CRITICAL;
case "high":
return JOB_PRIORITY_HIGH;
case "normal":
return JOB_PRIORITY_NORMAL;
case "low":
return JOB_PRIORITY_LOW;
default: {
// Exhaustiveness: TS will narrow `override` to `never`. Defensive at
// runtime in case a caller passes an untyped string.
const exhaustive: never = override;
throw new Error(`Unsupported priority override: ${String(exhaustive)}`);
}
}
}
export function priorityFromPlan(plan: TenantPlanIdentifier | null | undefined): number {
switch (plan) {
case "enterprise":
return JOB_PRIORITY_CRITICAL;
case "pro":
return JOB_PRIORITY_NORMAL;
case "free":
return JOB_PRIORITY_LOW;
default:
// Unknown / null plan → NORMAL so a tenant whose plan can't be looked
// up still gets fair scheduling instead of being silently demoted.
return JOB_PRIORITY_NORMAL;
}
}
/**
* Resolve a BullMQ priority for the given tenantId.
*
* - `tenantId == null` → CRITICAL (internal jobs always preempt user work)
* - feature-flag off → NORMAL (priority neutralised)
* - no lookup registered → NORMAL for tenant-scoped jobs
* - lookup throws → NORMAL (warn-only; never block enqueue on a
* cache/db error)
*/
export async function resolveJobPriorityForTenant(
tenantId: string | null | undefined,
options: { lookup?: TenantPlanLookup | null; logger?: { warn: (obj: Record<string, unknown>, msg: string) => void } } = {},
): Promise<number> {
if (!tenantId) {
return JOB_PRIORITY_CRITICAL;
}
if (!jobPriorityEnabled()) {
return JOB_PRIORITY_NORMAL;
}
const lookup = options.lookup === undefined ? registeredLookup : options.lookup;
if (!lookup) {
return JOB_PRIORITY_NORMAL;
}
try {
const plan = await lookup(tenantId);
return priorityFromPlan(plan);
} catch (error) {
options.logger?.warn(
{
tenantId,
error: error instanceof Error ? error.message : String(error),
},
"resolveJobPriorityForTenant: plan lookup failed, defaulting to NORMAL",
);
return JOB_PRIORITY_NORMAL;
}
}
/**
* Translate a numeric BullMQ priority back to its label. Used by metrics
* exporters so the `priority` label stays low-cardinality
* (`critical|high|normal|low|other`).
*/
export function priorityLabel(priority: number): "critical" | "high" | "normal" | "low" | "other" {
switch (priority) {
case JOB_PRIORITY_CRITICAL:
return "critical";
case JOB_PRIORITY_HIGH:
return "high";
case JOB_PRIORITY_NORMAL:
return "normal";
case JOB_PRIORITY_LOW:
return "low";
default:
return "other";
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,583 @@
import type { JobsOptions, TypedQueue } from "./types.js";
import { queueNames, type QueueName } from "./names.js";
import type {
AiJob,
BrowserAutomationJob,
CleanupJob,
ExportJob,
FilePreviewJob,
FileScanJob,
ImportJob,
NotificationJob,
PluginInvokeJob,
QueueJobDescriptor,
QueueJsonValue,
TransactionalMailJob,
WebhookDeliveryJob,
WorkflowExecutionContinueRequestedOutboxEvent,
WorkflowExecutionRequestedOutboxEvent,
WorkflowTriggerOutboxEvent,
} from "./payloads.js";
import { JOB_PRIORITY_CRITICAL, priorityFromOverride, priorityLabel, resolveJobPriorityForTenant, type PriorityOverride } from "./priority.js";
type AiJobForKind<Kind extends AiJob["kind"]> = AiJob & { kind: Kind };
type BrowserAutomationJobForKind<Kind extends BrowserAutomationJob["kind"]> = BrowserAutomationJob & { kind: Kind };
export type TransactionalMailJobInput = Omit<TransactionalMailJob, "kind">;
export type WebhookDeliveryJobInput = Omit<WebhookDeliveryJob, "kind">;
export type NotificationJobInput = Omit<NotificationJob, "kind">;
export type CleanupJobInput = Omit<CleanupJob, "kind">;
export type AiChatCompletionJobInput = Omit<AiJobForKind<"ai.chat-completion">, "kind">;
export type AiEmbeddingJobInput = Omit<AiJobForKind<"ai.embedding">, "kind">;
export type AiBrowserTaskJobInput = Omit<AiJobForKind<"ai.browser-task">, "kind">;
export type BrowserSessionRunJobInput = Omit<BrowserAutomationJobForKind<"browser.session-run">, "kind">;
export type BrowserSessionCleanupJobInput = Omit<BrowserAutomationJobForKind<"browser.session-cleanup">, "kind">;
export type BrowserUsageFlushJobInput = Omit<BrowserAutomationJobForKind<"usage.flush">, "kind">;
export type FileScanJobInput = Omit<FileScanJob, "kind">;
export type FilePreviewJobInput = Omit<FilePreviewJob, "kind">;
export type ExportJobInput = Omit<ExportJob, "kind">;
export type ImportJobInput = Omit<ImportJob, "kind">;
export type PluginInvokeJobInput = Omit<PluginInvokeJob, "kind">;
export type WorkflowTriggerOutboxEventInput = {
tenantId: string | null;
event: string;
data: QueueJsonValue;
userId: string | null;
sourceId: string;
};
export type WorkflowExecutionRequestedOutboxEventInput = {
tenantId: string | null;
workflowId: string;
executionId: string;
type: "manual" | "api";
input: QueueJsonValue;
userId: string | null;
sourceId: string;
};
export type WorkflowExecutionContinueRequestedOutboxEventInput = WorkflowExecutionRequestedOutboxEventInput;
const WORKFLOW_OUTBOX_SOURCE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,234}$/u;
function requireWorkflowOutboxText(value: string, fieldName: string, eventType: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${eventType} outbox events require ${fieldName}.`);
}
return trimmed;
}
function requireWorkflowOutboxSourceId(value: string, eventType: string): string {
const sourceId = requireWorkflowOutboxText(value, "sourceId", eventType);
if (!WORKFLOW_OUTBOX_SOURCE_ID_PATTERN.test(sourceId)) {
throw new Error(`${eventType} outbox events require sourceId to match ${WORKFLOW_OUTBOX_SOURCE_ID_PATTERN.source}.`);
}
return sourceId;
}
export type TransactionalMailJobDescriptor = QueueJobDescriptor<typeof queueNames.email> & {
jobName: "send-transactional-email";
payload: TransactionalMailJob;
};
export type WebhookDeliveryJobDescriptor = QueueJobDescriptor<typeof queueNames.email> & {
jobName: "deliver-webhook";
payload: WebhookDeliveryJob;
};
export type NotificationJobDescriptor = QueueJobDescriptor<typeof queueNames.notifications> & {
jobName: "send-notification";
payload: NotificationJob;
};
export type CleanupJobDescriptor = QueueJobDescriptor<typeof queueNames.maintenance> & {
jobName: "run-cleanup";
payload: CleanupJob;
};
export type AiChatCompletionJobDescriptor = QueueJobDescriptor<typeof queueNames.aiChatbot> & {
jobName: "chat-completion";
payload: AiJobForKind<"ai.chat-completion">;
};
export type AiEmbeddingJobDescriptor = QueueJobDescriptor<typeof queueNames.aiChatbot> & {
jobName: "create-embedding";
payload: AiJobForKind<"ai.embedding">;
};
export type AiBrowserTaskJobDescriptor = QueueJobDescriptor<typeof queueNames.aiChatbot> & {
jobName: "browser-task";
payload: AiJobForKind<"ai.browser-task">;
};
export type BrowserSessionRunJobDescriptor = QueueJobDescriptor<typeof queueNames.browserAutomation> & {
jobName: "browser-session-run";
payload: BrowserAutomationJobForKind<"browser.session-run">;
};
export type BrowserSessionCleanupJobDescriptor = QueueJobDescriptor<typeof queueNames.browserAutomation> & {
jobName: "browser-session-cleanup";
payload: BrowserAutomationJobForKind<"browser.session-cleanup">;
};
export type BrowserUsageFlushJobDescriptor = QueueJobDescriptor<typeof queueNames.browserAutomation> & {
jobName: "usage-flush";
payload: BrowserAutomationJobForKind<"usage.flush">;
};
export type FileScanJobDescriptor = QueueJobDescriptor<typeof queueNames.fileScan> & {
jobName: "scan-file";
payload: FileScanJob;
};
export type FilePreviewJobDescriptor = QueueJobDescriptor<typeof queueNames.filePreview> & {
jobName: "generate-file-preview";
payload: FilePreviewJob;
};
export type ExportJobDescriptor = QueueJobDescriptor<typeof queueNames.exports> & {
jobName: "run-export";
payload: ExportJob;
};
export type ImportJobDescriptor = QueueJobDescriptor<typeof queueNames.imports> & {
jobName: "run-import";
payload: ImportJob;
};
export type PluginInvokeJobDescriptor = QueueJobDescriptor<typeof queueNames.pluginInvoke> & {
jobName: "invoke-plugin";
payload: PluginInvokeJob;
};
export type EmailProducerJobDescriptor = TransactionalMailJobDescriptor | WebhookDeliveryJobDescriptor;
export type AiProducerJobDescriptor = AiChatCompletionJobDescriptor | AiEmbeddingJobDescriptor | AiBrowserTaskJobDescriptor;
export type BrowserAutomationProducerJobDescriptor = BrowserSessionRunJobDescriptor | BrowserSessionCleanupJobDescriptor | BrowserUsageFlushJobDescriptor;
export type ProducerJobDescriptor =
| EmailProducerJobDescriptor
| NotificationJobDescriptor
| CleanupJobDescriptor
| AiProducerJobDescriptor
| BrowserAutomationProducerJobDescriptor
| FileScanJobDescriptor
| FilePreviewJobDescriptor
| ExportJobDescriptor
| ImportJobDescriptor
| PluginInvokeJobDescriptor;
const retryJobOptionsByQueueAndJobName: {
[Name in QueueName]: Record<QueueJobDescriptor<Name>["jobName"], Pick<JobsOptions, "attempts" | "backoff">>;
} = {
[queueNames.email]: {
"send-transactional-email": { attempts: 3, backoff: { type: "exponential", delay: 1_000 } },
"deliver-webhook": { attempts: 5, backoff: { type: "exponential", delay: 2_000 } },
},
[queueNames.notifications]: {
"send-notification": { attempts: 3, backoff: { type: "exponential", delay: 1_000 } },
},
[queueNames.maintenance]: {
"run-cleanup": { attempts: 2, backoff: { type: "fixed", delay: 5_000 } },
// Cron-scheduled grace-period scan: each tick is idempotent (the
// underlying SQL filter only finds overdue rows), but we still allow
// one retry on transient Prisma/Redis hiccups before letting the
// next 24h tick recover the run.
"dsgvo-grace-period-scan": { attempts: 2, backoff: { type: "fixed", delay: 30_000 } },
},
[queueNames.aiChatbot]: {
"chat-completion": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } },
"create-embedding": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } },
"browser-task": { attempts: 2, backoff: { type: "exponential", delay: 5_000 } },
},
[queueNames.browserAutomation]: {
"browser-session-run": { attempts: 2, backoff: { type: "exponential", delay: 5_000 } },
"browser-session-cleanup": { attempts: 3, backoff: { type: "fixed", delay: 10_000 } },
"usage-flush": { attempts: 3, backoff: { type: "fixed", delay: 10_000 } },
},
[queueNames.fileScan]: {
"scan-file": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } },
},
[queueNames.filePreview]: {
// Preview generation is best-effort: a couple of retries cover transient
// storage/decoder hiccups, then the handler marks previewStatus=failed and
// the user can re-trigger from the detail view.
"generate-file-preview": { attempts: 3, backoff: { type: "exponential", delay: 3_000 } },
},
[queueNames.exports]: {
"run-export": { attempts: 2, backoff: { type: "exponential", delay: 10_000 } },
},
[queueNames.imports]: {
"run-import": { attempts: 2, backoff: { type: "exponential", delay: 10_000 } },
},
[queueNames.pluginInvoke]: {
"invoke-plugin": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } },
},
};
export function buildRetryJobOptions(descriptor: ProducerJobDescriptor): JobsOptions {
const queueRetryOptions = retryJobOptionsByQueueAndJobName[descriptor.queueName] as Record<
ProducerJobDescriptor["jobName"],
Pick<JobsOptions, "attempts" | "backoff">
>;
return {
...queueRetryOptions[descriptor.jobName],
removeOnComplete: true,
removeOnFail: 1_000,
};
}
export function buildTransactionalMailJob(input: TransactionalMailJobInput): TransactionalMailJobDescriptor {
return {
queueName: queueNames.email,
jobName: "send-transactional-email",
payload: {
kind: "mail.transactional",
...input,
},
};
}
export function buildWebhookDeliveryJob(input: WebhookDeliveryJobInput): WebhookDeliveryJobDescriptor {
return {
queueName: queueNames.email,
jobName: "deliver-webhook",
payload: {
kind: "webhook.deliver",
...input,
},
};
}
export function buildNotificationJob(input: NotificationJobInput): NotificationJobDescriptor {
if (input.channel !== "email" && input.channel !== "in-app") {
throw new Error(`Unsupported notification channel: ${input.channel}`);
}
if (!input.tenantId?.trim()) {
throw new Error("Notification jobs require tenantId.");
}
return {
queueName: queueNames.notifications,
jobName: "send-notification",
payload: {
kind: "notification.send",
...input,
},
};
}
export function buildCleanupJob(input: CleanupJobInput): CleanupJobDescriptor {
return {
queueName: queueNames.maintenance,
jobName: "run-cleanup",
payload: {
kind: "cleanup.run",
...input,
},
};
}
export function buildAiChatCompletionJob(input: AiChatCompletionJobInput): AiChatCompletionJobDescriptor {
return {
queueName: queueNames.aiChatbot,
jobName: "chat-completion",
payload: {
kind: "ai.chat-completion",
...input,
},
};
}
export function buildAiEmbeddingJob(input: AiEmbeddingJobInput): AiEmbeddingJobDescriptor {
return {
queueName: queueNames.aiChatbot,
jobName: "create-embedding",
payload: {
kind: "ai.embedding",
...input,
},
};
}
export function buildAiBrowserTaskJob(input: AiBrowserTaskJobInput): AiBrowserTaskJobDescriptor {
return {
queueName: queueNames.aiChatbot,
jobName: "browser-task",
payload: {
kind: "ai.browser-task",
...input,
},
};
}
export function buildBrowserSessionRunJob(input: BrowserSessionRunJobInput): BrowserSessionRunJobDescriptor {
return {
queueName: queueNames.browserAutomation,
jobName: "browser-session-run",
payload: {
kind: "browser.session-run",
...input,
},
};
}
export function buildBrowserSessionCleanupJob(input: BrowserSessionCleanupJobInput): BrowserSessionCleanupJobDescriptor {
return {
queueName: queueNames.browserAutomation,
jobName: "browser-session-cleanup",
payload: {
kind: "browser.session-cleanup",
...input,
},
};
}
export function buildBrowserUsageFlushJob(input: BrowserUsageFlushJobInput): BrowserUsageFlushJobDescriptor {
return {
queueName: queueNames.browserAutomation,
jobName: "usage-flush",
payload: {
kind: "usage.flush",
...input,
},
};
}
export function buildFileScanJob(input: FileScanJobInput): FileScanJobDescriptor {
return {
queueName: queueNames.fileScan,
jobName: "scan-file",
payload: {
kind: "scan.file",
...input,
},
};
}
export function buildFilePreviewJob(input: FilePreviewJobInput): FilePreviewJobDescriptor {
if (!input.fileId?.trim()) {
throw new Error("File preview jobs require fileId.");
}
if (!input.tenantId?.trim()) {
throw new Error("File preview jobs require tenantId.");
}
return {
queueName: queueNames.filePreview,
jobName: "generate-file-preview",
payload: {
kind: "preview.file",
...input,
},
};
}
export function buildExportJob(input: ExportJobInput): ExportJobDescriptor {
return {
queueName: queueNames.exports,
jobName: "run-export",
payload: {
kind: "export.run",
...input,
},
};
}
export function buildImportJob(input: ImportJobInput): ImportJobDescriptor {
return {
queueName: queueNames.imports,
jobName: "run-import",
payload: {
kind: "import.run",
...input,
},
};
}
export function buildPluginInvokeJob(input: PluginInvokeJobInput): PluginInvokeJobDescriptor {
return {
queueName: queueNames.pluginInvoke,
jobName: "invoke-plugin",
payload: {
kind: "plugin.invoke",
...input,
},
};
}
export function buildWorkflowTriggerOutboxEvent(input: WorkflowTriggerOutboxEventInput): WorkflowTriggerOutboxEvent {
const eventType = "workflow.trigger.event";
const event = requireWorkflowOutboxText(input.event, "event", eventType);
const sourceId = requireWorkflowOutboxSourceId(input.sourceId, eventType);
return {
tenantId: input.tenantId,
type: eventType,
payload: {
event,
data: input.data,
userId: input.userId,
},
dispatchKey: `workflow-trigger:${sourceId}`,
};
}
export function buildWorkflowExecutionRequestedOutboxEvent(input: WorkflowExecutionRequestedOutboxEventInput): WorkflowExecutionRequestedOutboxEvent {
const eventType = "workflow.execution.requested";
const workflowId = requireWorkflowOutboxText(input.workflowId, "workflowId", eventType);
const executionId = requireWorkflowOutboxText(input.executionId, "executionId", eventType);
const sourceId = requireWorkflowOutboxSourceId(input.sourceId, eventType);
return {
tenantId: input.tenantId,
type: eventType,
payload: {
workflowId,
executionId,
type: input.type,
input: input.input,
userId: input.userId,
},
dispatchKey: `workflow-execution:${sourceId}`,
};
}
export function buildWorkflowExecutionContinueRequestedOutboxEvent(
input: WorkflowExecutionContinueRequestedOutboxEventInput
): WorkflowExecutionContinueRequestedOutboxEvent {
const eventType = "workflow.execution.continue.requested";
const workflowId = requireWorkflowOutboxText(input.workflowId, "workflowId", eventType);
const executionId = requireWorkflowOutboxText(input.executionId, "executionId", eventType);
const sourceId = requireWorkflowOutboxSourceId(input.sourceId, eventType);
return {
tenantId: input.tenantId,
type: eventType,
payload: {
workflowId,
executionId,
type: input.type,
input: input.input,
userId: input.userId,
},
dispatchKey: `workflow-continuation:${sourceId}`,
};
}
export type QueueEnqueueOptions = JobsOptions & {
idempotencyKey?: string;
/**
* Tenant scope for plan-based priority resolution. Pass `null` for
* internal/system jobs (cron, retention, audit) which always run at
* CRITICAL priority. Omitting `tenantId` leaves the priority untouched
* unless `priorityOverride` is set.
*/
tenantId?: string | null;
/**
* Explicit priority hint that bypasses tenant-plan lookup. Use for
* semantically critical jobs (password-reset mail, verification mail)
* regardless of plan, and for explicit low-priority batches.
*/
priorityOverride?: PriorityOverride;
};
/**
* Optional enqueue observer. Producer-host apps (api / web / worker) can
* register one during boot to forward priority/queue/jobName info to their
* Prometheus registry. Kept as a module-level setter so the queue package
* does not depend on prom-client or any app metrics bundle.
*/
export type EnqueueObserver = (event: { queueName: QueueName; jobName: string; priority: number; priorityLabel: ReturnType<typeof priorityLabel> }) => void;
let enqueueObserver: EnqueueObserver | null = null;
export function setEnqueueObserver(observer: EnqueueObserver | null): void {
enqueueObserver = observer;
}
export function getEnqueueObserver(): EnqueueObserver | null {
return enqueueObserver;
}
export function buildIdempotentJobId(descriptor: ProducerJobDescriptor, options?: QueueEnqueueOptions): string | undefined {
if (typeof options?.jobId === "string") {
return options.jobId;
}
if (typeof options?.idempotencyKey === "string") {
return options.idempotencyKey;
}
if (descriptor.payload.kind === "export.run") {
return descriptor.payload.exportJobId;
}
if (descriptor.payload.kind === "import.run") {
return descriptor.payload.importJobId;
}
const metadata = descriptor.payload.metadata;
if (metadata && typeof metadata.idempotencyKey === "string") {
return metadata.idempotencyKey;
}
return undefined;
}
async function resolvePriorityForEnqueue(options: QueueEnqueueOptions | undefined): Promise<number | undefined> {
if (!options) return undefined;
if (typeof options.priority === "number") {
// Explicit numeric priority always wins so power-users / tests can pin
// a precise value.
return options.priority;
}
if (options.priorityOverride !== undefined) {
return priorityFromOverride(options.priorityOverride);
}
if (options.tenantId !== undefined) {
if (options.tenantId === null) {
// Explicit `null` tenantId → internal job → CRITICAL. Short-circuit
// the resolver to avoid a useless feature-flag round-trip.
return JOB_PRIORITY_CRITICAL;
}
return resolveJobPriorityForTenant(options.tenantId);
}
return undefined;
}
export async function enqueueJob<Name extends QueueName>(queue: TypedQueue<Name>, descriptor: QueueJobDescriptor<Name>, options?: QueueEnqueueOptions) {
const producerDescriptor = descriptor as ProducerJobDescriptor;
const callerOptions: JobsOptions = options ? { ...options } : {};
delete (callerOptions as QueueEnqueueOptions).idempotencyKey;
delete (callerOptions as QueueEnqueueOptions).tenantId;
delete (callerOptions as QueueEnqueueOptions).priorityOverride;
const jobId = buildIdempotentJobId(producerDescriptor, options);
const idempotentCompletionRetention: Pick<JobsOptions, "removeOnComplete"> =
jobId && callerOptions.removeOnComplete === undefined ? { removeOnComplete: false } : {};
const resolvedPriority = await resolvePriorityForEnqueue(options);
const priorityOptions: Pick<JobsOptions, "priority"> = resolvedPriority !== undefined ? { priority: resolvedPriority } : {};
const enqueueOptions: JobsOptions = {
...buildRetryJobOptions(producerDescriptor),
...idempotentCompletionRetention,
...(jobId ? { jobId } : {}),
...priorityOptions,
...callerOptions,
};
if (enqueueObserver && typeof enqueueOptions.priority === "number") {
try {
enqueueObserver({
queueName: descriptor.queueName as QueueName,
jobName: descriptor.jobName,
priority: enqueueOptions.priority,
priorityLabel: priorityLabel(enqueueOptions.priority),
});
} catch {
// Observer must never block enqueue. Swallow.
}
}
return queue.add(descriptor.jobName, descriptor.payload, enqueueOptions);
}
@@ -0,0 +1,33 @@
const bullmq = require("bullmq");
const defaultJobOptions = {
attempts: 3,
backoff: {
delay: 1000,
type: "exponential",
},
removeOnComplete: 100,
removeOnFail: 250,
};
function createQueue(queueName, options) {
return new bullmq.Queue(queueName, {
defaultJobOptions,
...options,
});
}
function createQueueEvents(queueName, options) {
return new bullmq.QueueEvents(queueName, options);
}
function createWorker(queueName, processor, options) {
return new bullmq.Worker(queueName, processor, options);
}
module.exports = {
createQueue,
createQueueEvents,
createWorker,
defaultJobOptions,
};
@@ -0,0 +1,3 @@
export { createQueue, createQueueEvents, createWorker } from "./runtime.js";
export { defaultJobOptions } from "./types.js";
export type { TypedProcessor, TypedQueue, TypedQueueEvents, TypedWorker } from "./types.js";
@@ -0,0 +1,19 @@
import { Queue, QueueEvents, Worker, type QueueEventsOptions, type QueueOptions, type WorkerOptions } from "bullmq";
import type { QueueJobName, QueuePayload } from "./payloads.js";
import type { QueueName } from "./names.js";
import { defaultJobOptions, type TypedProcessor, type TypedQueue, type TypedQueueEvents, type TypedWorker } from "./types.js";
export function createQueue<Name extends QueueName>(queueName: Name, options: QueueOptions): TypedQueue<Name> {
return new Queue<QueuePayload<Name>, unknown, QueueJobName<Name>>(queueName, {
defaultJobOptions,
...options,
});
}
export function createQueueEvents<Name extends QueueName>(queueName: Name, options?: QueueEventsOptions): TypedQueueEvents<Name> {
return new QueueEvents(queueName, options);
}
export function createWorker<Name extends QueueName>(queueName: Name, processor: TypedProcessor<Name>, options: WorkerOptions): TypedWorker<Name> {
return new Worker<QueuePayload<Name>, unknown, QueueJobName<Name>>(queueName, processor, options);
}
@@ -0,0 +1,51 @@
import type { QueueName } from "./names.js";
import type { QueueJobName, QueuePayload } from "./payloads.js";
export type JobsOptions = {
attempts?: number;
backoff?: {
delay?: number;
type: string;
};
jobId?: string;
removeOnComplete?: boolean | number;
removeOnFail?: boolean | number;
/**
* BullMQ priority — 1 is highest, larger numbers are lower priority. We
* use the constants from `priority.ts` (1 = critical, 3 = high, 5 = normal,
* 10 = low) so dashboards stay legible.
*/
priority?: number;
};
export type TypedQueue<Name extends QueueName> = {
add(jobName: QueueJobName<Name>, payload: QueuePayload<Name>, options?: JobsOptions): Promise<{ id?: string }>;
close(): Promise<void>;
};
export type TypedQueueEvents<Name extends QueueName> = unknown | { readonly queueName?: Name };
export type TypedJob<Name extends QueueName> = {
id?: string | number;
name: QueueJobName<Name>;
data: QueuePayload<Name>;
attemptsMade?: number;
opts?: {
attempts?: number;
};
updateData?(data: QueuePayload<Name>): Promise<void>;
};
export type TypedProcessor<Name extends QueueName> = (job: TypedJob<Name>) => Promise<unknown>;
export type TypedWorker<Name extends QueueName> = {
close(): Promise<void>;
name: string;
on(event: string, listener: (...args: never[]) => void): TypedWorker<Name>;
};
export const defaultJobOptions = {
attempts: 3,
backoff: {
delay: 1_000,
type: "exponential",
},
removeOnComplete: 100,
removeOnFail: 250,
} as const satisfies JobsOptions;
@@ -0,0 +1,300 @@
import { describe, expect, test, vi } from "vitest";
import {
buildAiBrowserTaskJob,
buildAiChatCompletionJob,
buildAiEmbeddingJob,
buildBrowserSessionCleanupJob,
buildBrowserSessionRunJob,
buildBrowserUsageFlushJob,
buildCleanupJob,
buildExportJob,
buildFilePreviewJob,
buildFileScanJob,
buildImportJob,
buildNotificationJob,
buildPluginInvokeJob,
buildRetryJobOptions,
buildTransactionalMailJob,
buildWebhookDeliveryJob,
enqueueJob,
queueNames,
type ProducerJobDescriptor,
type QueueName,
type TypedQueue,
} from "../src/index.js";
import * as producerExports from "../src/producers.js";
type RetryMetrics = Pick<
ReturnType<typeof buildRetryJobOptions>,
"attempts" | "backoff" | "removeOnComplete" | "removeOnFail"
>;
const descriptorBuilders = {
buildAiBrowserTaskJob: () => buildAiBrowserTaskJob({ tenantId: "tenant_1", task: { action: "research" } }),
buildAiChatCompletionJob: () =>
buildAiChatCompletionJob({ tenantId: "tenant_1", messages: [{ role: "user", content: "Hello" }] }),
buildAiEmbeddingJob: () =>
buildAiEmbeddingJob({ tenantId: "tenant_1", messages: [{ role: "user", content: "Embed this" }] }),
buildBrowserSessionCleanupJob: () => buildBrowserSessionCleanupJob({ tenantId: "tenant_1", sessionId: "session_1" }),
buildBrowserSessionRunJob: () =>
buildBrowserSessionRunJob({ tenantId: "tenant_1", sessionId: "session_1", task: { action: "capture" } }),
buildBrowserUsageFlushJob: () => buildBrowserUsageFlushJob({ tenantId: "tenant_1", sessionId: "session_1" }),
buildCleanupJob: () => buildCleanupJob({ task: "expired-sessions" }),
buildExportJob: () =>
buildExportJob({
exportJobId: "export_job_metrics_1",
tenantId: "tenant_1",
requestedByUserId: "user_1",
exportType: "members",
format: "csv",
}),
buildFilePreviewJob: () =>
buildFilePreviewJob({ fileId: "file_1", tenantId: "tenant_1", bucket: "uploads", storageKey: "tenants/tenant_1/files/file_1/original", mimeType: "image/heic" }),
buildFileScanJob: () => buildFileScanJob({ bucket: "uploads", key: "tenant_1/upload_1/file.pdf" }),
buildImportJob: () =>
buildImportJob({
importJobId: "import_job_metrics_1",
tenantId: "tenant_1",
requestedByUserId: "user_1",
importType: "members",
format: "csv",
sourceBucket: "uploads",
sourceKey: "tenant_1/imports/members.csv",
}),
buildNotificationJob: () => buildNotificationJob({ tenantId: "tenant_1", userId: "user_1", channel: "in-app", messageKey: "message.ready" }),
buildPluginInvokeJob: () => buildPluginInvokeJob({ tenantId: "tenant_1", pluginId: "plugin_1", version: "1.0.0", hookName: "onEvent" }),
buildTransactionalMailJob: () => buildTransactionalMailJob({ to: ["user@example.com"], templateId: "welcome" }),
buildWebhookDeliveryJob: () => buildWebhookDeliveryJob({ url: "https://example.com/webhook", eventName: "entity.created" }),
} satisfies Record<string, () => ProducerJobDescriptor>;
function producerHelperExportNames(): string[] {
return Object.keys(producerExports)
.filter((name) => /^build[A-Z].*Job$/.test(name))
.sort();
}
function retryMetricsFor(descriptor: ProducerJobDescriptor): RetryMetrics {
const { attempts, backoff, removeOnComplete, removeOnFail } = buildRetryJobOptions(descriptor);
return { attempts, backoff, removeOnComplete, removeOnFail };
}
function sortedRetryMetricsSnapshot(): Record<string, RetryMetrics> {
return Object.fromEntries(
Object.values(descriptorBuilders)
.map((buildDescriptor) => {
const descriptor = buildDescriptor();
return [`${descriptor.queueName}:${descriptor.jobName}`, retryMetricsFor(descriptor)] as const;
})
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
);
}
function createMockQueue<Name extends QueueName>(): TypedQueue<Name> {
return { add: vi.fn().mockResolvedValue({ id: "queued-job" }) } as unknown as TypedQueue<Name>;
}
async function enqueueOptionsFor(descriptor: ProducerJobDescriptor, options?: Parameters<typeof enqueueJob>[2]) {
const queue = createMockQueue<typeof descriptor.queueName>();
await enqueueJob(queue, descriptor, options);
return vi.mocked(queue.add).mock.calls[0]?.[2];
}
describe("queue retry metrics snapshot", () => {
test("covers every exported producer descriptor helper", () => {
expect(Object.keys(descriptorBuilders).sort()).toEqual(producerHelperExportNames());
});
test("locks retry defaults for every queue producer descriptor", () => {
expect(sortedRetryMetricsSnapshot()).toMatchInlineSnapshot(`
{
"${queueNames.aiChatbot}:browser-task": {
"attempts": 2,
"backoff": {
"delay": 5000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.aiChatbot}:chat-completion": {
"attempts": 3,
"backoff": {
"delay": 2000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.aiChatbot}:create-embedding": {
"attempts": 3,
"backoff": {
"delay": 2000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.browserAutomation}:browser-session-cleanup": {
"attempts": 3,
"backoff": {
"delay": 10000,
"type": "fixed",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.browserAutomation}:browser-session-run": {
"attempts": 2,
"backoff": {
"delay": 5000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.browserAutomation}:usage-flush": {
"attempts": 3,
"backoff": {
"delay": 10000,
"type": "fixed",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.email}:deliver-webhook": {
"attempts": 5,
"backoff": {
"delay": 2000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.email}:send-transactional-email": {
"attempts": 3,
"backoff": {
"delay": 1000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.exports}:run-export": {
"attempts": 2,
"backoff": {
"delay": 10000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.filePreview}:generate-file-preview": {
"attempts": 3,
"backoff": {
"delay": 3000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.fileScan}:scan-file": {
"attempts": 3,
"backoff": {
"delay": 2000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.imports}:run-import": {
"attempts": 2,
"backoff": {
"delay": 10000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.maintenance}:run-cleanup": {
"attempts": 2,
"backoff": {
"delay": 5000,
"type": "fixed",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.notifications}:send-notification": {
"attempts": 3,
"backoff": {
"delay": 1000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
"${queueNames.pluginInvoke}:invoke-plugin": {
"attempts": 3,
"backoff": {
"delay": 2000,
"type": "exponential",
},
"removeOnComplete": true,
"removeOnFail": 1000,
},
}
`);
});
});
describe("queue idempotency diagnostics", () => {
test("uses an explicit jobId before an idempotencyKey", async () => {
const options = await enqueueOptionsFor(buildTransactionalMailJob({ to: ["user@example.com"], templateId: "welcome" }), {
idempotencyKey: "idempotency-key",
jobId: "explicit-job-id",
});
expect(options?.jobId).toBe("explicit-job-id");
expect(options).not.toHaveProperty("idempotencyKey");
});
test("uses an explicit idempotencyKey before payload metadata", async () => {
const options = await enqueueOptionsFor(
buildNotificationJob({
tenantId: "tenant_1",
userId: "user_1",
channel: "in-app",
messageKey: "message.ready",
metadata: { idempotencyKey: "metadata-key" },
}),
{ idempotencyKey: "explicit-idempotency-key" }
);
expect(options?.jobId).toBe("explicit-idempotency-key");
expect(options).not.toHaveProperty("idempotencyKey");
});
test("uses metadata idempotencyKey when no explicit key is present", async () => {
const options = await enqueueOptionsFor(
buildCleanupJob({ task: "temporary-files", metadata: { idempotencyKey: "metadata-key" } })
);
expect(options?.jobId).toBe("metadata-key");
});
test("does not derive jobId values from body payloads", async () => {
const options = await enqueueOptionsFor(
buildWebhookDeliveryJob({
url: "https://example.com/webhook",
eventName: "entity.created",
body: { idempotencyKey: "unsafe-body-key", jobId: "unsafe-body-job-id" },
})
);
expect(options).not.toHaveProperty("jobId");
});
});
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}