langaro-api 1.2.2 → 1.2.4

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.
@@ -0,0 +1,504 @@
1
+ # CRUD Layer (knex-extended-crud)
2
+
3
+ The `CRUD` class from `knex-extended-crud` is the foundation of all database operations. Every model and service inherits from it.
4
+
5
+ ---
6
+
7
+ ## Constructor Parameters
8
+
9
+ When the loader creates a model, it passes these to the CRUD constructor:
10
+
11
+ ```javascript
12
+ {
13
+ knex, // Knex instance (injected by loader)
14
+ table, // Table name (injected by loader)
15
+ hide: [], // Fields never returned in responses
16
+ append: {}, // Relationship definitions
17
+ fields: {}, // Field configuration
18
+ schema: null, // Yup validation schema
19
+ fieldsSpec: [], // Per-field specifications
20
+ forceInteger: [], // Fields stored as int but exposed as float (÷100)
21
+ sortable: false, // Enable manual sorting
22
+ redis: null, // Redis instance for query caching
23
+ cacheEnabled: true, // Master cache toggle
24
+ cacheTimeout: 300000, // Schema cache TTL in ms (5 min)
25
+ singularTableName: null, // Override singular form (for relationship detection)
26
+ pluralTableName: null, // Override plural form
27
+ notSearchableFields: [], // Fields excluded from search
28
+ modelsPath: '', // Path to model files (for append resolution)
29
+ }
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Default Options
35
+
36
+ Every CRUD instance has these defaults for query operations:
37
+
38
+ ```javascript
39
+ this.options = {
40
+ perPage: 100, // Records per page
41
+ currentPage: 1, // Starting page
42
+ isLengthAware: true, // Include total count in pagination
43
+ exactMatch: false, // Search uses LIKE %term% by default
44
+ sortBy: 'id', // Default sort column
45
+ sort: 'desc', // Default sort direction
46
+ }
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Read Methods
52
+
53
+ ### `get(options, transaction)`
54
+
55
+ Retrieves records with pagination, filtering, searching, and relationship loading.
56
+
57
+ **Options (CRUDGetOptions):**
58
+
59
+ ```javascript
60
+ {
61
+ // Pagination
62
+ perPage: 100, // Records per page
63
+ currentPage: 1, // Page number
64
+
65
+ // Sorting
66
+ sortBy: 'created_at', // Sort column
67
+ sort: 'desc', // 'asc' or 'desc'
68
+
69
+ // Field selection
70
+ showOnly: ['id', 'name'], // Return ONLY these fields
71
+ show: ['field1'], // Whitelist (inverse of hide)
72
+
73
+ // Filtering
74
+ where: (query) => { // Knex query builder function
75
+ query.where('status', 'active');
76
+ },
77
+ andWhere: [ // Array-based conditions (see andWhere section)
78
+ ['status', 'active'],
79
+ ['amount', '>=', 100],
80
+ ],
81
+
82
+ // Search
83
+ search: 'john', // Search term
84
+ searchFields: ['name', 'email'],// Fields to search (overrides model config)
85
+ searchExactMatch: false, // true = exact, false = fuzzy (%term%)
86
+
87
+ // Relationships
88
+ append: ['companies', 'users'], // Relations to load
89
+ appendOptions: { // Per-relation options
90
+ companies: { showOnly: ['id', 'name'] },
91
+ users: { show: ['id', 'email'] },
92
+ },
93
+
94
+ // Caching
95
+ cache: true, // Read from Redis cache
96
+ cacheHours: 24, // Write result to cache with TTL
97
+
98
+ // Special
99
+ firstOnly: true, // Return only the first result
100
+ }
101
+ ```
102
+
103
+ **Returns:**
104
+ ```javascript
105
+ {
106
+ success: true,
107
+ data: [...], // Array of records (or single object if firstOnly)
108
+ pagination: { // Only if isLengthAware
109
+ perPage: 100,
110
+ currentPage: 1,
111
+ total: 500,
112
+ lastPage: 5
113
+ },
114
+ cached: true // Present if served from cache
115
+ }
116
+ ```
117
+
118
+ ### `getWhere(prop, value, options, transaction)`
119
+
120
+ Retrieves records matching a single condition. Shorthand for `get()` with a where clause.
121
+
122
+ ```javascript
123
+ const { data: user } = await this.service.getWhere('id', userId, {
124
+ firstOnly: true,
125
+ append: ['users_permissions'],
126
+ });
127
+ ```
128
+
129
+ ### `search(field, term, options, transaction)`
130
+
131
+ Full-text search on a specific field. Uses case-insensitive `LOWER()` comparison.
132
+
133
+ ```javascript
134
+ const results = await this.service.search('name', 'john', {
135
+ perPage: 50,
136
+ searchExactMatch: false,
137
+ });
138
+ ```
139
+
140
+ The `field` must exist in the table and not be in `notSearchableFields`.
141
+
142
+ ### `count(options, transaction)`
143
+
144
+ Counts matching records.
145
+
146
+ ```javascript
147
+ const { data: total } = await this.service.count({
148
+ andWhere: [['status', 'active']],
149
+ });
150
+ // total = 125
151
+ ```
152
+
153
+ ### `tableInfo(options, transaction)`
154
+
155
+ Returns schema information for the table. Cached in memory.
156
+
157
+ ```javascript
158
+ const { data: schema } = await this.service.tableInfo();
159
+ // schema = { id: { type: 'uuid', nullable: false, ... }, name: { type: 'varchar', ... } }
160
+ ```
161
+
162
+ ### `requiredFields(options, transaction)`
163
+
164
+ Returns list of non-nullable field names.
165
+
166
+ ```javascript
167
+ const { data: required } = await this.service.requiredFields();
168
+ // required = ['name', 'email', 'company_id']
169
+ ```
170
+
171
+ ---
172
+
173
+ ## Write Methods
174
+
175
+ ### `create(data, transaction)`
176
+
177
+ Creates a single record.
178
+
179
+ **Automatic behaviors:**
180
+ - Generates UUID for `id` if not provided
181
+ - Hashes `password` field with bcrypt (10 salt rounds)
182
+ - Converts `forceInteger` fields (multiplies by 100)
183
+ - Validates against Yup schema
184
+ - Clears table query cache
185
+
186
+ ```javascript
187
+ const { data: created } = await this.service.create({
188
+ name: 'John',
189
+ email: 'john@example.com',
190
+ company_id: companyId,
191
+ });
192
+ // created = { id: 'uuid-xxx' }
193
+ ```
194
+
195
+ **Returns:** `{ success: true, data: { id: 'generated-uuid' } }`
196
+
197
+ ### `batchCreate(data[], transaction)`
198
+
199
+ Creates multiple records in a single INSERT.
200
+
201
+ ```javascript
202
+ const { data: created } = await this.service.batchCreate([
203
+ { name: 'John', email: 'john@example.com' },
204
+ { name: 'Jane', email: 'jane@example.com' },
205
+ ]);
206
+ // created = [{ id: 'uuid-1' }, { id: 'uuid-2' }]
207
+ ```
208
+
209
+ Same auto-behaviors as `create()` applied to each item.
210
+
211
+ ---
212
+
213
+ ## Update Methods
214
+
215
+ ### `updateWhere(prop, value, data, options, transaction)`
216
+
217
+ Updates records matching a condition.
218
+
219
+ **Parameters:**
220
+ - `prop` — Column to match (e.g., `'id'`)
221
+ - `value` — Value to match
222
+ - `data` — Fields to update
223
+ - `options` — Update options
224
+ - `transaction` — Optional Knex transaction
225
+
226
+ **Options (CRUDUpdateOptions):**
227
+ ```javascript
228
+ {
229
+ allowForbiddenUpdates: false, // Allow updating protected fields
230
+ where: (query) => {}, // Additional where clause
231
+ andWhere: [...], // Additional conditions
232
+ skipCacheInvalidation: false, // Don't clear Redis cache
233
+ whenSuccess: (data, value) => {},// Callback after successful update
234
+ extraValidations: [ // Custom validation functions
235
+ async (existingItem, newData, trx) => {
236
+ // Throw to prevent update
237
+ }
238
+ ],
239
+ }
240
+ ```
241
+
242
+ **Protected fields** (cannot be updated without `allowForbiddenUpdates`):
243
+ - `id`, `created_at`, `updated_at`
244
+ - Fields listed in `fields.notUpdatable`
245
+
246
+ **Automatic behaviors:**
247
+ - Per-field Yup schema validation
248
+ - Password auto-hashing
249
+ - JSON field stringification
250
+ - Foreign key reference validation
251
+ - Cache invalidation
252
+
253
+ ```javascript
254
+ await this.service.updateWhere('id', userId, {
255
+ name: 'New Name',
256
+ email: 'new@email.com',
257
+ });
258
+ ```
259
+
260
+ **Returns:** `{ success: true, data: { id: 'uuid-xxx' } }`
261
+
262
+ ### `batchUpdate(data[], options, transaction)`
263
+
264
+ Updates multiple records with concurrency control.
265
+
266
+ **Options (CRUDBatchUpdateOptions):**
267
+ ```javascript
268
+ {
269
+ getItemBy: 'id', // Field to identify each item (default: 'id')
270
+ socket: socketInstance, // Socket.io for progress events
271
+ ...CRUDUpdateOptions
272
+ }
273
+ ```
274
+
275
+ **Behavior:**
276
+ 1. Pre-fetches all items in a single query
277
+ 2. Limits concurrency to 10 parallel updates (prevents connection pool exhaustion)
278
+ 3. Creates its own transaction if none provided (auto commit/rollback)
279
+ 4. Emits Socket.io progress events: `batchUpdate:started`, `batchUpdate:progress`, `batchUpdate:completed`
280
+
281
+ ```javascript
282
+ const results = await this.service.batchUpdate([
283
+ { id: 'uuid-1', name: 'Updated 1' },
284
+ { id: 'uuid-2', name: 'Updated 2' },
285
+ ], { socket: io.sockets.in(userId) });
286
+ // results = [{ id: 'uuid-1', success: true }, { id: 'uuid-2', success: true }]
287
+ ```
288
+
289
+ ---
290
+
291
+ ## Delete Methods
292
+
293
+ ### `deleteWhere(prop, values, options, transaction)`
294
+
295
+ Deletes records matching condition(s).
296
+
297
+ ```javascript
298
+ // Single delete
299
+ await this.service.deleteWhere('id', itemId);
300
+
301
+ // Batch delete
302
+ await this.service.deleteWhere('id', [id1, id2, id3]);
303
+
304
+ // Conditional delete
305
+ await this.service.deleteWhere('id', itemId, {
306
+ andWhere: [['company_id', companyId]],
307
+ });
308
+ ```
309
+
310
+ **Returns:** `{ success: true, data: { id: [...] } }`
311
+
312
+ ---
313
+
314
+ ## andWhere Query Builder
315
+
316
+ The `andWhere` option provides a declarative way to build complex WHERE clauses:
317
+
318
+ ```javascript
319
+ andWhere: [
320
+ // Equality: [column, value]
321
+ ['status', 'active'],
322
+
323
+ // Comparison: [column, operator, value]
324
+ ['amount', '>=', 100],
325
+ ['created_at', '<', '2025-01-01'],
326
+
327
+ // IN operator: [column, 'in', array]
328
+ ['status', 'in', ['active', 'pending']],
329
+
330
+ // NOT IN: [column, 'not in', array]
331
+ ['role', 'not in', ['banned']],
332
+
333
+ // BETWEEN: [column, 'between', [start, end]]
334
+ ['created_at', 'between', ['2025-01-01', '2025-12-31']],
335
+
336
+ // NULL handling
337
+ ['deleted_at', null], // WHERE deleted_at IS NULL
338
+ ['deleted_at', '<>', null], // WHERE deleted_at IS NOT NULL
339
+
340
+ // LIKE: [column, 'like', pattern]
341
+ ['name', 'like', '%john%'],
342
+
343
+ // IN with NULL: [column, 'in', [values, null]]
344
+ ['status', 'in', ['active', null]], // WHERE status IN ('active') OR status IS NULL
345
+
346
+ // Boolean columns (TINYINT): use 1/0, NOT true/false
347
+ ['is_active', 1], // WHERE is_active = 1
348
+ ['is_deleted', 0], // WHERE is_deleted = 0
349
+ ]
350
+ ```
351
+
352
+ **Supported operators:** `=`, `<>`, `>`, `<`, `>=`, `<=`, `in`, `not in`, `between`, `not between`, `like`
353
+
354
+ **Boolean gotcha:** MySQL stores booleans as `TINYINT(1)`. In `andWhere`, use `1`/`0` — not `true`/`false`. JavaScript `true`/`false` does not work correctly with the query builder.
355
+
356
+ ---
357
+
358
+ ## Relationship System (append)
359
+
360
+ Relationships are configured in the model file and resolved at query time.
361
+
362
+ ### Configuration (in model file)
363
+
364
+ ```javascript
365
+ module.exports = {
366
+ append: {
367
+ companies: 'one', // This table has company_id FK → load one company
368
+ posts: 'many', // Other table has this_table_id FK → load many posts
369
+ categories: 'categories_users_join', // Many-to-many via join table
370
+ },
371
+ };
372
+ ```
373
+
374
+ ### Relationship Types
375
+
376
+ **one** — This table has a foreign key pointing to the related table:
377
+ - `users` table has `company_id` → `append: { companies: 'one' }`
378
+ - Returns: each user gets a `companies` object
379
+
380
+ **many** — The related table has a foreign key pointing to this table:
381
+ - `posts` table has `user_id` → on users model: `append: { posts: 'many' }`
382
+ - Returns: each user gets a `posts` array
383
+
384
+ **join table** — Many-to-many via intermediate table:
385
+ - `categories_users_join` table with `category_id` and `user_id`
386
+ - On users model: `append: { categories: 'categories_users_join' }`
387
+
388
+ ### Nested Appending
389
+
390
+ Up to 3 levels of nesting:
391
+ ```javascript
392
+ append: ['companies', 'companies.subscriptions', 'companies.subscriptions.plans']
393
+ ```
394
+
395
+ ### appendOptions
396
+
397
+ Control which fields are loaded per relation:
398
+ ```javascript
399
+ {
400
+ append: ['companies', 'users'],
401
+ appendOptions: {
402
+ companies: { showOnly: ['id', 'name'] }, // Only id and name
403
+ users: { show: ['id', 'email', 'name'] }, // Only these fields
404
+ },
405
+ }
406
+ ```
407
+
408
+ ---
409
+
410
+ ## Validation Pipeline
411
+
412
+ When creating or updating records, the CRUD class runs validation in this order:
413
+
414
+ ### On Create (`defaultInsertValidations`):
415
+ 1. Check for forbidden fields (fields in `hide` list)
416
+ 2. Validate required fields (non-nullable fields)
417
+ 3. Run `validateAndFormatFields`:
418
+ - Field exists in table schema
419
+ - Nullable check
420
+ - Empty string check (if `notEmpty` spec)
421
+ - Max length check
422
+ - Allowed values check (enum)
423
+ - Yup schema validation (per-field)
424
+ - Unique constraint check
425
+ - Foreign key reference validation
426
+ - Boolean type check
427
+ - Integer type check
428
+ - JSON field handling (auto-stringify objects)
429
+ - Email lowercase normalization
430
+
431
+ ### On Update (`defaultUpdateValidations`):
432
+ 1. Verify item exists (throws NOT_FOUND)
433
+ 2. Check for protected fields (id, created_at, updated_at, notUpdatable fields)
434
+ 3. Run custom `extraValidations` if provided
435
+ 4. Check for forbidden fields (unless `allowForbiddenUpdates`)
436
+ 5. Run same `validateAndFormatFields` pipeline (per changed field only)
437
+
438
+ ---
439
+
440
+ ## Transaction Management
441
+
442
+ ```javascript
443
+ const trx = await this.service.createTransaction();
444
+ try {
445
+ const { data: user } = await this.service.create(userData, trx);
446
+ await this.services.PermissionsServices.batchCreate(permissions, trx);
447
+ await trx.commit();
448
+ return user;
449
+ } catch (error) {
450
+ await trx.rollback();
451
+ throw error;
452
+ }
453
+ ```
454
+
455
+ **Rules:**
456
+ - All methods accept an optional `transaction` parameter as their last argument
457
+ - If no transaction is provided, methods use the default knex instance
458
+ - `batchUpdate` creates its own transaction if none is provided
459
+ - Always use try/catch/rollback — there is no auto-rollback
460
+
461
+ ---
462
+
463
+ ## Redis Query Cache
464
+
465
+ The CRUD layer supports two caching tiers:
466
+
467
+ ### In-Memory Schema Cache
468
+ - Stores table schema info (`tableInfo` results)
469
+ - TTL: `cacheTimeout` (default 5 min)
470
+ - Cleared via `clearSchemaCache()` or `clearTableCache(tableName)`
471
+
472
+ ### Redis Query Cache
473
+ - Stores full query results
474
+ - Key format: `crud-cache:{table}:{method}:{sha256hash}`
475
+ - **Read from cache**: set `cache: true` in options
476
+ - **Write to cache**: set `cacheHours: N` in options
477
+ - **Invalidation**: Automatic on create/update/delete (unless `skipCacheInvalidation`)
478
+ - **Disable**: `disableCache()` / `enableCache()`
479
+
480
+ **Cache management methods:**
481
+ ```javascript
482
+ this.service.clearQueryCache(); // Invalidate this table's cache
483
+ this.service.clearSchemaCache(); // Clear all schema caches
484
+ this.service.clearTableCache('users');// Clear specific table schema
485
+ this.service.refreshCache(trx); // Pre-load caches
486
+ CRUD.clearAllCrudCache(redis); // Global invalidation (static)
487
+ ```
488
+
489
+ ---
490
+
491
+ ## forceInteger Fields
492
+
493
+ For fields that store monetary values as integers (cents) but should be exposed as decimals:
494
+
495
+ ```javascript
496
+ // Model config
497
+ { forceInteger: ['price', 'amount'] }
498
+
499
+ // On create: value * 100 before INSERT
500
+ // price: 29.99 → stored as 2999
501
+
502
+ // On read: value / 100 after SELECT
503
+ // stored 2999 → returned as 29.99
504
+ ```