@rapidd/core 2.1.1 → 2.1.3

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/.env.example CHANGED
@@ -68,3 +68,9 @@ RLS_ENABLED=
68
68
  # Namespace prefix for SQL session variables (default: app)
69
69
  RLS_NAMESPACE=app
70
70
  # Configure RLS variables in src/config/rls.ts
71
+
72
+ # ── Logging ───────────────────────────────────────────
73
+ # essential (default), fine, or finest
74
+ LOG_LEVEL=essential
75
+ # Directory for log files (default: logs/). Empty string disables file logging.
76
+ LOG_DIR=logs
package/README.md CHANGED
@@ -31,18 +31,14 @@ Rapidd generates a fully-featured REST API from your database schema — then ge
31
31
 
32
32
  ```bash
33
33
  mkdir my-api && cd my-api
34
- npx rapidd create-project # scaffold project files
35
- npm install
34
+ npx @rapidd/core create-project && npm install
36
35
  ```
37
36
 
38
- ```env
39
- DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"
40
- ```
37
+ Set `DATABASE_URL` in `.env`, then add your schema in `prisma/schema.prisma`(or create from DB via `npx prisma db pull`):
41
38
 
42
39
  ```bash
43
- npx prisma db pull # introspect existing database
44
- npx rapidd build # generate models, routes & ACL scaffold
45
- npm run dev # http://localhost:3000
40
+ npx rapidd build # generate models, routes & ACL scaffold
41
+ npm run dev # http://localhost:3000
46
42
  ```
47
43
 
48
44
  Every table gets full CRUD endpoints. Auth is enabled automatically when a user table is detected. Every auto-detected value — auth fields, password hashing, JWT secrets, session store — is overridable via env vars. See [`.env.example`](.env.example) for the full list.
@@ -51,25 +47,22 @@ Every table gets full CRUD endpoints. Auth is enabled automatically when a user
51
47
 
52
48
  ---
53
49
 
