@pipeline-builder/api-server 0.0.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.
Files changed (61) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +79 -0
  3. package/lib/api/app-factory.d.ts +95 -0
  4. package/lib/api/app-factory.js +328 -0
  5. package/lib/api/check-quota.d.ts +13 -0
  6. package/lib/api/check-quota.js +74 -0
  7. package/lib/api/context-middleware.d.ts +34 -0
  8. package/lib/api/context-middleware.js +42 -0
  9. package/lib/api/etag-middleware.d.ts +7 -0
  10. package/lib/api/etag-middleware.js +44 -0
  11. package/lib/api/get-context.d.ts +22 -0
  12. package/lib/api/get-context.js +28 -0
  13. package/lib/api/health-checks.d.ts +62 -0
  14. package/lib/api/health-checks.js +87 -0
  15. package/lib/api/idempotency-middleware.d.ts +80 -0
  16. package/lib/api/idempotency-middleware.js +321 -0
  17. package/lib/api/index.d.ts +20 -0
  18. package/lib/api/index.js +34 -0
  19. package/lib/api/meter-quota.d.ts +32 -0
  20. package/lib/api/meter-quota.js +70 -0
  21. package/lib/api/metrics.d.ts +47 -0
  22. package/lib/api/metrics.js +221 -0
  23. package/lib/api/middleware-factory.d.ts +56 -0
  24. package/lib/api/middleware-factory.js +80 -0
  25. package/lib/api/middleware.d.ts +1 -0
  26. package/lib/api/middleware.js +8 -0
  27. package/lib/api/mongo-connect.d.ts +19 -0
  28. package/lib/api/mongo-connect.js +41 -0
  29. package/lib/api/quota-helpers.d.ts +23 -0
  30. package/lib/api/quota-helpers.js +22 -0
  31. package/lib/api/rate-limit-by-org.d.ts +29 -0
  32. package/lib/api/rate-limit-by-org.js +64 -0
  33. package/lib/api/readiness.d.ts +29 -0
  34. package/lib/api/readiness.js +65 -0
  35. package/lib/api/request-types.d.ts +55 -0
  36. package/lib/api/request-types.js +65 -0
  37. package/lib/api/require-org-id.d.ts +14 -0
  38. package/lib/api/require-org-id.js +28 -0
  39. package/lib/api/route-wrapper.d.ts +50 -0
  40. package/lib/api/route-wrapper.js +84 -0
  41. package/lib/api/server.d.ts +79 -0
  42. package/lib/api/server.js +234 -0
  43. package/lib/api/tenant-context.d.ts +28 -0
  44. package/lib/api/tenant-context.js +48 -0
  45. package/lib/api/tracing.d.ts +25 -0
  46. package/lib/api/tracing.js +69 -0
  47. package/lib/http/index.d.ts +4 -0
  48. package/lib/http/index.js +7 -0
  49. package/lib/http/sse-connection-manager.d.ts +357 -0
  50. package/lib/http/sse-connection-manager.js +665 -0
  51. package/lib/http/sse-relay.d.ts +63 -0
  52. package/lib/http/sse-relay.js +70 -0
  53. package/lib/http/sse-ticket-channel.d.ts +31 -0
  54. package/lib/http/sse-ticket-channel.js +52 -0
  55. package/lib/http/sse-ticket-store.d.ts +75 -0
  56. package/lib/http/sse-ticket-store.js +160 -0
  57. package/lib/index.d.ts +30 -0
  58. package/lib/index.js +35 -0
  59. package/lib/otel-bootstrap.d.ts +1 -0
  60. package/lib/otel-bootstrap.js +75 -0
  61. package/package.json +183 -0
