@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 +6 -0
- package/README.md +22 -31
- package/bin/cli.js +26 -35
- package/dockerfile +9 -1
- package/main.ts +9 -3
- package/package.json +2 -2
- package/src/app.ts +12 -4
- package/src/auth/Auth.ts +5 -4
- package/src/auth/stores/RedisStore.ts +6 -5
- package/src/auth/stores/index.ts +7 -6
- package/src/core/env.ts +7 -1
- package/src/core/i18n.ts +3 -2
- package/src/index.ts +2 -0
- package/src/orm/Model.ts +6 -2
- package/src/orm/QueryBuilder.ts +28 -10
- package/src/plugins/response.ts +3 -2
- package/src/utils/Logger.ts +157 -0
- package/src/utils/index.ts +3 -0
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
|
|
35
|
-
npm install
|
|
34
|
+
npx @rapidd/core create-project && npm install
|
|
36
35
|
```
|
|
37
36
|
|
|
38
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
##
|
|
55
|
-
|
|
56
|
-
|
|
|
57
|
-
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
| Per-model ACL | ✓ | ✓ |
|
|
64
|
-
| Row-
|
|
65
|
-
|
|
|
66
|
-
|
|
|
67
|
-
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
242
|
-
npx 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'
|
|
103
|
-
'@
|
|
104
|
-
'
|
|
105
|
-
'
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'@
|
|
109
|
-
'@
|
|
110
|
-
'@
|
|
111
|
-
'
|
|
112
|
-
|
|
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:
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
197
|
+
Logger.error(err);
|
|
190
198
|
process.exit(1);
|
|
191
199
|
});
|
|
192
200
|
|
|
193
|
-
process.on('unhandledRejection', (reason
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', () =>
|
|
40
|
-
this.client.on('ready', () =>
|
|
41
|
-
this.client.on('error', (err: Error) =>
|
|
42
|
-
this.client.on('close', () =>
|
|
43
|
-
this.client.on('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;
|
package/src/auth/stores/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/src/orm/QueryBuilder.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
521
|
-
const [
|
|
522
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
if (isNaN(numValue)) return null;
|
|
531
|
+
const parsed = this.#parseNumericValue(numValue);
|
|
532
|
+
if (parsed == null) return null;
|
|
531
533
|
|
|
532
|
-
return { [prismaOp]:
|
|
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
|
-
|
|
2069
|
+
Logger.error(error);
|
|
2052
2070
|
|
|
2053
2071
|
// Default values
|
|
2054
2072
|
let statusCode: number = error.status_code || 500;
|
package/src/plugins/response.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/src/utils/index.ts
CHANGED
|
@@ -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',
|