@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.
Files changed (59) hide show
  1. package/.dockerignore +71 -0
  2. package/.env.example +70 -0
  3. package/.gitignore +11 -0
  4. package/LICENSE +15 -0
  5. package/README.md +231 -0
  6. package/bin/cli.js +145 -0
  7. package/config/app.json +166 -0
  8. package/config/rate-limit.json +12 -0
  9. package/dist/main.js +26 -0
  10. package/dockerfile +57 -0
  11. package/locales/ar_SA.json +179 -0
  12. package/locales/de_DE.json +179 -0
  13. package/locales/en_US.json +180 -0
  14. package/locales/es_ES.json +179 -0
  15. package/locales/fr_FR.json +179 -0
  16. package/locales/it_IT.json +179 -0
  17. package/locales/ja_JP.json +179 -0
  18. package/locales/pt_BR.json +179 -0
  19. package/locales/ru_RU.json +179 -0
  20. package/locales/tr_TR.json +179 -0
  21. package/main.ts +25 -0
  22. package/package.json +126 -0
  23. package/prisma/schema.prisma +9 -0
  24. package/prisma.config.ts +12 -0
  25. package/public/static/favicon.ico +0 -0
  26. package/public/static/image/logo.png +0 -0
  27. package/routes/api/v1/index.ts +113 -0
  28. package/src/app.ts +197 -0
  29. package/src/auth/Auth.ts +446 -0
  30. package/src/auth/stores/ISessionStore.ts +19 -0
  31. package/src/auth/stores/MemoryStore.ts +70 -0
  32. package/src/auth/stores/RedisStore.ts +92 -0
  33. package/src/auth/stores/index.ts +149 -0
  34. package/src/config/acl.ts +9 -0
  35. package/src/config/rls.ts +38 -0
  36. package/src/core/dmmf.ts +226 -0
  37. package/src/core/env.ts +183 -0
  38. package/src/core/errors.ts +87 -0
  39. package/src/core/i18n.ts +144 -0
  40. package/src/core/middleware.ts +123 -0
  41. package/src/core/prisma.ts +236 -0
  42. package/src/index.ts +112 -0
  43. package/src/middleware/model.ts +61 -0
  44. package/src/orm/Model.ts +881 -0
  45. package/src/orm/QueryBuilder.ts +2078 -0
  46. package/src/plugins/auth.ts +162 -0
  47. package/src/plugins/language.ts +79 -0
  48. package/src/plugins/rateLimit.ts +210 -0
  49. package/src/plugins/response.ts +80 -0
  50. package/src/plugins/rls.ts +51 -0
  51. package/src/plugins/security.ts +23 -0
  52. package/src/plugins/upload.ts +299 -0
  53. package/src/types.ts +308 -0
  54. package/src/utils/ApiClient.ts +526 -0
  55. package/src/utils/Mailer.ts +348 -0
  56. package/src/utils/index.ts +25 -0
  57. package/templates/email/example.ejs +17 -0
  58. package/templates/layouts/email.ejs +35 -0
  59. package/tsconfig.json +33 -0
