@vobase/core 0.10.0 → 0.11.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 (188) hide show
  1. package/package.json +7 -9
  2. package/src/__tests__/drizzle-introspection.test.ts +77 -0
  3. package/src/__tests__/e2e.test.ts +225 -0
  4. package/src/__tests__/permissions.test.ts +157 -0
  5. package/src/__tests__/rpc-types.test.ts +92 -0
  6. package/src/app.test.ts +99 -0
  7. package/src/app.ts +178 -0
  8. package/src/audit.test.ts +126 -0
  9. package/src/auth.test.ts +74 -0
  10. package/src/contracts/auth.ts +37 -0
  11. package/{dist/contracts/module.d.ts → src/contracts/module.ts} +6 -6
  12. package/src/contracts/notify.ts +47 -0
  13. package/src/contracts/permissions.ts +10 -0
  14. package/src/contracts/storage.ts +61 -0
  15. package/src/ctx.test.ts +162 -0
  16. package/src/ctx.ts +64 -0
  17. package/src/db/client.test.ts +75 -0
  18. package/src/db/client.ts +15 -0
  19. package/src/db/helpers.test.ts +147 -0
  20. package/src/db/helpers.ts +51 -0
  21. package/src/db/index.ts +8 -0
  22. package/{dist/index.d.ts → src/index.ts} +103 -6
  23. package/src/infra/circuit-breaker.test.ts +74 -0
  24. package/src/infra/circuit-breaker.ts +57 -0
  25. package/src/infra/errors.test.ts +175 -0
  26. package/src/infra/errors.ts +64 -0
  27. package/src/infra/http-client.test.ts +482 -0
  28. package/src/infra/http-client.ts +221 -0
  29. package/src/infra/index.ts +35 -0
  30. package/src/infra/job.test.ts +85 -0
  31. package/src/infra/job.ts +94 -0
  32. package/src/infra/logger.test.ts +65 -0
  33. package/src/infra/logger.ts +18 -0
  34. package/src/infra/queue.test.ts +46 -0
  35. package/src/infra/queue.ts +147 -0
  36. package/src/infra/throw-proxy.test.ts +34 -0
  37. package/src/infra/throw-proxy.ts +17 -0
  38. package/src/infra/webhooks-schema.ts +17 -0
  39. package/src/infra/webhooks.test.ts +364 -0
  40. package/src/infra/webhooks.ts +146 -0
  41. package/src/mcp/auth.test.ts +129 -0
  42. package/src/mcp/crud.test.ts +128 -0
  43. package/src/mcp/crud.ts +171 -0
  44. package/{dist/mcp/index.d.ts → src/mcp/index.ts} +0 -1
  45. package/src/mcp/server.test.ts +153 -0
  46. package/src/mcp/server.ts +178 -0
  47. package/src/middleware/audit.test.ts +169 -0
  48. package/src/module-registry.ts +18 -0
  49. package/src/module.test.ts +168 -0
  50. package/src/module.ts +111 -0
  51. package/src/modules/audit/index.ts +18 -0
  52. package/src/modules/audit/middleware.ts +33 -0
  53. package/src/modules/audit/schema.ts +35 -0
  54. package/src/modules/audit/track-changes.ts +70 -0
  55. package/src/modules/auth/audit-hooks.ts +74 -0
  56. package/src/modules/auth/index.ts +101 -0
  57. package/src/modules/auth/middleware.ts +51 -0
  58. package/src/modules/auth/permissions.ts +46 -0
  59. package/src/modules/auth/schema.ts +184 -0
  60. package/src/modules/credentials/encrypt.ts +95 -0
  61. package/src/modules/credentials/index.ts +15 -0
  62. package/src/modules/credentials/schema.ts +10 -0
  63. package/src/modules/notify/index.ts +90 -0
  64. package/src/modules/notify/notify.test.ts +145 -0
  65. package/src/modules/notify/providers/resend.ts +47 -0
  66. package/src/modules/notify/providers/smtp.ts +117 -0
  67. package/src/modules/notify/providers/waba.ts +82 -0
  68. package/src/modules/notify/schema.ts +27 -0
  69. package/src/modules/notify/service.ts +93 -0
  70. package/src/modules/sequences/index.ts +15 -0
  71. package/src/modules/sequences/next-sequence.ts +48 -0
  72. package/src/modules/sequences/schema.ts +12 -0
  73. package/src/modules/storage/index.ts +44 -0
  74. package/src/modules/storage/providers/local.ts +124 -0
  75. package/src/modules/storage/providers/s3.ts +83 -0
  76. package/src/modules/storage/routes.ts +76 -0
  77. package/src/modules/storage/schema.ts +26 -0
  78. package/src/modules/storage/service.ts +202 -0
  79. package/src/modules/storage/storage.test.ts +225 -0
  80. package/src/schemas.test.ts +44 -0
  81. package/src/schemas.ts +63 -0
  82. package/src/sequence.test.ts +56 -0
  83. package/dist/app.d.ts +0 -37
  84. package/dist/app.d.ts.map +0 -1
  85. package/dist/contracts/auth.d.ts +0 -35
  86. package/dist/contracts/auth.d.ts.map +0 -1
  87. package/dist/contracts/module.d.ts.map +0 -1
  88. package/dist/contracts/notify.d.ts +0 -46
  89. package/dist/contracts/notify.d.ts.map +0 -1
  90. package/dist/contracts/permissions.d.ts +0 -10
  91. package/dist/contracts/permissions.d.ts.map +0 -1
  92. package/dist/contracts/storage.d.ts +0 -54
  93. package/dist/contracts/storage.d.ts.map +0 -1
  94. package/dist/ctx.d.ts +0 -40
  95. package/dist/ctx.d.ts.map +0 -1
  96. package/dist/db/client.d.ts +0 -4
  97. package/dist/db/client.d.ts.map +0 -1
  98. package/dist/db/helpers.d.ts +0 -26
  99. package/dist/db/helpers.d.ts.map +0 -1
  100. package/dist/db/index.d.ts +0 -3
  101. package/dist/db/index.d.ts.map +0 -1
  102. package/dist/index.d.ts.map +0 -1
  103. package/dist/index.js +0 -98611
  104. package/dist/infra/circuit-breaker.d.ts +0 -17
  105. package/dist/infra/circuit-breaker.d.ts.map +0 -1
  106. package/dist/infra/errors.d.ts +0 -26
  107. package/dist/infra/errors.d.ts.map +0 -1
  108. package/dist/infra/http-client.d.ts +0 -31
  109. package/dist/infra/http-client.d.ts.map +0 -1
  110. package/dist/infra/index.d.ts +0 -11
  111. package/dist/infra/index.d.ts.map +0 -1
  112. package/dist/infra/job.d.ts +0 -14
  113. package/dist/infra/job.d.ts.map +0 -1
  114. package/dist/infra/logger.d.ts +0 -7
  115. package/dist/infra/logger.d.ts.map +0 -1
  116. package/dist/infra/queue.d.ts +0 -18
  117. package/dist/infra/queue.d.ts.map +0 -1
  118. package/dist/infra/throw-proxy.d.ts +0 -7
  119. package/dist/infra/throw-proxy.d.ts.map +0 -1
  120. package/dist/infra/webhooks-schema.d.ts +0 -60
  121. package/dist/infra/webhooks-schema.d.ts.map +0 -1
  122. package/dist/infra/webhooks.d.ts +0 -46
  123. package/dist/infra/webhooks.d.ts.map +0 -1
  124. package/dist/mcp/crud.d.ts +0 -12
  125. package/dist/mcp/crud.d.ts.map +0 -1
  126. package/dist/mcp/index.d.ts.map +0 -1
  127. package/dist/mcp/server.d.ts +0 -16
  128. package/dist/mcp/server.d.ts.map +0 -1
  129. package/dist/module-registry.d.ts +0 -3
  130. package/dist/module-registry.d.ts.map +0 -1
  131. package/dist/module.d.ts +0 -29
  132. package/dist/module.d.ts.map +0 -1
  133. package/dist/modules/audit/index.d.ts +0 -5
  134. package/dist/modules/audit/index.d.ts.map +0 -1
  135. package/dist/modules/audit/middleware.d.ts +0 -3
  136. package/dist/modules/audit/middleware.d.ts.map +0 -1
  137. package/dist/modules/audit/schema.d.ts +0 -247
  138. package/dist/modules/audit/schema.d.ts.map +0 -1
  139. package/dist/modules/audit/track-changes.d.ts +0 -3
  140. package/dist/modules/audit/track-changes.d.ts.map +0 -1
  141. package/dist/modules/auth/audit-hooks.d.ts +0 -6
  142. package/dist/modules/auth/audit-hooks.d.ts.map +0 -1
  143. package/dist/modules/auth/index.d.ts +0 -25
  144. package/dist/modules/auth/index.d.ts.map +0 -1
  145. package/dist/modules/auth/middleware.d.ts +0 -15
  146. package/dist/modules/auth/middleware.d.ts.map +0 -1
  147. package/dist/modules/auth/permissions.d.ts +0 -5
  148. package/dist/modules/auth/permissions.d.ts.map +0 -1
  149. package/dist/modules/auth/schema.d.ts +0 -2519
  150. package/dist/modules/auth/schema.d.ts.map +0 -1
  151. package/dist/modules/credentials/encrypt.d.ts +0 -12
  152. package/dist/modules/credentials/encrypt.d.ts.map +0 -1
  153. package/dist/modules/credentials/index.d.ts +0 -4
  154. package/dist/modules/credentials/index.d.ts.map +0 -1
  155. package/dist/modules/credentials/schema.d.ts +0 -56
  156. package/dist/modules/credentials/schema.d.ts.map +0 -1
  157. package/dist/modules/notify/index.d.ts +0 -36
  158. package/dist/modules/notify/index.d.ts.map +0 -1
  159. package/dist/modules/notify/providers/resend.d.ts +0 -7
  160. package/dist/modules/notify/providers/resend.d.ts.map +0 -1
  161. package/dist/modules/notify/providers/smtp.d.ts +0 -18
  162. package/dist/modules/notify/providers/smtp.d.ts.map +0 -1
  163. package/dist/modules/notify/providers/waba.d.ts +0 -12
  164. package/dist/modules/notify/providers/waba.d.ts.map +0 -1
  165. package/dist/modules/notify/schema.d.ts +0 -337
  166. package/dist/modules/notify/schema.d.ts.map +0 -1
  167. package/dist/modules/notify/service.d.ts +0 -22
  168. package/dist/modules/notify/service.d.ts.map +0 -1
  169. package/dist/modules/sequences/index.d.ts +0 -4
  170. package/dist/modules/sequences/index.d.ts.map +0 -1
  171. package/dist/modules/sequences/next-sequence.d.ts +0 -8
  172. package/dist/modules/sequences/next-sequence.d.ts.map +0 -1
  173. package/dist/modules/sequences/schema.d.ts +0 -72
  174. package/dist/modules/sequences/schema.d.ts.map +0 -1
  175. package/dist/modules/storage/index.d.ts +0 -24
  176. package/dist/modules/storage/index.d.ts.map +0 -1
  177. package/dist/modules/storage/providers/local.d.ts +0 -3
  178. package/dist/modules/storage/providers/local.d.ts.map +0 -1
  179. package/dist/modules/storage/providers/s3.d.ts +0 -3
  180. package/dist/modules/storage/providers/s3.d.ts.map +0 -1
  181. package/dist/modules/storage/routes.d.ts +0 -4
  182. package/dist/modules/storage/routes.d.ts.map +0 -1
  183. package/dist/modules/storage/schema.d.ts +0 -273
  184. package/dist/modules/storage/schema.d.ts.map +0 -1
  185. package/dist/modules/storage/service.d.ts +0 -35
  186. package/dist/modules/storage/service.d.ts.map +0 -1
  187. package/dist/schemas.d.ts +0 -19
  188. package/dist/schemas.d.ts.map +0 -1