@@ -0,0 +1,44 @@
1
+ // Copyright 2026 Pipeline Builder Contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { createHash } from 'crypto';
4
+ /**
5
+ * Middleware that adds ETag support for conditional GET requests.
6
+ * Generates a weak ETag from the response body hash.
7
+ * Returns 304 Not Modified when the client's If-None-Match header matches.
8
+ */
9
+ export function etagMiddleware() {
10
+ return (req, res, next) => {
11
+ // Only apply to GET requests
12
+ if (req.method !== 'GET')
13
+ return next();
14
+ res.json = (body) => {
15
+ // Serialize once, hash, and send — avoids double JSON.stringify
16
+ const serialized = JSON.stringify(body);
17
+ // Only ETag/304 SUCCESSFUL responses. For non-2xx (errors), skip the
18
+ // conditional logic entirely — otherwise an error response whose body
19
+ // happens to hash-match the client's If-None-Match would be turned into a
20
+ // 304 Not Modified, making the client serve a stale (possibly successful)
21
+ // cached body in place of the error.
22
+ if (res.statusCode < 200 || res.statusCode >= 300) {
23
+ res.setHeader('Content-Type', 'application/json');
24
+ res.end(serialized);
25
+ return res;
26
+ }
27
+ const hash = createHash('md5').update(serialized).digest('hex').slice(0, 16);
28
+ const etag = `W/"${hash}"`;
29
+ res.setHeader('ETag', etag);
30
+ // Check If-None-Match header
31
+ const ifNoneMatch = req.headers['if-none-match'];
32
+ if (ifNoneMatch && ifNoneMatch === etag) {
33
+ res.status(304).end();
34
+ return res;
35
+ }
36
+ // Send pre-serialized body to avoid double JSON.stringify
37
+ res.setHeader('Content-Type', 'application/json');
38
+ res.end(serialized);
39
+ return res;
40
+ };
41
+ next();
42
+ };
43
+ }
44
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXRhZy1taWRkbGV3YXJlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2FwaS9ldGFnLW1pZGRsZXdhcmUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsK0NBQStDO0FBQy9DLHNDQUFzQztBQUV0QyxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sUUFBUSxDQUFDO0FBR3BDOzs7O0dBSUc7QUFDSCxNQUFNLFVBQVUsY0FBYztJQUM1QixPQUFPLENBQUMsR0FBWSxFQUFFLEdBQWEsRUFBRSxJQUFrQixFQUFFLEVBQUU7UUFDekQsNkJBQTZCO1FBQzdCLElBQUksR0FBRyxDQUFDLE1BQU0sS0FBSyxLQUFLO1lBQUUsT0FBTyxJQUFJLEVBQUUsQ0FBQztRQUV4QyxHQUFHLENBQUMsSUFBSSxHQUFHLENBQUMsSUFBYSxFQUFFLEVBQUU7WUFDM0IsZ0VBQWdFO1lBQ2hFLE1BQU0sVUFBVSxHQUFHLElBQUksQ0FBQyxTQUFTLENBQUMsSUFBSSxDQUFDLENBQUM7WUFFeEMscUVBQXFFO1lBQ3JFLHNFQUFzRTtZQUN0RSwwRUFBMEU7WUFDMUUsMEVBQTBFO1lBQzFFLHFDQUFxQztZQUNyQyxJQUFJLEdBQUcsQ0FBQyxVQUFVLEdBQUcsR0FBRyxJQUFJLEdBQUcsQ0FBQyxVQUFVLElBQUksR0FBRyxFQUFFLENBQUM7Z0JBQ2xELEdBQUcsQ0FBQyxTQUFTLENBQUMsY0FBYyxFQUFFLGtCQUFrQixDQUFDLENBQUM7Z0JBQ2xELEdBQUcsQ0FBQyxHQUFHLENBQUMsVUFBVSxDQUFDLENBQUM7Z0JBQ3BCLE9BQU8sR0FBRyxDQUFDO1lBQ2IsQ0FBQztZQUVELE1BQU0sSUFBSSxHQUFHLFVBQVUsQ0FBQyxLQUFLLENBQUMsQ0FBQyxNQUFNLENBQUMsVUFBVSxDQUFDLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLEtBQUssQ0FBQyxDQUFDLEVBQUUsRUFBRSxDQUFDLENBQUM7WUFDN0UsTUFBTSxJQUFJLEdBQUcsTUFBTSxJQUFJLEdBQUcsQ0FBQztZQUUzQixHQUFHLENBQUMsU0FBUyxDQUFDLE1BQU0sRUFBRSxJQUFJLENBQUMsQ0FBQztZQUU1Qiw2QkFBNkI7WUFDN0IsTUFBTSxXQUFXLEdBQUcsR0FBRyxDQUFDLE9BQU8sQ0FBQyxlQUFlLENBQUMsQ0FBQztZQUNqRCxJQUFJLFdBQVcsSUFBSSxXQUFXLEtBQUssSUFBSSxFQUFFLENBQUM7Z0JBQ3hDLEdBQUcsQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFDLENBQUMsR0FBRyxFQUFFLENBQUM7Z0JBQ3RCLE9BQU8sR0FBRyxDQUFDO1lBQ2IsQ0FBQztZQUVELDBEQUEwRDtZQUMxRCxHQUFHLENBQUMsU0FBUyxDQUFDLGNBQWMsRUFBRSxrQkFBa0IsQ0FBQyxDQUFDO1lBQ2xELEdBQUcsQ0FBQyxHQUFHLENBQUMsVUFBVSxDQUFDLENBQUM7WUFDcEIsT0FBTyxHQUFHLENBQUM7UUFDYixDQUFDLENBQUM7UUFFRixJQUFJLEVBQUUsQ0FBQztJQUNULENBQUMsQ0FBQztBQUNKLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyIvLyBDb3B5cmlnaHQgMjAyNiBQaXBlbGluZSBCdWlsZGVyIENvbnRyaWJ1dG9yc1xuLy8gU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IEFwYWNoZS0yLjBcblxuaW1wb3J0IHsgY3JlYXRlSGFzaCB9IGZyb20gJ2NyeXB0byc7XG5pbXBvcnQgdHlwZSB7IFJlcXVlc3QsIFJlc3BvbnNlLCBOZXh0RnVuY3Rpb24gfSBmcm9tICdleHByZXNzJztcblxuLyoqXG4gKiBNaWRkbGV3YXJlIHRoYXQgYWRkcyBFVGFnIHN1cHBvcnQgZm9yIGNvbmRpdGlvbmFsIEdFVCByZXF1ZXN0cy5cbiAqIEdlbmVyYXRlcyBhIHdlYWsgRVRhZyBmcm9tIHRoZSByZXNwb25zZSBib2R5IGhhc2guXG4gKiBSZXR1cm5zIDMwNCBOb3QgTW9kaWZpZWQgd2hlbiB0aGUgY2xpZW50J3MgSWYtTm9uZS1NYXRjaCBoZWFkZXIgbWF0Y2hlcy5cbiAqL1xuZXhwb3J0IGZ1bmN0aW9uIGV0YWdNaWRkbGV3YXJlKCkge1xuICByZXR1cm4gKHJlcTogUmVxdWVzdCwgcmVzOiBSZXNwb25zZSwgbmV4dDogTmV4dEZ1bmN0aW9uKSA9PiB7XG4gICAgLy8gT25seSBhcHBseSB0byBHRVQgcmVxdWVzdHNcbiAgICBpZiAocmVxLm1ldGhvZCAhPT0gJ0dFVCcpIHJldHVybiBuZXh0KCk7XG5cbiAgICByZXMuanNvbiA9IChib2R5OiB1bmtub3duKSA9PiB7XG4gICAgICAvLyBTZXJpYWxpemUgb25jZSwgaGFzaCwgYW5kIHNlbmQg4oCUIGF2b2lkcyBkb3VibGUgSlNPTi5zdHJpbmdpZnlcbiAgICAgIGNvbnN0IHNlcmlhbGl6ZWQgPSBKU09OLnN0cmluZ2lmeShib2R5KTtcblxuICAgICAgLy8gT25seSBFVGFnLzMwNCBTVUNDRVNTRlVMIHJlc3BvbnNlcy4gRm9yIG5vbi0yeHggKGVycm9ycyksIHNraXAgdGhlXG4gICAgICAvLyBjb25kaXRpb25hbCBsb2dpYyBlbnRpcmVseSDigJQgb3RoZXJ3aXNlIGFuIGVycm9yIHJlc3BvbnNlIHdob3NlIGJvZHlcbiAgICAgIC8vIGhhcHBlbnMgdG8gaGFzaC1tYXRjaCB0aGUgY2xpZW50J3MgSWYtTm9uZS1NYXRjaCB3b3VsZCBiZSB0dXJuZWQgaW50byBhXG4gICAgICAvLyAzMDQgTm90IE1vZGlmaWVkLCBtYWtpbmcgdGhlIGNsaWVudCBzZXJ2ZSBhIHN0YWxlIChwb3NzaWJseSBzdWNjZXNzZnVsKVxuICAgICAgLy8gY2FjaGVkIGJvZHkgaW4gcGxhY2Ugb2YgdGhlIGVycm9yLlxuICAgICAgaWYgKHJlcy5zdGF0dXNDb2RlIDwgMjAwIHx8IHJlcy5zdGF0dXNDb2RlID49IDMwMCkge1xuICAgICAgICByZXMuc2V0SGVhZGVyKCdDb250ZW50LVR5cGUnLCAnYXBwbGljYXRpb24vanNvbicpO1xuICAgICAgICByZXMuZW5kKHNlcmlhbGl6ZWQpO1xuICAgICAgICByZXR1cm4gcmVzO1xuICAgICAgfVxuXG4gICAgICBjb25zdCBoYXNoID0gY3JlYXRlSGFzaCgnbWQ1JykudXBkYXRlKHNlcmlhbGl6ZWQpLmRpZ2VzdCgnaGV4Jykuc2xpY2UoMCwgMTYpO1xuICAgICAgY29uc3QgZXRhZyA9IGBXL1wiJHtoYXNofVwiYDtcblxuICAgICAgcmVzLnNldEhlYWRlcignRVRhZycsIGV0YWcpO1xuXG4gICAgICAvLyBDaGVjayBJZi1Ob25lLU1hdGNoIGhlYWRlclxuICAgICAgY29uc3QgaWZOb25lTWF0Y2ggPSByZXEuaGVhZGVyc1snaWYtbm9uZS1tYXRjaCddO1xuICAgICAgaWYgKGlmTm9uZU1hdGNoICYmIGlmTm9uZU1hdGNoID09PSBldGFnKSB7XG4gICAgICAgIHJlcy5zdGF0dXMoMzA0KS5lbmQoKTtcbiAgICAgICAgcmV0dXJuIHJlcztcbiAgICAgIH1cblxuICAgICAgLy8gU2VuZCBwcmUtc2VyaWFsaXplZCBib2R5IHRvIGF2b2lkIGRvdWJsZSBKU09OLnN0cmluZ2lmeVxuICAgICAgcmVzLnNldEhlYWRlcignQ29udGVudC1UeXBlJywgJ2FwcGxpY2F0aW9uL2pzb24nKTtcbiAgICAgIHJlcy5lbmQoc2VyaWFsaXplZCk7XG4gICAgICByZXR1cm4gcmVzO1xuICAgIH07XG5cbiAgICBuZXh0KCk7XG4gIH07XG59XG4iXX0=
@@ -0,0 +1,22 @@
1
+ import type { Request } from 'express';
2
+ import type { RequestContext } from './request-types.js';
3
+ /**
4
+ * Safely retrieve the RequestContext from an Express request.
5
+ *
6
+ * Throws a descriptive error if the context middleware has not been applied,
7
+ * instead of silently returning undefined or crashing with a cryptic error.
8
+ *
9
+ * @param req - Express request object
10
+ * @returns The attached RequestContext
11
+ * @throws Error if req.context is not initialized
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * app.get('/pipelines', requireAuth, async (req, res) => {
16
+ * const ctx = getContext(req);
17
+ * ctx.log('INFO', 'Fetching pipelines');
18
+ * // ...
19
+ * });
20
+ * ```
21
+ */
22
+ export declare function getContext(req: Request): RequestContext;
@@ -0,0 +1,28 @@
1
+ // Copyright 2026 Pipeline Builder Contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Safely retrieve the RequestContext from an Express request.
5
+ *
6
+ * Throws a descriptive error if the context middleware has not been applied,
7
+ * instead of silently returning undefined or crashing with a cryptic error.
8
+ *
9
+ * @param req - Express request object
10
+ * @returns The attached RequestContext
11
+ * @throws Error if req.context is not initialized
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * app.get('/pipelines', requireAuth, async (req, res) => {
16
+ * const ctx = getContext(req);
17
+ * ctx.log('INFO', 'Fetching pipelines');
18
+ * // ...
19
+ * });
20
+ * ```
21
+ */
22
+ export function getContext(req) {
23
+ if (!req.context) {
24
+ throw new Error('Request context not initialized. Ensure attachRequestContext middleware is applied.');
25
+ }
26
+ return req.context;
27
+ }
28
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZ2V0LWNvbnRleHQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvYXBpL2dldC1jb250ZXh0LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLCtDQUErQztBQUMvQyxzQ0FBc0M7QUFLdEM7Ozs7Ozs7Ozs7Ozs7Ozs7OztHQWtCRztBQUNILE1BQU0sVUFBVSxVQUFVLENBQUMsR0FBWTtJQUNyQyxJQUFJLENBQUMsR0FBRyxDQUFDLE9BQU8sRUFBRSxDQUFDO1FBQ2pCLE1BQU0sSUFBSSxLQUFLLENBQ2IscUZBQXFGLENBQ3RGLENBQUM7SUFDSixDQUFDO0lBQ0QsT0FBTyxHQUFHLENBQUMsT0FBTyxDQUFDO0FBQ3JCLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyIvLyBDb3B5cmlnaHQgMjAyNiBQaXBlbGluZSBCdWlsZGVyIENvbnRyaWJ1dG9yc1xuLy8gU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IEFwYWNoZS0yLjBcblxuaW1wb3J0IHR5cGUgeyBSZXF1ZXN0IH0gZnJvbSAnZXhwcmVzcyc7XG5pbXBvcnQgdHlwZSB7IFJlcXVlc3RDb250ZXh0IH0gZnJvbSAnLi9yZXF1ZXN0LXR5cGVzLmpzJztcblxuLyoqXG4gKiBTYWZlbHkgcmV0cmlldmUgdGhlIFJlcXVlc3RDb250ZXh0IGZyb20gYW4gRXhwcmVzcyByZXF1ZXN0LlxuICpcbiAqIFRocm93cyBhIGRlc2NyaXB0aXZlIGVycm9yIGlmIHRoZSBjb250ZXh0IG1pZGRsZXdhcmUgaGFzIG5vdCBiZWVuIGFwcGxpZWQsXG4gKiBpbnN0ZWFkIG9mIHNpbGVudGx5IHJldHVybmluZyB1bmRlZmluZWQgb3IgY3Jhc2hpbmcgd2l0aCBhIGNyeXB0aWMgZXJyb3IuXG4gKlxuICogQHBhcmFtIHJlcSAtIEV4cHJlc3MgcmVxdWVzdCBvYmplY3RcbiAqIEByZXR1cm5zIFRoZSBhdHRhY2hlZCBSZXF1ZXN0Q29udGV4dFxuICogQHRocm93cyBFcnJvciBpZiByZXEuY29udGV4dCBpcyBub3QgaW5pdGlhbGl6ZWRcbiAqXG4gKiBAZXhhbXBsZVxuICogYGBgdHlwZXNjcmlwdFxuICogYXBwLmdldCgnL3BpcGVsaW5lcycsIHJlcXVpcmVBdXRoLCBhc3luYyAocmVxLCByZXMpID0+IHtcbiAqICAgY29uc3QgY3R4ID0gZ2V0Q29udGV4dChyZXEpO1xuICogICBjdHgubG9nKCdJTkZPJywgJ0ZldGNoaW5nIHBpcGVsaW5lcycpO1xuICogICAvLyAuLi5cbiAqIH0pO1xuICogYGBgXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBnZXRDb250ZXh0KHJlcTogUmVxdWVzdCk6IFJlcXVlc3RDb250ZXh0IHtcbiAgaWYgKCFyZXEuY29udGV4dCkge1xuICAgIHRocm93IG5ldyBFcnJvcihcbiAgICAgICdSZXF1ZXN0IGNvbnRleHQgbm90IGluaXRpYWxpemVkLiBFbnN1cmUgYXR0YWNoUmVxdWVzdENvbnRleHQgbWlkZGxld2FyZSBpcyBhcHBsaWVkLicsXG4gICAgKTtcbiAgfVxuICByZXR1cm4gcmVxLmNvbnRleHQ7XG59XG4iXX0=
@@ -0,0 +1,62 @@
1
+ type HealthStatus = 'connected' | 'disconnected' | 'unknown';
2
+ type HealthResult = Record<string, HealthStatus>;
3
+ /**
4
+ * Postgres health probe: returns `{ postgres: 'connected' }` when the pool's
5
+ * connection test succeeds, `{ postgres: 'disconnected' }` on any error or
6
+ * timeout (so `/ready` correctly reports 503). Use as (part of) the
7
+ * `checkDependencies` option of `createApp()` for any service backed by the
8
+ * shared pipeline-data Drizzle connection.
9
+ */
10
+ export declare function postgresHealthCheck(timeoutMs?: number): Promise<HealthResult>;
11
+ /** Minimal shape of an ioredis client needed for a health PING. */
12
+ interface RedisLike {
13
+ ping(): Promise<string>;
14
+ }
15
+ /**
16
+ * Source of a redis client: either the client itself, or a (sync/async) getter.
17
+ * The getter form supports BullMQ's `queue.client` (a `Promise<RedisClient>`)
18
+ * so a service can probe the exact connection its queue uses without opening
19
+ * a second one.
20
+ */
21
+ type RedisClientSource = RedisLike | (() => RedisLike | Promise<RedisLike>);
22
+ /**
23
+ * Redis health probe factory: pass the service's EXISTING ioredis connection
24
+ * (or a getter for it — e.g. `() => queue.client`) and get back a
25
+ * `checkDependencies` callback that `PING`s it. Mirrors `mongoHealthCheck` —
26
+ * it probes the real connection rather than opening a throwaway one per call.
27
+ * The PING is time-boxed so a down redis (whose offline queue would otherwise
28
+ * buffer the command) reports `disconnected` quickly instead of hanging.
29
+ */
30
+ export declare function redisHealthCheck(source: RedisClientSource, timeoutMs?: number): () => Promise<HealthResult>;
31
+ /**
32
+ * Combine several dependency probes into one `checkDependencies` callback that
33
+ * runs them IN PARALLEL and merges their results. A slow/failed probe can't
34
+ * delay the others or time out the whole `/ready` response.
35
+ *
36
+ * CONTRACT: each probe must CATCH internally and RETURN its `{ name: status }`
37
+ * (as the built-in postgres/redis/mongo probes do). A probe that THROWS is
38
+ * merged as `{}` — its dependency then vanishes from the map and `/ready`
39
+ * reports 200 with the dep simply absent (a silent false-healthy), rather than
40
+ * surfacing it as `disconnected`. The `.catch(() => ({}))` below only exists so
41
+ * one misbehaving probe can't take down the others, NOT as a substitute for a
42
+ * probe returning its own disconnected status.
43
+ */
44
+ export declare function combineHealthChecks(...checks: Array<() => Promise<HealthResult>>): () => Promise<HealthResult>;
45
+ /**
46
+ * Minimal shape expected of a mongoose connection for the health probe.
47
+ * Accepts `mongoose.connection` directly without requiring mongoose as a
48
+ * dependency of api-server.
49
+ */
50
+ interface MongooseConnectionLike {
51
+ readyState: number;
52
+ }
53
+ /**
54
+ * MongoDB health probe factory: pass `mongoose.connection` and get back a
55
+ * `checkDependencies` callback that reports based on mongoose's readyState.
56
+ *
57
+ * Mapping (per mongoose docs): 1=connected, 2=connecting, 3=disconnecting,
58
+ * any other value (0=disconnected, 99=uninitialized) is treated as
59
+ * 'disconnected' so `/health` returns 503.
60
+ */
61
+ export declare function mongoHealthCheck(connection: MongooseConnectionLike): () => Promise<HealthResult>;
62
+ export {};
@@ -0,0 +1,87 @@
1
+ // Copyright 2026 Pipeline Builder Contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { getConnection } from '@pipeline-builder/pipeline-data';
4
+ /**
5
+ * Reject if `promise` hasn't settled within `ms`. A health/readiness probe
6
+ * must never hang the `/ready` response — a down datastore (or a BullMQ
7
+ * ioredis client with `maxRetriesPerRequest: null` + an offline queue) can
8
+ * leave a `ping()`/query pending indefinitely, so every probe is time-boxed.
9
+ */
10
+ function withTimeout(promise, ms, label) {
11
+ return new Promise((resolve, reject) => {
12
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
13
+ timer.unref?.();
14
+ promise.then((value) => { clearTimeout(timer); resolve(value); }, (err) => { clearTimeout(timer); reject(err); });
15
+ });
16
+ }
17
+ /**
18
+ * Postgres health probe: returns `{ postgres: 'connected' }` when the pool's
19
+ * connection test succeeds, `{ postgres: 'disconnected' }` on any error or
20
+ * timeout (so `/ready` correctly reports 503). Use as (part of) the
21
+ * `checkDependencies` option of `createApp()` for any service backed by the
22
+ * shared pipeline-data Drizzle connection.
23
+ */
24
+ export async function postgresHealthCheck(timeoutMs = 3000) {
25
+ try {
26
+ await withTimeout(getConnection().testConnection(), timeoutMs, 'postgres testConnection');
27
+ return { postgres: 'connected' };
28
+ }
29
+ catch {
30
+ return { postgres: 'disconnected' };
31
+ }
32
+ }
33
+ /**
34
+ * Redis health probe factory: pass the service's EXISTING ioredis connection
35
+ * (or a getter for it — e.g. `() => queue.client`) and get back a
36
+ * `checkDependencies` callback that `PING`s it. Mirrors `mongoHealthCheck` —
37
+ * it probes the real connection rather than opening a throwaway one per call.
38
+ * The PING is time-boxed so a down redis (whose offline queue would otherwise
39
+ * buffer the command) reports `disconnected` quickly instead of hanging.
40
+ */
41
+ export function redisHealthCheck(source, timeoutMs = 2000) {
42
+ return async () => {
43
+ try {
44
+ const client = typeof source === 'function' ? await source() : source;
45
+ await withTimeout(client.ping(), timeoutMs, 'redis ping');
46
+ return { redis: 'connected' };
47
+ }
48
+ catch {
49
+ return { redis: 'disconnected' };
50
+ }
51
+ };
52
+ }
53
+ /**
54
+ * Combine several dependency probes into one `checkDependencies` callback that
55
+ * runs them IN PARALLEL and merges their results. A slow/failed probe can't
56
+ * delay the others or time out the whole `/ready` response.
57
+ *
58
+ * CONTRACT: each probe must CATCH internally and RETURN its `{ name: status }`
59
+ * (as the built-in postgres/redis/mongo probes do). A probe that THROWS is
60
+ * merged as `{}` — its dependency then vanishes from the map and `/ready`
61
+ * reports 200 with the dep simply absent (a silent false-healthy), rather than
62
+ * surfacing it as `disconnected`. The `.catch(() => ({}))` below only exists so
63
+ * one misbehaving probe can't take down the others, NOT as a substitute for a
64
+ * probe returning its own disconnected status.
65
+ */
66
+ export function combineHealthChecks(...checks) {
67
+ return async () => {
68
+ const results = await Promise.all(checks.map((check) => check().catch(() => ({}))));
69
+ return Object.assign({}, ...results);
70
+ };
71
+ }
72
+ /**
73
+ * MongoDB health probe factory: pass `mongoose.connection` and get back a
74
+ * `checkDependencies` callback that reports based on mongoose's readyState.
75
+ *
76
+ * Mapping (per mongoose docs): 1=connected, 2=connecting, 3=disconnecting,
77
+ * any other value (0=disconnected, 99=uninitialized) is treated as
78
+ * 'disconnected' so `/health` returns 503.
79
+ */
80
+ export function mongoHealthCheck(connection) {
81
+ return async () => ({
82
+ mongodb: connection.readyState === 1 ? 'connected'
83
+ : connection.readyState === 2 ? 'unknown' // connecting; treat as transient
84
+ : 'disconnected',
85
+ });
86
+ }
87
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaGVhbHRoLWNoZWNrcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9hcGkvaGVhbHRoLWNoZWNrcy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSwrQ0FBK0M7QUFDL0Msc0NBQXNDO0FBRXRDLE9BQU8sRUFBRSxhQUFhLEVBQUUsTUFBTSxpQ0FBaUMsQ0FBQztBQUtoRTs7Ozs7R0FLRztBQUNILFNBQVMsV0FBVyxDQUFJLE9BQW1CLEVBQUUsRUFBVSxFQUFFLEtBQWE7SUFDcEUsT0FBTyxJQUFJLE9BQU8sQ0FBSSxDQUFDLE9BQU8sRUFBRSxNQUFNLEVBQUUsRUFBRTtRQUN4QyxNQUFNLEtBQUssR0FBRyxVQUFVLENBQUMsR0FBRyxFQUFFLENBQUMsTUFBTSxDQUFDLElBQUksS0FBSyxDQUFDLEdBQUcsS0FBSyxvQkFBb0IsRUFBRSxJQUFJLENBQUMsQ0FBQyxFQUFFLEVBQUUsQ0FBQyxDQUFDO1FBQzFGLEtBQUssQ0FBQyxLQUFLLEVBQUUsRUFBRSxDQUFDO1FBQ2hCLE9BQU8sQ0FBQyxJQUFJLENBQ1YsQ0FBQyxLQUFLLEVBQUUsRUFBRSxHQUFHLFlBQVksQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLENBQUMsRUFDbkQsQ0FBQyxHQUFHLEVBQUUsRUFBRSxHQUFHLFlBQVksQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FDL0MsQ0FBQztJQUNKLENBQUMsQ0FBQyxDQUFDO0FBQ0wsQ0FBQztBQUVEOzs7Ozs7R0FNRztBQUNILE1BQU0sQ0FBQyxLQUFLLFVBQVUsbUJBQW1CLENBQUMsU0FBUyxHQUFHLElBQUk7SUFDeEQsSUFBSSxDQUFDO1FBQ0gsTUFBTSxXQUFXLENBQUMsYUFBYSxFQUFFLENBQUMsY0FBYyxFQUFFLEVBQUUsU0FBUyxFQUFFLHlCQUF5QixDQUFDLENBQUM7UUFDMUYsT0FBTyxFQUFFLFFBQVEsRUFBRSxXQUFXLEVBQUUsQ0FBQztJQUNuQyxDQUFDO0lBQUMsTUFBTSxDQUFDO1FBQ1AsT0FBTyxFQUFFLFFBQVEsRUFBRSxjQUFjLEVBQUUsQ0FBQztJQUN0QyxDQUFDO0FBQ0gsQ0FBQztBQWVEOzs7Ozs7O0dBT0c7QUFDSCxNQUFNLFVBQVUsZ0JBQWdCLENBQUMsTUFBeUIsRUFBRSxTQUFTLEdBQUcsSUFBSTtJQUMxRSxPQUFPLEtBQUssSUFBSSxFQUFFO1FBQ2hCLElBQUksQ0FBQztZQUNILE1BQU0sTUFBTSxHQUFHLE9BQU8sTUFBTSxLQUFLLFVBQVUsQ0FBQyxDQUFDLENBQUMsTUFBTSxNQUFNLEVBQUUsQ0FBQyxDQUFDLENBQUMsTUFBTSxDQUFDO1lBQ3RFLE1BQU0sV0FBVyxDQUFDLE1BQU0sQ0FBQyxJQUFJLEVBQUUsRUFBRSxTQUFTLEVBQUUsWUFBWSxDQUFDLENBQUM7WUFDMUQsT0FBTyxFQUFFLEtBQUssRUFBRSxXQUFXLEVBQUUsQ0FBQztRQUNoQyxDQUFDO1FBQUMsTUFBTSxDQUFDO1lBQ1AsT0FBTyxFQUFFLEtBQUssRUFBRSxjQUFjLEVBQUUsQ0FBQztRQUNuQyxDQUFDO0lBQ0gsQ0FBQyxDQUFDO0FBQ0osQ0FBQztBQUVEOzs7Ozs7Ozs7Ozs7R0FZRztBQUNILE1BQU0sVUFBVSxtQkFBbUIsQ0FDakMsR0FBRyxNQUEwQztJQUU3QyxPQUFPLEtBQUssSUFBSSxFQUFFO1FBQ2hCLE1BQU0sT0FBTyxHQUFHLE1BQU0sT0FBTyxDQUFDLEdBQUcsQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFDLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxLQUFLLEVBQUUsQ0FBQyxLQUFLLENBQUMsR0FBRyxFQUFFLENBQUMsQ0FBQyxFQUFtQixDQUFBLENBQUMsQ0FBQyxDQUFDLENBQUM7UUFDcEcsT0FBTyxNQUFNLENBQUMsTUFBTSxDQUFDLEVBQUUsRUFBRSxHQUFHLE9BQU8sQ0FBaUIsQ0FBQztJQUN2RCxDQUFDLENBQUM7QUFDSixDQUFDO0FBV0Q7Ozs7Ozs7R0FPRztBQUNILE1BQU0sVUFBVSxnQkFBZ0IsQ0FBQyxVQUFrQztJQUNqRSxPQUFPLEtBQUssSUFBSSxFQUFFLENBQUMsQ0FBQztRQUNsQixPQUFPLEVBQUUsVUFBVSxDQUFDLFVBQVUsS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUFDLFdBQVc7WUFDaEQsQ0FBQyxDQUFDLFVBQVUsQ0FBQyxVQUFVLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxTQUFTLENBQUMsaUNBQWlDO2dCQUN6RSxDQUFDLENBQUMsY0FBYztLQUNyQixDQUFDLENBQUM7QUFDTCxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiLy8gQ29weXJpZ2h0IDIwMjYgUGlwZWxpbmUgQnVpbGRlciBDb250cmlidXRvcnNcbi8vIFNQRFgtTGljZW5zZS1JZGVudGlmaWVyOiBBcGFjaGUtMi4wXG5cbmltcG9ydCB7IGdldENvbm5lY3Rpb24gfSBmcm9tICdAcGlwZWxpbmUtYnVpbGRlci9waXBlbGluZS1kYXRhJztcblxudHlwZSBIZWFsdGhTdGF0dXMgPSAnY29ubmVjdGVkJyB8ICdkaXNjb25uZWN0ZWQnIHwgJ3Vua25vd24nO1xudHlwZSBIZWFsdGhSZXN1bHQgPSBSZWNvcmQ8c3RyaW5nLCBIZWFsdGhTdGF0dXM+O1xuXG4vKipcbiAqIFJlamVjdCBpZiBgcHJvbWlzZWAgaGFzbid0IHNldHRsZWQgd2l0aGluIGBtc2AuIEEgaGVhbHRoL3JlYWRpbmVzcyBwcm9iZVxuICogbXVzdCBuZXZlciBoYW5nIHRoZSBgL3JlYWR5YCByZXNwb25zZSDigJQgYSBkb3duIGRhdGFzdG9yZSAob3IgYSBCdWxsTVFcbiAqIGlvcmVkaXMgY2xpZW50IHdpdGggYG1heFJldHJpZXNQZXJSZXF1ZXN0OiBudWxsYCArIGFuIG9mZmxpbmUgcXVldWUpIGNhblxuICogbGVhdmUgYSBgcGluZygpYC9xdWVyeSBwZW5kaW5nIGluZGVmaW5pdGVseSwgc28gZXZlcnkgcHJvYmUgaXMgdGltZS1ib3hlZC5cbiAqL1xuZnVuY3Rpb24gd2l0aFRpbWVvdXQ8VD4ocHJvbWlzZTogUHJvbWlzZTxUPiwgbXM6IG51bWJlciwgbGFiZWw6IHN0cmluZyk6IFByb21pc2U8VD4ge1xuICByZXR1cm4gbmV3IFByb21pc2U8VD4oKHJlc29sdmUsIHJlamVjdCkgPT4ge1xuICAgIGNvbnN0IHRpbWVyID0gc2V0VGltZW91dCgoKSA9PiByZWplY3QobmV3IEVycm9yKGAke2xhYmVsfSB0aW1lZCBvdXQgYWZ0ZXIgJHttc31tc2ApKSwgbXMpO1xuICAgIHRpbWVyLnVucmVmPy4oKTtcbiAgICBwcm9taXNlLnRoZW4oXG4gICAgICAodmFsdWUpID0+IHsgY2xlYXJUaW1lb3V0KHRpbWVyKTsgcmVzb2x2ZSh2YWx1ZSk7IH0sXG4gICAgICAoZXJyKSA9PiB7IGNsZWFyVGltZW91dCh0aW1lcik7IHJlamVjdChlcnIpOyB9LFxuICAgICk7XG4gIH0pO1xufVxuXG4vKipcbiAqIFBvc3RncmVzIGhlYWx0aCBwcm9iZTogcmV0dXJucyBgeyBwb3N0Z3JlczogJ2Nvbm5lY3RlZCcgfWAgd2hlbiB0aGUgcG9vbCdzXG4gKiBjb25uZWN0aW9uIHRlc3Qgc3VjY2VlZHMsIGB7IHBvc3RncmVzOiAnZGlzY29ubmVjdGVkJyB9YCBvbiBhbnkgZXJyb3Igb3JcbiAqIHRpbWVvdXQgKHNvIGAvcmVhZHlgIGNvcnJlY3RseSByZXBvcnRzIDUwMykuIFVzZSBhcyAocGFydCBvZikgdGhlXG4gKiBgY2hlY2tEZXBlbmRlbmNpZXNgIG9wdGlvbiBvZiBgY3JlYXRlQXBwKClgIGZvciBhbnkgc2VydmljZSBiYWNrZWQgYnkgdGhlXG4gKiBzaGFyZWQgcGlwZWxpbmUtZGF0YSBEcml6emxlIGNvbm5lY3Rpb24uXG4gKi9cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBwb3N0Z3Jlc0hlYWx0aENoZWNrKHRpbWVvdXRNcyA9IDMwMDApOiBQcm9taXNlPEhlYWx0aFJlc3VsdD4ge1xuICB0cnkge1xuICAgIGF3YWl0IHdpdGhUaW1lb3V0KGdldENvbm5lY3Rpb24oKS50ZXN0Q29ubmVjdGlvbigpLCB0aW1lb3V0TXMsICdwb3N0Z3JlcyB0ZXN0Q29ubmVjdGlvbicpO1xuICAgIHJldHVybiB7IHBvc3RncmVzOiAnY29ubmVjdGVkJyB9O1xuICB9IGNhdGNoIHtcbiAgICByZXR1cm4geyBwb3N0Z3JlczogJ2Rpc2Nvbm5lY3RlZCcgfTtcbiAgfVxufVxuXG4vKiogTWluaW1hbCBzaGFwZSBvZiBhbiBpb3JlZGlzIGNsaWVudCBuZWVkZWQgZm9yIGEgaGVhbHRoIFBJTkcuICovXG5pbnRlcmZhY2UgUmVkaXNMaWtlIHtcbiAgcGluZygpOiBQcm9taXNlPHN0cmluZz47XG59XG5cbi8qKlxuICogU291cmNlIG9mIGEgcmVkaXMgY2xpZW50OiBlaXRoZXIgdGhlIGNsaWVudCBpdHNlbGYsIG9yIGEgKHN5bmMvYXN5bmMpIGdldHRlci5cbiAqIFRoZSBnZXR0ZXIgZm9ybSBzdXBwb3J0cyBCdWxsTVEncyBgcXVldWUuY2xpZW50YCAoYSBgUHJvbWlzZTxSZWRpc0NsaWVudD5gKVxuICogc28gYSBzZXJ2aWNlIGNhbiBwcm9iZSB0aGUgZXhhY3QgY29ubmVjdGlvbiBpdHMgcXVldWUgdXNlcyB3aXRob3V0IG9wZW5pbmdcbiAqIGEgc2Vjb25kIG9uZS5cbiAqL1xudHlwZSBSZWRpc0NsaWVudFNvdXJjZSA9IFJlZGlzTGlrZSB8ICgoKSA9PiBSZWRpc0xpa2UgfCBQcm9taXNlPFJlZGlzTGlrZT4pO1xuXG4vKipcbiAqIFJlZGlzIGhlYWx0aCBwcm9iZSBmYWN0b3J5OiBwYXNzIHRoZSBzZXJ2aWNlJ3MgRVhJU1RJTkcgaW9yZWRpcyBjb25uZWN0aW9uXG4gKiAob3IgYSBnZXR0ZXIgZm9yIGl0IOKAlCBlLmcuIGAoKSA9PiBxdWV1ZS5jbGllbnRgKSBhbmQgZ2V0IGJhY2sgYVxuICogYGNoZWNrRGVwZW5kZW5jaWVzYCBjYWxsYmFjayB0aGF0IGBQSU5HYHMgaXQuIE1pcnJvcnMgYG1vbmdvSGVhbHRoQ2hlY2tgIOKAlFxuICogaXQgcHJvYmVzIHRoZSByZWFsIGNvbm5lY3Rpb24gcmF0aGVyIHRoYW4gb3BlbmluZyBhIHRocm93YXdheSBvbmUgcGVyIGNhbGwuXG4gKiBUaGUgUElORyBpcyB0aW1lLWJveGVkIHNvIGEgZG93biByZWRpcyAod2hvc2Ugb2ZmbGluZSBxdWV1ZSB3b3VsZCBvdGhlcndpc2VcbiAqIGJ1ZmZlciB0aGUgY29tbWFuZCkgcmVwb3J0cyBgZGlzY29ubmVjdGVkYCBxdWlja2x5IGluc3RlYWQgb2YgaGFuZ2luZy5cbiAqL1xuZXhwb3J0IGZ1bmN0aW9uIHJlZGlzSGVhbHRoQ2hlY2soc291cmNlOiBSZWRpc0NsaWVudFNvdXJjZSwgdGltZW91dE1zID0gMjAwMCk6ICgpID0+IFByb21pc2U8SGVhbHRoUmVzdWx0PiB7XG4gIHJldHVybiBhc3luYyAoKSA9PiB7XG4gICAgdHJ5IHtcbiAgICAgIGNvbnN0IGNsaWVudCA9IHR5cGVvZiBzb3VyY2UgPT09ICdmdW5jdGlvbicgPyBhd2FpdCBzb3VyY2UoKSA6IHNvdXJjZTtcbiAgICAgIGF3YWl0IHdpdGhUaW1lb3V0KGNsaWVudC5waW5nKCksIHRpbWVvdXRNcywgJ3JlZGlzIHBpbmcnKTtcbiAgICAgIHJldHVybiB7IHJlZGlzOiAnY29ubmVjdGVkJyB9O1xuICAgIH0gY2F0Y2gge1xuICAgICAgcmV0dXJuIHsgcmVkaXM6ICdkaXNjb25uZWN0ZWQnIH07XG4gICAgfVxuICB9O1xufVxuXG4vKipcbiAqIENvbWJpbmUgc2V2ZXJhbCBkZXBlbmRlbmN5IHByb2JlcyBpbnRvIG9uZSBgY2hlY2tEZXBlbmRlbmNpZXNgIGNhbGxiYWNrIHRoYXRcbiAqIHJ1bnMgdGhlbSBJTiBQQVJBTExFTCBhbmQgbWVyZ2VzIHRoZWlyIHJlc3VsdHMuIEEgc2xvdy9mYWlsZWQgcHJvYmUgY2FuJ3RcbiAqIGRlbGF5IHRoZSBvdGhlcnMgb3IgdGltZSBvdXQgdGhlIHdob2xlIGAvcmVhZHlgIHJlc3BvbnNlLlxuICpcbiAqIENPTlRSQUNUOiBlYWNoIHByb2JlIG11c3QgQ0FUQ0ggaW50ZXJuYWxseSBhbmQgUkVUVVJOIGl0cyBgeyBuYW1lOiBzdGF0dXMgfWBcbiAqIChhcyB0aGUgYnVpbHQtaW4gcG9zdGdyZXMvcmVkaXMvbW9uZ28gcHJvYmVzIGRvKS4gQSBwcm9iZSB0aGF0IFRIUk9XUyBpc1xuICogbWVyZ2VkIGFzIGB7fWAg4oCUIGl0cyBkZXBlbmRlbmN5IHRoZW4gdmFuaXNoZXMgZnJvbSB0aGUgbWFwIGFuZCBgL3JlYWR5YFxuICogcmVwb3J0cyAyMDAgd2l0aCB0aGUgZGVwIHNpbXBseSBhYnNlbnQgKGEgc2lsZW50IGZhbHNlLWhlYWx0aHkpLCByYXRoZXIgdGhhblxuICogc3VyZmFjaW5nIGl0IGFzIGBkaXNjb25uZWN0ZWRgLiBUaGUgYC5jYXRjaCgoKSA9PiAoe30pKWAgYmVsb3cgb25seSBleGlzdHMgc29cbiAqIG9uZSBtaXNiZWhhdmluZyBwcm9iZSBjYW4ndCB0YWtlIGRvd24gdGhlIG90aGVycywgTk9UIGFzIGEgc3Vic3RpdHV0ZSBmb3IgYVxuICogcHJvYmUgcmV0dXJuaW5nIGl0cyBvd24gZGlzY29ubmVjdGVkIHN0YXR1cy5cbiAqL1xuZXhwb3J0IGZ1bmN0aW9uIGNvbWJpbmVIZWFsdGhDaGVja3MoXG4gIC4uLmNoZWNrczogQXJyYXk8KCkgPT4gUHJvbWlzZTxIZWFsdGhSZXN1bHQ+PlxuKTogKCkgPT4gUHJvbWlzZTxIZWFsdGhSZXN1bHQ+IHtcbiAgcmV0dXJuIGFzeW5jICgpID0+IHtcbiAgICBjb25zdCByZXN1bHRzID0gYXdhaXQgUHJvbWlzZS5hbGwoY2hlY2tzLm1hcCgoY2hlY2spID0+IGNoZWNrKCkuY2F0Y2goKCkgPT4gKHt9IGFzIEhlYWx0aFJlc3VsdCkpKSk7XG4gICAgcmV0dXJuIE9iamVjdC5hc3NpZ24oe30sIC4uLnJlc3VsdHMpIGFzIEhlYWx0aFJlc3VsdDtcbiAgfTtcbn1cblxuLyoqXG4gKiBNaW5pbWFsIHNoYXBlIGV4cGVjdGVkIG9mIGEgbW9uZ29vc2UgY29ubmVjdGlvbiBmb3IgdGhlIGhlYWx0aCBwcm9iZS5cbiAqIEFjY2VwdHMgYG1vbmdvb3NlLmNvbm5lY3Rpb25gIGRpcmVjdGx5IHdpdGhvdXQgcmVxdWlyaW5nIG1vbmdvb3NlIGFzIGFcbiAqIGRlcGVuZGVuY3kgb2YgYXBpLXNlcnZlci5cbiAqL1xuaW50ZXJmYWNlIE1vbmdvb3NlQ29ubmVjdGlvbkxpa2Uge1xuICByZWFkeVN0YXRlOiBudW1iZXI7XG59XG5cbi8qKlxuICogTW9uZ29EQiBoZWFsdGggcHJvYmUgZmFjdG9yeTogcGFzcyBgbW9uZ29vc2UuY29ubmVjdGlvbmAgYW5kIGdldCBiYWNrIGFcbiAqIGBjaGVja0RlcGVuZGVuY2llc2AgY2FsbGJhY2sgdGhhdCByZXBvcnRzIGJhc2VkIG9uIG1vbmdvb3NlJ3MgcmVhZHlTdGF0ZS5cbiAqXG4gKiBNYXBwaW5nIChwZXIgbW9uZ29vc2UgZG9jcyk6IDE9Y29ubmVjdGVkLCAyPWNvbm5lY3RpbmcsIDM9ZGlzY29ubmVjdGluZyxcbiAqIGFueSBvdGhlciB2YWx1ZSAoMD1kaXNjb25uZWN0ZWQsIDk5PXVuaW5pdGlhbGl6ZWQpIGlzIHRyZWF0ZWQgYXNcbiAqICdkaXNjb25uZWN0ZWQnIHNvIGAvaGVhbHRoYCByZXR1cm5zIDUwMy5cbiAqL1xuZXhwb3J0IGZ1bmN0aW9uIG1vbmdvSGVhbHRoQ2hlY2soY29ubmVjdGlvbjogTW9uZ29vc2VDb25uZWN0aW9uTGlrZSk6ICgpID0+IFByb21pc2U8SGVhbHRoUmVzdWx0PiB7XG4gIHJldHVybiBhc3luYyAoKSA9PiAoe1xuICAgIG1vbmdvZGI6IGNvbm5lY3Rpb24ucmVhZHlTdGF0ZSA9PT0gMSA/ICdjb25uZWN0ZWQnXG4gICAgICA6IGNvbm5lY3Rpb24ucmVhZHlTdGF0ZSA9PT0gMiA/ICd1bmtub3duJyAvLyBjb25uZWN0aW5nOyB0cmVhdCBhcyB0cmFuc2llbnRcbiAgICAgICAgOiAnZGlzY29ubmVjdGVkJyxcbiAgfSk7XG59XG4iXX0=
@@ -0,0 +1,80 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+ interface CachedEntry {
3
+ statusCode: number;
4
+ body: unknown;
5
+ expiresAt: number;
6
+ /** Reservation placeholder: the request is in-flight, no real response yet.
7
+ * A duplicate that sees a pending entry is rejected with 409, not replayed. */
8
+ pending?: boolean;
9
+ }
10
+ /**
11
+ * Pluggable backend for idempotency-key replay cache. The default in-memory
12
+ * implementation works fine single-replica; a Redis-backed implementation
13
+ * is required for correct deduplication across horizontally-scaled replicas
14
+ * (otherwise two replicas behind a load-balancer cache independently and a
15
+ * retry hitting a different pod won't replay).
16
+ */
17
+ export interface IdempotencyStore {
18
+ get(key: string): Promise<CachedEntry | null>;
19
+ set(key: string, entry: CachedEntry, ttlSeconds: number): Promise<void>;
20
+ /**
21
+ * Atomically claim `key` IFF it is currently absent. Returns `true` when THIS
22
+ * caller set the entry (won the race), `false` when an entry already exists.
23
+ * This is what serializes concurrent first-time requests that share an
24
+ * Idempotency-Key — a plain get-then-set leaves a window where two requests
25
+ * both see "not cached" and both execute the mutation.
26
+ */
27
+ reserve(key: string, entry: CachedEntry, ttlSeconds: number): Promise<boolean>;
28
+ /** Remove a key — releases a reservation after a non-cacheable (non-2xx) response. */
29
+ delete(key: string): Promise<void>;
30
+ }
31
+ /** Default in-memory store. Single-replica only. Exported for testing. */
32
+ export declare function createMemoryStore(): IdempotencyStore;
33
+ /**
34
+ * Minimal Redis surface the idempotency store needs (an ioredis subset).
35
+ * `set` supports the variadic option tail: `SET key val EX <ttl> [NX]`, which
36
+ * returns `'OK'` on success and `null` when an `NX` set was refused.
37
+ */
38
+ export interface RedisIdempotencyClient {
39
+ get(key: string): Promise<string | null>;
40
+ set(key: string, value: string, ...args: (string | number)[]): Promise<string | null>;
41
+ del(key: string): Promise<number>;
42
+ }
43
+ /**
44
+ * Redis-backed {@link IdempotencyStore} for correct deduplication across
45
+ * horizontally-scaled replicas. `reserve` maps to an atomic `SET … NX` so two
46
+ * pods behind a load-balancer can't both win a first-time request sharing an
47
+ * Idempotency-Key; a retry landing on ANY pod sees the same committed response.
48
+ * Redis TTL owns expiry (the `CachedEntry.expiresAt` field is carried only for
49
+ * API-shape parity with the memory store).
50
+ */
51
+ export declare function createRedisIdempotencyStore(redis: RedisIdempotencyClient): IdempotencyStore;
52
+ /**
53
+ * Build a Redis-backed idempotency store from the shared env Redis (the same
54
+ * `REDIS_URL` / `REDIS_HOST` wiring the rate-limiter and audit-spool use).
55
+ * Returns `null` when Redis isn't configured/available so the caller keeps the
56
+ * in-memory default (single-replica correctness). Never throws.
57
+ */
58
+ export declare function createEnvRedisIdempotencyStore(): IdempotencyStore | null;
59
+ /** Inject the process-wide idempotency store (e.g. a Redis-backed one). */
60
+ export declare function setIdempotencyStore(store: IdempotencyStore): void;
61
+ /** The current process-wide default store (in-memory unless overridden). */
62
+ export declare function getIdempotencyStore(): IdempotencyStore;
63
+ export interface IdempotencyMiddlewareOptions {
64
+ /** Custom store backend. Defaults to the process-wide store set via
65
+ * {@link setIdempotencyStore} (in-memory until `createApp` wires Redis). */
66
+ store?: IdempotencyStore;
67
+ }
68
+ /**
69
+ * Middleware that supports idempotency keys for POST/PUT/DELETE mutations.
70
+ *
71
+ * When a request includes the `Idempotency-Key` header:
72
+ * - First call: processes normally, caches the response
73
+ * - Subsequent calls with same key: returns cached response (prevents duplicate mutations)
74
+ *
75
+ * Defaults to an in-memory store (single-replica). Pass a custom
76
+ * cross-replica `{ store }` implementing `IdempotencyStore` to dedupe across
77
+ * replicas.
78
+ */
79
+ export declare function idempotencyMiddleware(options?: IdempotencyMiddlewareOptions): (req: Request, res: Response, next: NextFunction) => void;
80
+ export {};