yaxa-svelte 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -14
- package/dist/server/db/schema-pg.d.ts +262 -0
- package/dist/server/db/schema-pg.js +20 -0
- package/dist/server/db/schema-sqlite.d.ts +276 -0
- package/dist/server/db/schema-sqlite.js +20 -0
- package/dist/server/db/types.d.ts +18 -0
- package/dist/server/endpoint/guards.d.ts +5 -0
- package/dist/server/endpoint/guards.js +19 -3
- package/dist/server/endpoint/index.js +4 -0
- package/dist/server/endpoint/types.d.ts +7 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/jobs/events.d.ts +29 -0
- package/dist/server/jobs/events.js +74 -0
- package/dist/server/jobs/index.d.ts +4 -0
- package/dist/server/jobs/index.js +3 -0
- package/dist/server/jobs/jobs.test.d.ts +1 -0
- package/dist/server/jobs/jobs.test.js +189 -0
- package/dist/server/jobs/queue.d.ts +14 -0
- package/dist/server/jobs/queue.js +90 -0
- package/dist/server/jobs/runner.d.ts +12 -0
- package/dist/server/jobs/runner.js +176 -0
- package/dist/server/jobs/types.d.ts +136 -0
- package/dist/server/jobs/types.js +1 -0
- package/dist/server/openapi/generator.d.ts +5 -0
- package/dist/server/openapi/generator.js +317 -0
- package/dist/server/openapi/handler.d.ts +7 -0
- package/dist/server/openapi/handler.js +117 -0
- package/dist/server/openapi/index.d.ts +5 -0
- package/dist/server/openapi/index.js +4 -0
- package/dist/server/openapi/openapi.test.d.ts +1 -0
- package/dist/server/openapi/openapi.test.js +219 -0
- package/dist/server/openapi/registry.d.ts +35 -0
- package/dist/server/openapi/registry.js +51 -0
- package/dist/server/openapi/schema.d.ts +4 -0
- package/dist/server/openapi/schema.js +59 -0
- package/dist/server/openapi/types.d.ts +127 -0
- package/dist/server/openapi/types.js +1 -0
- package/package.json +1 -1
|
@@ -146,3 +146,23 @@ export const subscription = sqliteTable('subscription', {
|
|
|
146
146
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
147
147
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
|
|
148
148
|
});
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Background Jobs & Workflows Queue (SQLite / Turso)
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
export const job = sqliteTable('yaxa_job', {
|
|
153
|
+
id: text('id').primaryKey(),
|
|
154
|
+
queue: text('queue').notNull().default('default'),
|
|
155
|
+
name: text('name').notNull(),
|
|
156
|
+
payload: text('payload').notNull(),
|
|
157
|
+
status: text('status').notNull().default('pending'), // 'pending' | 'processing' | 'completed' | 'failed'
|
|
158
|
+
priority: integer('priority').notNull().default(0),
|
|
159
|
+
attempts: integer('attempts').notNull().default(0),
|
|
160
|
+
maxAttempts: integer('max_attempts').notNull().default(3),
|
|
161
|
+
runAt: integer('run_at', { mode: 'timestamp' }).notNull(),
|
|
162
|
+
lockedAt: integer('locked_at', { mode: 'timestamp' }),
|
|
163
|
+
lockedBy: text('locked_by'),
|
|
164
|
+
failedReason: text('failed_reason'),
|
|
165
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
166
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
|
167
|
+
completedAt: integer('completed_at', { mode: 'timestamp' })
|
|
168
|
+
});
|
|
@@ -70,3 +70,21 @@ export interface PricingTier {
|
|
|
70
70
|
buttonText?: string;
|
|
71
71
|
features: (string | PricingFeature)[];
|
|
72
72
|
}
|
|
73
|
+
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
|
74
|
+
export interface YaxaJob<TPayload = any> {
|
|
75
|
+
id: string;
|
|
76
|
+
queue: string;
|
|
77
|
+
name: string;
|
|
78
|
+
payload: TPayload;
|
|
79
|
+
status: JobStatus;
|
|
80
|
+
priority: number;
|
|
81
|
+
attempts: number;
|
|
82
|
+
maxAttempts: number;
|
|
83
|
+
runAt: Date;
|
|
84
|
+
lockedAt?: Date | null;
|
|
85
|
+
lockedBy?: string | null;
|
|
86
|
+
failedReason?: string | null;
|
|
87
|
+
createdAt: Date;
|
|
88
|
+
updatedAt: Date;
|
|
89
|
+
completedAt?: Date | null;
|
|
90
|
+
}
|
|
@@ -11,6 +11,11 @@ export interface RequireAuthOptions {
|
|
|
11
11
|
*/
|
|
12
12
|
detail?: string;
|
|
13
13
|
}
|
|
14
|
+
export interface YaxaGuardMetadata {
|
|
15
|
+
type: 'auth' | 'role' | 'rateLimit' | string;
|
|
16
|
+
roles?: string[];
|
|
17
|
+
options?: any;
|
|
18
|
+
}
|
|
14
19
|
/**
|
|
15
20
|
* Declarative Guard: Enforces that the request has an active authenticated user in `event.locals.user`.
|
|
16
21
|
*/
|
|
@@ -4,7 +4,7 @@ import { problemResponse } from './problem';
|
|
|
4
4
|
* Declarative Guard: Enforces that the request has an active authenticated user in `event.locals.user`.
|
|
5
5
|
*/
|
|
6
6
|
export function requireAuth(options = {}) {
|
|
7
|
-
|
|
7
|
+
const guard = (event) => {
|
|
8
8
|
const user = event.locals?.user;
|
|
9
9
|
if (!user) {
|
|
10
10
|
if (options.redirect) {
|
|
@@ -22,6 +22,11 @@ export function requireAuth(options = {}) {
|
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
|
+
guard._yaxaGuard = {
|
|
26
|
+
type: 'auth',
|
|
27
|
+
options
|
|
28
|
+
};
|
|
29
|
+
return guard;
|
|
25
30
|
}
|
|
26
31
|
/**
|
|
27
32
|
* Declarative Guard: Enforces that the active user/member possesses one of the allowed roles.
|
|
@@ -29,7 +34,7 @@ export function requireAuth(options = {}) {
|
|
|
29
34
|
*/
|
|
30
35
|
export function requireRole(roles, options = {}) {
|
|
31
36
|
const allowedRoles = Array.isArray(roles) ? roles : [roles];
|
|
32
|
-
|
|
37
|
+
const guard = (event) => {
|
|
33
38
|
const locals = event.locals;
|
|
34
39
|
const user = locals?.user;
|
|
35
40
|
if (!user) {
|
|
@@ -54,6 +59,12 @@ export function requireRole(roles, options = {}) {
|
|
|
54
59
|
});
|
|
55
60
|
}
|
|
56
61
|
};
|
|
62
|
+
guard._yaxaGuard = {
|
|
63
|
+
type: 'role',
|
|
64
|
+
roles: allowedRoles,
|
|
65
|
+
options
|
|
66
|
+
};
|
|
67
|
+
return guard;
|
|
57
68
|
}
|
|
58
69
|
const rateLimitStore = new Map();
|
|
59
70
|
// Periodic cleanup of stale rate-limit buckets (every 5 minutes)
|
|
@@ -77,7 +88,7 @@ if (typeof setInterval !== 'undefined') {
|
|
|
77
88
|
export function rateLimit(options = {}) {
|
|
78
89
|
const points = options.points ?? 60;
|
|
79
90
|
const windowMs = (options.windowSeconds ?? 60) * 1000;
|
|
80
|
-
|
|
91
|
+
const guard = (event) => {
|
|
81
92
|
let key;
|
|
82
93
|
if (options.keyGenerator) {
|
|
83
94
|
key = options.keyGenerator(event);
|
|
@@ -122,6 +133,11 @@ export function rateLimit(options = {}) {
|
|
|
122
133
|
}
|
|
123
134
|
bucket.tokens -= 1;
|
|
124
135
|
};
|
|
136
|
+
guard._yaxaGuard = {
|
|
137
|
+
type: 'rateLimit',
|
|
138
|
+
options: { points, windowSeconds: options.windowSeconds ?? 60 }
|
|
139
|
+
};
|
|
140
|
+
return guard;
|
|
125
141
|
}
|
|
126
142
|
/**
|
|
127
143
|
* Combines multiple guards into a single composite guard.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isProblemDetails, problemResponse } from './problem';
|
|
2
2
|
import { validateData } from './validate';
|
|
3
|
+
import { globalApiRegistry } from '../openapi/registry';
|
|
3
4
|
/**
|
|
4
5
|
* Parses URLSearchParams into a plain key-value object.
|
|
5
6
|
* Duplicate keys are parsed into arrays.
|
|
@@ -230,6 +231,9 @@ export function yaxaEndpoint(config) {
|
|
|
230
231
|
};
|
|
231
232
|
// Attach configuration metadata for runtime reflection and OpenAPI generation (Milestone 3)
|
|
232
233
|
handler._yaxaConfig = config;
|
|
234
|
+
if (config.path && config.method) {
|
|
235
|
+
globalApiRegistry.register(config.path, { [config.method]: handler });
|
|
236
|
+
}
|
|
233
237
|
return handler;
|
|
234
238
|
}
|
|
235
239
|
export * from './types';
|
|
@@ -137,6 +137,13 @@ export interface YaxaEndpointConfig<TBodySchema = any, TQuerySchema = any, TPara
|
|
|
137
137
|
description?: string;
|
|
138
138
|
tags?: string[];
|
|
139
139
|
deprecated?: boolean;
|
|
140
|
+
operationId?: string;
|
|
141
|
+
path?: string;
|
|
142
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | string;
|
|
143
|
+
responses?: Record<number, {
|
|
144
|
+
description: string;
|
|
145
|
+
schema?: any;
|
|
146
|
+
}>;
|
|
140
147
|
/**
|
|
141
148
|
* Core business logic handler.
|
|
142
149
|
*/
|
package/dist/server/index.d.ts
CHANGED
|
@@ -24,3 +24,7 @@ export { getS3Client, createPresignedUploadUrl, createS3UploadHandler } from './
|
|
|
24
24
|
export type { S3StorageConfig, PresignedUploadOptions, PresignedUploadResult, S3UploadHandlerOptions } from './storage/index';
|
|
25
25
|
export { yaxaEndpoint, createProblemDetails, problemResponse, isProblemDetails, requireAuth, requireRole, rateLimit, composeGuards, validateData } from './endpoint/index';
|
|
26
26
|
export type { YaxaEndpointConfig, YaxaEndpointContext, YaxaEndpointSchemaConfig, YaxaInterceptor, Guard, GuardResult, ProblemDetails, CreateProblemOptions, RequireAuthOptions, RequireRoleOptions, RateLimitOptions, AnyValidator, InferValidatorOutput, ValidationOutcome, ValidationSuccess, ValidationFailure } from './endpoint/index';
|
|
27
|
+
export { generateOpenApiSpec, createApiDocsHandler, createApiRegistry, globalApiRegistry, convertToJsonSchema } from './openapi/index';
|
|
28
|
+
export type { OpenApiSpec31, OpenApiOperation, OpenApiParameter, OpenApiRequestBody, OpenApiResponse, GenerateOpenApiOptions, ApiDocsOptions, ApiDocsTheme, EndpointLike, EndpointMap } from './openapi/index';
|
|
29
|
+
export { enqueueJob, defineWorker, createJobRunner, eventBus, EventBus, parseJobDelay } from './jobs/index';
|
|
30
|
+
export type { JobRecord, JobDelay, EnqueueJobOptions, WorkerContext, WorkerHandler, WorkerDefinition, JobRunnerOptions, JobBatchResult, JobRunner, EventHandler, EventSubscription, IEventBus } from './jobs/index';
|
package/dist/server/index.js
CHANGED
|
@@ -20,3 +20,7 @@ export { getResendClient, sendMagicLinkEmail, sendWelcomeEmail } from './email/i
|
|
|
20
20
|
export { getS3Client, createPresignedUploadUrl, createS3UploadHandler } from './storage/index';
|
|
21
21
|
// Endpoint Pipeline, Guards & RFC 7807 Validation (Milestone 1)
|
|
22
22
|
export { yaxaEndpoint, createProblemDetails, problemResponse, isProblemDetails, requireAuth, requireRole, rateLimit, composeGuards, validateData } from './endpoint/index';
|
|
23
|
+
// Auto-Generated OpenAPI 3.1 & Interactive Docs (Milestone 3)
|
|
24
|
+
export { generateOpenApiSpec, createApiDocsHandler, createApiRegistry, globalApiRegistry, convertToJsonSchema } from './openapi/index';
|
|
25
|
+
// Embedded Background Workflows, Job Queue & Domain Events (Milestone 6)
|
|
26
|
+
export { enqueueJob, defineWorker, createJobRunner, eventBus, EventBus, parseJobDelay } from './jobs/index';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { EventHandler, EventSubscription, IEventBus } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* In-process Domain Event Bus for decoupled asynchronous event notifications.
|
|
4
|
+
*/
|
|
5
|
+
export declare class EventBus implements IEventBus {
|
|
6
|
+
private subscribers;
|
|
7
|
+
private counter;
|
|
8
|
+
/**
|
|
9
|
+
* Subscribes a handler to an event.
|
|
10
|
+
*/
|
|
11
|
+
subscribe<TData = any>(eventName: string, handler: EventHandler<TData>): EventSubscription;
|
|
12
|
+
/**
|
|
13
|
+
* Subscribes a handler to an event for exactly one invocation.
|
|
14
|
+
*/
|
|
15
|
+
once<TData = any>(eventName: string, handler: EventHandler<TData>): EventSubscription;
|
|
16
|
+
/**
|
|
17
|
+
* Publishes an event to all registered subscribers.
|
|
18
|
+
* Handlers run asynchronously and errors in one subscriber do not crash others.
|
|
19
|
+
*/
|
|
20
|
+
publish<TData = any>(eventName: string, data: TData): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Clears all registered subscribers. Useful for test isolation.
|
|
23
|
+
*/
|
|
24
|
+
clear(): void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Global singleton EventBus instance.
|
|
28
|
+
*/
|
|
29
|
+
export declare const eventBus: EventBus;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process Domain Event Bus for decoupled asynchronous event notifications.
|
|
3
|
+
*/
|
|
4
|
+
export class EventBus {
|
|
5
|
+
subscribers = new Map();
|
|
6
|
+
counter = 0;
|
|
7
|
+
/**
|
|
8
|
+
* Subscribes a handler to an event.
|
|
9
|
+
*/
|
|
10
|
+
subscribe(eventName, handler) {
|
|
11
|
+
const id = `sub_${++this.counter}_${Date.now()}`;
|
|
12
|
+
const list = this.subscribers.get(eventName) || [];
|
|
13
|
+
list.push({ id, handler });
|
|
14
|
+
this.subscribers.set(eventName, list);
|
|
15
|
+
return {
|
|
16
|
+
unsubscribe: () => {
|
|
17
|
+
const current = this.subscribers.get(eventName);
|
|
18
|
+
if (current) {
|
|
19
|
+
this.subscribers.set(eventName, current.filter((item) => item.id !== id));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Subscribes a handler to an event for exactly one invocation.
|
|
26
|
+
*/
|
|
27
|
+
once(eventName, handler) {
|
|
28
|
+
const id = `sub_once_${++this.counter}_${Date.now()}`;
|
|
29
|
+
const list = this.subscribers.get(eventName) || [];
|
|
30
|
+
list.push({ id, handler, once: true });
|
|
31
|
+
this.subscribers.set(eventName, list);
|
|
32
|
+
return {
|
|
33
|
+
unsubscribe: () => {
|
|
34
|
+
const current = this.subscribers.get(eventName);
|
|
35
|
+
if (current) {
|
|
36
|
+
this.subscribers.set(eventName, current.filter((item) => item.id !== id));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Publishes an event to all registered subscribers.
|
|
43
|
+
* Handlers run asynchronously and errors in one subscriber do not crash others.
|
|
44
|
+
*/
|
|
45
|
+
async publish(eventName, data) {
|
|
46
|
+
const current = this.subscribers.get(eventName);
|
|
47
|
+
if (!current || current.length === 0) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Filter out one-time subscribers
|
|
51
|
+
const toRun = [...current];
|
|
52
|
+
const remaining = current.filter((item) => !item.once);
|
|
53
|
+
this.subscribers.set(eventName, remaining);
|
|
54
|
+
const promises = toRun.map(async (sub) => {
|
|
55
|
+
try {
|
|
56
|
+
await sub.handler(data);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
console.error(`[EventBus] Error in subscriber for event "${eventName}":`, err);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
await Promise.allSettled(promises);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Clears all registered subscribers. Useful for test isolation.
|
|
66
|
+
*/
|
|
67
|
+
clear() {
|
|
68
|
+
this.subscribers.clear();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Global singleton EventBus instance.
|
|
73
|
+
*/
|
|
74
|
+
export const eventBus = new EventBus();
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { enqueueJob, parseJobDelay, memoryJobStore } from './queue';
|
|
2
|
+
export { defineWorker, createJobRunner } from './runner';
|
|
3
|
+
export { EventBus, eventBus } from './events';
|
|
4
|
+
export type { JobRecord, JobDelay, EnqueueJobOptions, WorkerContext, WorkerHandler, WorkerDefinition, JobRunnerOptions, JobBatchResult, JobRunner, EventHandler, EventSubscription, IEventBus } from './types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { EventBus, eventBus, enqueueJob, defineWorker, createJobRunner, parseJobDelay, memoryJobStore } from './index.js';
|
|
3
|
+
describe('Domain Event Bus (EventBus)', () => {
|
|
4
|
+
let bus;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
bus = new EventBus();
|
|
7
|
+
eventBus.clear();
|
|
8
|
+
});
|
|
9
|
+
it('dispatches events asynchronously to registered subscribers', async () => {
|
|
10
|
+
const received = [];
|
|
11
|
+
bus.subscribe('user.registered', async (data) => {
|
|
12
|
+
received.push(data);
|
|
13
|
+
});
|
|
14
|
+
await bus.publish('user.registered', { id: 'usr_1', email: 'alex@example.com' });
|
|
15
|
+
expect(received).toHaveLength(1);
|
|
16
|
+
expect(received[0].email).toBe('alex@example.com');
|
|
17
|
+
});
|
|
18
|
+
it('supports unsubscription via returned handler', async () => {
|
|
19
|
+
let count = 0;
|
|
20
|
+
const sub = bus.subscribe('counter.increment', () => {
|
|
21
|
+
count++;
|
|
22
|
+
});
|
|
23
|
+
await bus.publish('counter.increment', {});
|
|
24
|
+
expect(count).toBe(1);
|
|
25
|
+
sub.unsubscribe();
|
|
26
|
+
await bus.publish('counter.increment', {});
|
|
27
|
+
expect(count).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
it('supports one-time subscribers with once()', async () => {
|
|
30
|
+
let triggered = 0;
|
|
31
|
+
bus.once('invoice.paid', () => {
|
|
32
|
+
triggered++;
|
|
33
|
+
});
|
|
34
|
+
await bus.publish('invoice.paid', { amount: 100 });
|
|
35
|
+
await bus.publish('invoice.paid', { amount: 200 });
|
|
36
|
+
expect(triggered).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
it('isolates subscriber errors without crashing other subscribers or the publisher', async () => {
|
|
39
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
40
|
+
const healthyReceived = [];
|
|
41
|
+
bus.subscribe('order.placed', () => {
|
|
42
|
+
throw new Error('Failing external analytics webhook');
|
|
43
|
+
});
|
|
44
|
+
bus.subscribe('order.placed', (data) => {
|
|
45
|
+
healthyReceived.push(data.orderId);
|
|
46
|
+
});
|
|
47
|
+
// Publishing should complete without throwing
|
|
48
|
+
await expect(bus.publish('order.placed', { orderId: 'ord_999' })).resolves.not.toThrow();
|
|
49
|
+
expect(healthyReceived).toEqual(['ord_999']);
|
|
50
|
+
errorSpy.mockRestore();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('Job Delay Parser (parseJobDelay)', () => {
|
|
54
|
+
it('parses second, minute, hour, and day string abbreviations', () => {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const in30s = parseJobDelay('30s').getTime();
|
|
57
|
+
expect(in30s).toBeGreaterThanOrEqual(now + 29000);
|
|
58
|
+
expect(in30s).toBeLessThanOrEqual(now + 31000);
|
|
59
|
+
const in5m = parseJobDelay('5m').getTime();
|
|
60
|
+
expect(in5m).toBeGreaterThanOrEqual(now + 5 * 60 * 1000 - 1000);
|
|
61
|
+
const in2h = parseJobDelay('2h').getTime();
|
|
62
|
+
expect(in2h).toBeGreaterThanOrEqual(now + 2 * 60 * 60 * 1000 - 1000);
|
|
63
|
+
const in1d = parseJobDelay('1d').getTime();
|
|
64
|
+
expect(in1d).toBeGreaterThanOrEqual(now + 24 * 60 * 60 * 1000 - 1000);
|
|
65
|
+
});
|
|
66
|
+
it('accepts raw millisecond numbers and Date instances directly', () => {
|
|
67
|
+
const targetDate = new Date(Date.now() + 10000);
|
|
68
|
+
expect(parseJobDelay(targetDate)).toEqual(targetDate);
|
|
69
|
+
const msResult = parseJobDelay(5000).getTime();
|
|
70
|
+
expect(msResult).toBeGreaterThanOrEqual(Date.now() + 4900);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('Job Queue & Runner Engine', () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
memoryJobStore.clear();
|
|
76
|
+
});
|
|
77
|
+
it('enqueues jobs with default metadata and parameters', async () => {
|
|
78
|
+
const job = await enqueueJob('send-email', { to: 'elena@example.com' }, {
|
|
79
|
+
priority: 5,
|
|
80
|
+
maxAttempts: 4
|
|
81
|
+
});
|
|
82
|
+
expect(job.id).toBeDefined();
|
|
83
|
+
expect(job.name).toBe('send-email');
|
|
84
|
+
expect(job.payload.to).toBe('elena@example.com');
|
|
85
|
+
expect(job.status).toBe('pending');
|
|
86
|
+
expect(job.priority).toBe(5);
|
|
87
|
+
expect(job.maxAttempts).toBe(4);
|
|
88
|
+
expect(job.attempts).toBe(0);
|
|
89
|
+
expect(memoryJobStore.has(job.id)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
it('processes pending jobs successfully via defined worker', async () => {
|
|
92
|
+
const processed = [];
|
|
93
|
+
const emailWorker = defineWorker('send-welcome', async (payload) => {
|
|
94
|
+
processed.push(payload.to);
|
|
95
|
+
});
|
|
96
|
+
const runner = createJobRunner({
|
|
97
|
+
workers: [emailWorker]
|
|
98
|
+
});
|
|
99
|
+
const job = await enqueueJob('send-welcome', { to: 'alex@example.com' });
|
|
100
|
+
const result = await runner.processNextBatch();
|
|
101
|
+
expect(result.processed).toBe(1);
|
|
102
|
+
expect(result.succeeded).toBe(1);
|
|
103
|
+
expect(result.failed).toBe(0);
|
|
104
|
+
expect(processed).toEqual(['alex@example.com']);
|
|
105
|
+
// Verify job record state
|
|
106
|
+
const updatedJob = memoryJobStore.get(job.id);
|
|
107
|
+
expect(updatedJob?.status).toBe('completed');
|
|
108
|
+
expect(updatedJob?.completedAt).toBeDefined();
|
|
109
|
+
expect(updatedJob?.lockedAt).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
it('retries failing jobs with exponential backoff when attempts < maxAttempts', async () => {
|
|
112
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
113
|
+
let callCount = 0;
|
|
114
|
+
const flakyWorker = defineWorker('flaky-task', async () => {
|
|
115
|
+
callCount++;
|
|
116
|
+
throw new Error('Temporary 503 from external service');
|
|
117
|
+
});
|
|
118
|
+
const runner = createJobRunner({
|
|
119
|
+
workers: [flakyWorker]
|
|
120
|
+
});
|
|
121
|
+
const job = await enqueueJob('flaky-task', {}, { maxAttempts: 3 });
|
|
122
|
+
const result = await runner.processNextBatch();
|
|
123
|
+
expect(result.processed).toBe(1);
|
|
124
|
+
expect(result.succeeded).toBe(0);
|
|
125
|
+
expect(result.failed).toBe(0); // Not marked permanently failed yet
|
|
126
|
+
expect(callCount).toBe(1);
|
|
127
|
+
const updatedJob = memoryJobStore.get(job.id);
|
|
128
|
+
expect(updatedJob?.attempts).toBe(1);
|
|
129
|
+
expect(updatedJob?.status).toBe('pending');
|
|
130
|
+
expect(updatedJob?.failedReason).toContain('Temporary 503');
|
|
131
|
+
// Backoff placed runAt into the future
|
|
132
|
+
expect(updatedJob?.runAt.getTime()).toBeGreaterThan(Date.now());
|
|
133
|
+
warnSpy.mockRestore();
|
|
134
|
+
});
|
|
135
|
+
it('transitions to Dead Letter Queue (status: failed) when maxAttempts is reached', async () => {
|
|
136
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
137
|
+
const fatalWorker = defineWorker('fatal-task', async () => {
|
|
138
|
+
throw new Error('Permanent database constraint error');
|
|
139
|
+
});
|
|
140
|
+
const runner = createJobRunner({
|
|
141
|
+
workers: [fatalWorker]
|
|
142
|
+
});
|
|
143
|
+
// maxAttempts = 1 -> should fail immediately after 1 attempt
|
|
144
|
+
const job = await enqueueJob('fatal-task', {}, { maxAttempts: 1 });
|
|
145
|
+
const result = await runner.processNextBatch();
|
|
146
|
+
expect(result.processed).toBe(1);
|
|
147
|
+
expect(result.succeeded).toBe(0);
|
|
148
|
+
expect(result.failed).toBe(1);
|
|
149
|
+
const updatedJob = memoryJobStore.get(job.id);
|
|
150
|
+
expect(updatedJob?.status).toBe('failed');
|
|
151
|
+
expect(updatedJob?.attempts).toBe(1);
|
|
152
|
+
expect(updatedJob?.failedReason).toContain('Permanent database constraint');
|
|
153
|
+
errorSpy.mockRestore();
|
|
154
|
+
});
|
|
155
|
+
it('prioritizes high priority jobs before lower priority jobs', async () => {
|
|
156
|
+
const order = [];
|
|
157
|
+
const worker = defineWorker('priority-test', async (payload) => {
|
|
158
|
+
order.push(payload.label);
|
|
159
|
+
});
|
|
160
|
+
const runner = createJobRunner({
|
|
161
|
+
workers: [worker],
|
|
162
|
+
batchSize: 5
|
|
163
|
+
});
|
|
164
|
+
await enqueueJob('priority-test', { label: 'low' }, { priority: 0 });
|
|
165
|
+
await enqueueJob('priority-test', { label: 'high' }, { priority: 10 });
|
|
166
|
+
await enqueueJob('priority-test', { label: 'medium' }, { priority: 5 });
|
|
167
|
+
await runner.processNextBatch();
|
|
168
|
+
expect(order).toEqual(['high', 'medium', 'low']);
|
|
169
|
+
});
|
|
170
|
+
it('skips scheduled delayed jobs until their runAt threshold has passed', async () => {
|
|
171
|
+
const worker = defineWorker('delayed-test', async () => { });
|
|
172
|
+
const runner = createJobRunner({ workers: [worker] });
|
|
173
|
+
// Schedule 1 hour in future
|
|
174
|
+
await enqueueJob('delayed-test', {}, { delay: '1h' });
|
|
175
|
+
const result = await runner.processNextBatch();
|
|
176
|
+
expect(result.processed).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
it('starts and stops continuous background polling cleanly', () => {
|
|
179
|
+
const runner = createJobRunner({
|
|
180
|
+
workers: [],
|
|
181
|
+
pollIntervalMs: 100
|
|
182
|
+
});
|
|
183
|
+
expect(runner.isRunning()).toBe(false);
|
|
184
|
+
runner.start();
|
|
185
|
+
expect(runner.isRunning()).toBe(true);
|
|
186
|
+
runner.stop();
|
|
187
|
+
expect(runner.isRunning()).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EnqueueJobOptions, JobDelay, JobRecord } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Parses delay specifier ('5m', '30s', '2h', number ms, Date) into an absolute Date.
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseJobDelay(delay?: JobDelay): Date;
|
|
6
|
+
/**
|
|
7
|
+
* In-memory fallback job store for unit testing or serverless instances without DB setup.
|
|
8
|
+
*/
|
|
9
|
+
export declare const memoryJobStore: Map<string, JobRecord<any>>;
|
|
10
|
+
/**
|
|
11
|
+
* Enqueues a background job for asynchronous execution.
|
|
12
|
+
* Supports delayed scheduling, priority, custom partitions, and the Transactional Outbox pattern.
|
|
13
|
+
*/
|
|
14
|
+
export declare function enqueueJob<TPayload = any>(name: string, payload: TPayload, options?: EnqueueJobOptions): Promise<JobRecord<TPayload>>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { schemaPg, schemaSqlite } from '../db';
|
|
2
|
+
/**
|
|
3
|
+
* Parses delay specifier ('5m', '30s', '2h', number ms, Date) into an absolute Date.
|
|
4
|
+
*/
|
|
5
|
+
export function parseJobDelay(delay) {
|
|
6
|
+
const now = Date.now();
|
|
7
|
+
if (!delay) {
|
|
8
|
+
return new Date(now);
|
|
9
|
+
}
|
|
10
|
+
if (delay instanceof Date) {
|
|
11
|
+
return delay;
|
|
12
|
+
}
|
|
13
|
+
if (typeof delay === 'number') {
|
|
14
|
+
return new Date(now + Math.max(0, delay));
|
|
15
|
+
}
|
|
16
|
+
if (typeof delay === 'string') {
|
|
17
|
+
const match = delay.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d|ms)?$/i);
|
|
18
|
+
if (match) {
|
|
19
|
+
const value = parseFloat(match[1]);
|
|
20
|
+
const unit = (match[2] || 'ms').toLowerCase();
|
|
21
|
+
const multipliers = {
|
|
22
|
+
s: 1000,
|
|
23
|
+
m: 60 * 1000,
|
|
24
|
+
h: 60 * 60 * 1000,
|
|
25
|
+
d: 24 * 60 * 60 * 1000,
|
|
26
|
+
ms: 1
|
|
27
|
+
};
|
|
28
|
+
const multiplier = multipliers[unit] ?? 1;
|
|
29
|
+
return new Date(now + Math.round(value * multiplier));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return new Date(now);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* In-memory fallback job store for unit testing or serverless instances without DB setup.
|
|
36
|
+
*/
|
|
37
|
+
export const memoryJobStore = new Map();
|
|
38
|
+
/**
|
|
39
|
+
* Enqueues a background job for asynchronous execution.
|
|
40
|
+
* Supports delayed scheduling, priority, custom partitions, and the Transactional Outbox pattern.
|
|
41
|
+
*/
|
|
42
|
+
export async function enqueueJob(name, payload, options = {}) {
|
|
43
|
+
const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
44
|
+
const queue = options.queue || 'default';
|
|
45
|
+
const priority = options.priority ?? 0;
|
|
46
|
+
const maxAttempts = options.maxAttempts ?? 3;
|
|
47
|
+
const runAt = parseJobDelay(options.delay);
|
|
48
|
+
const now = new Date();
|
|
49
|
+
const jobRecord = {
|
|
50
|
+
id,
|
|
51
|
+
queue,
|
|
52
|
+
name,
|
|
53
|
+
payload,
|
|
54
|
+
status: 'pending',
|
|
55
|
+
priority,
|
|
56
|
+
attempts: 0,
|
|
57
|
+
maxAttempts,
|
|
58
|
+
runAt,
|
|
59
|
+
createdAt: now,
|
|
60
|
+
updatedAt: now
|
|
61
|
+
};
|
|
62
|
+
// Save to in-memory store
|
|
63
|
+
memoryJobStore.set(id, jobRecord);
|
|
64
|
+
const client = options.tx || options.db;
|
|
65
|
+
if (client) {
|
|
66
|
+
try {
|
|
67
|
+
const payloadString = JSON.stringify(payload);
|
|
68
|
+
// Determine table schema (detect postgres vs sqlite table)
|
|
69
|
+
const table = client?.schema?.job || schemaPg.job || schemaSqlite.job;
|
|
70
|
+
await client.insert(table).values({
|
|
71
|
+
id,
|
|
72
|
+
queue,
|
|
73
|
+
name,
|
|
74
|
+
payload: payloadString,
|
|
75
|
+
status: 'pending',
|
|
76
|
+
priority,
|
|
77
|
+
attempts: 0,
|
|
78
|
+
maxAttempts,
|
|
79
|
+
runAt,
|
|
80
|
+
createdAt: now,
|
|
81
|
+
updatedAt: now
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
// In case DB insert fails, log error
|
|
86
|
+
console.error(`[JobQueue] Failed to persist job "${name}" (${id}) to database:`, err);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return jobRecord;
|
|
90
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { JobRunner, JobRunnerOptions, WorkerDefinition, WorkerHandler } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Declares a typed worker handler for a specific job name.
|
|
4
|
+
*/
|
|
5
|
+
export declare function defineWorker<TPayload = any>(name: string, handler: WorkerHandler<TPayload>, options?: {
|
|
6
|
+
queue?: string;
|
|
7
|
+
}): WorkerDefinition<TPayload>;
|
|
8
|
+
/**
|
|
9
|
+
* Creates an embedded job runner capable of executing pending jobs either on-demand
|
|
10
|
+
* (for serverless cron invocations) or continuously in the background (for Node.js servers).
|
|
11
|
+
*/
|
|
12
|
+
export declare function createJobRunner(options: JobRunnerOptions): JobRunner;
|