@@ -0,0 +1,881 @@
1
+ import { QueryBuilder } from './QueryBuilder';
2
+ import { prisma, prismaTransaction, getAcl } from '../core/prisma';
3
+ import { modelMiddleware } from '../core/middleware';
4
+ import { ErrorResponse } from '../core/errors';
5
+ import type { RapiddUser, ModelOptions, GetManyResult, UpsertManyResult, UpsertManyOptions, ModelAcl, MiddlewareContext } from '../types';
6
+
7
+ /**
8
+ * Base Model class for Rapidd ORM operations
9
+ * Provides CRUD operations with built-in ACL (Access Control List) and middleware support
10
+ *
11
+ * @example
12
+ * class Users extends Model {
13
+ * constructor(options: ModelOptions) {
14
+ * super('users', options);
15
+ * }
16
+ * }
17
+ *
18
+ * const users = new Users({ user: { id: '123', role: 'admin' } });
19
+ * const result = await users.getMany({}, 'profile', 10, 0);
20
+ *
21
+ * @example
22
+ * // Register middleware to auto-add timestamps
23
+ * Model.middleware.use('before', 'create', async (ctx: MiddlewareContext) => {
24
+ * ctx.data.createdAt = new Date();
25
+ * ctx.data.createdBy = ctx.user?.id;
26
+ * return ctx;
27
+ * });
28
+ */
29
+ class Model {
30
+ name!: string;
31
+ queryBuilder: QueryBuilder;
32
+ acl: ModelAcl;
33
+ options: ModelOptions;
34
+ user: RapiddUser;
35
+ user_id: string | number | null;
36
+ prisma: any; // Prisma model delegate
37
+
38
+ static QueryBuilder?: QueryBuilder;
39
+ static relatedObjects: any[] = [];
40
+ static Error = ErrorResponse;
41
+ static middleware = modelMiddleware;
42
+ static prismaTransaction = prismaTransaction;
43
+
44
+ /**
45
+ * Create a new Model instance
46
+ * @param name - The Prisma model name (e.g., 'users', 'company_profiles')
47
+ * @param options - Configuration options
48
+ */
49
+ constructor(name: string, options?: ModelOptions) {
50
+ this.name = name;
51
+ this.prisma = (prisma as any)[name];
52
+ this.queryBuilder = (this.constructor as typeof Model).QueryBuilder ?? new QueryBuilder(name);
53
+ const aclConfig = getAcl();
54
+ this.acl = aclConfig.model[name] || {};
55
+ this.options = options || {};
56
+ this.user = this.options.user || { id: 'system', role: 'application' };
57
+ this.user_id = this.user ? this.user.id : null;
58
+ }
59
+
60
+ /**
61
+ * Get the primary key field name for this model
62
+ * For composite keys, returns fields joined with underscore (Prisma composite key format)
63
+ */
64
+ get primaryKey(): string {
65
+ const pkey = this.queryBuilder.getPrimaryKey();
66
+ return Array.isArray(pkey) ? pkey.join('_') : pkey;
67
+ }
68
+
69
+ /**
70
+ * Get raw primary key field(s) from DMMF
71
+ * Returns string for simple PKs, string[] for composite PKs
72
+ */
73
+ get primaryKeyFields(): string | string[] {
74
+ return this.queryBuilder.getPrimaryKey();
75
+ }
76
+
77
+ /**
78
+ * Get the default sort field for this model
79
+ * For composite keys, returns the first field
80
+ */
81
+ get defaultSortField(): string {
82
+ const pk = this.primaryKeyFields;
83
+ return Array.isArray(pk) ? pk[0] : pk;
84
+ }
85
+
86
+ /**
87
+ * Whether this model has a composite primary key
88
+ */
89
+ get isCompositePK(): boolean {
90
+ return Array.isArray(this.primaryKeyFields);
91
+ }
92
+
93
+ /**
94
+ * Build a Prisma where clause for the given ID value(s)
95
+ * For simple PKs: { id: value }
96
+ * For composite PKs with object: { email_companyId: { email: '...', companyId: '...' } }
97
+ * For composite PKs with tilde-delimited string: parses "val1~val2" into fields
98
+ */
99
+ buildWhereId(id: string | number | Record<string, any>): Record<string, any> {
100
+ const pkFields = this.primaryKeyFields;
101
+
102
+ if (!Array.isArray(pkFields)) {
103
+ // Simple PK
104
+ return { [pkFields]: id };
105
+ }
106
+
107
+ // Composite PK
108
+ const compositeKeyName = pkFields.join('_');
109
+
110
+ if (typeof id === 'object' && id !== null) {
111
+ // Already an object with field values
112
+ return { [compositeKeyName]: id };
113
+ }
114
+
115
+ if (typeof id === 'string' && id.includes('~')) {
116
+ // Tilde-delimited string from URL
117
+ const parts = id.split('~');
118
+ if (parts.length !== pkFields.length) {
119
+ throw new ErrorResponse(400, "invalid_composite_key", {
120
+ expected: pkFields,
121
+ received: parts.length
122
+ });
123
+ }
124
+ const values: Record<string, any> = {};
125
+ pkFields.forEach((field: string, i: number) => {
126
+ values[field] = this.#coercePrimaryKeyValue(field, parts[i]);
127
+ });
128
+ return { [compositeKeyName]: values };
129
+ }
130
+
131
+ throw new ErrorResponse(400, "invalid_composite_key_format", {
132
+ message: "Composite key requires either an object or tilde-separated string",
133
+ fields: pkFields
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Build a Prisma where clause for a unique key (used in upsert)
139
+ */
140
+ buildWhereUniqueKey(uniqueKey: string | string[], data: Record<string, any>): Record<string, any> {
141
+ if (Array.isArray(uniqueKey)) {
142
+ const compositeKeyName = uniqueKey.join('_');
143
+ const compositeKeyValues: Record<string, any> = {};
144
+ uniqueKey.forEach((key: string) => {
145
+ compositeKeyValues[key] = data[key];
146
+ });
147
+ return { [compositeKeyName]: compositeKeyValues };
148
+ }
149
+ return { [uniqueKey]: data[uniqueKey] };
150
+ }
151
+
152
+ /**
153
+ * Build a Prisma select clause for primary key fields
154
+ */
155
+ #buildPrimaryKeySelect(): Record<string, true> {
156
+ const pkFields = this.primaryKeyFields;
157
+ if (!Array.isArray(pkFields)) {
158
+ return { [pkFields]: true };
159
+ }
160
+ return pkFields.reduce((acc: Record<string, true>, f: string) => { acc[f] = true; return acc; }, {});
161
+ }
162
+
163
+ /**
164
+ * Compare two records by their primary key fields
165
+ */
166
+ #primaryKeysMatch(a: Record<string, any>, b: Record<string, any>): boolean {
167
+ if (!a || !b) return false;
168
+ const pkFields = this.primaryKeyFields;
169
+ const fields = Array.isArray(pkFields) ? pkFields : [pkFields];
170
+ return fields.every((f: string) => a[f] != null && String(a[f]) === String(b[f]));
171
+ }
172
+
173
+ /**
174
+ * Coerce a string PK value to the correct type based on DMMF field type
175
+ */
176
+ #coercePrimaryKeyValue(fieldName: string, value: string): any {
177
+ const field = this.fields[fieldName];
178
+ if (!field) return value;
179
+ if (field.type === 'Int') return parseInt(value, 10);
180
+ if (field.type === 'Float' || field.type === 'Decimal') return parseFloat(value);
181
+ if (field.type === 'Boolean') return value === 'true';
182
+ return value;
183
+ }
184
+
185
+ /**
186
+ * Get all fields for this model from DMMF
187
+ */
188
+ get fields(): Record<string, any> {
189
+ return this.queryBuilder.fields;
190
+ }
191
+
192
+ /** Build select clause */
193
+ _select = (fields?: string[] | Record<string, any> | null): Record<string, any> => this.queryBuilder.select(fields as any);
194
+ /** Build filter/where clause */
195
+ _filter = (q: any): Record<string, any> => this.queryBuilder.filter(q);
196
+ /** Build include clause for relations */
197
+ _include = (include: string | Record<string, any>): Record<string, any> => this.queryBuilder.include(include, this.user);
198
+ /** Transform data for create operation */
199
+ _queryCreate = (data: Record<string, any>): Record<string, any> => this.queryBuilder.create(data, this.user);
200
+ /** Transform data for update operation */
201
+ _queryUpdate = (id: string | number, data: Record<string, any>): Record<string, any> => this.queryBuilder.update(id, data, this.user);
202
+
203
+ // ACL METHODS
204
+ /** Check if user can create records */
205
+ _canCreate = (data?: Record<string, any>): boolean => this.acl.canCreate ? this.acl.canCreate(this.user, data) : true;
206
+ /** Get access filter from ACL */
207
+ _getAccessFilter = (): any => this.acl.getAccessFilter?.(this.user);
208
+ /** Get update filter from ACL */
209
+ _getUpdateFilter = (): any => this.acl.getUpdateFilter?.(this.user);
210
+ /** Get delete filter from ACL */
211
+ _getDeleteFilter = (): any => this.acl.getDeleteFilter?.(this.user);
212
+ /** Get fields to omit from response */
213
+ _omit = (): Record<string, boolean> | undefined => this.queryBuilder.omit(this.user) as Record<string, boolean> | undefined;
214
+
215
+ /**
216
+ * Execute middleware chain for an operation
217
+ */
218
+ async _executeMiddleware(hook: 'before' | 'after', operation: string, params: Record<string, any>): Promise<MiddlewareContext> {
219
+ const context = modelMiddleware.createContext({ name: this.name }, operation, params, this.user);
220
+ return await modelMiddleware.execute(hook, operation as any, context);
221
+ }
222
+
223
+ /**
224
+ * Internal method to fetch multiple records with filtering and pagination
225
+ */
226
+ _getMany = async (
227
+ q: Record<string, any> = {},
228
+ include: string | Record<string, any> = "",
229
+ limit: number = 25,
230
+ offset: number = 0,
231
+ sortBy: string = this.defaultSortField,
232
+ sortOrder: string = "asc",
233
+ options: Record<string, any> = {},
234
+ fields: string | null = null,
235
+ ): Promise<GetManyResult> => {
236
+ const take = this.take(Number(limit));
237
+ const skip = this.skip(Number(offset));
238
+
239
+ sortBy = sortBy?.trim();
240
+ sortOrder = sortOrder?.trim();
241
+
242
+ // Validate sort field - fall back to default for composite PK names
243
+ 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
+ if (sortBy === this.primaryKey && this.isCompositePK) {
246
+ sortBy = this.defaultSortField;
247
+ } else {
248
+ throw new ErrorResponse(400, "invalid_sort_field", { sortBy, modelName: this.constructor.name });
249
+ }
250
+ }
251
+
252
+ // Execute before middleware
253
+ const beforeCtx = await this._executeMiddleware('before', 'getMany', { query: q, include, take, skip, sortBy, sortOrder, options, fields });
254
+
255
+ if (beforeCtx.abort) {
256
+ return (beforeCtx.result as GetManyResult) || { data: [], meta: { take, skip, total: 0 } };
257
+ }
258
+
259
+ // Build field selection (handles select vs include+omit based on fields param)
260
+ // Use beforeCtx.fields directly since it's always initialized from the context params
261
+ const fieldSelection = this.queryBuilder.buildFieldSelection(
262
+ beforeCtx.fields as string | null,
263
+ beforeCtx.include || include,
264
+ this.user
265
+ );
266
+
267
+ // Query the database using Prisma with filters, pagination, and limits
268
+ const [data, total] = await prismaTransaction([
269
+ (tx: any) => tx[this.name].findMany({
270
+ 'where': this.filter(beforeCtx.query || q),
271
+ ...fieldSelection,
272
+ 'take': beforeCtx.take || take,
273
+ 'skip': beforeCtx.skip || skip,
274
+ 'orderBy': this.sort(beforeCtx.sortBy || sortBy, beforeCtx.sortOrder || sortOrder),
275
+ ...(beforeCtx.options || options)
276
+ }),
277
+ (tx: any) => tx[this.name].count({
278
+ 'where': this.filter(beforeCtx.query || q)
279
+ })
280
+ ]);
281
+
282
+ const result: GetManyResult = { data, meta: { take: Number(beforeCtx.take) || take, skip: Number(beforeCtx.skip) || skip, total } };
283
+
284
+ // Execute after middleware
285
+ const afterCtx = await this._executeMiddleware('after', 'getMany', { result });
286
+ return (afterCtx.result as GetManyResult) || result;
287
+ }
288
+
289
+ /**
290
+ * Internal method to fetch a single record by primary key
291
+ * Performs parallel permission check to distinguish 404 vs 403 errors
292
+ */
293
+ _get = async (id: string | number | Record<string, any>, include: string | Record<string, any> = '', options: Record<string, any> = {}, fields: string | null = null): Promise<any> => {
294
+ // Execute before middleware
295
+ const beforeCtx = await this._executeMiddleware('before', 'get', { id, include, options, fields });
296
+
297
+ if (beforeCtx.abort) {
298
+ return beforeCtx.result;
299
+ }
300
+
301
+ const { omit, ..._options } = beforeCtx.options || options;
302
+ const targetId = beforeCtx.id || id;
303
+ const whereId = this.buildWhereId(targetId);
304
+
305
+ // Build field selection (handles select vs include+omit based on fields param)
306
+ // Use beforeCtx.fields directly since it's always initialized from the context params
307
+ const effectiveFields = beforeCtx.fields as string | null;
308
+ const effectiveInclude = beforeCtx.include || include;
309
+
310
+ let dataQuery: Record<string, any>;
311
+ if (effectiveFields) {
312
+ const fieldSelection = this.queryBuilder.buildFieldSelection(effectiveFields, effectiveInclude, this.user);
313
+ dataQuery = { 'where': whereId, ...fieldSelection, ..._options };
314
+ } else {
315
+ dataQuery = {
316
+ 'where': whereId,
317
+ 'include': this.include(effectiveInclude),
318
+ 'omit': { ...this._omit(), ...omit },
319
+ ..._options
320
+ };
321
+ }
322
+
323
+ // Parallel queries: one for data, one for permission check
324
+ const _response = this.prisma.findUnique(dataQuery);
325
+
326
+ const _checkPermission = this.prisma.findUnique({
327
+ 'where': {
328
+ ...whereId,
329
+ ...this.getAccessFilter()
330
+ },
331
+ 'select': this.#buildPrimaryKeySelect()
332
+ });
333
+
334
+ const [response, checkPermission] = await Promise.all([_response, _checkPermission]);
335
+ if (response) {
336
+ if (checkPermission) {
337
+ if (!this.#primaryKeysMatch(response, checkPermission)) {
338
+ throw new ErrorResponse(403, "no_permission");
339
+ }
340
+ } else {
341
+ throw new ErrorResponse(403, "no_permission");
342
+ }
343
+ } else {
344
+ throw new ErrorResponse(404, "record_not_found");
345
+ }
346
+
347
+ // Execute after middleware
348
+ const afterCtx = await this._executeMiddleware('after', 'get', { id: targetId, result: response });
349
+ return afterCtx.result || response;
350
+ }
351
+
352
+ /**
353
+ * Internal method to create a new record
354
+ */
355
+ _create = async (data: Record<string, any>, options: Record<string, any> = {}): Promise<any> => {
356
+ // CHECK CREATE PERMISSION
357
+ if (!this.canCreate(data)) {
358
+ throw new ErrorResponse(403, "no_permission_to_create");
359
+ }
360
+
361
+ // Execute before middleware
362
+ const beforeCtx = await this._executeMiddleware('before', 'create', { data: { ...data }, options });
363
+
364
+ if (beforeCtx.abort) {
365
+ return beforeCtx.result;
366
+ }
367
+
368
+ const createData = beforeCtx.data || data;
369
+
370
+ // VALIDATE PASSED FIELDS AND RELATIONSHIPS (returns new transformed object)
371
+ const transformedData = this._queryCreate(createData);
372
+
373
+ // CREATE
374
+ const result = await this.prisma.create({
375
+ 'data': transformedData,
376
+ 'include': this.include('ALL'),
377
+ ...(beforeCtx.options || options)
378
+ });
379
+
380
+ // Execute after middleware
381
+ const afterCtx = await this._executeMiddleware('after', 'create', { data: transformedData, result });
382
+ return afterCtx.result || result;
383
+ }
384
+
385
+ /**
386
+ * Internal method to update an existing record
387
+ * Automatically removes createdAt/createdBy from update data
388
+ */
389
+ _update = async (id: string | number | Record<string, any>, data: Record<string, any>, options: Record<string, any> = {}): Promise<any> => {
390
+ // Create a copy to avoid mutating the caller's data
391
+ const inputData: Record<string, any> = { ...data };
392
+ delete inputData.createdAt;
393
+ delete inputData.createdBy;
394
+
395
+ // CHECK UPDATE PERMISSION
396
+ const updateFilter = this.getUpdateFilter();
397
+ if (updateFilter === false) {
398
+ throw new ErrorResponse(403, "no_permission_to_update");
399
+ }
400
+
401
+ // Execute before middleware
402
+ const beforeCtx = await this._executeMiddleware('before', 'update', { id, data: { ...inputData }, options });
403
+
404
+ if (beforeCtx.abort) {
405
+ return beforeCtx.result;
406
+ }
407
+
408
+ const targetId = beforeCtx.id || id;
409
+ const updateData = beforeCtx.data || inputData;
410
+
411
+ // VALIDATE PASSED FIELDS AND RELATIONSHIPS (returns new transformed object)
412
+ const transformedData = this._queryUpdate(targetId as string | number, updateData);
413
+
414
+ const result = await this.prisma.update({
415
+ 'where': {
416
+ ...this.buildWhereId(targetId),
417
+ ...updateFilter
418
+ },
419
+ 'data': transformedData,
420
+ 'include': this.include('ALL'),
421
+ ...(beforeCtx.options || options)
422
+ });
423
+
424
+ if (!result) {
425
+ throw new ErrorResponse(403, "no_permission");
426
+ }
427
+
428
+ // Execute after middleware
429
+ const afterCtx = await this._executeMiddleware('after', 'update', { id: targetId, data: transformedData, result });
430
+ return afterCtx.result || result;
431
+ }
432
+
433
+ /**
434
+ * Internal method to create or update a record based on unique key
435
+ * Supports both single and composite primary keys
436
+ */
437
+ async _upsert(data: Record<string, any>, unique_key: string | string[] = this.primaryKey, options: Record<string, any> = {}): Promise<any> {
438
+ // Execute before middleware
439
+ const beforeCtx = await this._executeMiddleware('before', 'upsert', { data: { ...data }, unique_key, options });
440
+
441
+ if (beforeCtx.abort) {
442
+ return beforeCtx.result;
443
+ }
444
+
445
+ const upsertData = beforeCtx.data || data;
446
+ const targetKey = beforeCtx.unique_key || unique_key;
447
+
448
+ // create() and update() are now pure - they return new objects without mutating input
449
+ const createData = this.queryBuilder.create(upsertData, this.user);
450
+
451
+ const updatePrimaryKey = Array.isArray(targetKey) ? targetKey[0] : this.primaryKey;
452
+ const updateData = this.queryBuilder.update(updatePrimaryKey, upsertData, this.user);
453
+
454
+ // Build where clause that supports composite keys
455
+ const whereClause = this.buildWhereUniqueKey(targetKey, upsertData);
456
+
457
+ const result = await this.prisma.upsert({
458
+ 'where': whereClause,
459
+ 'create': createData,
460
+ 'update': updateData,
461
+ 'include': this.include('ALL'),
462
+ ...(beforeCtx.options || options)
463
+ });
464
+
465
+ // Execute after middleware
466
+ const afterCtx = await this._executeMiddleware('after', 'upsert', { data: upsertData, result });
467
+ return afterCtx.result || result;
468
+ }
469
+
470
+ /**
471
+ * Internal method to create or update multiple records based on unique key
472
+ * Supports both transactional and non-transactional operations with optional relation validation
473
+ */
474
+ async _upsertMany(
475
+ data: Record<string, any>[],
476
+ unique_key: string | string[] = this.primaryKey,
477
+ prismaOptions: Record<string, any> = {},
478
+ options: UpsertManyOptions = {}
479
+ ): Promise<UpsertManyResult> {
480
+ if (!Array.isArray(data) || data.length === 0) {
481
+ return { created: 0, updated: 0, failed: [], totalSuccess: 0, totalFailed: 0 } as UpsertManyResult;
482
+ }
483
+
484
+ // Extract operation-specific options
485
+ const validateRelation = options.validateRelation ?? false;
486
+ const useTransaction = options.transaction ?? true;
487
+ const timeout = options.timeout ?? 30000;
488
+
489
+ // Execute before middleware
490
+ const beforeCtx = await this._executeMiddleware('before', 'upsertMany', { data, unique_key, prismaOptions });
491
+
492
+ if (beforeCtx.abort) {
493
+ return beforeCtx.result as UpsertManyResult;
494
+ }
495
+
496
+ const upsertData: Record<string, any>[] = (Array.isArray(beforeCtx.data) ? beforeCtx.data : data) as Record<string, any>[];
497
+ const targetKey: string | string[] = beforeCtx.unique_key || unique_key;
498
+ const _prismaOptions: Record<string, any> = beforeCtx.prismaOptions || prismaOptions;
499
+
500
+ // Define the upsert operation logic
501
+ const executeUpsertMany = async (tx: any): Promise<UpsertManyResult> => {
502
+ // Find existing records - handle both simple and composite keys
503
+ let existingRecords: Record<string, any>[];
504
+ if (Array.isArray(targetKey)) {
505
+ // Composite key: use OR clause for lookup
506
+ const whereConditions = upsertData
507
+ .map((record: Record<string, any>) => {
508
+ const compositeKeyName = targetKey.join('_');
509
+ const values: Record<string, any> = {};
510
+ targetKey.forEach((k: string) => { values[k] = record[k]; });
511
+ if (Object.values(values).some((v: any) => v == null)) return null;
512
+ return { [compositeKeyName]: values };
513
+ })
514
+ .filter(Boolean) as Record<string, any>[];
515
+
516
+ existingRecords = whereConditions.length > 0
517
+ ? await tx[this.name].findMany({
518
+ 'where': { OR: whereConditions },
519
+ 'select': targetKey.reduce((acc: Record<string, true>, k: string) => { acc[k] = true; return acc; }, {})
520
+ })
521
+ : [];
522
+ } else {
523
+ const uniqueValues = upsertData.map((record: Record<string, any>) => record[targetKey as string]).filter((v: any) => v != null);
524
+ existingRecords = uniqueValues.length > 0
525
+ ? await tx[this.name].findMany({
526
+ 'where': { [targetKey as string]: { 'in': uniqueValues } },
527
+ 'select': { [targetKey as string]: true }
528
+ })
529
+ : [];
530
+ }
531
+
532
+ // Build existence check helper
533
+ const existsInDb = (record: Record<string, any>): boolean => {
534
+ if (Array.isArray(targetKey)) {
535
+ return existingRecords.some((existing: Record<string, any>) =>
536
+ targetKey.every((k: string) => String(existing[k]) === String(record[k]))
537
+ );
538
+ }
539
+ return existingRecords.some((e: Record<string, any>) => String(e[targetKey as string]) === String(record[targetKey as string]));
540
+ };
541
+
542
+ // Separate data into creates and updates, using pure create/update
543
+ const createRecords: { original: Record<string, any>; transformed: Record<string, any> }[] = [];
544
+ const updateRecords: { original: Record<string, any>; transformed: Record<string, any> }[] = [];
545
+
546
+ for (const record of upsertData) {
547
+ if (existsInDb(record)) {
548
+ // Record exists, prepare for update (pure - returns new object)
549
+ const updatePrimaryKey = Array.isArray(targetKey) ? targetKey[0] : this.primaryKey;
550
+ const transformedRecord = this.queryBuilder.update(record[updatePrimaryKey] || record[targetKey as string], record, this.user);
551
+ updateRecords.push({ original: record, transformed: transformedRecord });
552
+ } else {
553
+ // Record doesn't exist, prepare for create
554
+ if (validateRelation) {
555
+ const transformedRecord = this.queryBuilder.create(record, this.user);
556
+ createRecords.push({ original: record, transformed: transformedRecord });
557
+ } else {
558
+ createRecords.push({ original: record, transformed: { ...record } });
559
+ }
560
+ }
561
+ }
562
+
563
+ let createdCount = 0;
564
+ let updatedCount = 0;
565
+ const failed: { record?: Record<string, any>; records?: Record<string, any>[]; error: any }[] = [];
566
+
567
+ // Batch create
568
+ if (createRecords.length > 0) {
569
+ if (validateRelation) {
570
+ for (const { transformed } of createRecords) {
571
+ try {
572
+ await tx[this.name].create({
573
+ 'data': transformed,
574
+ ..._prismaOptions
575
+ });
576
+ createdCount++;
577
+ } catch (error: any) {
578
+ failed.push({ record: transformed, error });
579
+ }
580
+ }
581
+ } else {
582
+ try {
583
+ const createResult = await tx[this.name].createMany({
584
+ 'data': createRecords.map((r: { original: Record<string, any>; transformed: Record<string, any> }) => r.transformed),
585
+ 'skipDuplicates': true,
586
+ ..._prismaOptions
587
+ });
588
+ createdCount = createResult.count;
589
+ } catch (error: any) {
590
+ failed.push({ records: createRecords.map((r: { original: Record<string, any>; transformed: Record<string, any> }) => r.transformed), error });
591
+ }
592
+ }
593
+ }
594
+ // Batch update
595
+ if (updateRecords.length > 0) {
596
+ for (const { original, transformed } of updateRecords) {
597
+ try {
598
+ const whereClause = Array.isArray(targetKey)
599
+ ? this.buildWhereUniqueKey(targetKey, original)
600
+ : { [targetKey as string]: original[targetKey as string] };
601
+ await tx[this.name].update({
602
+ 'where': whereClause,
603
+ 'data': transformed,
604
+ ..._prismaOptions
605
+ });
606
+ updatedCount++;
607
+ } catch (error: any) {
608
+ failed.push({ record: transformed, error });
609
+ }
610
+ }
611
+ }
612
+
613
+ return {
614
+ created: createdCount,
615
+ updated: updatedCount,
616
+ failed,
617
+ totalSuccess: createdCount + updatedCount,
618
+ totalFailed: failed.length
619
+ } as UpsertManyResult;
620
+ };
621
+
622
+ // Execute with or without transaction based on option
623
+ const result: UpsertManyResult = useTransaction
624
+ ? await prismaTransaction(executeUpsertMany, { timeout })
625
+ : await executeUpsertMany(prisma);
626
+
627
+ // Execute after middleware
628
+ const afterCtx = await this._executeMiddleware('after', 'upsertMany', { data: upsertData, result });
629
+ return (afterCtx.result as UpsertManyResult) || result;
630
+ }
631
+
632
+ /**
633
+ * Internal method to count records matching a filter
634
+ */
635
+ _count = async (q: Record<string, any> = {}): Promise<number> => {
636
+ // Execute before middleware
637
+ const beforeCtx = await this._executeMiddleware('before', 'count', { query: q });
638
+
639
+ if (beforeCtx.abort) {
640
+ return (beforeCtx.result as number) || 0;
641
+ }
642
+
643
+ const result = await this.prisma.count({
644
+ 'where': this.filter(beforeCtx.query || q)
645
+ });
646
+
647
+ // Execute after middleware
648
+ const afterCtx = await this._executeMiddleware('after', 'count', { query: beforeCtx.query || q, result });
649
+ return (afterCtx.result as number) ?? result;
650
+ }
651
+
652
+ /**
653
+ * Internal method to delete a record by primary key
654
+ */
655
+ _delete = async (id: string | number | Record<string, any>, options: Record<string, any> = {}): Promise<any> => {
656
+ // CHECK DELETE PERMISSION
657
+ const deleteFilter = this.getDeleteFilter();
658
+ if (deleteFilter === false) {
659
+ throw new ErrorResponse(403, "no_permission_to_delete");
660
+ }
661
+
662
+ // Execute before middleware
663
+ const beforeCtx = await this._executeMiddleware('before', 'delete', { id, options });
664
+
665
+ if (beforeCtx.abort) {
666
+ return beforeCtx.result;
667
+ }
668
+
669
+ const targetId = beforeCtx.id || id;
670
+ const whereId = this.buildWhereId(targetId);
671
+
672
+ // Support soft delete via middleware
673
+ if (beforeCtx.softDelete && beforeCtx.data) {
674
+ const result = await this.prisma.update({
675
+ 'where': {
676
+ ...whereId,
677
+ ...deleteFilter
678
+ },
679
+ 'data': beforeCtx.data,
680
+ 'select': this.select(),
681
+ ...(beforeCtx.options || options)
682
+ });
683
+
684
+ const afterCtx = await this._executeMiddleware('after', 'delete', { id: targetId, result, softDelete: true });
685
+ return afterCtx.result || result;
686
+ }
687
+
688
+ const result = await this.prisma.delete({
689
+ 'where': {
690
+ ...whereId,
691
+ ...deleteFilter
692
+ },
693
+ 'select': this.select(),
694
+ ...(beforeCtx.options || options)
695
+ });
696
+
697
+ if (!result) {
698
+ throw new ErrorResponse(403, "no_permission");
699
+ }
700
+
701
+ // Execute after middleware
702
+ const afterCtx = await this._executeMiddleware('after', 'delete', { id: targetId, result });
703
+ return afterCtx.result || result;
704
+ }
705
+
706
+ /**
707
+ * Fetch multiple records with filtering, pagination, and sorting
708
+ */
709
+ async getMany(
710
+ q: Record<string, any> = {},
711
+ include: string | Record<string, any> = "",
712
+ limit: number = 25,
713
+ offset: number = 0,
714
+ sortBy: string = this.defaultSortField,
715
+ sortOrder: string = "asc",
716
+ fields: string | null = null
717
+ ): Promise<GetManyResult> {
718
+ return await this._getMany(q, include, Number(limit), Number(offset), sortBy, sortOrder, {}, fields);
719
+ }
720
+
721
+ /**
722
+ * Fetch a single record by primary key
723
+ */
724
+ async get(id: string | number | Record<string, any>, include?: string | Record<string, any>, options: Record<string, any> = {}, fields: string | null = null): Promise<any> {
725
+ return await this._get(id, include, options, fields);
726
+ }
727
+
728
+ /**
729
+ * Create a new record
730
+ */
731
+ async create(data: Record<string, any>, options: Record<string, any> = {}): Promise<any> {
732
+ return await this._create(data, options);
733
+ }
734
+
735
+ /**
736
+ * Update an existing record by primary key
737
+ */
738
+ async update(id: string | number | Record<string, any>, data: Record<string, any>, options: Record<string, any> = {}): Promise<any> {
739
+ return await this._update(id, data, options);
740
+ }
741
+
742
+ /**
743
+ * Create or update a record based on unique key
744
+ */
745
+ async upsert(data: Record<string, any>, unique_key: string | string[] = this.primaryKey, options: Record<string, any> = {}): Promise<any> {
746
+ return await this._upsert(data, unique_key, options);
747
+ }
748
+
749
+ /**
750
+ * Create or update multiple records based on unique key
751
+ * Performs atomic batch operations with optional transaction support
752
+ */
753
+ async upsertMany(
754
+ data: Record<string, any>[],
755
+ unique_key: string | string[] = this.primaryKey,
756
+ prismaOptions: Record<string, any> = {},
757
+ options: UpsertManyOptions = {}
758
+ ): Promise<UpsertManyResult> {
759
+ return await this._upsertMany(data, unique_key, prismaOptions, options);
760
+ }
761
+
762
+ /**
763
+ * Count records matching a filter
764
+ */
765
+ async count(q: Record<string, any> = {}): Promise<number> {
766
+ return await this._count(q);
767
+ }
768
+
769
+ /**
770
+ * Delete a record by primary key
771
+ */
772
+ async delete(id: string | number | Record<string, any>, options: Record<string, any> = {}): Promise<any> {
773
+ return await this._delete(id, options);
774
+ }
775
+
776
+ /**
777
+ * Build a select clause for queries
778
+ */
779
+ select(fields?: string[] | Record<string, any>): Record<string, any> {
780
+ return this._select(fields);
781
+ }
782
+
783
+ /**
784
+ * Build a filter/where clause with ACL applied
785
+ */
786
+ filter(include: string | Record<string, any>): Record<string, any> {
787
+ return { ...this._filter(include), ...this.getAccessFilter() };
788
+ }
789
+
790
+ /**
791
+ * Build an include clause for relations
792
+ */
793
+ include(include: string | Record<string, any>): Record<string, any> {
794
+ return this._include(include);
795
+ }
796
+
797
+ /**
798
+ * Build an orderBy clause
799
+ */
800
+ sort(sortBy: string, sortOrder: string): Record<string, any> {
801
+ return this.queryBuilder.sort(sortBy, sortOrder);
802
+ }
803
+
804
+ /**
805
+ * Normalize and validate the limit (take) value
806
+ */
807
+ take(limit: number): number {
808
+ return this.queryBuilder.take(Number(limit));
809
+ }
810
+
811
+ /**
812
+ * Normalize and validate the offset (skip) value
813
+ */
814
+ skip(offset: number | string): number {
815
+ const parsed = parseInt(offset as string);
816
+ if (isNaN(parsed) || parsed < 0) {
817
+ return 0;
818
+ }
819
+ return parsed;
820
+ }
821
+
822
+ /**
823
+ * Get access filter for read operations
824
+ * Returns empty filter for 'application' role or when ACL returns true
825
+ */
826
+ getAccessFilter(): Record<string, any> {
827
+ const filter = this._getAccessFilter();
828
+ if (this.user.role == "application" || filter === true) {
829
+ return {};
830
+ }
831
+ if (filter === false) {
832
+ throw new ErrorResponse(403, "no_permission");
833
+ }
834
+ if (!filter || typeof filter !== 'object') {
835
+ return {};
836
+ }
837
+ return filter;
838
+ }
839
+
840
+ /**
841
+ * Check if user has permission to create records
842
+ */
843
+ canCreate(data?: Record<string, any>): boolean {
844
+ if (this.user.role == "application") return true;
845
+ return this._canCreate(data);
846
+ }
847
+
848
+ /**
849
+ * Get filter for update operations
850
+ * Returns empty filter for 'application' role or when ACL returns true
851
+ */
852
+ getUpdateFilter(): Record<string, any> | false {
853
+ const filter = this._getUpdateFilter();
854
+ if (this.user.role == "application" || filter === true) {
855
+ return {};
856
+ }
857
+ return filter;
858
+ }
859
+
860
+ /**
861
+ * Get filter for delete operations
862
+ * Returns empty filter for 'application' role or when ACL returns true
863
+ */
864
+ getDeleteFilter(): Record<string, any> | false {
865
+ const filter = this._getDeleteFilter();
866
+ if (this.user.role == "application" || filter === true) {
867
+ return {};
868
+ }
869
+ return filter;
870
+ }
871
+
872
+ /**
873
+ * Set the model name and initialize the Prisma client delegate
874
+ */
875
+ set modelName(name: string) {
876
+ this.name = name;
877
+ this.prisma = (prisma as any)[name];
878
+ }
879
+ }
880
+
881
+ export { Model, QueryBuilder, prisma };