54
- ## Features
55
-
56
- | Feature | PostgreSQL | MySQL/MariaDB |
57
- |---------|:----------:|:-------------:|
58
- | CRUD API generation | ✓ | ✓ |
59
- | Query filtering (20+ operators) | ✓ | ✓ |
60
- | Relations & deep includes | ✓ | ✓ |
61
- | Field selection | ✓ | ✓ |
62
- | JWT authentication (4 methods) | ✓ | ✓ |
63
- | Per-model ACL | ✓ | ✓ |
64
- | Row-Level Security (database-enforced) | ✓ | — |
65
- | Rate limiting (Redis + memory fallback) | | ✓ |
66
- | File uploads with MIME validation | ✓ | ✓ |
67
- | SMTP mailer with EJS templates | ✓ | ✓ |
68
- | Config-driven HTTP client | ✓ | ✓ |
69
- | i18n (10 languages) | | |
70
- | Security headers (HSTS, CSP, etc.) | ✓ | ✓ |
71
-
72
- > **MySQL note:** ACL provides application-level access control for all databases. RLS adds database-enforced row filtering as a second layer (PostgreSQL-only). For MySQL, ACL is your primary access control mechanism and covers most use cases. See the **[Access Control wiki](https://github.com/MertDalbudak/rapidd/wiki/Access-Control-(ACL))** for details.
50
+ ## How It Compares
51
+
52
+ | | Rapidd | Hasura | PostgREST | Supabase | Strapi |
53
+ |---|:---:|:---:|:---:|:---:|:---:|
54
+ | Full source code ownership | ✓ | — | — | — | ✓ |
55
+ | Schema-first (no UI) | ✓ | ✓ | ✓ | ✓ | — |
56
+ | REST API | | — | ✓ | ✓ | ✓ |
57
+ | Multi-database | | ✓ | — | — | ✓ |
58
+ | Built-in auth | | — | — | ✓ | ✓ |
59
+ | Per-model ACL | ✓ | ✓ | — | — | ✓ |
60
+ | Row-level security | ✓* | ✓ | ✓ | ✓ | — |
61
+ | Before/after middleware | | | | — | ✓ |
62
+ | Custom routes alongside generated | | — | — | ✓ | ✓ |
63
+ | No vendor dependency | | ✓ | ✓ | — | ✓ |
64
+
65
+ <sub>* PostgreSQL only. All other features support PostgreSQL and MySQL/MariaDB.</sub>
73
66
 
74
67
  ---
75
68
 
@@ -235,11 +228,9 @@ docker build -t my-api . && docker run -p 3000:3000 --env-file .env my-api
235
228
  | [`@rapidd/core`](https://www.npmjs.com/package/@rapidd/core) | Framework runtime, project scaffolding, and unified `npx rapidd` CLI |
236
229
  | [`@rapidd/build`](https://www.npmjs.com/package/@rapidd/build) | Code generation — models, routes, and ACL from your Prisma schema |
237
230
 
238
- All commands go through `npx rapidd`:
239
-
240
231
  ```bash
241
- npx rapidd create-project # scaffold a new project (@rapidd/core)
242
- npx rapidd build # generate from schema (@rapidd/build)
232
+ npx @rapidd/core create-project # scaffold a new project
233
+ npx rapidd build # generate from schema (after npm install)
243
234
  ```
244
235
 
245
236
  ---
package/bin/cli.js CHANGED
@@ -87,6 +87,19 @@ function createProject() {
87
87
  }
88
88
  }
89
89
 
90
+ // Read versions from this package's own package.json so they never drift
91
+ const corePkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
92
+ const coreDeps = corePkg.dependencies || {};
93
+ const coreDevDeps = corePkg.devDependencies || {};
94
+
95
+ function pick(source, keys) {
96
+ const result = {};
97
+ for (const key of keys) {
98
+ if (source[key]) result[key] = source[key];
99
+ }
100
+ return result;
101
+ }
102
+
90
103
  // Generate a fresh package.json for the new project
91
104
  const pkg = {
92
105
  name: projectName,
@@ -97,41 +110,19 @@ function createProject() {
97
110
  dev: 'tsx watch main.ts',
98
111
  build: 'tsc',
99
112
  },
100
- engines: { node: '>=24.0.0' },
101
- dependencies: {
102
- '@fastify/cookie': '^11.0.2',
103
- '@fastify/cors': '^11.0.0',
104
- '@fastify/formbody': '^8.0.2',
105
- '@fastify/multipart': '^9.4.0',
106
- '@fastify/static': '^9.0.0',
107
- '@prisma/adapter-mariadb': '^7.0.1',
108
- '@prisma/adapter-pg': '^7.0.1',
109
- '@prisma/client': '^7.0.1',
110
- '@prisma/internals': '^7.0.1',
111
- 'bcrypt': '^6.0.0',
112
- 'dotenv': '^17.3.1',
113
- 'ejs': '^4.0.1',
114
- 'fastify': '^5.2.1',
115
- 'fastify-plugin': '^5.0.1',
116
- 'ioredis': '^5.6.1',
117
- 'jsonwebtoken': '^9.0.2',
118
- 'luxon': '^3.7.2',
119
- 'nodemailer': '^8.0.1',
120
- 'pg': '^8.16.3',
121
- },
122
- devDependencies: {
123
- '@rapidd/build': '^2.1.3',
124
- '@types/bcrypt': '^6.0.0',
125
- '@types/ejs': '^3.1.5',
126
- '@types/jsonwebtoken': '^9.0.8',
127
- '@types/luxon': '^3.7.1',
128
- '@types/node': '^22.12.0',
129
- '@types/nodemailer': '^7.0.9',
130
- '@types/pg': '^8.11.11',
131
- 'prisma': '^7.0.2',
132
- 'tsx': '^4.19.2',
133
- 'typescript': '^5.7.3',
134
- },
113
+ engines: corePkg.engines || { node: '>=24.0.0' },
114
+ dependencies: pick(coreDeps, [
115
+ '@fastify/cookie', '@fastify/cors', '@fastify/formbody', '@fastify/multipart', '@fastify/static',
116
+ '@prisma/adapter-mariadb', '@prisma/adapter-pg', '@prisma/client', '@prisma/internals',
117
+ 'bcrypt', 'dotenv', 'ejs', 'fastify', 'fastify-plugin',
118
+ 'ioredis', 'jsonwebtoken', 'luxon', 'nodemailer', 'pg',
119
+ ]),
120
+ devDependencies: pick(coreDevDeps, [
121
+ '@rapidd/build',
122
+ '@types/bcrypt', '@types/ejs', '@types/jsonwebtoken', '@types/luxon',
123
+ '@types/node', '@types/nodemailer', '@types/pg',
124
+ 'prisma', 'tsx', 'typescript',
125
+ ]),
135
126
  };
136
127
 
137
128
  fs.writeFileSync(
package/dockerfile CHANGED
@@ -25,7 +25,7 @@ COPY prisma ./prisma
25
25
  RUN npx prisma generate --generator client
26
26
 
27
27
  # Stage 3: Runtime
28
- FROM node:24-alpine
28
+ FROM node:current-alpine
29
29
 
30
30
  WORKDIR /app
31
31
 
@@ -50,8 +50,16 @@ COPY --chown=rapidd:nodejs public ./public
50
50
 
51
51
  RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/*
52
52
 
53
+ RUN mkdir -p /app/uploads /app/temp/uploads /app/logs && \
54
+ chown -R rapidd:nodejs /app/uploads /app/temp /app/logs
55
+
56
+ VOLUME ["/app/uploads", "/app/logs"]
57
+
53
58
  USER rapidd
54
59
 
55
60
  EXPOSE 3000
56
61
 
62
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
63
+ CMD node -e "fetch('http://localhost:3000/').then(r => process.exit(r.status === 404 ? 0 : 1)).catch(() => process.exit(1))"
64
+
57
65
  ENTRYPOINT ["node", "dist/main.js"]
package/main.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import 'dotenv/config';
2
2
  import { getEnv } from './src/core/env';
3
3
  import { buildApp } from './src/app';
4
+ import { Logger } from './src/utils/Logger';
4
5
 
5
6
  /**
6
7
  * Application entry point
@@ -14,10 +15,15 @@ export async function start(): Promise<void> {
14
15
  const app = await buildApp();
15
16
 
16
17
  await app.listen({ port, host });
17
- console.log(`[Rapidd] Server running at http://${host}:${port}`);
18
- console.log(`[Rapidd] Environment: ${getEnv('NODE_ENV')}`);
18
+ Logger.log('Server running', { host, port });
19
+ Logger.log('Environment', { env: getEnv('NODE_ENV') });
20
+
21
+ // Warn if running compiled build with development NODE_ENV
22
+ if (process.argv[1]?.includes('/dist/') && getEnv('NODE_ENV') === 'development') {
23
+ Logger.warn('Running compiled build with NODE_ENV=development. Set NODE_ENV=production in your .env for production use.');
24
+ }
19
25
  } catch (err) {
20
- console.error('[Startup Error]', (err as Error).message);
26
+ Logger.error(err as Error);
21
27
  process.exit(1);
22
28
  }
23
29
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "2.1.1",
6
+ "version": "2.1.3",
7
7
  "description": "Code-first REST API framework for TypeScript. Database in, API out.",
8
8
  "main": "dist/main.js",
9
9
  "bin": {
@@ -83,7 +83,7 @@
83
83
  "pg": "^8.16.3"
84
84
  },
85
85
  "devDependencies": {
86
- "@rapidd/build": "^2.1.3",
86
+ "@rapidd/build": "^2.1.5",
87
87
  "@types/bcrypt": "^6.0.0",
88
88
  "@types/ejs": "^3.1.5",
89
89
  "@types/jest": "^30.0.0",
package/src/app.ts CHANGED
@@ -12,6 +12,7 @@ import { LanguageDict } from './core/i18n';
12
12
  import { disconnectAll } from './core/prisma';
13
13
  import { validateEnv } from './core/env';
14
14
  import { env } from './utils';
15
+ import { Logger } from './utils/Logger';
15
16
 
16
17
  // Plugins
17
18
  import securityPlugin from './plugins/security';
@@ -22,6 +23,13 @@ import rlsPlugin from './plugins/rls';
22
23
 
23
24
  import type { RapiddOptions } from './types';
24
25
 
26
+ // ─── BigInt Serialization ────────────────────────────
27
+ // Prisma returns BigInt values that JSON.stringify cannot handle natively.
28
+ // This polyfill converts them to strings during serialization.
29
+ (BigInt.prototype as any).toJSON = function () {
30
+ return this.toString();
31
+ };
32
+
25
33
  // ─── Path Setup ─────────────────────────────────────
26
34
  // Use process.cwd() as the project root — works from both source (tsx) and compiled (dist/) contexts.
27
35
 
@@ -176,7 +184,7 @@ async function loadRoutes(app: FastifyInstance, routePath: string): Promise<void
176
184
  await app.register(plugin, { prefix: route });
177
185
  }
178
186
  } catch (err) {
179
- console.error(`Failed to load route ${route}:`, (err as Error).message);
187
+ Logger.error(err as Error, { route });
180
188
  }
181
189
  }
182
190
  }
@@ -186,12 +194,12 @@ async function loadRoutes(app: FastifyInstance, routePath: string): Promise<void
186
194
 
187
195
  // Handle uncaught errors
188
196
  process.on('uncaughtException', (err) => {
189
- console.error('[Uncaught Exception]', err);
197
+ Logger.error(err);
190
198
  process.exit(1);
191
199
  });
192
200
 
193
- process.on('unhandledRejection', (reason, promise) => {
194
- console.error('[Unhandled Rejection] at:', promise, 'reason:', reason);
201
+ process.on('unhandledRejection', (reason) => {
202
+ Logger.error(reason as Error);
195
203
  });
196
204
 
197
205
  export default buildApp;
package/src/auth/Auth.ts CHANGED
@@ -5,6 +5,7 @@ import { authPrisma } from '../core/prisma';
5
5
  import { ErrorResponse } from '../core/errors';
6
6
  import { createStore, SessionStoreManager } from './stores';
7
7
  import { loadDMMF, findUserModel, findIdentifierFields, findPasswordField } from '../core/dmmf';
8
+ import { Logger } from '../utils/Logger';
8
9
  import type { RapiddUser, AuthOptions, AuthMethod, ISessionStore } from '../types';
9
10
 
10
11
  /**
@@ -99,7 +100,7 @@ export class Auth {
99
100
  try {
100
101
  await loadDMMF();
101
102
  } catch {
102
- console.warn('[Auth] Could not load DMMF, auth auto-detection skipped');
103
+ Logger.warn('Auth: could not load DMMF, auto-detection skipped');
103
104
  return;
104
105
  }
105
106
 
@@ -109,7 +110,7 @@ export class Auth {
109
110
  // Check if a model name was explicitly configured
110
111
  if (!this.options.userModel) {
111
112
  this._authDisabled = true;
112
- console.log('[Auth] No user table detected in schema, auth disabled');
113
+ Logger.log('Auth: no user table detected, auth disabled');
113
114
  return;
114
115
  }
115
116
  // Model name was set but not found in DMMF — warn but don't disable
@@ -142,7 +143,7 @@ export class Auth {
142
143
  throw new Error('[Auth] JWT_SECRET is required in production. Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
143
144
  }
144
145
  this.options.jwt.secret = crypto.randomBytes(32).toString('hex');
145
- console.warn('[Auth] No JWT_SECRET set, using auto-generated secret (sessions won\'t persist across restarts)');
146
+ Logger.warn('Auth: no JWT_SECRET set, using auto-generated secret (sessions won\'t persist across restarts)');
146
147
  }
147
148
  if (!this.options.jwt.refreshSecret) {
148
149
  if (process.env.NODE_ENV === 'production') {
@@ -173,7 +174,7 @@ export class Auth {
173
174
  this._userModel = (authPrisma as any)[match];
174
175
  return this._userModel;
175
176
  }
176
- console.warn(`[Auth] userModel="${modelName}" not found in Prisma models`);
177
+ Logger.warn('Auth: userModel not found in Prisma models', { userModel: modelName });
177
178
  }
178
179
 
179
180
  for (const name of ['users', 'user', 'Users', 'User']) {
@@ -1,5 +1,6 @@
1
1
  import Redis from 'ioredis';
2
2
  import { ISessionStore } from './ISessionStore';
3
+ import { Logger } from '../../utils/Logger';
3
4
 
4
5
  /**
5
6
  * Redis session store using ioredis.
@@ -36,11 +37,11 @@ export class RedisStore extends ISessionStore {
36
37
  connectTimeout: 5000,
37
38
  });
38
39
 
39
- this.client.on('connect', () => console.log('[RedisStore] Connected'));
40
- this.client.on('ready', () => console.log('[RedisStore] Ready'));
41
- this.client.on('error', (err: Error) => console.error('[RedisStore] Error:', err.message));
42
- this.client.on('close', () => console.warn('[RedisStore] Connection closed'));
43
- this.client.on('reconnecting', () => console.log('[RedisStore] Reconnecting...'));
40
+ this.client.on('connect', () => Logger.log('RedisStore connected'));
41
+ this.client.on('ready', () => Logger.log('RedisStore ready'));
42
+ this.client.on('error', (err: Error) => Logger.error(err));
43
+ this.client.on('close', () => Logger.warn('RedisStore connection closed'));
44
+ this.client.on('reconnecting', () => Logger.log('RedisStore reconnecting'));
44
45
 
45
46
  this._initialized = true;
46
47
  return this.client;
@@ -1,6 +1,7 @@
1
1
  import { ISessionStore } from './ISessionStore';
2
2
  import { MemoryStore } from './MemoryStore';
3
3
  import { RedisStore } from './RedisStore';
4
+ import { Logger } from '../../utils/Logger';
4
5
 
5
6
  const builtInStores: Record<string, new (options?: any) => ISessionStore> = {
6
7
  memory: MemoryStore,
@@ -41,7 +42,7 @@ export class SessionStoreManager extends ISessionStore {
41
42
 
42
43
  const StoreClass = builtInStores[this.storeName];
43
44
  if (!StoreClass) {
44
- console.warn(`[SessionStore] Unknown store "${this.storeName}", using memory`);
45
+ Logger.warn('SessionStore: unknown store, using memory', { store: this.storeName });
45
46
  this._primaryStore = this._fallbackStore;
46
47
  this._initialized = true;
47
48
  return;
@@ -50,7 +51,7 @@ export class SessionStoreManager extends ISessionStore {
50
51
  try {
51
52
  this._primaryStore = new StoreClass({ ttl: this.ttl });
52
53
  } catch (err) {
53
- console.warn(`[SessionStore] Failed to create ${this.storeName}: ${(err as Error).message}, using memory`);
54
+ Logger.warn('SessionStore: failed to create store, using memory', { store: this.storeName, error: (err as Error).message });
54
55
  this._primaryStore = this._fallbackStore;
55
56
  }
56
57
 
@@ -70,15 +71,15 @@ export class SessionStoreManager extends ISessionStore {
70
71
  try {
71
72
  const isHealthy = await this._primaryStore!.isHealthy();
72
73
  if (isHealthy && this._usingFallback) {
73
- console.log(`[SessionStore] ${this.storeName} recovered, switching back from memory`);
74
+ Logger.log('SessionStore: recovered, switching back from memory', { store: this.storeName });
74
75
  this._usingFallback = false;
75
76
  } else if (!isHealthy && !this._usingFallback) {
76
- console.warn(`[SessionStore] ${this.storeName} unavailable, switching to memory`);
77
+ Logger.warn('SessionStore: unavailable, switching to memory', { store: this.storeName });
77
78
  this._usingFallback = true;
78
79
  }
79
80
  } catch {
80
81
  if (!this._usingFallback) {
81
- console.warn(`[SessionStore] ${this.storeName} health check failed, switching to memory`);
82
+ Logger.warn('SessionStore: health check failed, switching to memory', { store: this.storeName });
82
83
  this._usingFallback = true;
83
84
  }
84
85
  }
@@ -98,7 +99,7 @@ export class SessionStoreManager extends ISessionStore {
98
99
  try {
99
100
  return await (this._primaryStore as any)[operation](...args);
100
101
  } catch (err) {
101
- console.warn(`[SessionStore] ${this.storeName}.${operation} failed: ${(err as Error).message}, switching to memory`);
102
+ Logger.warn('SessionStore: operation failed, switching to memory', { store: this.storeName, operation, error: (err as Error).message });
102
103
  this._usingFallback = true;
103
104
  return (this._fallbackStore as any)[operation](...args);
104
105
  }
package/src/core/env.ts CHANGED
@@ -51,6 +51,10 @@ export interface EnvConfig {
51
51
 
52
52
  // Proxy
53
53
  TRUST_PROXY?: boolean;
54
+
55
+ // Logging
56
+ LOG_LEVEL: 'essential' | 'fine' | 'finest';
57
+ LOG_DIR: string;
54
58
  }
55
59
 
56
60
  const REQUIRED_VARS = [
@@ -76,7 +80,9 @@ const DEFAULTS: Partial<Record<keyof EnvConfig, string | number | boolean>> = {
76
80
  REDIS_PORT: 6379,
77
81
  REDIS_DB_RATE_LIMIT: 0,
78
82
  REDIS_DB_AUTH: 1,
79
- RLS_NAMESPACE: 'app'
83
+ RLS_NAMESPACE: 'app',
84
+ LOG_LEVEL: 'essential',
85
+ LOG_DIR: 'logs',
80
86
  };
81
87
 
82
88
  /**
package/src/core/i18n.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readdirSync } from 'fs';
2
2
  import path from 'path';
3
+ import { Logger } from '../utils/Logger';
3
4
 
4
5
  const ROOT = process.env.ROOT || '';
5
6
  const DEFAULT_STRINGS_PATH = ROOT ? path.join(ROOT, 'locales') : './locale';
@@ -40,14 +41,14 @@ export class LanguageDict {
40
41
  this._dictionaries[langCode] = require(dictPath);
41
42
  this._available.push(langCode);
42
43
  } catch (error) {
43
- console.error(`Failed to load dictionary for ${langCode}:`, (error as Error).message);
44
+ Logger.error(error as Error, { langCode });
44
45
  }
45
46
  }
46
47
  }
47
48
 
48
49
  this._initialized = true;
49
50
  } catch (error) {
50
- console.error('Failed to initialize LanguageDict:', (error as Error).message);
51
+ Logger.error(error as Error);
51
52
  this._dictionaries = {};
52
53
  this._available = [];
53
54
  }
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export { modelMiddleware } from './core/middleware';
44
44
  // ── Utilities ────────────────────────────────────────────────────────────────
45
45
  export { ApiClient, ApiClientError } from './utils/ApiClient';
46
46
  export { Mailer } from './utils/Mailer';
47
+ export { Logger } from './utils/Logger';
47
48
  export { env } from './utils';
48
49
 
49
50
  // ── Environment ──────────────────────────────────────────────────────────────
@@ -110,3 +111,4 @@ export type {
110
111
  } from './plugins/upload';
111
112
 
112
113
  export type { EnvConfig } from './core/env';
114
+ export type { LogLevel } from './utils/Logger';
package/src/orm/Model.ts CHANGED
@@ -177,6 +177,7 @@ class Model {
177
177
  const field = this.fields[fieldName];
178
178
  if (!field) return value;
179
179
  if (field.type === 'Int') return parseInt(value, 10);
180
+ if (field.type === 'BigInt') return BigInt(value);
180
181
  if (field.type === 'Float' || field.type === 'Decimal') return parseFloat(value);
181
182
  if (field.type === 'Boolean') return value === 'true';
182
183
  return value;
@@ -239,10 +240,13 @@ class Model {
239
240
  sortBy = sortBy?.trim();
240
241
  sortOrder = sortOrder?.trim();
241
242
 
242
- // Validate sort field - fall back to default for composite PK names
243
+ // Validate sort field - fall back to default for composite PK names or missing 'id'
243
244
  if (!sortBy.includes('.') && this.fields[sortBy] == undefined) {
244
- // If the sortBy is a composite key name (e.g., "email_companyId"), use first PK field
245
245
  if (sortBy === this.primaryKey && this.isCompositePK) {
246
+ // Composite key name (e.g., "email_companyId") → use first PK field
247
+ sortBy = this.defaultSortField;
248
+ } else if (sortBy === 'id') {
249
+ // Model doesn't have an 'id' field → fall back to actual primary key
246
250
  sortBy = this.defaultSortField;
247
251
  } else {
248
252
  throw new ErrorResponse(400, "invalid_sort_field", { sortBy, modelName: this.constructor.name });
@@ -1,5 +1,6 @@
1
1
  import { prisma, prismaTransaction, getAcl } from '../core/prisma';
2
2
  import { ErrorResponse } from '../core/errors';
3
+ import { Logger } from '../utils/Logger';
3
4
  import * as dmmf from '../core/dmmf';
4
5
  import type {
5
6
  RelationConfig,
@@ -501,10 +502,10 @@ class QueryBuilder {
501
502
  /**
502
503
  * Parse numeric filter operators
503
504
  */
504
- #filterNumber(value: string): Record<string, number> | null {
505
+ #filterNumber(value: string): Record<string, number | bigint> | null {
505
506
  const numOperators = ['lt:', 'lte:', 'gt:', 'gte:', 'eq:', 'ne:', 'between:'];
506
507
  const foundOperator = numOperators.find((op: string) => value.startsWith(op));
507
- let numValue: string | number = value;
508
+ let numValue: string = value;
508
509
  let prismaOp = 'equals';
509
510
 
510
511
  if (foundOperator) {
@@ -517,19 +518,36 @@ class QueryBuilder {
517
518
  case 'eq:': prismaOp = 'equals'; break;
518
519
  case 'ne:': prismaOp = 'not'; break;
519
520
  case 'between:': {
520
- // Support between for decimals: between:1.5;3.7
521
- const [start, end] = (numValue as string).split(';').map((v: string) => parseFloat(v.trim()));
522
- if (isNaN(start) || isNaN(end)) return null;
521
+ const parts = numValue.split(';').map((v: string) => v.trim());
522
+ const [startStr, endStr] = parts;
523
+ const start = this.#parseNumericValue(startStr);
524
+ const end = this.#parseNumericValue(endStr);
525
+ if (start == null || end == null) return null;
523
526
  return { gte: start, lte: end };
524
527
  }
525
528
  }
526
529
  }
527
530
 
528
- // Support decimal numbers
529
- numValue = parseFloat(numValue as string);
530
- if (isNaN(numValue)) return null;
531
+ const parsed = this.#parseNumericValue(numValue);
532
+ if (parsed == null) return null;
531
533
 
532
- return { [prismaOp]: numValue };
534
+ return { [prismaOp]: parsed };
535
+ }
536
+
537
+ /**
538
+ * Parse a numeric string, returning BigInt for integers beyond safe range
539
+ */
540
+ #parseNumericValue(value: string): number | bigint | null {
541
+ if (value.includes('.')) {
542
+ const num = parseFloat(value);
543
+ return isNaN(num) ? null : num;
544
+ }
545
+ const num = Number(value);
546
+ if (isNaN(num)) return null;
547
+ if (!Number.isSafeInteger(num)) {
548
+ try { return BigInt(value); } catch { return null; }
549
+ }
550
+ return num;
533
551
  }
534
552
 
535
553
  /**
@@ -2048,7 +2066,7 @@ class QueryBuilder {
2048
2066
  * Handle Prisma errors and convert to standardized error responses
2049
2067
  */
2050
2068
  static errorHandler(error: any, data: Record<string, unknown> = {}): QueryErrorResponse {
2051
- console.error(error);
2069
+ Logger.error(error);
2052
2070
 
2053
2071
  // Default values
2054
2072
  let statusCode: number = error.status_code || 500;
@@ -2,6 +2,7 @@ import { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
2
2
  import fp from 'fastify-plugin';
3
3
  import { ErrorResponse, ErrorBasicResponse } from '../core/errors';
4
4
  import { LanguageDict } from '../core/i18n';
5
+ import { Logger } from '../utils/Logger';
5
6
  import type { ListMeta } from '../types';
6
7
 
7
8
  /**
@@ -37,7 +38,7 @@ const responsePlugin: FastifyPluginAsync = async (fastify) => {
37
38
  const request = this.request;
38
39
  const language = request?.language || 'en_US';
39
40
  const error = new ErrorResponse(statusCode, message, data as Record<string, unknown> | null);
40
- console.error(`Error ${statusCode}: ${message}`);
41
+ Logger.error(message, { statusCode });
41
42
  return this.code(statusCode).send(error.toJSON(language));
42
43
  });
43
44
 
@@ -72,7 +73,7 @@ const responsePlugin: FastifyPluginAsync = async (fastify) => {
72
73
  ? 'Something went wrong'
73
74
  : err.message || String(error);
74
75
 
75
- console.error(error);
76
+ Logger.error(error);
76
77
  return reply.code(status).send({ status_code: status, message });
77
78
  });
78
79
  };
@@ -0,0 +1,157 @@
1
+ import { appendFileSync, mkdirSync, existsSync } from 'fs';
2
+ import path from 'path';
3
+
4
+ // ── Types ────────────────────────────────────────────────────────────────────
5
+
6
+ export type LogLevel = 'essential' | 'fine' | 'finest';
7
+
8
+ // ── Level Config ─────────────────────────────────────────────────────────────
9
+
10
+ const LEVELS: LogLevel[] = ['essential', 'fine', 'finest'];
11
+
12
+ function parseLevel(value: string | undefined): LogLevel {
13
+ if (value && LEVELS.includes(value as LogLevel)) {
14
+ return value as LogLevel;
15
+ }
16
+ return 'essential';
17
+ }
18
+
19
+ // ── State ────────────────────────────────────────────────────────────────────
20
+
21
+ const _level: LogLevel = parseLevel(process.env.LOG_LEVEL);
22
+ const _silent: boolean = process.env.NODE_ENV === 'test';
23
+ const _logDir: string = process.env.LOG_DIR ?? 'logs';
24
+
25
+ // ── File Writing ─────────────────────────────────────────────────────────────
26
+
27
+ let _dirChecked = false;
28
+
29
+ function ensureLogDir(): void {
30
+ if (_dirChecked || !_logDir) return;
31
+ const dir = path.isAbsolute(_logDir) ? _logDir : path.join(process.cwd(), _logDir);
32
+ if (!existsSync(dir)) {
33
+ mkdirSync(dir, { recursive: true });
34
+ }
35
+ _dirChecked = true;
36
+ }
37
+
38
+ function writeToFile(filename: string, line: string): void {
39
+ if (!_logDir) return;
40
+ try {
41
+ ensureLogDir();
42
+ const dir = path.isAbsolute(_logDir) ? _logDir : path.join(process.cwd(), _logDir);
43
+ appendFileSync(path.join(dir, filename), line + '\n');
44
+ } catch {
45
+ // Silently ignore file write failures — don't crash the app for logging
46
+ }
47
+ }
48
+
49
+ // ── Formatting ───────────────────────────────────────────────────────────────
50
+
51
+ function timestamp(): string {
52
+ return new Date().toISOString();
53
+ }
54
+
55
+ function formatData(data: unknown[]): string {
56
+ if (data.length === 0) return '';
57
+ const parts = data.map(d => {
58
+ if (d === null || d === undefined) return String(d);
59
+ if (typeof d === 'object') {
60
+ try { return JSON.stringify(d); } catch { return String(d); }
61
+ }
62
+ return String(d);
63
+ });
64
+ return ' ' + parts.join(' ');
65
+ }
66
+
67
+ function formatDataPretty(data: unknown[]): string {
68
+ if (data.length === 0) return '';
69
+ const parts = data.map(d => {
70
+ if (d === null || d === undefined) return String(d);
71
+ if (typeof d === 'object') {
72
+ try { return JSON.stringify(d, null, 2); } catch { return String(d); }
73
+ }
74
+ return String(d);
75
+ });
76
+ return '\n' + parts.join('\n');
77
+ }
78
+
79
+ function formatError(error: Error | string | unknown): { message: string; toString: string; stack: string } {
80
+ if (error instanceof Error) {
81
+ return {
82
+ message: error.message,
83
+ toString: error.toString(),
84
+ stack: error.stack || error.toString(),
85
+ };
86
+ }
87
+ const str = String(error);
88
+ return { message: str, toString: str, stack: str };
89
+ }
90
+
91
+ // ── Logger ───────────────────────────────────────────────────────────────────
92
+
93
+ export const Logger = {
94
+ log(message: string, ...data: unknown[]): void {
95
+ if (_silent) return;
96
+
97
+ let output: string;
98
+ switch (_level) {
99
+ case 'essential':
100
+ output = `[${timestamp()}] [LOG] ${message}`;
101
+ break;
102
+ case 'fine':
103
+ output = `[${timestamp()}] [LOG] ${message}${formatData(data)}`;
104
+ break;
105
+ case 'finest':
106
+ output = `[${timestamp()}] [LOG] ${message}${formatDataPretty(data)}`;
107
+ break;
108
+ }
109
+
110
+ console.log(output);
111
+ writeToFile('app.log', output);
112
+ },
113
+
114
+ warn(message: string, ...data: unknown[]): void {
115
+ if (_silent) return;
116
+
117
+ let output: string;
118
+ switch (_level) {
119
+ case 'essential':
120
+ output = `[${timestamp()}] [WARN] ${message}`;
121
+ break;
122
+ case 'fine':
123
+ output = `[${timestamp()}] [WARN] ${message}${formatData(data)}`;
124
+ break;
125
+ case 'finest':
126
+ output = `[${timestamp()}] [WARN] ${message}${formatDataPretty(data)}`;
127
+ break;
128
+ }
129
+
130
+ console.warn(output);
131
+ writeToFile('app.log', output);
132
+ },
133
+
134
+ error(error: Error | string | unknown, ...data: unknown[]): void {
135
+ if (_silent) return;
136
+
137
+ const err = formatError(error);
138
+ let output: string;
139
+
140
+ switch (_level) {
141
+ case 'essential':
142
+ output = `[${timestamp()}] [ERROR] ${err.message}`;
143
+ break;
144
+ case 'fine':
145
+ output = `[${timestamp()}] [ERROR] ${err.toString}${formatData(data)}`;
146
+ break;
147
+ case 'finest':
148
+ output = `[${timestamp()}] [ERROR] ${err.stack}${formatDataPretty(data)}`;
149
+ break;
150
+ }
151
+
152
+ console.error(output);
153
+ writeToFile('error.log', output);
154
+ },
155
+ };
156
+
157
+ export default Logger;
@@ -4,6 +4,9 @@ export type { ServiceConfig, EndpointConfig, AuthConfig, RequestOptions, ApiResp
4
4
  export { Mailer } from './Mailer';
5
5
  export type { EmailConfig, EmailOptions, EmailAttachment, EmailResult } from './Mailer';
6
6
 
7
+ export { Logger } from './Logger';
8
+ export type { LogLevel } from './Logger';
9
+
7
10
  export const env = {
8
11
  isProduction: () => process.env.NODE_ENV === 'production',
9
12
  isDevelopment: () => __filename.endsWith('.ts') || process.env.NODE_ENV === 'development',