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.
Files changed (39) hide show
  1. package/README.md +85 -14
  2. package/dist/server/db/schema-pg.d.ts +262 -0
  3. package/dist/server/db/schema-pg.js +20 -0
  4. package/dist/server/db/schema-sqlite.d.ts +276 -0
  5. package/dist/server/db/schema-sqlite.js +20 -0
  6. package/dist/server/db/types.d.ts +18 -0
  7. package/dist/server/endpoint/guards.d.ts +5 -0
  8. package/dist/server/endpoint/guards.js +19 -3
  9. package/dist/server/endpoint/index.js +4 -0
  10. package/dist/server/endpoint/types.d.ts +7 -0
  11. package/dist/server/index.d.ts +4 -0
  12. package/dist/server/index.js +4 -0
  13. package/dist/server/jobs/events.d.ts +29 -0
  14. package/dist/server/jobs/events.js +74 -0
  15. package/dist/server/jobs/index.d.ts +4 -0
  16. package/dist/server/jobs/index.js +3 -0
  17. package/dist/server/jobs/jobs.test.d.ts +1 -0
  18. package/dist/server/jobs/jobs.test.js +189 -0
  19. package/dist/server/jobs/queue.d.ts +14 -0
  20. package/dist/server/jobs/queue.js +90 -0
  21. package/dist/server/jobs/runner.d.ts +12 -0
  22. package/dist/server/jobs/runner.js +176 -0
  23. package/dist/server/jobs/types.d.ts +136 -0
  24. package/dist/server/jobs/types.js +1 -0
  25. package/dist/server/openapi/generator.d.ts +5 -0
  26. package/dist/server/openapi/generator.js +317 -0
  27. package/dist/server/openapi/handler.d.ts +7 -0
  28. package/dist/server/openapi/handler.js +117 -0
  29. package/dist/server/openapi/index.d.ts +5 -0
  30. package/dist/server/openapi/index.js +4 -0
  31. package/dist/server/openapi/openapi.test.d.ts +1 -0
  32. package/dist/server/openapi/openapi.test.js +219 -0
  33. package/dist/server/openapi/registry.d.ts +35 -0
  34. package/dist/server/openapi/registry.js +51 -0
  35. package/dist/server/openapi/schema.d.ts +4 -0
  36. package/dist/server/openapi/schema.js +59 -0
  37. package/dist/server/openapi/types.d.ts +127 -0
  38. package/dist/server/openapi/types.js +1 -0
  39. 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
- return (event) => {
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
- return (event) => {
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
- return (event) => {
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
  */
@@ -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';
@@ -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,3 @@
1
+ export { enqueueJob, parseJobDelay, memoryJobStore } from './queue';
2
+ export { defineWorker, createJobRunner } from './runner';
3
+ export { EventBus, eventBus } from './events';
@@ -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;