Skip to content

Commit 727fda0

Browse files
authored
Merge pull request #173 from shogun444/feat/db-query-timeout-config
Feat/db query timeout config
2 parents 6c0ace2 + a7a7f12 commit 727fda0

8 files changed

Lines changed: 194 additions & 93 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ FRONTEND_URL=http://localhost:5173
55

66
# Docker Postgres defaults
77
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/accesslayer
8+
DB_QUERY_TIMEOUT_MS=5000
89
APP_SECRET=your_32_character_long_secret_string_here
910

1011
GOOGLE_CLIENT_ID=

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import { responseTimingMiddleware } from './middlewares/response-timing.middlewa
1414
import { apiVersionMiddleware } from './middlewares/api-version.middleware';
1515
import { schemaVersionMiddleware } from './middlewares/schema-version.middleware';
1616
import { requestLoggerMiddleware } from './middlewares/request-logger.middleware';
17+
import { requestContextMiddleware } from './middlewares/request-context.middleware';
1718
import { envConfig } from './config';
1819

1920
const app: Express = express();
2021

2122
// Middleware setup
2223
app.set('trust proxy', 1);
2324
app.use(responseTimingMiddleware);
25+
app.use(requestContextMiddleware);
2426
app.use(apiVersionMiddleware);
2527
app.use(schemaVersionMiddleware);
2628
app.use(requestIdMiddleware);