@@ -0,0 +1,35 @@
1
+ export {
2
+ conflict,
3
+ dbBusy,
4
+ ERROR_CODES,
5
+ type ErrorCode,
6
+ errorHandler,
7
+ forbidden,
8
+ notFound,
9
+ unauthorized,
10
+ VobaseError,
11
+ validation,
12
+ } from './errors';
13
+ export { logger } from './logger';
14
+ export {
15
+ createScheduler,
16
+ configureQueueDataPath,
17
+ DEFAULT_QUEUE_DB_PATH,
18
+ DEFAULT_QUEUE_NAME,
19
+ type JobOptions,
20
+ type Scheduler,
21
+ type SchedulerOptions,
22
+ } from './queue';
23
+ export type { JobDefinition, JobHandler, WorkerOptions } from './job';
24
+ export { createWorker, defineJob, jobRegistry } from './job';
25
+ export { CircuitBreaker, type CircuitBreakerOptions } from './circuit-breaker';
26
+ export {
27
+ createHttpClient,
28
+ type HttpClient,
29
+ type HttpClientOptions,
30
+ type HttpResponse,
31
+ type RequestOptions,
32
+ } from './http-client';
33
+ export { createThrowProxy } from './throw-proxy';
34
+ export { type WebhookConfig, verifyHmacSignature, createWebhookRoutes, webhookDedup } from './webhooks';
35
+ export { webhookDedup as webhookDedupSchema } from './webhooks-schema';
@@ -0,0 +1,85 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test';
3
+ import { shutdownManager } from 'bunqueue/client';
4
+
5
+ import { createWorker, defineJob, jobRegistry } from './job';
6
+ import { createScheduler } from './queue';
7
+
8
+ const TEST_DB_PATH = `/tmp/vobase-bunqueue-${process.pid}.db`;
9
+ const globalScope = globalThis as typeof globalThis & {
10
+ __vobaseBunqueueTestRefs__?: number;
11
+ };
12
+
13
+ function makeQueueName(prefix: string): string {
14
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
15
+ }
16
+
17
+ beforeAll(() => {
18
+ globalScope.__vobaseBunqueueTestRefs__ =
19
+ (globalScope.__vobaseBunqueueTestRefs__ ?? 0) + 1;
20
+ });
21
+
22
+ afterAll(() => {
23
+ const refs = (globalScope.__vobaseBunqueueTestRefs__ ?? 1) - 1;
24
+ globalScope.__vobaseBunqueueTestRefs__ = refs;
25
+
26
+ if (refs === 0) {
27
+ shutdownManager();
28
+ rmSync(TEST_DB_PATH, { force: true });
29
+ rmSync(`${TEST_DB_PATH}-shm`, { force: true });
30
+ rmSync(`${TEST_DB_PATH}-wal`, { force: true });
31
+ }
32
+ });
33
+
34
+ afterEach(() => {
35
+ jobRegistry.clear();
36
+ });
37
+
38
+ describe('defineJob()', () => {
39
+ it('registers a job definition in the module registry', async () => {
40
+ const handler = async (): Promise<void> => {
41
+ await Bun.sleep(0);
42
+ };
43
+
44
+ const definition = defineJob('report.generate', handler);
45
+
46
+ expect(definition.name).toBe('report.generate');
47
+ expect(definition.handler).toBe(handler);
48
+ expect(jobRegistry.get('report.generate')).toBe(handler);
49
+ });
50
+ });
51
+
52
+ describe('createWorker()', () => {
53
+ it('processes an enqueued job end-to-end', async () => {
54
+ const queueName = makeQueueName('worker-roundtrip');
55
+ const scheduler = await createScheduler({ dbPath: TEST_DB_PATH, queueName });
56
+
57
+ let processedData: unknown;
58
+ let resolveProcessed!: () => void;
59
+ const processed = new Promise<void>((resolve) => {
60
+ resolveProcessed = resolve;
61
+ });
62
+
63
+ const job = defineJob('invoice.sync', async (data) => {
64
+ processedData = data;
65
+ resolveProcessed();
66
+ });
67
+
68
+ const worker = await createWorker([job], { dbPath: TEST_DB_PATH, queueName });
69
+
70
+ try {
71
+ await scheduler.add('invoice.sync', { id: 'inv_1' });
72
+
73
+ await Promise.race([
74
+ processed,
75
+ Bun.sleep(1_500).then(() => {
76
+ throw new Error('Timed out waiting for invoice.sync to be processed');
77
+ }),
78
+ ]);
79
+
80
+ expect(processedData).toEqual({ id: 'inv_1' });
81
+ } finally {
82
+ await worker.close();
83
+ }
84
+ });
85
+ });
@@ -0,0 +1,94 @@
1
+ import type {
2
+ WorkerOptions as BunqueueWorkerOptions,
3
+ Job,
4
+ Worker,
5
+ } from 'bunqueue/client';
6
+
7
+ import { validation } from './errors';
8
+ import {
9
+ configureQueueDataPath,
10
+ DEFAULT_QUEUE_DB_PATH,
11
+ DEFAULT_QUEUE_NAME,
12
+ type SchedulerOptions,
13
+ } from './queue';
14
+
15
+ export type JobHandler = (data: unknown) => Promise<void>;
16
+
17
+ export interface JobDefinition {
18
+ name: string;
19
+ handler: JobHandler;
20
+ }
21
+
22
+ export interface WorkerOptions
23
+ extends Pick<SchedulerOptions, 'dbPath' | 'queueName'> {
24
+ concurrency?: number;
25
+ }
26
+
27
+ export const jobRegistry = new Map<string, JobHandler>();
28
+
29
+ function assertJobName(name: string): void {
30
+ if (!name.trim()) {
31
+ throw validation({ name }, 'Job name must be a non-empty string');
32
+ }
33
+ }
34
+
35
+ function assertConcurrency(concurrency: number): void {
36
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
37
+ throw validation(
38
+ { concurrency },
39
+ 'Worker concurrency must be a positive integer',
40
+ );
41
+ }
42
+ }
43
+
44
+ function registerJob(definition: JobDefinition): void {
45
+ assertJobName(definition.name);
46
+ jobRegistry.set(definition.name, definition.handler);
47
+ }
48
+
49
+ async function processJob(job: Job<unknown>): Promise<void> {
50
+ const handler = jobRegistry.get(job.name);
51
+ if (!handler) {
52
+ throw validation(
53
+ { jobName: job.name },
54
+ `No registered handler for job "${job.name}"`,
55
+ );
56
+ }
57
+
58
+ await handler(job.data);
59
+ }
60
+
61
+ export function defineJob(name: string, handler: JobHandler): JobDefinition {
62
+ assertJobName(name);
63
+
64
+ const definition = { name, handler };
65
+ registerJob(definition);
66
+ return definition;
67
+ }
68
+
69
+ export async function createWorker(
70
+ jobs: JobDefinition[],
71
+ options?: WorkerOptions,
72
+ ): Promise<Worker> {
73
+ const { Worker } = await import('bunqueue/client');
74
+
75
+ for (const job of jobs) {
76
+ registerJob(job);
77
+ }
78
+
79
+ const concurrency = options?.concurrency ?? 5;
80
+ assertConcurrency(concurrency);
81
+
82
+ await configureQueueDataPath(options?.dbPath ?? DEFAULT_QUEUE_DB_PATH);
83
+
84
+ const workerOptions: BunqueueWorkerOptions = {
85
+ embedded: true,
86
+ concurrency,
87
+ };
88
+
89
+ return new Worker(
90
+ options?.queueName ?? DEFAULT_QUEUE_NAME,
91
+ processJob,
92
+ workerOptions,
93
+ );
94
+ }
@@ -0,0 +1,65 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+
3
+ import { logger } from './logger';
4
+
5
+ describe('logger', () => {
6
+ let logs: string[] = [];
7
+ const originalLog = console.log;
8
+
9
+ beforeEach(() => {
10
+ logs = [];
11
+ console.log = (msg: string) => logs.push(msg);
12
+ });
13
+
14
+ afterEach(() => {
15
+ console.log = originalLog;
16
+ });
17
+
18
+ it('should log info messages', () => {
19
+ logger.info('test message', { foo: 'bar' });
20
+ expect(logs.length).toBe(1);
21
+ const parsed = JSON.parse(logs[0]);
22
+ expect(parsed.level).toBe('info');
23
+ expect(parsed.msg).toBe('test message');
24
+ expect(parsed.data).toEqual({ foo: 'bar' });
25
+ expect(typeof parsed.ts).toBe('number');
26
+ });
27
+
28
+ it('should log warn messages', () => {
29
+ logger.warn('warning');
30
+ const parsed = JSON.parse(logs[0]);
31
+ expect(parsed.level).toBe('warn');
32
+ expect(parsed.msg).toBe('warning');
33
+ });
34
+
35
+ it('should log error messages', () => {
36
+ logger.error('error occurred', { code: 500 });
37
+ const parsed = JSON.parse(logs[0]);
38
+ expect(parsed.level).toBe('error');
39
+ expect(parsed.msg).toBe('error occurred');
40
+ expect(parsed.data).toEqual({ code: 500 });
41
+ });
42
+
43
+ it('should log debug messages when DEBUG env is set', () => {
44
+ const oldDebug = Bun.env.DEBUG;
45
+ Bun.env.DEBUG = 'true';
46
+ logger.debug('debug info');
47
+ Bun.env.DEBUG = oldDebug;
48
+ expect(logs.length).toBe(1);
49
+ const parsed = JSON.parse(logs[0]);
50
+ expect(parsed.level).toBe('debug');
51
+ });
52
+
53
+ it('should include timestamp as number', () => {
54
+ logger.info('test');
55
+ const parsed = JSON.parse(logs[0]);
56
+ expect(typeof parsed.ts).toBe('number');
57
+ expect(parsed.ts).toBeGreaterThan(0);
58
+ });
59
+
60
+ it('should have data field in output', () => {
61
+ logger.info('message with data', { key: 'value' });
62
+ const parsed = JSON.parse(logs[0]);
63
+ expect(parsed.data).toEqual({ key: 'value' });
64
+ });
65
+ });
@@ -0,0 +1,18 @@
1
+ type LogLevel = 'info' | 'warn' | 'error' | 'debug';
2
+
3
+ function log(level: LogLevel, msg: string, data?: unknown): void {
4
+ if (
5
+ level === 'debug' &&
6
+ !Bun.env.DEBUG &&
7
+ process.env.NODE_ENV === 'production'
8
+ )
9
+ return;
10
+ console.log(JSON.stringify({ level, msg, data, ts: Date.now() }));
11
+ }
12
+
13
+ export const logger = {
14
+ info: (msg: string, data?: unknown) => log('info', msg, data),
15
+ warn: (msg: string, data?: unknown) => log('warn', msg, data),
16
+ error: (msg: string, data?: unknown) => log('error', msg, data),
17
+ debug: (msg: string, data?: unknown) => log('debug', msg, data),
18
+ };
@@ -0,0 +1,46 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
3
+ import { Queue, shutdownManager } from 'bunqueue/client';
4
+
5
+ import { createScheduler } from './queue';
6
+
7
+ const TEST_DB_PATH = `/tmp/vobase-bunqueue-${process.pid}.db`;
8
+ const globalScope = globalThis as typeof globalThis & {
9
+ __vobaseBunqueueTestRefs__?: number;
10
+ };
11
+
12
+ function makeQueueName(prefix: string): string {
13
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
14
+ }
15
+
16
+ beforeAll(() => {
17
+ globalScope.__vobaseBunqueueTestRefs__ =
18
+ (globalScope.__vobaseBunqueueTestRefs__ ?? 0) + 1;
19
+ });
20
+
21
+ afterAll(() => {
22
+ const refs = (globalScope.__vobaseBunqueueTestRefs__ ?? 1) - 1;
23
+ globalScope.__vobaseBunqueueTestRefs__ = refs;
24
+
25
+ if (refs === 0) {
26
+ shutdownManager();
27
+ rmSync(TEST_DB_PATH, { force: true });
28
+ rmSync(`${TEST_DB_PATH}-shm`, { force: true });
29
+ rmSync(`${TEST_DB_PATH}-wal`, { force: true });
30
+ }
31
+ });
32
+
33
+ describe('createScheduler()', () => {
34
+ it('enqueues a job via scheduler.add()', async () => {
35
+ const queueName = makeQueueName('scheduler-add');
36
+ const scheduler = await createScheduler({ dbPath: TEST_DB_PATH, queueName });
37
+
38
+ await scheduler.add('email.send', { to: 'user@example.com' });
39
+
40
+ const queue = new Queue(queueName, { embedded: true });
41
+ const waitingCount = await queue.getWaitingCount();
42
+ queue.close();
43
+
44
+ expect(waitingCount).toBe(1);
45
+ });
46
+ });
@@ -0,0 +1,147 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import type { JobOptions as BunqueueJobOptions } from 'bunqueue/client';
4
+
5
+ import { validation } from './errors';
6
+
7
+ export const DEFAULT_QUEUE_DB_PATH = '/data/bunqueue.db';
8
+ export const DEFAULT_QUEUE_NAME = 'vobase-jobs';
9
+
10
+ export interface JobOptions {
11
+ delay?: number | string;
12
+ priority?: number;
13
+ retry?: number;
14
+ retries?: number;
15
+ }
16
+
17
+ export interface SchedulerOptions {
18
+ dbPath?: string;
19
+ queueName?: string;
20
+ }
21
+
22
+ export interface Scheduler {
23
+ add(jobName: string, data: unknown, options?: JobOptions): Promise<void>;
24
+ }
25
+
26
+ const DELAY_MULTIPLIER: Record<string, number> = {
27
+ ms: 1,
28
+ s: 1000,
29
+ m: 60_000,
30
+ h: 3_600_000,
31
+ d: 86_400_000,
32
+ };
33
+
34
+ function parseDelay(delay?: number | string): number | undefined {
35
+ if (delay === undefined) {
36
+ return undefined;
37
+ }
38
+
39
+ if (typeof delay === 'number') {
40
+ if (!Number.isFinite(delay) || delay < 0) {
41
+ throw validation({ delay }, 'Job delay must be a non-negative number');
42
+ }
43
+ return Math.floor(delay);
44
+ }
45
+
46
+ const value = delay.trim().toLowerCase();
47
+ if (!value) {
48
+ throw validation({ delay }, 'Job delay string cannot be empty');
49
+ }
50
+
51
+ if (/^\d+$/.test(value)) {
52
+ return Number.parseInt(value, 10);
53
+ }
54
+
55
+ const match = value.match(/^(\d+)(ms|s|m|h|d)$/);
56
+ if (!match) {
57
+ throw validation(
58
+ { delay },
59
+ 'Invalid delay format. Use milliseconds or duration suffixes: ms, s, m, h, d',
60
+ );
61
+ }
62
+
63
+ const amount = Number.parseInt(match[1], 10);
64
+ const multiplier = DELAY_MULTIPLIER[match[2]];
65
+ return amount * multiplier;
66
+ }
67
+
68
+ function parseAttempts(options?: JobOptions): number | undefined {
69
+ const retryCount = options?.retries ?? options?.retry;
70
+ if (retryCount === undefined) {
71
+ return undefined;
72
+ }
73
+
74
+ if (!Number.isInteger(retryCount) || retryCount < 0) {
75
+ throw validation(
76
+ { retryCount },
77
+ 'Retry count must be a non-negative integer',
78
+ );
79
+ }
80
+
81
+ return retryCount + 1;
82
+ }
83
+
84
+ function toBunqueueJobOptions(
85
+ options?: JobOptions,
86
+ ): BunqueueJobOptions | undefined {
87
+ if (!options) {
88
+ return undefined;
89
+ }
90
+
91
+ if (options.priority !== undefined) {
92
+ if (!Number.isInteger(options.priority) || options.priority < 0) {
93
+ throw validation(
94
+ { priority: options.priority },
95
+ 'Job priority must be a non-negative integer',
96
+ );
97
+ }
98
+ }
99
+
100
+ const delay = parseDelay(options.delay);
101
+ const attempts = parseAttempts(options);
102
+
103
+ return {
104
+ delay,
105
+ priority: options.priority,
106
+ attempts,
107
+ };
108
+ }
109
+
110
+ export async function configureQueueDataPath(dbPath: string): Promise<string> {
111
+ if (!dbPath.trim()) {
112
+ throw validation({ dbPath }, 'Queue dbPath must be a non-empty string');
113
+ }
114
+
115
+ const existingDataPath = Bun.env.DATA_PATH;
116
+ if (existingDataPath && existingDataPath !== dbPath) {
117
+ const { shutdownManager } = await import('bunqueue/client');
118
+ shutdownManager();
119
+ }
120
+
121
+ mkdirSync(dirname(dbPath), { recursive: true });
122
+ Bun.env.DATA_PATH = dbPath;
123
+ return dbPath;
124
+ }
125
+
126
+ export async function createScheduler(options?: SchedulerOptions): Promise<Scheduler> {
127
+ const { Queue } = await import('bunqueue/client');
128
+
129
+ await configureQueueDataPath(options?.dbPath ?? DEFAULT_QUEUE_DB_PATH);
130
+ const queue = new Queue(options?.queueName ?? DEFAULT_QUEUE_NAME, {
131
+ embedded: true,
132
+ });
133
+
134
+ return {
135
+ async add(
136
+ jobName: string,
137
+ data: unknown,
138
+ options?: JobOptions,
139
+ ): Promise<void> {
140
+ if (!jobName.trim()) {
141
+ throw validation({ jobName }, 'Job name must be a non-empty string');
142
+ }
143
+
144
+ await queue.add(jobName, data, toBunqueueJobOptions(options));
145
+ },
146
+ };
147
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createThrowProxy } from './throw-proxy';
3
+
4
+ interface FakeService {
5
+ doSomething(): void;
6
+ value: string;
7
+ }
8
+
9
+ describe('createThrowProxy', () => {
10
+ test('throws on property access with service name in message', () => {
11
+ const proxy = createThrowProxy<FakeService>('storage');
12
+ expect(() => proxy.value).toThrow('storage is not configured');
13
+ });
14
+
15
+ test('throws on method call with service name in message', () => {
16
+ const proxy = createThrowProxy<FakeService>('notify');
17
+ expect(() => proxy.doSomething()).toThrow('notify is not configured');
18
+ });
19
+
20
+ test('error message includes configuration hint', () => {
21
+ const proxy = createThrowProxy<FakeService>('storage');
22
+ expect(() => proxy.value).toThrow('Add storage configuration');
23
+ });
24
+
25
+ test('does not throw for Symbol.toPrimitive', () => {
26
+ const proxy = createThrowProxy<FakeService>('storage');
27
+ expect((proxy as unknown as Record<symbol, unknown>)[Symbol.toPrimitive]).toBeUndefined();
28
+ });
29
+
30
+ test('does not throw for Symbol.toStringTag', () => {
31
+ const proxy = createThrowProxy<FakeService>('storage');
32
+ expect((proxy as unknown as Record<symbol, unknown>)[Symbol.toStringTag]).toBeUndefined();
33
+ });
34
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Creates a proxy that throws a descriptive error when any property is accessed.
3
+ * Used for optional services (storage, notify) that are typed as non-optional
4
+ * in VobaseCtx but may not be configured.
5
+ */
6
+ export function createThrowProxy<T>(serviceName: string): T {
7
+ return new Proxy(Object.create(null), {
8
+ get(_target, prop) {
9
+ if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag || prop === 'inspect') {
10
+ return undefined;
11
+ }
12
+ throw new Error(
13
+ `${serviceName} is not configured. Add ${serviceName.toLowerCase()} configuration to your createApp() config to use ctx.${serviceName.toLowerCase()}.`,
14
+ );
15
+ },
16
+ }) as T;
17
+ }
@@ -0,0 +1,17 @@
1
+ import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
2
+
3
+ /**
4
+ * Webhook deduplication table. Tracks processed webhook IDs
5
+ * to prevent duplicate processing.
6
+ */
7
+ export const webhookDedup = sqliteTable(
8
+ '_webhook_dedup',
9
+ {
10
+ id: text('id').notNull(),
11
+ source: text('source').notNull(),
12
+ receivedAt: integer('received_at', { mode: 'timestamp_ms' })
13
+ .notNull()
14
+ .$defaultFn(() => new Date()),
15
+ },
16
+ (table) => [primaryKey({ columns: [table.id, table.source] })],
17
+ );