@tumbaland/backend-core 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -48
- package/package.json +4 -1
- package/src/app/createBaseApp.test.ts +9 -0
- package/src/health/healthController.test.ts +23 -1
- package/src/logging/logger.test.ts +15 -0
- package/src/metrics/index.test.ts +25 -0
- package/src/middleware/validate.test.ts +12 -0
- package/src/tracing/index.test.ts +18 -0
- package/tsconfig.build.json +3 -0
- package/tsconfig.json +4 -1
package/README.md
CHANGED
|
@@ -120,54 +120,15 @@ This library is optimized for Docker deployments:
|
|
|
120
120
|
|
|
121
121
|
## 📦 Releases & Versioning
|
|
122
122
|
|
|
123
|
-
This library
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
```
|
|
134
|
-
type(scope): description
|
|
135
|
-
|
|
136
|
-
[optional body]
|
|
137
|
-
|
|
138
|
-
[optional footer]
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
**Types:**
|
|
142
|
-
- `feat`: New feature
|
|
143
|
-
- `fix`: Bug fix
|
|
144
|
-
- `docs`: Documentation
|
|
145
|
-
- `style`: Code style changes
|
|
146
|
-
- `refactor`: Code refactoring
|
|
147
|
-
- `test`: Testing
|
|
148
|
-
- `chore`: Maintenance
|
|
149
|
-
|
|
150
|
-
**Examples:**
|
|
151
|
-
```
|
|
152
|
-
feat(auth): add JWT token refresh
|
|
153
|
-
fix(logging): resolve memory leak in Winston transport
|
|
154
|
-
docs(api): update health check endpoint documentation
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### Release Process
|
|
158
|
-
|
|
159
|
-
1. **Make changes** with conventional commit messages
|
|
160
|
-
2. **Run release script**: `./release.sh`
|
|
161
|
-
3. **Push changes**: `git push origin main`
|
|
162
|
-
4. **Publish**: Automated via GitHub Actions
|
|
163
|
-
|
|
164
|
-
### Changelog
|
|
165
|
-
|
|
166
|
-
Changelogs are maintained at the project level in the root `CHANGELOG.md` file. Individual library changelogs are not generated to maintain consistency across the monorepo.
|
|
167
|
-
|
|
168
|
-
### Beta Releases
|
|
169
|
-
|
|
170
|
-
For beta releases: `npm run release:beta`
|
|
123
|
+
This library is a real published npm package (`@tumbaland/backend-core`), versioned independently
|
|
124
|
+
of the monorepo with [standard-version](https://github.com/conventional-changelog/standard-version)
|
|
125
|
+
and [Conventional Commits](https://conventionalcommits.org/). See the root
|
|
126
|
+
[README's "Commits & Releases" section](../../README.md#commits--releases) for the full process
|
|
127
|
+
and the reasoning behind it — summary: commit with conventional messages, then from this directory
|
|
128
|
+
run `npm run release` (never hand-edit `version` and `npm publish` directly, or the package,
|
|
129
|
+
changelog, and tags drift out of sync with each other). Tags for this package are scoped as
|
|
130
|
+
`backend-core-vX.Y.Z` so they can't collide with `frontend-core`'s, `components`', or the
|
|
131
|
+
monorepo's own `app-vX.Y.Z` tags.
|
|
171
132
|
|
|
172
133
|
---
|
|
173
134
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tumbaland/backend-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Core shared functionality for Tumbaland backend services",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
],
|
|
26
26
|
"author": "Tumbaland Team",
|
|
27
27
|
"license": "MIT",
|
|
28
|
+
"standard-version": {
|
|
29
|
+
"tagPrefix": "backend-core-v"
|
|
30
|
+
},
|
|
28
31
|
"devDependencies": {
|
|
29
32
|
"@types/cookie-parser": "^1.4.10",
|
|
30
33
|
"@types/cors": "^2.8.19",
|
|
@@ -54,6 +54,15 @@ describe('createBaseApp', () => {
|
|
|
54
54
|
expect(second.status).toBe(429);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
it('uses the plain request logger (no metrics histogram) when metrics is false', async () => {
|
|
58
|
+
const app = createBaseApp({ metrics: false });
|
|
59
|
+
app.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
60
|
+
|
|
61
|
+
const res = await request(app).get('/ping');
|
|
62
|
+
|
|
63
|
+
expect(res.status).toBe(200);
|
|
64
|
+
});
|
|
65
|
+
|
|
57
66
|
it('mounts /health/live ahead of the rate limiter, so it never gets 429s a busy service would', async () => {
|
|
58
67
|
const app = createBaseApp({ rateLimiter: (await import('../middleware/security')).createRateLimiter({ windowMs: 60_000, max: 1 }) });
|
|
59
68
|
app.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Request, Response } from 'express';
|
|
2
2
|
import mongoose from 'mongoose';
|
|
3
|
-
import { healthCheck, healthLive } from './healthController';
|
|
3
|
+
import { healthCheck, healthLive, metricsHandler } from './healthController';
|
|
4
4
|
|
|
5
5
|
function mockRes(): Response {
|
|
6
6
|
const res: Partial<Response> = {};
|
|
@@ -88,4 +88,26 @@ describe('healthCheck', () => {
|
|
|
88
88
|
expect(res.status).toHaveBeenCalledWith(503);
|
|
89
89
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ status: 'error', error: 'Health check failed' }));
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
it('falls back to default service/version/description in the error body when env vars are unset', async () => {
|
|
93
|
+
delete process.env.SERVICE_NAME;
|
|
94
|
+
delete process.env.npm_package_version;
|
|
95
|
+
delete process.env.SERVICE_DESCRIPTION;
|
|
96
|
+
jest.spyOn(mongoose, 'connection', 'get').mockImplementation(() => {
|
|
97
|
+
throw new Error('boom');
|
|
98
|
+
});
|
|
99
|
+
const res = mockRes();
|
|
100
|
+
|
|
101
|
+
await healthCheck({} as Request, res);
|
|
102
|
+
|
|
103
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
104
|
+
expect.objectContaining({ service: 'unknown-service', version: '1.0.0', description: 'Backend service' })
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('metricsHandler re-export', () => {
|
|
110
|
+
it('re-exports the same metricsHandler function from ../metrics', () => {
|
|
111
|
+
expect(typeof metricsHandler).toBe('function');
|
|
112
|
+
});
|
|
91
113
|
});
|
|
@@ -35,6 +35,21 @@ describe('logger', () => {
|
|
|
35
35
|
expect(logger.level).toBe('info');
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
it('defaults to development (debug level) when NODE_ENV is unset', async () => {
|
|
39
|
+
const ORIGINAL_ENV = process.env;
|
|
40
|
+
const merged: Record<string, string | undefined> = { ...ORIGINAL_ENV };
|
|
41
|
+
delete merged.NODE_ENV;
|
|
42
|
+
process.env = merged as NodeJS.ProcessEnv;
|
|
43
|
+
|
|
44
|
+
let loggerModule!: typeof import('./logger').default;
|
|
45
|
+
await jest.isolateModulesAsync(async () => {
|
|
46
|
+
loggerModule = (await import('./logger')).default;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
process.env = ORIGINAL_ENV;
|
|
50
|
+
expect(loggerModule.level).toBe('debug');
|
|
51
|
+
});
|
|
52
|
+
|
|
38
53
|
it('does not create a file transport when LOG_TO_FILE is unset', async () => {
|
|
39
54
|
const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: undefined });
|
|
40
55
|
expect(namedTransports(logger, 'file')).toHaveLength(0);
|
|
@@ -81,6 +81,31 @@ describe('metricsMiddleware', () => {
|
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
describe('default labels', () => {
|
|
85
|
+
it('falls back to app "unknown-service" and version "1.0.0" when their env vars are unset', async () => {
|
|
86
|
+
const ORIGINAL_ENV = process.env;
|
|
87
|
+
const merged: Record<string, string | undefined> = { ...ORIGINAL_ENV };
|
|
88
|
+
delete merged.SERVICE_NAME;
|
|
89
|
+
delete merged.npm_package_version;
|
|
90
|
+
process.env = merged as NodeJS.ProcessEnv;
|
|
91
|
+
|
|
92
|
+
// isolateModulesAsync sandboxes the whole module registry, including
|
|
93
|
+
// prom-client itself — the spy must target the Registry class required
|
|
94
|
+
// *inside* the sandbox, not the one imported at the top of this file.
|
|
95
|
+
await jest.isolateModulesAsync(async () => {
|
|
96
|
+
const innerPromClient = (await import('prom-client')).default;
|
|
97
|
+
const setDefaultLabelsSpy = jest.spyOn(innerPromClient.Registry.prototype, 'setDefaultLabels');
|
|
98
|
+
|
|
99
|
+
await import('./index');
|
|
100
|
+
|
|
101
|
+
expect(setDefaultLabelsSpy).toHaveBeenCalledWith({ app: 'unknown-service', version: '1.0.0' });
|
|
102
|
+
setDefaultLabelsSpy.mockRestore();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
process.env = ORIGINAL_ENV;
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
84
109
|
describe('businessMetrics', () => {
|
|
85
110
|
it('exposes counters that can be incremented without throwing', () => {
|
|
86
111
|
expect(() => businessMetrics.albumsCreated.inc()).not.toThrow();
|
|
@@ -57,4 +57,16 @@ describe('validate', () => {
|
|
|
57
57
|
expect(res.status).toBe(200);
|
|
58
58
|
expect(res.body.data).toEqual({ name: 'Carol' });
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
it('omits the field prefix when the zod issue has no path (whole-body validation)', async () => {
|
|
62
|
+
const arraySchema = z.array(z.string());
|
|
63
|
+
const app = buildApp(arraySchema);
|
|
64
|
+
const rawResult = arraySchema.safeParse({ not: 'an array' });
|
|
65
|
+
|
|
66
|
+
const res = await request(app).post('/things').send({ not: 'an array' });
|
|
67
|
+
|
|
68
|
+
expect(res.status).toBe(400);
|
|
69
|
+
expect(rawResult.success).toBe(false);
|
|
70
|
+
expect(res.body.message).toBe(!rawResult.success ? rawResult.error.issues[0].message : undefined);
|
|
71
|
+
});
|
|
60
72
|
});
|
|
@@ -39,6 +39,7 @@ jest.mock('@opentelemetry/resources', () => ({
|
|
|
39
39
|
}));
|
|
40
40
|
|
|
41
41
|
import { context } from '@opentelemetry/api';
|
|
42
|
+
import { ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
|
42
43
|
|
|
43
44
|
async function loadTracing() {
|
|
44
45
|
let tracing!: typeof import('./index');
|
|
@@ -92,6 +93,23 @@ describe('initTracer', () => {
|
|
|
92
93
|
expect(exporterCtorMock).toHaveBeenCalledWith({ url: 'http://jaeger-endpoint/v1/traces' });
|
|
93
94
|
});
|
|
94
95
|
|
|
96
|
+
it('falls back to version 1.0.0 and environment "development" when their env vars are unset', async () => {
|
|
97
|
+
const merged: Record<string, string | undefined> = { ...process.env };
|
|
98
|
+
delete merged.npm_package_version;
|
|
99
|
+
delete merged.NODE_ENV;
|
|
100
|
+
process.env = merged as NodeJS.ProcessEnv;
|
|
101
|
+
|
|
102
|
+
const { initTracer } = await loadTracing();
|
|
103
|
+
initTracer('album-service');
|
|
104
|
+
|
|
105
|
+
expect(resourceFromAttributesMock).toHaveBeenCalledWith(
|
|
106
|
+
expect.objectContaining({
|
|
107
|
+
[ATTR_SERVICE_VERSION]: '1.0.0',
|
|
108
|
+
'deployment.environment': 'development'
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
95
113
|
it('registers the provider and returns a tracer scoped to the given service name', async () => {
|
|
96
114
|
const { initTracer } = await loadTracing();
|
|
97
115
|
|
package/tsconfig.build.json
CHANGED
package/tsconfig.json
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"target": "es2023",
|
|
4
4
|
"module": "commonjs",
|
|
5
|
-
"lib": ["ES2020"],
|
|
6
5
|
"outDir": "./dist",
|
|
7
6
|
"rootDir": "./src",
|
|
8
7
|
"strict": true,
|
|
9
8
|
"esModuleInterop": true,
|
|
10
9
|
"skipLibCheck": true,
|
|
11
10
|
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"moduleDetection": "force",
|
|
12
|
+
"noImplicitReturns": true,
|
|
13
|
+
"noFallthroughCasesInSwitch": true,
|
|
14
|
+
"noImplicitOverride": true,
|
|
12
15
|
"declaration": true,
|
|
13
16
|
"declarationMap": true,
|
|
14
17
|
"sourceMap": true,
|