src/config.schema.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ import { z } from 'zod';
1515
* See docs/configuration.md for complete documentation.
1616
* See docs/CONFIG_SOURCE_PRECEDENCE.md for visual reference.
1717
*/
18+
/**
19+
* Helper to correctly coerce boolean strings from environment variables.
20+
* Zod's default z.coerce.boolean() returns true for any non-empty string,
21+
* including "false" and "0", which is usually not what we want for .env files.
22+
*/
23+
const booleanCoerce = z.preprocess((val) => {
24+
if (typeof val === 'string') {
25+
const lower = val.toLowerCase();
26+
if (lower === 'true' || lower === '1') return true;
27+
if (lower === 'false' || lower === '0') return false;
28+
}
29+
return val;
30+
}, z.coerce.boolean());
31+
1832
export const envSchema = z
1933
.object({
2034
PORT: z.coerce.number().default(3000),
@@ -60,11 +74,12 @@ export const envSchema = z
6074
.string()
6175
.min(1, 'PAYSTACK_PUBLIC_KEY is required for payment processing')
6276
.optional(),
63-
ENABLE_RESPONSE_TIMING: z.coerce.boolean().default(true),
77+
ENABLE_RESPONSE_TIMING: booleanCoerce.default(true),
6478
API_VERSION: z.string().default('1.0.0'),
65-
ENABLE_API_VERSION_HEADER: z.coerce.boolean().default(true),
66-
ENABLE_SCHEMA_VERSION_HEADER: z.coerce.boolean().default(true),
67-
ENABLE_REQUEST_LOGGING: z.coerce.boolean().default(true),
79+
ENABLE_API_VERSION_HEADER: booleanCoerce.default(true),
80+
ENABLE_SCHEMA_VERSION_HEADER: booleanCoerce.default(true),
81+
ENABLE_REQUEST_LOGGING: booleanCoerce.default(true),
82+
DB_QUERY_TIMEOUT_MS: z.coerce.number().default(5000),
6883

6984
APP_SECRET: z
7085
.string()
@@ -78,9 +93,9 @@ export const envSchema = z
7893
INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce.number().positive().default(300000),
7994

8095
// Indexer feature flags
81-
ENABLE_INDEXER_DEDUPE: z.coerce.boolean().default(true),
82-
ENABLE_INDEXER_DLQ: z.coerce.boolean().default(true),
83-
ENABLE_INDEXER_CURSOR_STALENESS_WARNING: z.coerce.boolean().default(true),
96+
ENABLE_INDEXER_DEDUPE: booleanCoerce.default(true),
97+
ENABLE_INDEXER_DLQ: booleanCoerce.default(true),
98+
ENABLE_INDEXER_CURSOR_STALENESS_WARNING: booleanCoerce.default(true),
8499

85100
// Stellar network
86101
STELLAR_NETWORK: z

src/config.test.ts

Lines changed: 99 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
1-
/// <reference types="node" />
2-
import { strict as assert } from 'assert';
3-
import { envSchema } from './config.schema';
1+
// src/config.test.ts
2+
// Tests for configuration source precedence and validation behavior.
3+
44
import { z } from 'zod';
5+
import { envSchema } from './config.schema';
56

67
/**
7-
* Minimal valid env fixture — satisfies every required field in envSchema
8-
* so individual tests can override only the values they care about.
8+
* Test configuration schema behavior without affecting actual config.
9+
* These tests validate the documented source precedence rules and Stellar-specific logic.
910
*/
11+
12+
const booleanCoerce = z.preprocess((val) => {
13+
if (typeof val === 'string') {
14+
const lower = val.toLowerCase();
15+
if (lower === 'true' || lower === '1') return true;
16+
if (lower === 'false' || lower === '0') return false;
17+
}
18+
return val;
19+
}, z.coerce.boolean());
20+
1021
const BASE_ENV = {
1122
PORT: '3000',
1223
MODE: 'development',
@@ -21,85 +32,102 @@ const BASE_ENV = {
2132
CLOUDINARY_API_KEY: 'api-key',
2233
CLOUDINARY_API_SECRET: 'api-secret',
2334
PAYSTACK_SECRET_KEY: 'pk-secret',
35+
APP_SECRET: 'accesslayer_default_development_secret_key_32_bytes_long',
2436
};
2537

26-
function run() {
27-
console.log('Running configuration validation tests...');
38+
describe('Config Validation and Source Precedence', () => {
39+
40+
describe('Stellar & General Schema Validation', () => {
41+
it('Defaults are applied correctly', () => {
42+
const defaults = envSchema.parse(BASE_ENV);
43+
expect(defaults.STELLAR_NETWORK).toBe('testnet');
44+
expect(defaults.STELLAR_HORIZON_URL).toBe('https://horizon-testnet.stellar.org');
45+
expect(defaults.API_VERSION).toBe('1.0.0');
46+
expect(defaults.ENABLE_INDEXER_DEDUPE).toBe(true);
47+
});
2848

29-
// ── SECTION 1: Project Schema Validation (Stellar & General) ──
49+
it('Valid explicit config is accepted', () => {
50+
const valid = envSchema.safeParse({
51+
...BASE_ENV,
52+
STELLAR_NETWORK: 'mainnet',
53+
STELLAR_HORIZON_URL: 'https://horizon.stellar.org',
54+
});
55+
expect(valid.success).toBe(true);
56+
});
3057

31-
// 1.1 Defaults are applied correctly
32-
const defaults = envSchema.parse(BASE_ENV);
33-
assert.equal(defaults.STELLAR_NETWORK, 'testnet');
34-
assert.equal(defaults.STELLAR_HORIZON_URL, 'https://horizon-testnet.stellar.org');
35-
assert.equal(defaults.API_VERSION, '1.0.0');
36-
assert.equal(defaults.ENABLE_INDEXER_DEDUPE, true);
58+
it('Invalid STELLAR_NETWORK value is rejected', () => {
59+
const badNetwork = envSchema.safeParse({
60+
...BASE_ENV,
61+
STELLAR_NETWORK: 'devnet',
62+
});
63+
expect(badNetwork.success).toBe(false);
64+
});
3765

38-
// 1.2 Valid explicit config is accepted
39-
const valid = envSchema.safeParse({
40-
...BASE_ENV,
41-
STELLAR_NETWORK: 'mainnet',
42-
STELLAR_HORIZON_URL: 'https://horizon.stellar.org',
66+
it('production MODE with testnet STELLAR_NETWORK should fail', () => {
67+
const mismatch = envSchema.safeParse({
68+
...BASE_ENV,
69+
MODE: 'production',
70+
STELLAR_NETWORK: 'testnet',
71+
});
72+
expect(mismatch.success).toBe(false);
73+
if (!mismatch.success) {
74+
const issue = mismatch.error.issues.find((i: z.ZodIssue) =>
75+
i.path.includes('STELLAR_NETWORK') && i.message.includes('mainnet')
76+
);
77+
expect(issue).toBeDefined();
78+
}
79+
});
4380
});
44-
assert.equal(valid.success, true, 'Valid Stellar config should parse');
4581

46-
// 1.3 Invalid STELLAR_NETWORK value is rejected
47-
const badNetwork = envSchema.safeParse({
48-
...BASE_ENV,
49-
STELLAR_NETWORK: 'devnet',
50-
});
51-
assert.equal(badNetwork.success, false, 'Invalid STELLAR_NETWORK should fail');
82+
describe('Source Precedence & Zod Behavior', () => {
83+
it('Environment variable takes precedence over default', () => {
84+
const schema = z.object({
85+
PORT: z.coerce.number().default(3000),
86+
});
5287

53-
// 1.4 Cross-field: production + testnet raises an issue
54-
const mismatch = envSchema.safeParse({
55-
...BASE_ENV,
56-
MODE: 'production',
57-
STELLAR_NETWORK: 'testnet',
58-
});
59-
assert.equal(mismatch.success, false, 'production MODE with testnet STELLAR_NETWORK should fail');
60-
if (!mismatch.success) {
61-
const issue = mismatch.error.issues.find((i: z.ZodIssue) =>
62-
i.path.includes('STELLAR_NETWORK') && i.message.includes('mainnet')
63-
);
64-
assert.ok(issue, 'Error should warn about STELLAR_NETWORK mismatch in production');
65-
}
88+
const result = schema.parse({ PORT: '4000' });
89+
expect(result.PORT).toBe(4000);
90+
});
6691

67-
// ── SECTION 2: Source Precedence & Zod Behavior (from main branch) ──
68-
console.log('Running source precedence and Zod behavior checks...');
92+
it('Default used when environment variable not provided', () => {
93+
const schema = z.object({
94+
PORT: z.coerce.number().default(3000),
95+
});
6996

70-
// 2.1 Environment variable takes precedence over default
71-
{
72-
const schema = z.object({ PORT: z.coerce.number().default(3000) });
73-
const result = schema.parse({ PORT: '4000' });
74-
assert.equal(result.PORT, 4000);
75-
}
97+
const result = schema.parse({});
98+
expect(result.PORT).toBe(3000);
99+
});
100+
101+
it('Type coercion for numbers', () => {
102+
const schema = z.object({
103+
PORT: z.coerce.number(),
104+
});
76105

77-
// 2.2 Type coercion for numbers and booleans
78-
{
79-
const schema = z.object({
80-
PORT: z.coerce.number(),
81-
ENABLED: z.coerce.boolean(),
106+
const result = schema.parse({ PORT: '4000' });
107+
expect(result.PORT).toBe(4000);
108+
expect(typeof result.PORT).toBe('number');
82109
});
83-
const result = schema.parse({ PORT: '4000', ENABLED: 'true' });
84-
assert.equal(typeof result.PORT, 'number');
85-
assert.equal(result.ENABLED, true);
86-
87-
// Note: z.coerce.boolean() uses Boolean(), so any non-empty string is true.
88-
// If we want 'false' or '0' to be false, we'd need a custom preprocessor.
89-
assert.equal(schema.parse({ PORT: '3000', ENABLED: 'false' }).ENABLED, true);
90-
}
91110

92-
// 2.3 Number range validation
93-
{
94-
const schema = z.object({
95-
JITTER: z.coerce.number().min(0).max(1).default(0.1),
111+
it('Type coercion for booleans', () => {
112+
const schema = z.object({
113+
ENABLED: booleanCoerce,
114+
});
115+
116+
expect(schema.parse({ ENABLED: 'true' }).ENABLED).toBe(true);
117+
expect(schema.parse({ ENABLED: 'false' }).ENABLED).toBe(false);
118+
expect(schema.parse({ ENABLED: '1' }).ENABLED).toBe(true);
119+
expect(schema.parse({ ENABLED: '0' }).ENABLED).toBe(false);
96120
});
97-
assert.equal(schema.parse({}).JITTER, 0.1);
98-
assert.ok(!schema.safeParse({ JITTER: '2' }).success);
99-
assert.ok(!schema.safeParse({ JITTER: '-1' }).success);
100-
}
101121

102-
console.log('✓ All configuration tests passed');
103-
}
122+
it('Number range validation', () => {
123+
const schema = z.object({
124+
JITTER: z.coerce.number().min(0).max(1).default(0.1),
125+
});
104126

105-
run();
127+
expect(schema.parse({ JITTER: '0.5' }).JITTER).toBe(0.5);
128+
expect(schema.parse({}).JITTER).toBe(0.1);
129+
expect(schema.safeParse({ JITTER: '2' }).success).toBe(false);
130+
expect(schema.safeParse({ JITTER: '-1' }).success).toBe(false);
131+
});
132+
});
133+
});

src/config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,11 @@ dotenv.config();
1919
* const port = envConfig.PORT;
2020
* const isProduction = envConfig.MODE === 'production';
2121
*/
22-
2322
export const envConfig = envSchema.parse(process.env);
2423

2524
/**
2625
* Derived application configuration.
27-
*
26+
*
2827
* These values are computed from envConfig at startup.
2928
*/
3029
export const appConfig = {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Request, Response, NextFunction } from 'express';
2+
import { requestContextStorage } from '../utils/als.utils';
3+
4+
export const requestContextMiddleware = (
5+
req: Request,
6+
_res: Response,
7+
next: NextFunction
8+
): void => {
9+
const context = {
10+
path: req.originalUrl || req.url,
11+
method: req.method,
12+
requestId: req.requestId as string | undefined,
13+
};
14+
15+
requestContextStorage.run(context, () => {
16+
next();
17+
});
18+
};

src/utils/als.utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { AsyncLocalStorage } from 'async_hooks';
2+
3+
export interface RequestContext {
4+
path: string;
5+
method: string;
6+
requestId?: string;
7+
}
8+
9+
export const requestContextStorage = new AsyncLocalStorage<RequestContext>();

src/utils/prisma.utils.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,52 @@
11
import { PrismaClient } from '@prisma/client';
22
import { envConfig } from '../config';
3+
import { requestContextStorage } from './als.utils';
4+
import { logger } from './logger.utils';
35

46
// Use global variable to prevent multiple instances in development
5-
// See: https://www.prisma.io/docs/guides/performance-and-optimization/connection-management
6-
77
declare global {
8-
var prisma: PrismaClient | undefined;
8+
var prisma: any | undefined;
99
}
1010

11-
// Export a singleton PrismaClient instance
12-
export const prisma =
13-
global.prisma ||
14-
new PrismaClient({
15-
log:
16-
envConfig.MODE === 'development'
17-
? ['query', 'error', 'warn']
18-
: ['error'],
19-
datasourceUrl: envConfig.DATABASE_URL,
20-
});
11+
const basePrisma = new PrismaClient({
12+
log:
13+
envConfig.MODE === 'development'
14+
? ['query', 'error', 'warn']
15+
: ['error'],
16+
datasourceUrl: envConfig.DATABASE_URL,
17+
});
18+
19+
// Extend Prisma with query timeout
20+
export const prisma = basePrisma.$extends({
21+
query: {
22+
$allOperations({ operation, model, args, query }) {
23+
const timeoutMs = envConfig.DB_QUERY_TIMEOUT_MS;
24+
const context = requestContextStorage.getStore();
25+
26+
let timeoutId: NodeJS.Timeout;
27+
const timeoutPromise = new Promise((_, reject) => {
28+
timeoutId = setTimeout(() => {
29+
const logContext = {
30+
type: 'database_timeout',
31+
operation,
32+
model,
33+
timeoutMs,
34+
path: context?.path,
35+
method: context?.method,
36+
requestId: context?.requestId,
37+
};
38+
logger.error(logContext, `Database query timed out after ${timeoutMs}ms`);
39+
reject(new Error(`Database query timed out after ${timeoutMs}ms`));
40+
}, timeoutMs);
41+
});
42+
43+
return Promise.race([
44+
query(args).finally(() => clearTimeout(timeoutId)),
45+
timeoutPromise,
46+
]);
47+
},
48+
},
49+
});
2150

2251
// Prevent multiple instances in development environment
2352
if (envConfig.MODE !== 'production') {

0 commit comments

Comments
 (0)