@devindex/api-kit 0.2.0 → 0.3.1
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 +114 -3
- package/cache/drivers/memory.js +43 -0
- package/cache/drivers/redis.js +53 -0
- package/cache/index.js +155 -0
- package/events/drivers/bullmq.js +2 -5
- package/http/index.js +1 -0
- package/http/page.js +13 -0
- package/http/schema.js +8 -0
- package/internal/bullmq.js +0 -26
- package/internal/redis.js +36 -0
- package/jobs/drivers/bullmq.js +2 -5
- package/package.json +6 -2
- package/schedule/drivers/bullmq.js +2 -5
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ driver from the environment.
|
|
|
9
9
|
npm install @devindex/api-kit
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
Node `>=22`. The memory drivers need no external service.
|
|
13
|
-
only on first use:
|
|
12
|
+
Node `>=22`. The memory drivers need no external service. The Redis-backed drivers load their
|
|
13
|
+
optional peers only on first use:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
npm install bullmq ioredis
|
|
@@ -103,7 +103,8 @@ The status is the error's own `status` when it has one, otherwise `STATUS_BY_COD
|
|
|
103
103
|
|
|
104
104
|
Inbound `x-request-id` is reused only when it is a valid UUID, otherwise a fresh v4 is generated; the
|
|
105
105
|
id is always echoed back. `schema.js` ships ODM-agnostic JSON-Schema helpers — `objectSchema`,
|
|
106
|
-
`stringSchema`, `pageQuery`, `email`, `dateTime`, `dateKey`, `clock
|
|
106
|
+
`stringSchema`, `pageQuery`, `pageResponse`, `email`, `dateTime`, `dateKey`, `clock` (see
|
|
107
|
+
[Pagination](#pagination)).
|
|
107
108
|
|
|
108
109
|
### CORS and security headers
|
|
109
110
|
|
|
@@ -145,6 +146,54 @@ await createApp({ helmet: { contentSecurityPolicy: { useDefaults: true } }, rout
|
|
|
145
146
|
Anything else — rate limits, compression — still goes through `plugins`, which registers after these
|
|
146
147
|
and before the routes.
|
|
147
148
|
|
|
149
|
+
### Pagination
|
|
150
|
+
|
|
151
|
+
Offset pagination in two shapes, `{ items, hasMore }` and `{ items, hasMore, total }`, over one rule:
|
|
152
|
+
**the query fetches `limit + 1` rows** and `paginate` slices the extra one off. The kit never runs the
|
|
153
|
+
query — `skip`/`limit` belong to Mongoose, `offset`/`limit` to a SQL builder, a cursor to an upstream
|
|
154
|
+
API — so it only does the arithmetic and the shape, and works with all of them.
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
import { paginate } from '@devindex/api-kit/http';
|
|
158
|
+
|
|
159
|
+
const rows = await Order.find(filter).skip(offset).limit(limit + 1);
|
|
160
|
+
return paginate(rows, limit); // { items, hasMore }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A resource that really needs the count passes it as the third argument, and gets `total` in the
|
|
164
|
+
response:
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
const [rows, total] = await Promise.all([
|
|
168
|
+
Order.find(filter).skip(offset).limit(limit + 1),
|
|
169
|
+
Order.countDocuments(filter),
|
|
170
|
+
]);
|
|
171
|
+
return paginate(rows, limit, total); // { items, hasMore, total }
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`hasMore` always comes from the extra row, never from `total`. Deriving it from the count would make
|
|
175
|
+
it depend on two queries that can disagree — a document inserted between them, and the flag promises
|
|
176
|
+
a page that is not there.
|
|
177
|
+
|
|
178
|
+
`pageQuery` and `pageResponse` are the two ends of the contract. The response schema is not optional
|
|
179
|
+
decoration: Fastify strips whatever it does not declare, so a missing `hasMore` there silently
|
|
180
|
+
disappears from a correct payload.
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
import { pageQuery, pageResponse } from '@devindex/api-kit/http';
|
|
184
|
+
|
|
185
|
+
instance.get('/orders', {
|
|
186
|
+
schema: {
|
|
187
|
+
querystring: pageQuery({ maxLimit: 50 }),
|
|
188
|
+
response: { 200: pageResponse(orderSchema) },
|
|
189
|
+
},
|
|
190
|
+
}, async (req) => orders.list(req.query));
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`pageQuery` applies the defaults (`limit` 20, `offset` 0), so the handler always reads two integers.
|
|
194
|
+
`pageResponse(items, { total: true })` adds `total` to the schema — pass it wherever `paginate` gets
|
|
195
|
+
a count.
|
|
196
|
+
|
|
148
197
|
## `./context`
|
|
149
198
|
|
|
150
199
|
An isolated `AsyncLocalStorage` store, owned by the service, so two services in one process never
|
|
@@ -425,6 +474,68 @@ Every Redis replica upserts the same scheduler and starts an equivalent Worker.
|
|
|
425
474
|
leader; BullMQ coordinates which Worker receives each occurrence. A new occurrence is produced when
|
|
426
475
|
the previous one starts, so global concurrency serializes slow runs rather than overlapping them.
|
|
427
476
|
|
|
477
|
+
## `./cache`
|
|
478
|
+
|
|
479
|
+
Key/value cache with one contract over two stores:
|
|
480
|
+
|
|
481
|
+
```js
|
|
482
|
+
import { createCache } from '@devindex/api-kit/cache';
|
|
483
|
+
|
|
484
|
+
const cache = createCache({
|
|
485
|
+
driver: 'redis',
|
|
486
|
+
redisUrl,
|
|
487
|
+
prefix: 'billing',
|
|
488
|
+
ttl: 300_000,
|
|
489
|
+
logger,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await cache.start();
|
|
493
|
+
|
|
494
|
+
const plan = await cache.wrap(`plan:${planId}`, () => plans.findById(planId));
|
|
495
|
+
|
|
496
|
+
await cache.set(`quote:${userId}`, quote, { ttl: 60_000 });
|
|
497
|
+
const quote = await cache.get(`quote:${userId}`);
|
|
498
|
+
const known = await cache.has(`quote:${userId}`);
|
|
499
|
+
|
|
500
|
+
await plans.update(planId, changes);
|
|
501
|
+
await cache.delete(`plan:${planId}`);
|
|
502
|
+
|
|
503
|
+
await cache.stop();
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Every key is namespaced by `prefix`, so `plan:42` is stored as `billing:plan:42`. `ttl` is in
|
|
507
|
+
milliseconds and `0` never expires; the cache default is overridable on each write.
|
|
508
|
+
|
|
509
|
+
Values must be JSON-serializable. `undefined` reports a miss and is never stored — writing it
|
|
510
|
+
throws — while `null` is a value like any other, so `get()` returns `null` and `has()` returns
|
|
511
|
+
`true` for it. Both drivers keep the serialized form, so the object the memory driver returns can
|
|
512
|
+
be mutated without corrupting what is cached.
|
|
513
|
+
|
|
514
|
+
`wrap()` returns the cached value or runs the loader once, stores the result and returns it; a
|
|
515
|
+
loader that resolves `undefined` is not cached. Concurrent misses on the same key run one loader
|
|
516
|
+
each — the cache does not collapse them.
|
|
517
|
+
|
|
518
|
+
### Failures
|
|
519
|
+
|
|
520
|
+
A cache read or write is an optimization and never fails a request: with the store unreachable,
|
|
521
|
+
`get()` and `has()` report a miss and `set()` logs a warning, so the caller falls back to its
|
|
522
|
+
source. `delete()` is the exception and rejects. An invalidation that did not happen keeps serving
|
|
523
|
+
stale data until the TTL expires, which only the caller can weigh — `.catch(() => {})` says so
|
|
524
|
+
explicitly.
|
|
525
|
+
|
|
526
|
+
### Driver guarantees
|
|
527
|
+
|
|
528
|
+
| | `memory` | `redis` |
|
|
529
|
+
|---|---|---|
|
|
530
|
+
| External service | None | Redis |
|
|
531
|
+
| Scope | One private cache per replica | One cache shared by every replica |
|
|
532
|
+
| Survives restart | No | Yes |
|
|
533
|
+
| Expiration | On read | Owned by Redis |
|
|
534
|
+
| Size bound | None | Redis eviction policy |
|
|
535
|
+
|
|
536
|
+
The memory driver frees an entry only when it is read after expiring, so a key written and never
|
|
537
|
+
read again occupies memory until `stop()`. Keep it for development, tests and small derived values.
|
|
538
|
+
|
|
428
539
|
## `./env`
|
|
429
540
|
|
|
430
541
|
`createEnvReader()` reads `process.env` and **collects** what is wrong instead of failing on the
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process store for tests and local runs. Entries expire on read and are lost
|
|
3
|
+
* with the process.
|
|
4
|
+
*
|
|
5
|
+
* @return {object} A store: start, get, has, set, delete and stop.
|
|
6
|
+
*/
|
|
7
|
+
export function memoryStore() {
|
|
8
|
+
const entries = new Map();
|
|
9
|
+
|
|
10
|
+
function read(key) {
|
|
11
|
+
const entry = entries.get(key);
|
|
12
|
+
if (!entry) return undefined;
|
|
13
|
+
if (entry.expiresAt !== 0 && entry.expiresAt <= Date.now()) {
|
|
14
|
+
entries.delete(key);
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
return entry.value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
async start() {},
|
|
22
|
+
|
|
23
|
+
async get(key) {
|
|
24
|
+
return read(key);
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async has(key) {
|
|
28
|
+
return read(key) !== undefined;
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async set(key, value, ttl) {
|
|
32
|
+
entries.set(key, { value, expiresAt: ttl === 0 ? 0 : Date.now() + ttl });
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async delete(key) {
|
|
36
|
+
return entries.delete(key);
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async stop() {
|
|
40
|
+
entries.clear();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { noopLogger } from '../../internal/logger.js';
|
|
2
|
+
import { loadIoredis, redisConnection } from '../../internal/redis.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared store on Redis, with expiration owned by Redis itself.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} options
|
|
8
|
+
* @param {string} options.redisUrl
|
|
9
|
+
* @param {object} [options.logger]
|
|
10
|
+
* @return {object} A store: start, get, has, set, delete and stop.
|
|
11
|
+
*/
|
|
12
|
+
export function redisStore({ redisUrl, logger = noopLogger } = {}) {
|
|
13
|
+
const connection = redisConnection(redisUrl);
|
|
14
|
+
let client;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
async start() {
|
|
18
|
+
const { default: Redis } = await loadIoredis();
|
|
19
|
+
client = new Redis(connection);
|
|
20
|
+
// ioredis emits 'error' on the client, and an EventEmitter with no 'error'
|
|
21
|
+
// listener throws: a dropped connection would kill the process the cache
|
|
22
|
+
// is only supposed to make faster.
|
|
23
|
+
client.on('error', (error) => logger.warn({ err: error }, 'cache connection error'));
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async get(key) {
|
|
27
|
+
const value = await client.get(key);
|
|
28
|
+
return value === null ? undefined : value;
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async has(key) {
|
|
32
|
+
return await client.exists(key) > 0;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async set(key, value, ttl) {
|
|
36
|
+
if (ttl === 0) await client.set(key, value);
|
|
37
|
+
else await client.set(key, value, 'PX', ttl);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async delete(key) {
|
|
41
|
+
return await client.del(key) > 0;
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async stop() {
|
|
45
|
+
if (!client) return;
|
|
46
|
+
const closing = client;
|
|
47
|
+
client = undefined;
|
|
48
|
+
// quit() rejects when the connection is already down; the socket still has
|
|
49
|
+
// to be released, or shutdown hangs on a Redis that died first.
|
|
50
|
+
await closing.quit().catch(() => closing.disconnect());
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
package/cache/index.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { noopLogger } from '../internal/logger.js';
|
|
2
|
+
import { assertDuration, assertHandler, assertKey } from '../internal/validation.js';
|
|
3
|
+
import { memoryStore } from './drivers/memory.js';
|
|
4
|
+
import { redisStore } from './drivers/redis.js';
|
|
5
|
+
|
|
6
|
+
const STORES = { memory: memoryStore, redis: redisStore };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a process-local or Redis-backed cache. Reads and writes degrade to a
|
|
10
|
+
* miss when the store fails; only `delete()` propagates, because a failed
|
|
11
|
+
* invalidation keeps serving stale data.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} [options]
|
|
14
|
+
* @param {'memory'|'redis'} [options.driver='memory']
|
|
15
|
+
* @param {string} [options.redisUrl] - Required by the redis driver.
|
|
16
|
+
* @param {string} [options.prefix='app'] - Namespace every key is stored under.
|
|
17
|
+
* @param {number} [options.ttl=0] - Default lifetime in milliseconds; 0 never expires.
|
|
18
|
+
* @param {object} [options.logger]
|
|
19
|
+
* @return {object} The cache, not yet started; call start() first.
|
|
20
|
+
*/
|
|
21
|
+
export function createCache({
|
|
22
|
+
driver = 'memory',
|
|
23
|
+
redisUrl,
|
|
24
|
+
prefix = 'app',
|
|
25
|
+
ttl = 0,
|
|
26
|
+
logger = noopLogger,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const createStore = STORES[driver];
|
|
29
|
+
if (!createStore) {
|
|
30
|
+
throw new Error(`unknown cache driver "${driver}", expected ${Object.keys(STORES).join(' or ')}`);
|
|
31
|
+
}
|
|
32
|
+
const defaultTtl = assertDuration('ttl', ttl);
|
|
33
|
+
const store = createStore({ redisUrl, logger });
|
|
34
|
+
let state = 'idle';
|
|
35
|
+
|
|
36
|
+
function scope(key, action) {
|
|
37
|
+
if (state !== 'started') throw new Error(`the cache must be started before ${action}()`);
|
|
38
|
+
return `${prefix}:${assertKey(key)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reads a cached value.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} key
|
|
45
|
+
* @return {Promise<*>} The stored value, or `undefined` on a miss.
|
|
46
|
+
*/
|
|
47
|
+
async function get(key) {
|
|
48
|
+
const scoped = scope(key, 'get');
|
|
49
|
+
try {
|
|
50
|
+
const stored = await store.get(scoped);
|
|
51
|
+
return stored === undefined ? undefined : JSON.parse(stored);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logger.warn({ err: error, key: scoped }, 'cache read failed');
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a live entry exists, without transferring or parsing its value.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} key
|
|
62
|
+
* @return {Promise<boolean>}
|
|
63
|
+
*/
|
|
64
|
+
async function has(key) {
|
|
65
|
+
const scoped = scope(key, 'has');
|
|
66
|
+
try {
|
|
67
|
+
return await store.has(scoped);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.warn({ err: error, key: scoped }, 'cache read failed');
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Stores a value, replacing any entry under the same key.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} key
|
|
78
|
+
* @param {*} value - Must be JSON-serializable; `undefined` is how a miss is reported.
|
|
79
|
+
* @param {object} [options]
|
|
80
|
+
* @param {number} [options.ttl] - Overrides the cache default, in milliseconds.
|
|
81
|
+
* @return {Promise<void>}
|
|
82
|
+
*/
|
|
83
|
+
async function set(key, value, { ttl: entryTtl = defaultTtl } = {}) {
|
|
84
|
+
const scoped = scope(key, 'set');
|
|
85
|
+
assertDuration('ttl', entryTtl);
|
|
86
|
+
// Both drivers store the serialized form, so a caller cannot mutate what the
|
|
87
|
+
// memory driver handed back and see the redis driver behave differently.
|
|
88
|
+
const serialized = JSON.stringify(value);
|
|
89
|
+
if (serialized === undefined) {
|
|
90
|
+
throw new TypeError('a cache value must be JSON-serializable and not `undefined`');
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
await store.set(scoped, serialized, entryTtl);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logger.warn({ err: error, key: scoped }, 'cache write failed');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Drops a cached value. Failures propagate: the caller decides what an
|
|
101
|
+
* invalidation that did not happen means for its request.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} key
|
|
104
|
+
* @return {Promise<boolean>} True when an entry was removed.
|
|
105
|
+
*/
|
|
106
|
+
async function remove(key) {
|
|
107
|
+
return store.delete(scope(key, 'delete'));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reads a cached value, or produces it with `loader` and stores it.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} key
|
|
114
|
+
* @param {() => (*|Promise<*>)} loader - Runs only on a miss; `undefined` is not cached.
|
|
115
|
+
* @param {object} [options]
|
|
116
|
+
* @param {number} [options.ttl] - Overrides the cache default, in milliseconds.
|
|
117
|
+
* @return {Promise<*>} The cached or freshly loaded value.
|
|
118
|
+
*/
|
|
119
|
+
async function wrap(key, loader, { ttl: entryTtl = defaultTtl } = {}) {
|
|
120
|
+
scope(key, 'wrap');
|
|
121
|
+
assertHandler(key, loader);
|
|
122
|
+
const cached = await get(key);
|
|
123
|
+
if (cached !== undefined) return cached;
|
|
124
|
+
const value = await loader();
|
|
125
|
+
if (value !== undefined) await set(key, value, { ttl: entryTtl });
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function start() {
|
|
130
|
+
if (state === 'started') return;
|
|
131
|
+
if (state === 'stopped') throw new Error('a stopped cache cannot be restarted');
|
|
132
|
+
await store.start();
|
|
133
|
+
state = 'started';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function stop() {
|
|
137
|
+
if (state === 'stopped') return;
|
|
138
|
+
await store.stop();
|
|
139
|
+
state = 'stopped';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
driver,
|
|
144
|
+
get,
|
|
145
|
+
has,
|
|
146
|
+
set,
|
|
147
|
+
delete: remove,
|
|
148
|
+
wrap,
|
|
149
|
+
start,
|
|
150
|
+
stop,
|
|
151
|
+
get state() {
|
|
152
|
+
return state;
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
package/events/drivers/bullmq.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
queueName,
|
|
4
|
-
redisConnection,
|
|
5
|
-
} from '../../internal/bullmq.js';
|
|
1
|
+
import { loadBullmq, queueName } from '../../internal/bullmq.js';
|
|
2
|
+
import { redisConnection } from '../../internal/redis.js';
|
|
6
3
|
import { noopLogger } from '../../internal/logger.js';
|
|
7
4
|
import { subscriberStream } from '../internal.js';
|
|
8
5
|
|
package/http/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { createApp } from './createApp.js';
|
|
2
2
|
export { default as decorators } from './plugins/decorators.js';
|
|
3
3
|
export { default as errorHandler, classifyError, STATUS_BY_CODE } from './plugins/errorHandler.js';
|
|
4
|
+
export { paginate } from './page.js';
|
|
4
5
|
export { default as rawBody } from './plugins/rawBody.js';
|
|
5
6
|
export { default as requestContext } from './plugins/requestContext.js';
|
|
6
7
|
export {
|
package/http/page.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes a page from rows fetched with `limit + 1` — the extra row is what reveals `hasMore`.
|
|
3
|
+
*
|
|
4
|
+
* @param {Array} rows - Up to `limit + 1` rows, in order.
|
|
5
|
+
* @param {number} limit - The page size the caller asked for.
|
|
6
|
+
* @param {number} [total] - Matching document count, when the resource exposes one.
|
|
7
|
+
* @return {{items: Array, hasMore: boolean, total?: number}} The page.
|
|
8
|
+
*/
|
|
9
|
+
export function paginate(rows, limit, total) {
|
|
10
|
+
const hasMore = rows.length > limit;
|
|
11
|
+
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
12
|
+
return total === undefined ? { items, hasMore } : { items, hasMore, total };
|
|
13
|
+
}
|
package/http/schema.js
CHANGED
|
@@ -9,6 +9,14 @@ export function pageQuery({ maxLimit = 100, defaultLimit = 20 } = {}) {
|
|
|
9
9
|
});
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function pageResponse(items, { total = false } = {}) {
|
|
13
|
+
return objectSchema({
|
|
14
|
+
items: { type: 'array', items },
|
|
15
|
+
hasMore: { type: 'boolean' },
|
|
16
|
+
...(total && { total: { type: 'integer', minimum: 0 } }),
|
|
17
|
+
}, total ? ['items', 'hasMore', 'total'] : ['items', 'hasMore']);
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
export const stringSchema = (options = {}) => ({ type: 'string', minLength: 1, ...options });
|
|
13
21
|
export const email = Object.freeze({ type: 'string', format: 'email' });
|
|
14
22
|
export const dateTime = Object.freeze({ type: 'string', format: 'date-time' });
|
package/internal/bullmq.js
CHANGED
|
@@ -14,32 +14,6 @@ export async function loadBullmq() {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function redisConnection(redisUrl, { worker = false } = {}) {
|
|
18
|
-
if (!redisUrl) throw new Error('a Redis connection requires `redisUrl`');
|
|
19
|
-
let url;
|
|
20
|
-
try {
|
|
21
|
-
url = new URL(redisUrl);
|
|
22
|
-
} catch (error) {
|
|
23
|
-
throw new Error(`invalid redisUrl: ${redisUrl}`, { cause: error });
|
|
24
|
-
}
|
|
25
|
-
if (!['redis:', 'rediss:'].includes(url.protocol)) {
|
|
26
|
-
throw new Error(`redisUrl must use redis:// or rediss://, got ${url.protocol}`);
|
|
27
|
-
}
|
|
28
|
-
const db = url.pathname.length > 1 ? Number(url.pathname.slice(1)) : 0;
|
|
29
|
-
if (!Number.isInteger(db) || db < 0) throw new Error(`invalid Redis database in ${redisUrl}`);
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
host: url.hostname,
|
|
33
|
-
port: Number(url.port || 6379),
|
|
34
|
-
db,
|
|
35
|
-
...(url.username ? { username: decodeURIComponent(url.username) } : {}),
|
|
36
|
-
...(url.password ? { password: decodeURIComponent(url.password) } : {}),
|
|
37
|
-
...(url.protocol === 'rediss:' ? { tls: {} } : {}),
|
|
38
|
-
// BullMQ workers refuse to start unless this is null; producers fail fast.
|
|
39
|
-
maxRetriesPerRequest: worker ? null : 1,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
17
|
function safeSegment(value) {
|
|
44
18
|
const source = String(value);
|
|
45
19
|
// Slugifying is lossy; the hash suffix keeps the queue name unique.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export async function loadIoredis() {
|
|
2
|
+
try {
|
|
3
|
+
return await import('ioredis');
|
|
4
|
+
} catch (error) {
|
|
5
|
+
if (['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND'].includes(error?.code)) {
|
|
6
|
+
throw new Error('Redis support requires `ioredis`: install the package', { cause: error });
|
|
7
|
+
}
|
|
8
|
+
throw error;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function redisConnection(redisUrl, { worker = false } = {}) {
|
|
13
|
+
if (!redisUrl) throw new Error('a Redis connection requires `redisUrl`');
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(redisUrl);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new Error(`invalid redisUrl: ${redisUrl}`, { cause: error });
|
|
19
|
+
}
|
|
20
|
+
if (!['redis:', 'rediss:'].includes(url.protocol)) {
|
|
21
|
+
throw new Error(`redisUrl must use redis:// or rediss://, got ${url.protocol}`);
|
|
22
|
+
}
|
|
23
|
+
const db = url.pathname.length > 1 ? Number(url.pathname.slice(1)) : 0;
|
|
24
|
+
if (!Number.isInteger(db) || db < 0) throw new Error(`invalid Redis database in ${redisUrl}`);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
host: url.hostname,
|
|
28
|
+
port: Number(url.port || 6379),
|
|
29
|
+
db,
|
|
30
|
+
...(url.username ? { username: decodeURIComponent(url.username) } : {}),
|
|
31
|
+
...(url.password ? { password: decodeURIComponent(url.password) } : {}),
|
|
32
|
+
...(url.protocol === 'rediss:' ? { tls: {} } : {}),
|
|
33
|
+
// BullMQ workers refuse to start unless this is null; producers fail fast.
|
|
34
|
+
maxRetriesPerRequest: worker ? null : 1,
|
|
35
|
+
};
|
|
36
|
+
}
|
package/jobs/drivers/bullmq.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
queueName,
|
|
4
|
-
redisConnection,
|
|
5
|
-
} from '../../internal/bullmq.js';
|
|
1
|
+
import { loadBullmq, queueName } from '../../internal/bullmq.js';
|
|
2
|
+
import { redisConnection } from '../../internal/redis.js';
|
|
6
3
|
import { noopLogger } from '../../internal/logger.js';
|
|
7
4
|
|
|
8
5
|
/**
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devindex/api-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Building blocks for Fastify services: typed domain errors, HTTP plugins, logging and background runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
+
"./cache": "./cache/index.js",
|
|
7
8
|
"./context": "./context/index.js",
|
|
8
9
|
"./env": "./env/index.js",
|
|
9
10
|
"./errors": "./errors/index.js",
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
"./package.json": "./package.json"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
20
|
+
"cache",
|
|
19
21
|
"context",
|
|
20
22
|
"env",
|
|
21
23
|
"errors",
|
|
@@ -50,7 +52,9 @@
|
|
|
50
52
|
"schedule",
|
|
51
53
|
"events",
|
|
52
54
|
"event-bus",
|
|
53
|
-
"pubsub"
|
|
55
|
+
"pubsub",
|
|
56
|
+
"cache",
|
|
57
|
+
"redis"
|
|
54
58
|
],
|
|
55
59
|
"peerDependencies": {
|
|
56
60
|
"@fastify/cors": "^11.0.0",
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
queueName,
|
|
4
|
-
redisConnection,
|
|
5
|
-
} from '../../internal/bullmq.js';
|
|
1
|
+
import { loadBullmq, queueName } from '../../internal/bullmq.js';
|
|
2
|
+
import { redisConnection } from '../../internal/redis.js';
|
|
6
3
|
import { noopLogger } from '../../internal/logger.js';
|
|
7
4
|
|
|
8
5
|
const SCHEDULER_ID = 'schedule';
|