@rapidd/core 2.1.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/.dockerignore +71 -0
- package/.env.example +70 -0
- package/.gitignore +11 -0
- package/LICENSE +15 -0
- package/README.md +231 -0
- package/bin/cli.js +145 -0
- package/config/app.json +166 -0
- package/config/rate-limit.json +12 -0
- package/dist/main.js +26 -0
- package/dockerfile +57 -0
- package/locales/ar_SA.json +179 -0
- package/locales/de_DE.json +179 -0
- package/locales/en_US.json +180 -0
- package/locales/es_ES.json +179 -0
- package/locales/fr_FR.json +179 -0
- package/locales/it_IT.json +179 -0
- package/locales/ja_JP.json +179 -0
- package/locales/pt_BR.json +179 -0
- package/locales/ru_RU.json +179 -0
- package/locales/tr_TR.json +179 -0
- package/main.ts +25 -0
- package/package.json +126 -0
- package/prisma/schema.prisma +9 -0
- package/prisma.config.ts +12 -0
- package/public/static/favicon.ico +0 -0
- package/public/static/image/logo.png +0 -0
- package/routes/api/v1/index.ts +113 -0
- package/src/app.ts +197 -0
- package/src/auth/Auth.ts +446 -0
- package/src/auth/stores/ISessionStore.ts +19 -0
- package/src/auth/stores/MemoryStore.ts +70 -0
- package/src/auth/stores/RedisStore.ts +92 -0
- package/src/auth/stores/index.ts +149 -0
- package/src/config/acl.ts +9 -0
- package/src/config/rls.ts +38 -0
- package/src/core/dmmf.ts +226 -0
- package/src/core/env.ts +183 -0
- package/src/core/errors.ts +87 -0
- package/src/core/i18n.ts +144 -0
- package/src/core/middleware.ts +123 -0
- package/src/core/prisma.ts +236 -0
- package/src/index.ts +112 -0
- package/src/middleware/model.ts +61 -0
- package/src/orm/Model.ts +881 -0
- package/src/orm/QueryBuilder.ts +2078 -0
- package/src/plugins/auth.ts +162 -0
- package/src/plugins/language.ts +79 -0
- package/src/plugins/rateLimit.ts +210 -0
- package/src/plugins/response.ts +80 -0
- package/src/plugins/rls.ts +51 -0
- package/src/plugins/security.ts +23 -0
- package/src/plugins/upload.ts +299 -0
- package/src/types.ts +308 -0
- package/src/utils/ApiClient.ts +526 -0
- package/src/utils/Mailer.ts +348 -0
- package/src/utils/index.ts +25 -0
- package/templates/email/example.ejs +17 -0
- package/templates/layouts/email.ejs +35 -0
- package/tsconfig.json +33 -0
package/src/core/i18n.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readdirSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const ROOT = process.env.ROOT || '';
|
|
5
|
+
const DEFAULT_STRINGS_PATH = ROOT ? path.join(ROOT, 'locales') : './locale';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Singleton LanguageDict class for efficient translation management.
|
|
9
|
+
* All dictionaries are loaded once at initialization and cached in memory.
|
|
10
|
+
*/
|
|
11
|
+
export class LanguageDict {
|
|
12
|
+
private static _dictionaries: Record<string, Record<string, string>> = {};
|
|
13
|
+
private static _available: string[] = [];
|
|
14
|
+
private static _dictionaryPath: string | null = null;
|
|
15
|
+
private static _defaultLanguage: string = 'en_US';
|
|
16
|
+
private static _initialized: boolean = false;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Initialize the LanguageDict system
|
|
20
|
+
*/
|
|
21
|
+
static initialize(dictionaryPath: string = DEFAULT_STRINGS_PATH, defaultLanguage: string = 'en_US'): void {
|
|
22
|
+
if (this._initialized && this._dictionaryPath === dictionaryPath) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this._dictionaryPath = dictionaryPath;
|
|
27
|
+
this._defaultLanguage = defaultLanguage;
|
|
28
|
+
this._dictionaries = {};
|
|
29
|
+
this._available = [];
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const files = readdirSync(dictionaryPath);
|
|
33
|
+
|
|
34
|
+
for (const fileName of files) {
|
|
35
|
+
if (path.extname(fileName) === '.json') {
|
|
36
|
+
const langCode = path.parse(fileName).name;
|
|
37
|
+
try {
|
|
38
|
+
const dictPath = path.join(dictionaryPath, fileName);
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
40
|
+
this._dictionaries[langCode] = require(dictPath);
|
|
41
|
+
this._available.push(langCode);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error(`Failed to load dictionary for ${langCode}:`, (error as Error).message);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this._initialized = true;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error('Failed to initialize LanguageDict:', (error as Error).message);
|
|
51
|
+
this._dictionaries = {};
|
|
52
|
+
this._available = [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get a translated string with optional parameter interpolation
|
|
58
|
+
*/
|
|
59
|
+
static get(key: string, data: Record<string, unknown> | null = null, language: string | null = null): string {
|
|
60
|
+
if (!this._initialized) {
|
|
61
|
+
this.initialize();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lang = language || this._defaultLanguage;
|
|
65
|
+
const dictionary = this._dictionaries[lang] || this._dictionaries[this._defaultLanguage] || {};
|
|
66
|
+
|
|
67
|
+
let translated: string | undefined = dictionary[key];
|
|
68
|
+
|
|
69
|
+
if (translated === undefined) {
|
|
70
|
+
return key;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Handle nested translations ({{key}} syntax)
|
|
74
|
+
translated = translated.replace(/{{\w+}}/g, (match: string) => {
|
|
75
|
+
const nestedKey = match.slice(2, -2);
|
|
76
|
+
return this.get(nestedKey, data, lang);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Handle parameter interpolation ({key} syntax)
|
|
80
|
+
if (data !== null && typeof data === 'object') {
|
|
81
|
+
translated = translated.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
|
|
82
|
+
return data[paramKey] !== undefined ? String(data[paramKey]) : match;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return translated;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get all available language codes
|
|
91
|
+
*/
|
|
92
|
+
static getAvailableLanguages(): string[] {
|
|
93
|
+
if (!this._initialized) {
|
|
94
|
+
this.initialize();
|
|
95
|
+
}
|
|
96
|
+
return [...this._available];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the entire dictionary for a specific language
|
|
101
|
+
*/
|
|
102
|
+
static getDictionary(language: string): Record<string, string> {
|
|
103
|
+
if (!this._initialized) {
|
|
104
|
+
this.initialize();
|
|
105
|
+
}
|
|
106
|
+
return this._dictionaries[language] || this._dictionaries[this._defaultLanguage] || {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if a language is available
|
|
111
|
+
*/
|
|
112
|
+
static hasLanguage(language: string): boolean {
|
|
113
|
+
if (!this._initialized) {
|
|
114
|
+
this.initialize();
|
|
115
|
+
}
|
|
116
|
+
return this._available.includes(language);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reload dictionaries from disk
|
|
121
|
+
*/
|
|
122
|
+
static reload(): void {
|
|
123
|
+
this._initialized = false;
|
|
124
|
+
this.initialize(this._dictionaryPath || DEFAULT_STRINGS_PATH, this._defaultLanguage);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Instance-based API for backward compatibility
|
|
128
|
+
private language: string;
|
|
129
|
+
|
|
130
|
+
constructor(language: string) {
|
|
131
|
+
if (!LanguageDict._initialized) {
|
|
132
|
+
LanguageDict.initialize();
|
|
133
|
+
}
|
|
134
|
+
this.language = language;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get(key: string, data: Record<string, unknown> | null = null): string {
|
|
138
|
+
return LanguageDict.get(key, data, this.language);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getDictionary(): Record<string, string> {
|
|
142
|
+
return LanguageDict.getDictionary(this.language);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { MiddlewareHook, MiddlewareOperation, MiddlewareContext, MiddlewareFn } from '../types';
|
|
2
|
+
|
|
3
|
+
const middlewareRegistry = new Map<string, MiddlewareFn[]>();
|
|
4
|
+
|
|
5
|
+
const OPERATIONS: MiddlewareOperation[] = ['create', 'update', 'upsert', 'upsertMany', 'delete', 'get', 'getMany', 'count'];
|
|
6
|
+
const HOOKS: MiddlewareHook[] = ['before', 'after'];
|
|
7
|
+
|
|
8
|
+
function getKey(hook: string, operation: string, model: string = '*'): string {
|
|
9
|
+
return `${hook}:${operation}:${model}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register a middleware function
|
|
14
|
+
*/
|
|
15
|
+
function use(hook: MiddlewareHook, operation: MiddlewareOperation, fn: MiddlewareFn, model: string = '*'): void {
|
|
16
|
+
if (!HOOKS.includes(hook)) {
|
|
17
|
+
throw new Error(`Invalid hook '${hook}'. Must be one of: ${HOOKS.join(', ')}`);
|
|
18
|
+
}
|
|
19
|
+
if (!OPERATIONS.includes(operation)) {
|
|
20
|
+
throw new Error(`Invalid operation '${operation}'. Must be one of: ${OPERATIONS.join(', ')}`);
|
|
21
|
+
}
|
|
22
|
+
if (typeof fn !== 'function') {
|
|
23
|
+
throw new Error('Middleware must be a function');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const key = getKey(hook, operation, model);
|
|
27
|
+
if (!middlewareRegistry.has(key)) {
|
|
28
|
+
middlewareRegistry.set(key, []);
|
|
29
|
+
}
|
|
30
|
+
middlewareRegistry.get(key)!.push(fn);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Remove a specific middleware function
|
|
35
|
+
*/
|
|
36
|
+
function remove(hook: MiddlewareHook, operation: MiddlewareOperation, fn: MiddlewareFn, model: string = '*'): boolean {
|
|
37
|
+
const key = getKey(hook, operation, model);
|
|
38
|
+
const middlewares = middlewareRegistry.get(key);
|
|
39
|
+
if (!middlewares) return false;
|
|
40
|
+
|
|
41
|
+
const index = middlewares.indexOf(fn);
|
|
42
|
+
if (index > -1) {
|
|
43
|
+
middlewares.splice(index, 1);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Clear all middleware for a specific hook/operation/model combination
|
|
51
|
+
*/
|
|
52
|
+
function clear(hook?: MiddlewareHook, operation?: MiddlewareOperation, model?: string): void {
|
|
53
|
+
if (!hook && !operation && !model) {
|
|
54
|
+
middlewareRegistry.clear();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const key = getKey(hook!, operation!, model);
|
|
58
|
+
middlewareRegistry.delete(key);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get all middleware functions for a specific hook/operation/model.
|
|
63
|
+
* Returns both model-specific and global ('*') middleware.
|
|
64
|
+
*/
|
|
65
|
+
function getMiddleware(hook: MiddlewareHook, operation: MiddlewareOperation, model: string): MiddlewareFn[] {
|
|
66
|
+
const globalKey = getKey(hook, operation, '*');
|
|
67
|
+
const modelKey = getKey(hook, operation, model);
|
|
68
|
+
|
|
69
|
+
const globalMiddleware = middlewareRegistry.get(globalKey) || [];
|
|
70
|
+
const modelSpecific = middlewareRegistry.get(modelKey) || [];
|
|
71
|
+
|
|
72
|
+
return [...globalMiddleware, ...modelSpecific];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Execute middleware chain for a given context
|
|
77
|
+
*/
|
|
78
|
+
async function execute(hook: MiddlewareHook, operation: MiddlewareOperation, context: MiddlewareContext): Promise<MiddlewareContext> {
|
|
79
|
+
const middlewares = getMiddleware(hook, operation, context.model.name);
|
|
80
|
+
|
|
81
|
+
let ctx: MiddlewareContext = { ...context };
|
|
82
|
+
for (const fn of middlewares) {
|
|
83
|
+
const result = await fn(ctx);
|
|
84
|
+
if (result !== undefined) {
|
|
85
|
+
ctx = result;
|
|
86
|
+
}
|
|
87
|
+
if (ctx.abort) break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return ctx;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a middleware context object
|
|
95
|
+
*/
|
|
96
|
+
function createContext(
|
|
97
|
+
model: { name: string },
|
|
98
|
+
operation: string,
|
|
99
|
+
params: Record<string, unknown>,
|
|
100
|
+
user: MiddlewareContext['user'] = null
|
|
101
|
+
): MiddlewareContext {
|
|
102
|
+
return {
|
|
103
|
+
model,
|
|
104
|
+
operation,
|
|
105
|
+
user,
|
|
106
|
+
timestamp: new Date(),
|
|
107
|
+
...params,
|
|
108
|
+
abort: false,
|
|
109
|
+
skip: false,
|
|
110
|
+
softDelete: false,
|
|
111
|
+
} as MiddlewareContext;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const modelMiddleware = {
|
|
115
|
+
use,
|
|
116
|
+
remove,
|
|
117
|
+
clear,
|
|
118
|
+
getMiddleware,
|
|
119
|
+
execute,
|
|
120
|
+
createContext,
|
|
121
|
+
OPERATIONS,
|
|
122
|
+
HOOKS,
|
|
123
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import type { RLSVariables, DatabaseProvider, AdapterResult, AclConfig } from '../types';
|
|
4
|
+
import * as dmmf from './dmmf';
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
7
|
+
const { PrismaClient, Prisma } = require(path.join(process.cwd(), 'prisma', 'client'));
|
|
8
|
+
|
|
9
|
+
/** Request context storage for RLS variables across async operations */
|
|
10
|
+
export const requestContext = new AsyncLocalStorage<{ variables: RLSVariables }>();
|
|
11
|
+
|
|
12
|
+
/** Validates that an RLS identifier contains only safe characters (letters, digits, underscores) */
|
|
13
|
+
const IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
14
|
+
function validateIdentifier(value: string, name: string): string {
|
|
15
|
+
if (!IDENTIFIER_RE.test(value)) {
|
|
16
|
+
throw new Error(`[RLS] Invalid identifier for ${name}: "${value}". Only letters, numbers, and underscores allowed.`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** RLS namespace for SQL session variable prefix */
|
|
22
|
+
const RLS_NAMESPACE = validateIdentifier(process.env.RLS_NAMESPACE || 'app', 'RLS_NAMESPACE');
|
|
23
|
+
|
|
24
|
+
// =====================================================
|
|
25
|
+
// DATABASE ADAPTER FACTORY
|
|
26
|
+
// =====================================================
|
|
27
|
+
|
|
28
|
+
function detectProvider(connectionString: string): DatabaseProvider {
|
|
29
|
+
if (!connectionString) return 'postgresql';
|
|
30
|
+
if (connectionString.startsWith('mysql://') || connectionString.startsWith('mariadb://')) {
|
|
31
|
+
return 'mysql';
|
|
32
|
+
}
|
|
33
|
+
return 'postgresql';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseMySQLConnectionString(connectionString: string): Record<string, unknown> {
|
|
37
|
+
const url = new URL(connectionString);
|
|
38
|
+
return {
|
|
39
|
+
host: url.hostname,
|
|
40
|
+
port: parseInt(url.port, 10) || 3306,
|
|
41
|
+
user: url.username,
|
|
42
|
+
password: url.password,
|
|
43
|
+
database: url.pathname.slice(1),
|
|
44
|
+
connectionLimit: 10,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createAdapter(connectionString: string, provider: string | null = null): AdapterResult {
|
|
49
|
+
const detectedProvider = (provider || process.env.DATABASE_PROVIDER || detectProvider(connectionString)) as DatabaseProvider;
|
|
50
|
+
|
|
51
|
+
if (detectedProvider === 'mysql' || (detectedProvider as string) === 'mariadb') {
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
53
|
+
const { PrismaMariaDb } = require('@prisma/adapter-mariadb');
|
|
54
|
+
const config = parseMySQLConnectionString(connectionString);
|
|
55
|
+
const adapter = new PrismaMariaDb(config);
|
|
56
|
+
return { adapter, pool: null, provider: 'mysql' };
|
|
57
|
+
} else {
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
|
+
const { PrismaPg } = require('@prisma/adapter-pg');
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
61
|
+
const { Pool } = require('pg');
|
|
62
|
+
const pool = new Pool({ connectionString });
|
|
63
|
+
const adapter = new PrismaPg(pool);
|
|
64
|
+
return { adapter, pool, provider: 'postgresql' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// =====================================================
|
|
69
|
+
// BASE PRISMA CLIENTS
|
|
70
|
+
// =====================================================
|
|
71
|
+
|
|
72
|
+
const authConnection = createAdapter(process.env.DATABASE_URL_ADMIN || process.env.DATABASE_URL || '');
|
|
73
|
+
const baseConnection = createAdapter(process.env.DATABASE_URL || '');
|
|
74
|
+
|
|
75
|
+
export const dbProvider: DatabaseProvider = baseConnection.provider;
|
|
76
|
+
|
|
77
|
+
/** Whether RLS is enabled. Auto: true for PostgreSQL, false for MySQL. Override with RLS_ENABLED env var. */
|
|
78
|
+
export const rlsEnabled: boolean = process.env.RLS_ENABLED !== undefined
|
|
79
|
+
? process.env.RLS_ENABLED === 'true'
|
|
80
|
+
: dbProvider === 'postgresql';
|
|
81
|
+
|
|
82
|
+
export const authPrisma = new PrismaClient({
|
|
83
|
+
adapter: authConnection.adapter,
|
|
84
|
+
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const basePrisma = new PrismaClient({
|
|
88
|
+
adapter: baseConnection.adapter,
|
|
89
|
+
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// =====================================================
|
|
93
|
+
// RLS HELPER FUNCTIONS
|
|
94
|
+
// =====================================================
|
|
95
|
+
|
|
96
|
+
function sanitizeRLSValue(value: string | number | null | undefined): string {
|
|
97
|
+
if (value === null || value === undefined) return '';
|
|
98
|
+
return String(value).replace(/'/g, "''");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function setRLSVariables(tx: any, variables: RLSVariables): Promise<void> {
|
|
102
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
103
|
+
if (value === null || value === undefined) continue;
|
|
104
|
+
const safeName = validateIdentifier(name, name);
|
|
105
|
+
const safeValue = sanitizeRLSValue(value);
|
|
106
|
+
|
|
107
|
+
if (dbProvider === 'mysql') {
|
|
108
|
+
await tx.$executeRawUnsafe(`SET @${RLS_NAMESPACE}_${safeName} = '${safeValue}'`);
|
|
109
|
+
} else {
|
|
110
|
+
await tx.$executeRawUnsafe(`SET LOCAL ${RLS_NAMESPACE}.${safeName} = '${safeValue}'`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function resetRLSVariables(tx: any, variables: RLSVariables): Promise<void> {
|
|
116
|
+
try {
|
|
117
|
+
for (const name of Object.keys(variables)) {
|
|
118
|
+
const safeName = validateIdentifier(name, name);
|
|
119
|
+
if (dbProvider === 'mysql') {
|
|
120
|
+
await tx.$executeRawUnsafe(`SET @${RLS_NAMESPACE}_${safeName} = NULL`);
|
|
121
|
+
} else {
|
|
122
|
+
await tx.$executeRawUnsafe(`RESET ${RLS_NAMESPACE}.${safeName}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// Ignore errors on reset
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// =====================================================
|
|
131
|
+
// EXTENDED PRISMA WITH AUTOMATIC RLS
|
|
132
|
+
// =====================================================
|
|
133
|
+
|
|
134
|
+
export const prisma = basePrisma.$extends({
|
|
135
|
+
query: {
|
|
136
|
+
async $allOperations({ operation, args, query, model }: any) {
|
|
137
|
+
if (!rlsEnabled) return query(args);
|
|
138
|
+
|
|
139
|
+
const context = requestContext.getStore();
|
|
140
|
+
const variables = context?.variables;
|
|
141
|
+
|
|
142
|
+
if (!variables || Object.keys(variables).length === 0) {
|
|
143
|
+
return query(args);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (operation === '$transaction') {
|
|
147
|
+
return basePrisma.$transaction(async (tx: any) => {
|
|
148
|
+
await setRLSVariables(tx, variables);
|
|
149
|
+
return query(args);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return basePrisma.$transaction(async (tx: any) => {
|
|
154
|
+
await setRLSVariables(tx, variables);
|
|
155
|
+
|
|
156
|
+
if (model) {
|
|
157
|
+
return tx[model][operation](args);
|
|
158
|
+
} else {
|
|
159
|
+
return tx[operation](args);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// =====================================================
|
|
167
|
+
// TRANSACTION HELPERS
|
|
168
|
+
// =====================================================
|
|
169
|
+
|
|
170
|
+
export async function prismaTransaction(
|
|
171
|
+
callback: ((tx: any) => Promise<any>) | Array<(tx: any) => Promise<any>>,
|
|
172
|
+
options?: { timeout?: number }
|
|
173
|
+
): Promise<any> {
|
|
174
|
+
const context = requestContext.getStore();
|
|
175
|
+
|
|
176
|
+
return basePrisma.$transaction(async (tx: any) => {
|
|
177
|
+
const variables = context?.variables;
|
|
178
|
+
if (rlsEnabled && variables && Object.keys(variables).length > 0) {
|
|
179
|
+
await setRLSVariables(tx, variables);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (Array.isArray(callback)) {
|
|
183
|
+
return await Promise.all(callback.map((fn: (tx: any) => Promise<any>) => fn(tx)));
|
|
184
|
+
}
|
|
185
|
+
return await callback(tx);
|
|
186
|
+
}, options);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// =====================================================
|
|
190
|
+
// GRACEFUL SHUTDOWN
|
|
191
|
+
// =====================================================
|
|
192
|
+
|
|
193
|
+
export async function disconnectAll(): Promise<void> {
|
|
194
|
+
await authPrisma.$disconnect();
|
|
195
|
+
await basePrisma.$disconnect();
|
|
196
|
+
if (authConnection.pool && typeof (authConnection.pool as any).end === 'function') {
|
|
197
|
+
await (authConnection.pool as any).end();
|
|
198
|
+
}
|
|
199
|
+
if (baseConnection.pool && typeof (baseConnection.pool as any).end === 'function') {
|
|
200
|
+
await (baseConnection.pool as any).end();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
process.on('beforeExit', async () => {
|
|
205
|
+
await disconnectAll();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// =====================================================
|
|
209
|
+
// INITIALIZATION
|
|
210
|
+
// =====================================================
|
|
211
|
+
|
|
212
|
+
export async function initializeDMMF(): Promise<any> {
|
|
213
|
+
return dmmf.loadDMMF();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Auto-initialize DMMF on module load
|
|
217
|
+
initializeDMMF();
|
|
218
|
+
|
|
219
|
+
// =====================================================
|
|
220
|
+
// LAZY ACL & MIDDLEWARE
|
|
221
|
+
// =====================================================
|
|
222
|
+
|
|
223
|
+
let _acl: AclConfig | null = null;
|
|
224
|
+
export function getAcl(): AclConfig {
|
|
225
|
+
if (!_acl) {
|
|
226
|
+
try {
|
|
227
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
228
|
+
_acl = require('../config/acl').default || require('../config/acl');
|
|
229
|
+
} catch {
|
|
230
|
+
_acl = { model: {} };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return _acl!;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export { PrismaClient, Prisma, dmmf };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rapidd Framework - Main exports
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* import { Model, QueryBuilder, prisma, Auth, ErrorResponse } from 'rapidd';
|
|
6
|
+
*
|
|
7
|
+
* class Users extends Model {
|
|
8
|
+
* constructor(options) {
|
|
9
|
+
* super('users', options);
|
|
10
|
+
* }
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ── ORM ──────────────────────────────────────────────────────────────────────
|
|
15
|
+
export { Model } from './orm/Model';
|
|
16
|
+
export { QueryBuilder } from './orm/QueryBuilder';
|
|
17
|
+
|
|
18
|
+
// ── Database ─────────────────────────────────────────────────────────────────
|
|
19
|
+
export {
|
|
20
|
+
prisma,
|
|
21
|
+
authPrisma,
|
|
22
|
+
prismaTransaction,
|
|
23
|
+
getAcl,
|
|
24
|
+
setRLSVariables,
|
|
25
|
+
resetRLSVariables,
|
|
26
|
+
requestContext,
|
|
27
|
+
dbProvider,
|
|
28
|
+
rlsEnabled
|
|
29
|
+
} from './core/prisma';
|
|
30
|
+
|
|
31
|
+
export * as dmmf from './core/dmmf';
|
|
32
|
+
|
|
33
|
+
// ── Authentication ───────────────────────────────────────────────────────────
|
|
34
|
+
export { Auth } from './auth/Auth';
|
|
35
|
+
export { SessionStoreManager, createStore } from './auth/stores';
|
|
36
|
+
export type { ISessionStore } from './auth/stores/ISessionStore';
|
|
37
|
+
|
|
38
|
+
// ── Errors & Responses ───────────────────────────────────────────────────────
|
|
39
|
+
export { ErrorResponse, Response } from './core/errors';
|
|
40
|
+
|
|
41
|
+
// ── Middleware ───────────────────────────────────────────────────────────────
|
|
42
|
+
export { modelMiddleware } from './core/middleware';
|
|
43
|
+
|
|
44
|
+
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
45
|
+
export { ApiClient, ApiClientError } from './utils/ApiClient';
|
|
46
|
+
export { Mailer } from './utils/Mailer';
|
|
47
|
+
export { env } from './utils';
|
|
48
|
+
|
|
49
|
+
// ── Environment ──────────────────────────────────────────────────────────────
|
|
50
|
+
export {
|
|
51
|
+
validateEnv,
|
|
52
|
+
getEnv,
|
|
53
|
+
getAllEnv,
|
|
54
|
+
isProduction,
|
|
55
|
+
isDevelopment,
|
|
56
|
+
isTest
|
|
57
|
+
} from './core/env';
|
|
58
|
+
|
|
59
|
+
// ── Plugins ──────────────────────────────────────────────────────────────────
|
|
60
|
+
export { uploadPlugin } from './plugins/upload';
|
|
61
|
+
export { default as responsePlugin } from './plugins/response';
|
|
62
|
+
export { default as apiPlugin } from './plugins/response'; // backward compat alias
|
|
63
|
+
export { default as authPlugin } from './plugins/auth';
|
|
64
|
+
export { default as languagePlugin } from './plugins/language';
|
|
65
|
+
export { default as securityPlugin } from './plugins/security';
|
|
66
|
+
export { default as rateLimitPlugin, RateLimiter } from './plugins/rateLimit';
|
|
67
|
+
export { default as rateLimiterPlugin } from './plugins/rateLimit'; // backward compat alias
|
|
68
|
+
export { default as rlsPlugin } from './plugins/rls';
|
|
69
|
+
|
|
70
|
+
// ── App Builder ──────────────────────────────────────────────────────────────
|
|
71
|
+
export { buildApp } from './app';
|
|
72
|
+
|
|
73
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
74
|
+
export type {
|
|
75
|
+
RapiddUser,
|
|
76
|
+
AuthStrategy,
|
|
77
|
+
RouteAuthConfig,
|
|
78
|
+
ModelOptions,
|
|
79
|
+
GetManyResult,
|
|
80
|
+
UpsertManyResult,
|
|
81
|
+
UpsertManyOptions,
|
|
82
|
+
ModelAcl,
|
|
83
|
+
AclConfig,
|
|
84
|
+
MiddlewareContext,
|
|
85
|
+
MiddlewareHook,
|
|
86
|
+
MiddlewareOperation,
|
|
87
|
+
RLSVariables,
|
|
88
|
+
RlsContextFn
|
|
89
|
+
} from './types';
|
|
90
|
+
|
|
91
|
+
export type {
|
|
92
|
+
ServiceConfig,
|
|
93
|
+
EndpointConfig,
|
|
94
|
+
AuthConfig,
|
|
95
|
+
RequestOptions,
|
|
96
|
+
ApiResponse
|
|
97
|
+
} from './utils/ApiClient';
|
|
98
|
+
|
|
99
|
+
export type {
|
|
100
|
+
EmailConfig,
|
|
101
|
+
EmailOptions,
|
|
102
|
+
EmailAttachment,
|
|
103
|
+
EmailResult
|
|
104
|
+
} from './utils/Mailer';
|
|
105
|
+
|
|
106
|
+
export type {
|
|
107
|
+
UploadOptions,
|
|
108
|
+
AllowedType,
|
|
109
|
+
UploadedFile
|
|
110
|
+
} from './plugins/upload';
|
|
111
|
+
|
|
112
|
+
export type { EnvConfig } from './core/env';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Middleware — Register before/after hooks for CRUD operations.
|
|
3
|
+
*
|
|
4
|
+
* Import this file in main.ts (before start()) to activate middleware.
|
|
5
|
+
* Hooks run for all models by default; pass a model name as the last
|
|
6
|
+
* argument to scope a hook to a specific model.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import './src/middleware/model';
|
|
10
|
+
*
|
|
11
|
+
* Available hooks: 'before' | 'after'
|
|
12
|
+
* Available ops: 'create' | 'update' | 'upsert' | 'upsertMany'
|
|
13
|
+
* | 'delete' | 'get' | 'getMany' | 'count'
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { modelMiddleware } from '../core/middleware';
|
|
17
|
+
|
|
18
|
+
// ── Timestamps (all models) ─────────────────────────
|
|
19
|
+
// Automatically set createdAt on create
|
|
20
|
+
|
|
21
|
+
// modelMiddleware.use('before', 'create', async (ctx) => {
|
|
22
|
+
// ctx.data = { ...ctx.data, createdAt: new Date(), createdBy: ctx.user?.id };
|
|
23
|
+
// return ctx;
|
|
24
|
+
// });
|
|
25
|
+
|
|
26
|
+
// Automatically set updatedAt on update
|
|
27
|
+
|
|
28
|
+
// modelMiddleware.use('before', 'update', async (ctx) => {
|
|
29
|
+
// ctx.data = { ...ctx.data, updatedAt: new Date(), updatedBy: ctx.user?.id };
|
|
30
|
+
// return ctx;
|
|
31
|
+
// });
|
|
32
|
+
|
|
33
|
+
// ── Soft Delete (specific model) ────────────────────
|
|
34
|
+
// Convert delete to an update that sets deletedAt
|
|
35
|
+
|
|
36
|
+
// modelMiddleware.use('before', 'delete', async (ctx) => {
|
|
37
|
+
// ctx.softDelete = true;
|
|
38
|
+
// ctx.data = { deletedAt: new Date(), deletedBy: ctx.user?.id };
|
|
39
|
+
// return ctx;
|
|
40
|
+
// }, 'posts');
|
|
41
|
+
|
|
42
|
+
// ── Transform Response ──────────────────────────────
|
|
43
|
+
// Add computed fields after fetching
|
|
44
|
+
|
|
45
|
+
// modelMiddleware.use('after', 'get', async (ctx) => {
|
|
46
|
+
// const result = ctx.result as Record<string, unknown> | undefined;
|
|
47
|
+
// if (result?.firstName && result?.lastName) {
|
|
48
|
+
// result.fullName = `${result.firstName} ${result.lastName}`;
|
|
49
|
+
// }
|
|
50
|
+
// return ctx;
|
|
51
|
+
// }, 'users');
|
|
52
|
+
|
|
53
|
+
// ── Abort on Condition ──────────────────────────────
|
|
54
|
+
// Prevent creation if a condition is not met
|
|
55
|
+
|
|
56
|
+
// modelMiddleware.use('before', 'create', async (ctx) => {
|
|
57
|
+
// if (!ctx.data?.email) {
|
|
58
|
+
// ctx.abort = true; // stops the operation
|
|
59
|
+
// }
|
|
60
|
+
// return ctx;
|
|
61
|
+
// }, 'users');
|