alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
294 lines (276 loc) • 9.38 kB
text/typescript
import {
$inject,
createPrimitive,
KIND,
Primitive,
type Service,
type Static,
type TSchema,
} from "alepha";
import { $logger } from "alepha/logger";
import { MemoryQueueProvider } from "../providers/MemoryQueueProvider.ts";
import { QueueProvider } from "../providers/QueueProvider.ts";
import { WorkerProvider } from "../providers/WorkerProvider.ts";
/**
* Creates a queue primitive for asynchronous message processing with background workers.
*
* `$queue` is the **raw transport layer**: it fans messages out to background
* workers over the configured backend with type-safe payloads. Delivery is
* **at-most-once** — a message is popped from the backend before the handler
* runs, so a handler error or a process crash loses it. There is no retry,
* no dead-letter queue, and no delivery guarantee at this layer.
*
* **For work that must not be lost, use `$job` (alepha/api/jobs) instead.**
* It layers a durable, DB-backed outbox over this transport: at-least-once
* delivery, retries, idempotency keys, priorities, crash recovery via a
* reconciliation sweep, and failure records.
*
* **What $queue gives you**
* - Type-safe payloads with schema validation at push and receive
* - Background workers with graceful shutdown and lifecycle management
* - Pluggable backends: memory (dev/test), Redis, Cloudflare Queues
* - Cheap fire-and-forget fan-out where occasional loss is acceptable
* (cache invalidation, presence pings, metrics, live notifications)
*
* @example Loss-tolerant event fan-out
* ```typescript
* const activityQueue = $queue({
* name: "activity-events",
* schema: z.object({
* userId: z.text(),
* event: z.text(),
* at: z.number()
* }),
* handler: async (message) => {
* await metrics.track(message.payload);
* }
* });
*
* // Push messages for background processing
* await activityQueue.push({
* userId: "u1",
* event: "page-view",
* at: 1700000000000
* });
* ```
*
* @example Batch processing with Redis
* ```typescript
* const imageQueue = $queue({
* name: "image-processing",
* provider: RedisQueueProvider,
* schema: z.object({
* imageId: z.text(),
* operations: z.array(z.enum(["resize", "compress", "thumbnail"]))
* }),
* handler: async (message) => {
* for (const op of message.payload.operations) {
* await processImage(message.payload.imageId, op);
* }
* }
* });
*
* // Batch processing multiple images
* await imageQueue.push(
* { imageId: "img1", operations: ["resize", "thumbnail"] },
* { imageId: "img2", operations: ["compress"] },
* { imageId: "img3", operations: ["resize", "compress", "thumbnail"] }
* );
* ```
*
* @example Development with memory provider
* ```typescript
* const taskQueue = $queue({
* name: "dev-tasks",
* provider: "memory",
* schema: z.object({
* taskType: z.enum(["cleanup", "backup", "report"]),
* data: z.record(z.text(), z.any())
* }),
* handler: async (message) => {
* switch (message.payload.taskType) {
* case "cleanup":
* await performCleanup(message.payload.data);
* break;
* case "backup":
* await createBackup(message.payload.data);
* break;
* case "report":
* await generateReport(message.payload.data);
* break;
* }
* }
* });
* ```
*/
export const $queue = <T extends TSchema>(
options: QueuePrimitiveOptions<T>,
): QueuePrimitive<T> => {
return createPrimitive(QueuePrimitive<T>, options);
};
// ---------------------------------------------------------------------------------------------------------------------
export interface QueuePrimitiveOptions<T extends TSchema> {
/**
* Unique name for the queue.
*
* This name is used for:
* - Queue identification across the system
* - Storage backend key generation
* - Logging and monitoring
* - Worker assignment and routing
*
* If not provided, defaults to the property key where the queue is declared.
*
* @example "email-notifications"
* @example "image-processing"
* @example "order-fulfillment"
*/
name?: string;
/**
* Human-readable description of the queue's purpose.
*
* Used for:
* - Documentation generation
* - Monitoring dashboards
* - Development team communication
* - Queue management interfaces
*
* @example "Process user registration emails and welcome sequences"
* @example "Handle image uploads, resizing, and thumbnail generation"
* @example "Manage order processing, payment, and shipping workflows"
*/
description?: string;
/**
* Queue storage provider configuration.
*
* Options:
* - **"memory"**: In-memory queue (default for development, lost on restart)
* - **Service<QueueProvider>**: Custom provider class (e.g., RedisQueueProvider)
* - **undefined**: Uses the default queue provider from dependency injection
*
* **Provider Selection Guidelines**:
* - Development: Use "memory" for fast, simple testing
* - Production: Use Redis so multiple instances share one queue (note:
* a popped message is gone — the at-most-once caveat above applies)
* - High-throughput: Use specialized providers with connection pooling
* - Distributed systems: Use Redis or message brokers for scalability
*
* @default Uses injected QueueProvider
* @example "memory"
* @example RedisQueueProvider
* @example DatabaseQueueProvider
*/
provider?: "memory" | Service<QueueProvider>;
/**
* TypeBox schema defining the structure of messages in this queue.
*
* This schema:
* - Validates all messages pushed to the queue
* - Provides full TypeScript type inference
* - Ensures type safety between producers and consumers
* - Enables automatic serialization/deserialization
*
* **Schema Design Best Practices**:
* - Keep schemas simple and focused on the specific task
* - Use optional fields for data that might not always be available
* - Include version fields for schema evolution
* - Use union types for different message types in the same queue
*
* @example
* ```ts
* z.object({
* userId: z.text(),
* action: z.enum(["create", "update"]),
* data: z.record(z.text(), z.any()),
* timestamp: z.number().optional()
* })
* ```
*/
schema: T;
/**
* Message handler function that processes queue messages.
*
* This function:
* - Runs in background worker threads for non-blocking processing
* - Receives type-safe message payloads based on the schema
* - Has access to the full Alepha dependency injection container
* - **Loses the message if it throws** — errors are logged, not retried.
* Use `$job` when failed work must be retried or recorded.
*
* **Handler Best Practices**:
* - Keep handlers focused on a single responsibility
* - Use proper error handling and logging
* - Make operations idempotent when possible
* - Validate critical business logic within handlers
* - Consider using transactions for data consistency
*
* @param message - The queue message with validated payload
* @returns Promise that resolves when processing is complete
*
* @example
* ```ts
* handler: async (message) => {
* const { userId, email, template } = message.payload;
*
* try {
* await this.emailService.send({
* to: email,
* template,
* data: { userId }
* });
*
* await this.userService.markEmailSent(userId, template);
* } catch (error) {
* // The message is NOT redelivered after a throw — record the
* // failure yourself, or use $job for retryable work.
* this.logger.error(`Failed to send email to ${email}`, error);
* throw error;
* }
* }
* ```
*/
handler?: (message: QueueMessage<T>) => Promise<void>;
}
// ---------------------------------------------------------------------------------------------------------------------
export class QueuePrimitive<T extends TSchema> extends Primitive<
QueuePrimitiveOptions<T>
> {
protected readonly log = $logger();
protected readonly workerProvider = $inject(WorkerProvider);
public readonly provider = this.$provider();
public async push(...payloads: Array<Static<T>>) {
await Promise.all(
payloads.map((payload) =>
this.provider.push(
this.name,
JSON.stringify({
headers: {},
payload: this.alepha.codec.decode(this.options.schema, payload),
}),
),
),
);
this.log.debug(`Pushed to queue ${this.name}`, payloads);
this.workerProvider.wakeUp();
}
public get name() {
return this.options.name || this.config.propertyKey;
}
protected $provider() {
if (!this.options.provider) {
return this.alepha.inject(QueueProvider);
}
if (this.options.provider === "memory") {
return this.alepha.inject(MemoryQueueProvider);
}
return this.alepha.inject(this.options.provider);
}
}
$queue[KIND] = QueuePrimitive;
// ---------------------------------------------------------------------------------------------------------------------
export interface QueueMessageSchema {
payload: TSchema;
}
export interface QueueMessage<T extends TSchema> {
payload: Static<T>;
}