data-primals-engine 1.4.2 → 1.5.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/README.md +878 -856
- package/client/package-lock.json +82 -0
- package/client/package.json +2 -0
- package/client/src/App.jsx +1 -1
- package/client/src/App.scss +25 -7
- package/client/src/AssistantChat.scss +3 -2
- package/client/src/ConditionBuilder.jsx +1 -1
- package/client/src/ConditionBuilder2.jsx +2 -1
- package/client/src/DashboardView.jsx +569 -569
- package/client/src/DataEditor.jsx +376 -368
- package/client/src/DataLayout.jsx +4 -10
- package/client/src/DataTable.jsx +858 -817
- package/client/src/Field.jsx +1825 -1784
- package/client/src/FlexDataRenderer.jsx +2 -0
- package/client/src/FlexTreeUtils.js +1 -1
- package/client/src/GeolocationField.jsx +94 -0
- package/client/src/KPIDialog.jsx +11 -1
- package/client/src/ModelCreator.jsx +1 -2
- package/client/src/ModelCreatorField.jsx +24 -27
- package/client/src/ModelList.jsx +1 -1
- package/client/src/RelationField.jsx +2 -2
- package/client/src/constants.js +3 -3
- package/client/src/filter.js +0 -155
- package/client/src/hooks/useTutorials.jsx +62 -65
- package/client/src/translations.js +14 -2
- package/package.json +2 -1
- package/perf/README.md +147 -0
- package/perf/artillery-hooks.js +37 -0
- package/perf/perf-shot-hardwork.yml +84 -0
- package/perf/perf-shot-search.yml +45 -0
- package/perf/setup.yml +26 -0
- package/server.js +1 -1
- package/src/constants.js +264 -31
- package/src/core.js +15 -1
- package/src/data.js +1 -1
- package/src/defaultModels.js +1544 -1540
- package/src/email.js +5 -2
- package/src/engine.js +10 -3
- package/src/filter.js +274 -260
- package/src/i18n.js +187 -177
- package/src/modules/assistant/assistant.js +3 -1
- package/src/modules/bucket.js +12 -15
- package/src/modules/data/data.backup.js +11 -8
- package/src/modules/data/data.core.js +2 -1
- package/src/modules/data/data.js +6 -3
- package/src/modules/data/data.operations.js +610 -168
- package/src/modules/data/data.routes.js +1821 -1785
- package/src/modules/data/data.scheduling.js +2 -1
- package/src/modules/data/data.validation.js +7 -1
- package/src/modules/file.js +4 -2
- package/src/modules/user.js +4 -1
- package/src/modules/workflow.js +9 -10
- package/src/openai.jobs.js +2 -0
- package/src/packs.js +22 -5
- package/src/providers.js +22 -7
- package/swagger-en.yml +133 -0
- package/test/data.integration.test.js +1060 -981
- package/test/import_export.integration.test.js +1 -1
- package/test/model.integration.test.js +377 -221
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {getObjectHash, getRandom, isPlainObject, randomDate, safeAssignObject} from "../../core.js";
|
|
1
|
+
import {getObjectHash, getRand, getRandom, isPlainObject, randomDate, safeAssignObject, setSeed} from "../../core.js";
|
|
2
2
|
import {
|
|
3
3
|
maxExportCount,
|
|
4
4
|
maxFileSize,
|
|
5
5
|
maxFilterDepth,
|
|
6
|
-
maxModelNameLength,
|
|
6
|
+
maxModelNameLength, maxPackData,
|
|
7
7
|
maxPasswordLength,
|
|
8
8
|
maxPostData,
|
|
9
9
|
maxRelationsPerData,
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
packsCollection
|
|
28
28
|
} from "../mongodb.js";
|
|
29
29
|
import i18n from "../../i18n.js";
|
|
30
|
-
import
|
|
30
|
+
import tinycolor from 'tinycolor2';
|
|
31
31
|
import {Config} from "../../config.js";
|
|
32
32
|
import {calculateTotalUserStorageUsage, hasPermission} from "../user.js";
|
|
33
33
|
import {BSON, ObjectId} from "mongodb";
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
processDocuments,
|
|
53
53
|
processFileArray
|
|
54
54
|
} from "./data.relations.js";
|
|
55
|
+
import crypto from 'crypto';
|
|
55
56
|
import cronstrue from 'cronstrue/i18n.js';
|
|
56
57
|
|
|
57
58
|
const delay = ms => new Promise(res => setTimeout(res, ms));
|
|
@@ -91,12 +92,14 @@ export const dataTypes = {
|
|
|
91
92
|
},
|
|
92
93
|
modelField: {
|
|
93
94
|
validate: (value, field) => {
|
|
94
|
-
|
|
95
|
+
const m = Config.Get('maxStringLength', maxStringLength);
|
|
96
|
+
return value === null || (typeof value === 'string' && value.length < m) || typeof value === 'object' && JSON.stringify(value).length <= maxModelNameLength + 100;
|
|
95
97
|
}
|
|
96
98
|
},
|
|
97
99
|
string: {
|
|
98
100
|
validate: (value, field) => {
|
|
99
|
-
const
|
|
101
|
+
const m = Config.Get('maxStringLength', maxStringLength);
|
|
102
|
+
const ml = Math.min(Math.max(field.maxlength, 0), m);
|
|
100
103
|
return value === null || typeof value === 'string' && (!ml || value.length <= ml)
|
|
101
104
|
},
|
|
102
105
|
anonymize: anonymizeText
|
|
@@ -125,7 +128,8 @@ export const dataTypes = {
|
|
|
125
128
|
},
|
|
126
129
|
richtext: {
|
|
127
130
|
validate: (value, field) => {
|
|
128
|
-
const
|
|
131
|
+
const m = Config.Get('maxRichTextLength', maxRichTextLength);
|
|
132
|
+
const ml = Math.min(Math.max(field.maxlength, 0), m);
|
|
129
133
|
return value === null || typeof value === 'string' && (!ml || value.length <= ml)
|
|
130
134
|
},
|
|
131
135
|
filter: async (value) => {
|
|
@@ -137,6 +141,7 @@ export const dataTypes = {
|
|
|
137
141
|
validate: (value, field) => {
|
|
138
142
|
if (value === null)
|
|
139
143
|
return true;
|
|
144
|
+
const m = Config.Get('maxStringLength', maxStringLength);
|
|
140
145
|
const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
|
|
141
146
|
// La valeur peut être une chaîne de caractères...
|
|
142
147
|
if (typeof value === 'string') {
|
|
@@ -167,7 +172,8 @@ export const dataTypes = {
|
|
|
167
172
|
return null;
|
|
168
173
|
},
|
|
169
174
|
validate: (value, field) => {
|
|
170
|
-
const
|
|
175
|
+
const m = Config.Get('maxPasswordLength', maxPasswordLength);
|
|
176
|
+
const ml = Math.min(Math.max(field.maxlength, 0), m);
|
|
171
177
|
return value === null || typeof value === 'string' && (!ml || value.length <= ml)
|
|
172
178
|
},
|
|
173
179
|
anonymize: anonymizeText
|
|
@@ -317,7 +323,8 @@ export const dataTypes = {
|
|
|
317
323
|
relation: {
|
|
318
324
|
validate: (value, field) => {
|
|
319
325
|
if (field.multiple) {
|
|
320
|
-
|
|
326
|
+
const m = Config.Get('maxRelationsPerData', maxRelationsPerData);
|
|
327
|
+
return typeof (value) === 'object' || (Array.isArray(value) && value.length <= m && !value.some(v => {
|
|
321
328
|
return !isObjectId(v);
|
|
322
329
|
}));
|
|
323
330
|
}
|
|
@@ -346,12 +353,9 @@ export const dataTypes = {
|
|
|
346
353
|
}));
|
|
347
354
|
}
|
|
348
355
|
|
|
356
|
+
const m = Config.Get('maxFileSize', maxFileSize);
|
|
349
357
|
// Check if the file size is within the limit
|
|
350
|
-
|
|
351
|
-
return false;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
return true;
|
|
358
|
+
return value.size <= (field.maxSize || m);
|
|
355
359
|
}
|
|
356
360
|
|
|
357
361
|
return false; // Invalid type
|
|
@@ -367,17 +371,19 @@ export const dataTypes = {
|
|
|
367
371
|
},
|
|
368
372
|
color: {
|
|
369
373
|
validate: (value) => {
|
|
370
|
-
|
|
371
|
-
|
|
374
|
+
if (value === null) return true;
|
|
375
|
+
if (typeof value !== 'string') return false;
|
|
376
|
+
// Utilise tinycolor pour valider n'importe quel format de couleur supporté (hex, rgb, hsl, etc.)
|
|
377
|
+
return tinycolor(value).isValid();
|
|
372
378
|
},
|
|
373
379
|
filter: async (value) => {
|
|
374
|
-
|
|
375
|
-
|
|
380
|
+
if (!value) return null;
|
|
381
|
+
const color = tinycolor(value);
|
|
382
|
+
// Stocke dans un format canonique : HEX8 (#RRGGBBAA) pour supporter la transparence alpha
|
|
383
|
+
return color.isValid() ? color.toHex8String().toUpperCase() : null;
|
|
376
384
|
},
|
|
377
385
|
anonymize: () => {
|
|
378
|
-
return
|
|
379
|
-
format: 'hex'
|
|
380
|
-
});
|
|
386
|
+
return '#FFFFFFFF';
|
|
381
387
|
}
|
|
382
388
|
},
|
|
383
389
|
calculated: {
|
|
@@ -388,6 +394,37 @@ export const dataTypes = {
|
|
|
388
394
|
return value;
|
|
389
395
|
}
|
|
390
396
|
},
|
|
397
|
+
geolocation: {
|
|
398
|
+
validate: (value) => {
|
|
399
|
+
if (value === null) return true;
|
|
400
|
+
// Basic GeoJSON structure validation
|
|
401
|
+
if (typeof value !== 'object' || !value.type || !value.coordinates) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
// For now, we only validate 'Point' type, which is the most common.
|
|
405
|
+
if (value.type !== 'Point') {
|
|
406
|
+
// This can be extended to support 'Polygon', 'LineString', etc.
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
// Validate coordinates for a Point
|
|
410
|
+
return Array.isArray(value.coordinates) &&
|
|
411
|
+
value.coordinates.length === 2 &&
|
|
412
|
+
typeof value.coordinates[0] === 'number' &&
|
|
413
|
+
typeof value.coordinates[1] === 'number';
|
|
414
|
+
},
|
|
415
|
+
anonymize: () => {
|
|
416
|
+
// Generate random coordinates for a GeoJSON Point.
|
|
417
|
+
// Longitude: -180 to 180
|
|
418
|
+
// Latitude: -90 to 90
|
|
419
|
+
setSeed(new Date().getTime()+'');
|
|
420
|
+
const longitude = (getRand() * 360) - 180;
|
|
421
|
+
const latitude = (getRand() * 180) - 90;
|
|
422
|
+
return {
|
|
423
|
+
type: 'Point',
|
|
424
|
+
coordinates: [longitude, latitude]
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
},
|
|
391
428
|
richtext_t: {
|
|
392
429
|
validate: (value, field) => {
|
|
393
430
|
// La valeur doit être un objet (ou null/undefined)
|
|
@@ -418,6 +455,26 @@ let engine, logger;
|
|
|
418
455
|
export function onInit(defaultEngine) {
|
|
419
456
|
engine = defaultEngine;
|
|
420
457
|
logger = engine.getComponent(Logger);
|
|
458
|
+
|
|
459
|
+
// Nettoyer périodiquement les relations obsolètes
|
|
460
|
+
setInterval(() => {
|
|
461
|
+
// Nettoyer les relations pour les clés qui n'existent plus
|
|
462
|
+
for (let [key, cacheKeys] of cacheRelations.entries()) {
|
|
463
|
+
const validCacheKeys = new Set();
|
|
464
|
+
|
|
465
|
+
cacheKeys.forEach(cacheKey => {
|
|
466
|
+
if (searchCache.has(cacheKey)) {
|
|
467
|
+
validCacheKeys.add(cacheKey);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
if (validCacheKeys.size === 0) {
|
|
472
|
+
cacheRelations.delete(key);
|
|
473
|
+
} else {
|
|
474
|
+
cacheRelations.set(key, validCacheKeys);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}, 300000);
|
|
421
478
|
}
|
|
422
479
|
|
|
423
480
|
export const editModel = async (user, id, data) => {
|
|
@@ -426,6 +483,10 @@ export const editModel = async (user, id, data) => {
|
|
|
426
483
|
return ({success: false, error: i18n.t('api.permission.editModel', 'Cannot edit models from the API')})
|
|
427
484
|
}
|
|
428
485
|
|
|
486
|
+
// --- AMÉLIORATION ---
|
|
487
|
+
// Vider complètement le cache de recherche, car une modification de modèle (ex: renommage de champ)
|
|
488
|
+
// peut rendre invalide n'importe quelle recherche mise en cache.
|
|
489
|
+
flushSearchCache();
|
|
429
490
|
const dataModel = data;
|
|
430
491
|
try {
|
|
431
492
|
const collection = await getCollectionForUser(user);
|
|
@@ -459,43 +520,82 @@ export const editModel = async (user, id, data) => {
|
|
|
459
520
|
})
|
|
460
521
|
}
|
|
461
522
|
|
|
462
|
-
const coll = await getCollectionForUser(user);
|
|
463
|
-
// Update indexes
|
|
464
523
|
// Update indexes
|
|
465
524
|
if (await engine.userProvider.hasFeature(user, 'indexes')) {
|
|
525
|
+
const coll = await getCollectionForUser(user);
|
|
466
526
|
let indexes = [];
|
|
467
527
|
try {
|
|
468
|
-
// On essaie de récupérer les index existants
|
|
469
528
|
indexes = await coll.indexes();
|
|
470
529
|
} catch (e) {
|
|
471
|
-
// Si la collection n'existe pas, c'est normal.
|
|
472
|
-
// createIndex la créera. Il n'y a juste pas d'index à supprimer.
|
|
473
530
|
if (e.codeName !== 'NamespaceNotFound') {
|
|
474
|
-
throw e;
|
|
531
|
+
throw e;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const oldFields = el.fields || [];
|
|
536
|
+
const newFields = data.fields || [];
|
|
537
|
+
|
|
538
|
+
// --- Text Index Management (Compound) ---
|
|
539
|
+
const newTextFields = newFields.filter(f => f.index && f.indexType === 'text').map(f => f.name).sort();
|
|
540
|
+
const textIndexName = `_text_search_idx_${data.name}`;
|
|
541
|
+
const existingTextIndex = indexes.find(i => i.name === textIndexName);
|
|
542
|
+
const existingTextFields = existingTextIndex ? Object.keys(existingTextIndex.weights || {}).sort() : [];
|
|
543
|
+
|
|
544
|
+
// Check if the text index definition has changed
|
|
545
|
+
const textIndexChanged = JSON.stringify(existingTextFields) !== JSON.stringify(newTextFields);
|
|
546
|
+
|
|
547
|
+
if (textIndexChanged) {
|
|
548
|
+
if (existingTextIndex) {
|
|
549
|
+
logger.info(`[Index] Dropping existing text index '${textIndexName}' due to changes.`);
|
|
550
|
+
await coll.dropIndex(textIndexName);
|
|
551
|
+
}
|
|
552
|
+
if (newTextFields.length > 0) {
|
|
553
|
+
const textIndexSpec = newTextFields.reduce((acc, fieldName) => {
|
|
554
|
+
acc[fieldName] = 'text';
|
|
555
|
+
return acc;
|
|
556
|
+
}, {});
|
|
557
|
+
const indexOptions = {
|
|
558
|
+
name: textIndexName,
|
|
559
|
+
partialFilterExpression: { _model: data.name, _user: user.username }
|
|
560
|
+
};
|
|
561
|
+
logger.info(`[Index] Creating compound text index on fields: [${newTextFields.join(', ')}].`);
|
|
562
|
+
await coll.createIndex(textIndexSpec, indexOptions);
|
|
475
563
|
}
|
|
476
564
|
}
|
|
477
565
|
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
566
|
+
// --- Regular and 2dsphere Index Management (per field) ---
|
|
567
|
+
const managedFields = newFields.concat(oldFields.filter(oldField => !newFields.some(nf => nf.name === oldField.name)));
|
|
568
|
+
|
|
569
|
+
for (const field of managedFields) {
|
|
570
|
+
const oldField = oldFields.find(f => f.name === field.name);
|
|
571
|
+
const newField = newFields.find(f => f.name === field.name);
|
|
572
|
+
const fieldName = field.name;
|
|
573
|
+
|
|
574
|
+
// Skip text fields, they are handled above
|
|
575
|
+
if ((oldField?.indexType === 'text') || (newField?.indexType === 'text')) continue;
|
|
576
|
+
|
|
577
|
+
const wasIndexed = oldField?.index;
|
|
578
|
+
const isIndexed = newField?.index ?? false;
|
|
579
|
+
const oldIndexType = oldField?.indexType || 'regular';
|
|
580
|
+
const newIndexType = newField?.indexType || 'regular';
|
|
581
|
+
const indexName = `${fieldName}_${newIndexType}_idx`;
|
|
582
|
+
const existingIndex = indexes.find(i => i.key[fieldName] && i.name.startsWith(fieldName));
|
|
583
|
+
const indexExists = !!existingIndex;
|
|
584
|
+
const existingIndexTypeFromName = indexExists ? existingIndex.name.split('_')[1] : null;
|
|
482
585
|
|
|
483
|
-
const
|
|
484
|
-
i.partialFilterExpression?._model === el.name &&
|
|
485
|
-
i.partialFilterExpression?._user === user.username);
|
|
586
|
+
const needsUpdate = !newField || (isIndexed !== indexExists) || (isIndexed && indexExists && newIndexType !== existingIndexTypeFromName);
|
|
486
587
|
|
|
487
|
-
if (
|
|
488
|
-
if (
|
|
489
|
-
|
|
588
|
+
if (needsUpdate) {
|
|
589
|
+
if (existingIndex) {
|
|
590
|
+
logger.info(`[Index] Dropping existing index '${existingIndex.name}' for field '${fieldName}' due to changes or deletion.`);
|
|
591
|
+
await coll.dropIndex(existingIndex.name);
|
|
490
592
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
}
|
|
498
|
-
});
|
|
593
|
+
if (isIndexed && newField) {
|
|
594
|
+
const indexValue = newIndexType === '2dsphere' ? '2dsphere' : 1;
|
|
595
|
+
const indexSpec = { [fieldName]: indexValue };
|
|
596
|
+
const indexOptions = { name: indexName, partialFilterExpression: { _model: data.name, _user: user.username } };
|
|
597
|
+
logger.info(`[Index] Creating '${newIndexType}' index on field '${fieldName}'.`);
|
|
598
|
+
await coll.createIndex(indexSpec, indexOptions);
|
|
499
599
|
}
|
|
500
600
|
}
|
|
501
601
|
}
|
|
@@ -583,6 +683,10 @@ export const insertData = async (modelName, data, files, user, triggerWorkflow =
|
|
|
583
683
|
};
|
|
584
684
|
}
|
|
585
685
|
|
|
686
|
+
// Invalider le cache pour ce modèle puisque de nouvelles données ont été ajoutées.
|
|
687
|
+
invalidateModelCache(modelName);
|
|
688
|
+
|
|
689
|
+
|
|
586
690
|
// Convertir les IDs en ObjectId pour la recherche
|
|
587
691
|
const objectIds = insertedIds.map(id => new ObjectId(id));
|
|
588
692
|
const insertedDocs = await collection.find({_id: {$in: objectIds}}).toArray();
|
|
@@ -864,11 +968,13 @@ async function checkLimits(datas, model, collection, me) {
|
|
|
864
968
|
|
|
865
969
|
// Vérification nombre max de documents
|
|
866
970
|
const count = await collection.countDocuments({_user: me._user || me.username});
|
|
867
|
-
|
|
971
|
+
const m = Config.Get('maxTotalDataPerUser', maxTotalDataPerUser);
|
|
972
|
+
if (count + datas.length > m) {
|
|
868
973
|
throw new Error(i18n.t("api.data.tooManyData"));
|
|
869
974
|
}
|
|
870
975
|
|
|
871
|
-
|
|
976
|
+
const mp = Config.Get('maxPostData', maxPostData);
|
|
977
|
+
if (datas.length > mp) {
|
|
872
978
|
throw new Error(i18n.t('api.data.tooManyData'));
|
|
873
979
|
}
|
|
874
980
|
}
|
|
@@ -885,6 +991,18 @@ function calculateDataSize(datas) {
|
|
|
885
991
|
}
|
|
886
992
|
}
|
|
887
993
|
|
|
994
|
+
// Fonction utilitaire pour trouver les modèles liés
|
|
995
|
+
async function findRelatedModels(modelName, user) {
|
|
996
|
+
try {
|
|
997
|
+
return await modelsCollection.find({
|
|
998
|
+
"fields.relation": modelName,
|
|
999
|
+
_user: user.username
|
|
1000
|
+
}).toArray();
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
console.error("Error finding related models:", error);
|
|
1003
|
+
return [];
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
888
1006
|
export const patchData = async (modelName, filter, data, files, user, triggerWorkflow = true, waitForWorkflow = false) => {
|
|
889
1007
|
return await internalEditOrPatchData(modelName, filter, data, files, user, true, triggerWorkflow, waitForWorkflow);
|
|
890
1008
|
};
|
|
@@ -1054,6 +1172,18 @@ const internalEditOrPatchData = async (modelName, filter, data, files, user, isP
|
|
|
1054
1172
|
const bulkResult = await collection.bulkWrite(bulkOps);
|
|
1055
1173
|
const modifiedCount = bulkResult.modifiedCount || 0;
|
|
1056
1174
|
|
|
1175
|
+
if (modifiedCount > 0) {
|
|
1176
|
+
// Invalider le cache pour les IDs modifiés
|
|
1177
|
+
const idsToInvalidate = ids.map(id => id.toString());
|
|
1178
|
+
invalidateIdsCache(modelName, idsToInvalidate);
|
|
1179
|
+
// Invalider aussi les modèles qui pourraient référencer ces documents
|
|
1180
|
+
const relatedModels = await findRelatedModels(modelName, user);
|
|
1181
|
+
for (const relatedModel of relatedModels) {
|
|
1182
|
+
invalidateModelCache(relatedModel.name);
|
|
1183
|
+
console.log(`Also invalidated cache for related model: ${relatedModel.name}`);
|
|
1184
|
+
}
|
|
1185
|
+
console.log(`Invalidated cache for ${idsToInvalidate.length} documents in model: ${modelName}`);
|
|
1186
|
+
}
|
|
1057
1187
|
// Déclencher l'événement OnDataEdited avec les états avant/après
|
|
1058
1188
|
if (modifiedCount > 0) {
|
|
1059
1189
|
const updatedDocs = await collection.find({_id: {$in: ids}}).toArray();
|
|
@@ -1135,6 +1265,7 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
|
|
|
1135
1265
|
}
|
|
1136
1266
|
|
|
1137
1267
|
const finalIdsToDelete = []; // IDs des documents qui seront effectivement supprimés
|
|
1268
|
+
const idsToInvalidate = []; // *** AJOUT: IDs à invalider dans le cache ***
|
|
1138
1269
|
|
|
1139
1270
|
for (const docToDelete of documentsToDelete) {
|
|
1140
1271
|
const deletePromises = [];
|
|
@@ -1278,7 +1409,7 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
|
|
|
1278
1409
|
|
|
1279
1410
|
// Ajouter l'ID à la liste finale si la permission est accordée
|
|
1280
1411
|
finalIdsToDelete.push(docToDelete._id);
|
|
1281
|
-
|
|
1412
|
+
idsToInvalidate.push(docToDelete._id.toString());
|
|
1282
1413
|
} // Fin de la boucle sur documentsToDelete
|
|
1283
1414
|
|
|
1284
1415
|
// 3. Supprimer effectivement les documents (ceux pour lesquels on a la permission)
|
|
@@ -1288,6 +1419,9 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
|
|
|
1288
1419
|
_id: {$in: finalIdsToDelete}
|
|
1289
1420
|
// Le filtre _user est déjà implicite car on a fetch les documents de l'utilisateur
|
|
1290
1421
|
});
|
|
1422
|
+
invalidateIdsCache(modelName, idsToInvalidate);
|
|
1423
|
+
console.log(`Invalidated cache for ${idsToInvalidate.length} deleted documents in model: ${modelName}`);
|
|
1424
|
+
|
|
1291
1425
|
deletedCount = result.deletedCount;
|
|
1292
1426
|
logger.info(`[deleteData] Successfully deleted ${deletedCount} documents for user ${user?.username}.`);
|
|
1293
1427
|
} else {
|
|
@@ -1309,8 +1443,101 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
|
|
|
1309
1443
|
});
|
|
1310
1444
|
}
|
|
1311
1445
|
}
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
// List of operators that cannot be used inside $expr. $geoNear is handled separately
|
|
1449
|
+
// as it's a full stage, not just an operator.
|
|
1450
|
+
const specialOpKeys = ['$text', '$near', '$nearSphere', '$geoWithin', '$geoIntersects', '$regex'];
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Recursively checks if any part of a filter expression contains a special operator.
|
|
1454
|
+
* @param {*} expression - The filter expression or a part of it.
|
|
1455
|
+
* @returns {boolean}
|
|
1456
|
+
*/
|
|
1457
|
+
const containsSpecialOp = (expression) => {
|
|
1458
|
+
if (Array.isArray(expression)) {
|
|
1459
|
+
return expression.some(item => containsSpecialOp(item));
|
|
1460
|
+
}
|
|
1461
|
+
if (!isPlainObject(expression)) {
|
|
1462
|
+
return false;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
for (const key in expression) {
|
|
1466
|
+
if (specialOpKeys.includes(key)) {
|
|
1467
|
+
return true;
|
|
1468
|
+
}
|
|
1469
|
+
if (containsSpecialOp(expression[key])) {
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return false;
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Transforms a simple $find condition object into a full MongoDB aggregation expression.
|
|
1478
|
+
* e.g., { relatedValue: 101 } becomes { $eq: ['$$this.relatedValue', 101] }
|
|
1479
|
+
* e.g., { name: 'A', value: 1 } becomes { $and: [{ $eq: ['$$this.name', 'A'] }, { $eq: ['$$this.value', 1] }] }
|
|
1480
|
+
* @param {object} findCondition - The condition object from the $find operator.
|
|
1481
|
+
* @returns {object} The complete MongoDB aggregation expression.
|
|
1482
|
+
*/
|
|
1483
|
+
function transformFindShorthand(findCondition) {
|
|
1484
|
+
// If it's not a plain object, do nothing.
|
|
1485
|
+
if (typeof findCondition !== 'object' || findCondition === null || Array.isArray(findCondition)) {
|
|
1486
|
+
return findCondition;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
const keys = Object.keys(findCondition);
|
|
1490
|
+
|
|
1491
|
+
// If the object is empty or already contains MongoDB operators (keys starting with '$'),
|
|
1492
|
+
// assume it's already in the full format and don't modify it.
|
|
1493
|
+
if (keys.length === 0 || keys.some(key => key.startsWith('$'))) {
|
|
1494
|
+
return findCondition;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// It's the shorthand format. Transform it.
|
|
1498
|
+
// Create an $eq condition for each key/value pair.
|
|
1499
|
+
const conditions = keys.map(key => ({
|
|
1500
|
+
$eq: [`$$this.${key}`, findCondition[key]]
|
|
1501
|
+
}));
|
|
1502
|
+
|
|
1503
|
+
// If there's only one condition, return the $eq object directly.
|
|
1504
|
+
if (conditions.length === 1) return conditions[0];
|
|
1505
|
+
|
|
1506
|
+
// If there are multiple conditions, combine them with an $and.
|
|
1507
|
+
return { $and: conditions };
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// Fonction pour générer une clé de cache unique
|
|
1511
|
+
const generateCacheKey = (query, user) => {
|
|
1512
|
+
const keyData = {
|
|
1513
|
+
query: {
|
|
1514
|
+
page: query.page,
|
|
1515
|
+
limit: query.limit,
|
|
1516
|
+
sort: query.sort,
|
|
1517
|
+
model: query.model,
|
|
1518
|
+
pipelinesPosition: query.pipelinesPosition,
|
|
1519
|
+
customPipelines: query.pipelines || [],
|
|
1520
|
+
ids: query.ids,
|
|
1521
|
+
filter: query.filter,
|
|
1522
|
+
depth: query.depth,
|
|
1523
|
+
autoExpand: query.autoExpand,
|
|
1524
|
+
pack: query.pack
|
|
1525
|
+
},
|
|
1526
|
+
user: user.username
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
return crypto.createHash('sha256').update(JSON.stringify(keyData)).digest('hex');
|
|
1530
|
+
};
|
|
1531
|
+
|
|
1532
|
+
// Configuration du cache (TTL de 5 minutes par défaut)
|
|
1533
|
+
const searchCache = new NodeCache({
|
|
1534
|
+
stdTTL: 300, // 5 minutes
|
|
1535
|
+
checkperiod: 60, // Vérification des expirations toutes les 60 secondes
|
|
1536
|
+
useClones: false // Meilleures performances
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1312
1539
|
export const searchData = async (query, user) => {
|
|
1313
|
-
const {page, limit, sort, model, ids, timeout, pack} = query;
|
|
1540
|
+
const {page, limit, sort, model, pipelinesPosition, pipelines: customPipelines = [], ids, timeout, pack} = query;
|
|
1314
1541
|
|
|
1315
1542
|
if (user && user.username !== 'demo' && isLocalUser(user) && (
|
|
1316
1543
|
!await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_" + model], user) ||
|
|
@@ -1318,35 +1545,88 @@ export const searchData = async (query, user) => {
|
|
|
1318
1545
|
throw new Error(i18n.t('api.permission.searchData'));
|
|
1319
1546
|
}
|
|
1320
1547
|
|
|
1548
|
+
const cacheKey = generateCacheKey(query, user);
|
|
1549
|
+
// Vérifier si les données sont en cache
|
|
1550
|
+
const cachedData = searchCache.get(cacheKey);
|
|
1551
|
+
if (cachedData) {
|
|
1552
|
+
console.log('Cache hit for key:', cacheKey);
|
|
1553
|
+
return cachedData;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1321
1556
|
const collection = await getCollectionForUser(user);
|
|
1322
1557
|
const modelElement = await getModel(model, user);
|
|
1323
1558
|
|
|
1324
1559
|
const allIds = (ids || '').split(",").map(m => m.trim()).filter(Boolean).map(m => {
|
|
1325
1560
|
return new ObjectId(m);
|
|
1326
1561
|
});
|
|
1327
|
-
let
|
|
1562
|
+
let m = Config.Get('maxRequestData', maxRequestData);
|
|
1563
|
+
let l = Math.min(modelElement.maxRequestData || m, limit ? parseInt(limit, 10) : m)
|
|
1328
1564
|
let p = parseInt(page, 10);
|
|
1329
1565
|
let filter = query.filter || {};
|
|
1330
1566
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1567
|
+
// --- START: Added logic for special query operators ---
|
|
1568
|
+
const specialFilterOps = {};
|
|
1569
|
+
let standardFilter = {};
|
|
1570
|
+
|
|
1571
|
+
// Recursively separate special operators ($text, $nearSphere, etc.) that cannot be used within $expr
|
|
1572
|
+
for (const key in filter) {
|
|
1573
|
+
const value = filter[key];
|
|
1574
|
+
|
|
1575
|
+
// $geoNear is a special case; it's a stage and must be at the top level of the filter.
|
|
1576
|
+
if (key === '$geoNear') {
|
|
1577
|
+
specialFilterOps[key] = value;
|
|
1578
|
+
}
|
|
1579
|
+
// For logical operators, we split their child arrays based on whether they contain special ops.
|
|
1580
|
+
else if ((key === '$and' || key === '$or' || key === '$nor') && Array.isArray(value)) {
|
|
1581
|
+
const hasSpecial = value.some(child => containsSpecialOp(child));
|
|
1582
|
+
|
|
1583
|
+
// If a logical operator contains any condition with a special operator (like $regex or a geo-op),
|
|
1584
|
+
// the entire logical block must be processed in a standard `$match` stage.
|
|
1585
|
+
// This is because splitting the block would break the original logic (e.g., an `$or` would become an `$and`).
|
|
1586
|
+
if (hasSpecial) {
|
|
1587
|
+
specialFilterOps[key] = value;
|
|
1588
|
+
} else {
|
|
1589
|
+
// Otherwise, the entire block is "standard" and can be processed by the $expr-based logic.
|
|
1590
|
+
standardFilter[key] = value;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
// For other keys, check if the expression {key: value} contains a special op.
|
|
1594
|
+
else if (containsSpecialOp({ [key]: value })) {
|
|
1595
|
+
specialFilterOps[key] = value;
|
|
1596
|
+
} else {
|
|
1597
|
+
standardFilter[key] = value;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// The rest of the function will use `standardFilter` for the recursive lookup.
|
|
1602
|
+
filter = standardFilter;
|
|
1603
|
+
// --- END: Added logic for special query operators ---
|
|
1604
|
+
|
|
1605
|
+
let sortObj = null; // Initialize to null
|
|
1606
|
+
if (sort) {
|
|
1607
|
+
sortObj = {};
|
|
1608
|
+
sort.split(',').forEach(s => {
|
|
1609
|
+
const v = s.split(':');
|
|
1610
|
+
sortObj[v[0] || s] = v[1] === 'DESC' ? -1 : 1;
|
|
1611
|
+
});
|
|
1338
1612
|
}
|
|
1339
1613
|
|
|
1340
1614
|
let i = 0;
|
|
1341
1615
|
const f = {...filter};
|
|
1342
1616
|
|
|
1343
|
-
let
|
|
1617
|
+
let mf = Config.Get('maxFilterDepth', maxFilterDepth);
|
|
1618
|
+
let depthParam = Math.max(1, Math.min(mf, typeof (query.depth) === 'string' ? parseInt(query.depth) : (typeof (query.depth) === 'number' ? query.depth : 1)));
|
|
1344
1619
|
let autoExpand = typeof (query.autoExpand) === 'undefined' || (typeof (query.autoExpand) === 'string' && ['1', 'true'].includes(query.autoExpand.toLowerCase()));
|
|
1345
1620
|
|
|
1346
1621
|
const recursiveLookup = async (model, data, depth = 1, already = [], parentPath = '') => {
|
|
1347
1622
|
|
|
1348
|
-
if (depth > depthParam)
|
|
1623
|
+
if (depth > depthParam) {
|
|
1349
1624
|
return [];
|
|
1625
|
+
}
|
|
1626
|
+
// Handle null, array, or other non-object data gracefully to prevent crashes.
|
|
1627
|
+
if (!isPlainObject(data)) {
|
|
1628
|
+
return [];
|
|
1629
|
+
}
|
|
1350
1630
|
|
|
1351
1631
|
let pipelines = [], pipelinesLookups = [];
|
|
1352
1632
|
let modelElement;
|
|
@@ -1364,13 +1644,14 @@ export const searchData = async (query, user) => {
|
|
|
1364
1644
|
if (!field || !name)
|
|
1365
1645
|
return {};
|
|
1366
1646
|
if (field.type === "relation") {
|
|
1647
|
+
const findCondition = transformFindShorthand(d);
|
|
1367
1648
|
const dt = {
|
|
1368
1649
|
'$ne': [
|
|
1369
1650
|
{
|
|
1370
1651
|
'$filter': {
|
|
1371
1652
|
'input': (depth === 1 ? "$" + name : "$this." + name),
|
|
1372
1653
|
'as': 'this',
|
|
1373
|
-
'cond':
|
|
1654
|
+
'cond': findCondition
|
|
1374
1655
|
}
|
|
1375
1656
|
}
|
|
1376
1657
|
, []]
|
|
@@ -1396,16 +1677,13 @@ export const searchData = async (query, user) => {
|
|
|
1396
1677
|
return v <= v2 ? -1 : (t1 <= t2 ? -1 : 1);
|
|
1397
1678
|
})) {
|
|
1398
1679
|
|
|
1399
|
-
// **Circular Reference Check:**
|
|
1400
1680
|
if (already.includes(fi.relation)) {
|
|
1401
|
-
// Skip the lookup if we've already processed this relation in the current chain.
|
|
1402
1681
|
console.warn(`Skipping circular reference to model: ${fi.relation}`);
|
|
1403
1682
|
continue;
|
|
1404
1683
|
}
|
|
1405
1684
|
const relSort = {};
|
|
1406
1685
|
if (fi.type === 'relation' && depthParam !== 1) {
|
|
1407
|
-
|
|
1408
|
-
if (sortObj[fi.name]) {
|
|
1686
|
+
if (sortObj?.[fi.name]) {
|
|
1409
1687
|
|
|
1410
1688
|
const sortColumn = await getModel(fi.relation, user);
|
|
1411
1689
|
let t = sortColumn.fields.find(f => f.asMain)?.name;
|
|
@@ -1428,7 +1706,6 @@ export const searchData = async (query, user) => {
|
|
|
1428
1706
|
}
|
|
1429
1707
|
}
|
|
1430
1708
|
|
|
1431
|
-
// Création du lookup si l'expand est activé
|
|
1432
1709
|
++i;
|
|
1433
1710
|
const lookup = {
|
|
1434
1711
|
$lookup: {
|
|
@@ -1448,7 +1725,7 @@ export const searchData = async (query, user) => {
|
|
|
1448
1725
|
fi.multiple ? {
|
|
1449
1726
|
$in: [{$toString: "$_id"}, {
|
|
1450
1727
|
$map: {
|
|
1451
|
-
input: {$ifNull: ["$$convertedId", []]},
|
|
1728
|
+
input: {$ifNull: ["$$convertedId", []]},
|
|
1452
1729
|
as: "relationId",
|
|
1453
1730
|
in: {$toString: "$$relationId"}
|
|
1454
1731
|
}
|
|
@@ -1464,15 +1741,16 @@ export const searchData = async (query, user) => {
|
|
|
1464
1741
|
}
|
|
1465
1742
|
}
|
|
1466
1743
|
},
|
|
1467
|
-
{$limit: maxRelationsPerData}
|
|
1744
|
+
{$limit: Config.Get('maxRelationsPerData', maxRelationsPerData)}
|
|
1468
1745
|
]
|
|
1469
1746
|
}
|
|
1470
1747
|
};
|
|
1471
1748
|
|
|
1472
1749
|
pipelinesLookups.push(lookup);
|
|
1473
|
-
pipelinesLookups.push({$limit: Math.floor(maxTotalDataPerUser)});
|
|
1474
1750
|
|
|
1475
|
-
|
|
1751
|
+
const m = Config.Get('maxTotalDataPerUser', maxTotalDataPerUser);
|
|
1752
|
+
pipelinesLookups.push({$limit: Math.floor(m)});
|
|
1753
|
+
|
|
1476
1754
|
const currentPath = parentPath ? `${parentPath}_${fi.name}` : fi.name;
|
|
1477
1755
|
fi.path = currentPath;
|
|
1478
1756
|
pipelinesLookups.push(
|
|
@@ -1489,7 +1767,6 @@ export const searchData = async (query, user) => {
|
|
|
1489
1767
|
}
|
|
1490
1768
|
);
|
|
1491
1769
|
|
|
1492
|
-
//found = true;
|
|
1493
1770
|
pipelinesLookups.push(
|
|
1494
1771
|
{$project: {['items' + i]: 0}}
|
|
1495
1772
|
);
|
|
@@ -1517,13 +1794,10 @@ export const searchData = async (query, user) => {
|
|
|
1517
1794
|
);
|
|
1518
1795
|
|
|
1519
1796
|
} else if (fi.type === 'file') {
|
|
1520
|
-
// Logique pour enrichir un champ fichier unique
|
|
1521
|
-
|
|
1522
|
-
// Stage 1: Lookup file details from the 'files' collection
|
|
1523
1797
|
pipelinesLookups.push({
|
|
1524
1798
|
$lookup: {
|
|
1525
|
-
from: "files",
|
|
1526
|
-
let: {fileGuid: '$' + fi.name},
|
|
1799
|
+
from: "files",
|
|
1800
|
+
let: {fileGuid: '$' + fi.name},
|
|
1527
1801
|
pipeline: [
|
|
1528
1802
|
{
|
|
1529
1803
|
$match: {
|
|
@@ -1535,95 +1809,75 @@ export const searchData = async (query, user) => {
|
|
|
1535
1809
|
}
|
|
1536
1810
|
}
|
|
1537
1811
|
},
|
|
1538
|
-
{$limit: 1}
|
|
1812
|
+
{$limit: 1}
|
|
1539
1813
|
],
|
|
1540
|
-
as: fi.name + "_details_temp"
|
|
1814
|
+
as: fi.name + "_details_temp"
|
|
1541
1815
|
}
|
|
1542
1816
|
});
|
|
1543
1817
|
|
|
1544
|
-
// Stage 2: Replace the original GUID string with the fetched file object (or null if not found)
|
|
1545
1818
|
pipelinesLookups.push({
|
|
1546
1819
|
$addFields: {
|
|
1547
1820
|
[fi.name]: {
|
|
1548
|
-
// $lookup returns an array, take the first element.
|
|
1549
|
-
// If lookup result is empty or null, set the field to null.
|
|
1550
1821
|
$ifNull: [{$first: '$' + fi.name + "_details_temp"}, null]
|
|
1551
1822
|
}
|
|
1552
1823
|
}
|
|
1553
1824
|
});
|
|
1554
1825
|
|
|
1555
|
-
// Stage 3: Clean up the temporary lookup field
|
|
1556
1826
|
pipelinesLookups.push({
|
|
1557
1827
|
$project: {
|
|
1558
1828
|
[fi.name + "_details_temp"]: 0
|
|
1559
1829
|
}
|
|
1560
1830
|
});
|
|
1561
1831
|
} else if (fi.type === 'array' && fi.itemsType === 'file' && depthParam !== 1) {
|
|
1562
|
-
// This field (e.g., 'myImageGallery') stores an array of GUID strings: ["guid1", "guid2"]
|
|
1563
1832
|
pipelinesLookups.push(
|
|
1564
1833
|
{
|
|
1565
1834
|
$lookup: {
|
|
1566
|
-
from: "files",
|
|
1567
|
-
let: {localGuidsArray: '$' + fi.name},
|
|
1835
|
+
from: "files",
|
|
1836
|
+
let: {localGuidsArray: '$' + fi.name},
|
|
1568
1837
|
pipeline: [
|
|
1569
1838
|
{
|
|
1570
1839
|
$match: {
|
|
1571
1840
|
$expr: {
|
|
1572
|
-
|
|
1573
|
-
$in: ['$guid', {$ifNull: ['$$localGuidsArray', []]}] // Handle null or missing array
|
|
1841
|
+
$in: ['$guid', {$ifNull: ['$$localGuidsArray', []]}]
|
|
1574
1842
|
}
|
|
1575
1843
|
}
|
|
1576
1844
|
}
|
|
1577
|
-
// Optional: Project only necessary fields from the "files" collection if needed
|
|
1578
|
-
// {
|
|
1579
|
-
// $project: {
|
|
1580
|
-
// _id: 0, // Exclude MongoDB's _id from the 'files' collection documents
|
|
1581
|
-
// // mainUser: 0, // Example: if you don't need these in the result
|
|
1582
|
-
// // user: 0,
|
|
1583
|
-
// // _model:0, // The _model "privateFile" might not be useful here
|
|
1584
|
-
// // Keep: guid, filename (as name), mimetype, size, timestamp etc.
|
|
1585
|
-
// }
|
|
1586
|
-
// }
|
|
1587
1845
|
],
|
|
1588
|
-
as: fi.name + "_details_temp"
|
|
1846
|
+
as: fi.name + "_details_temp"
|
|
1589
1847
|
}
|
|
1590
1848
|
},
|
|
1591
|
-
// The following $addFields and $project stages are what you had
|
|
1592
|
-
// in your fi.type === 'file' block, and they are correct for this array scenario.
|
|
1593
1849
|
{
|
|
1594
1850
|
$addFields: {
|
|
1595
|
-
[fi.name]: {
|
|
1596
|
-
$ifNull: [
|
|
1851
|
+
[fi.name]: {
|
|
1852
|
+
$ifNull: [
|
|
1597
1853
|
{
|
|
1598
1854
|
$map: {
|
|
1599
|
-
input: '$' + fi.name,
|
|
1600
|
-
as: "originalGuidString",
|
|
1855
|
+
input: '$' + fi.name,
|
|
1856
|
+
as: "originalGuidString",
|
|
1601
1857
|
in: {
|
|
1602
1858
|
$let: {
|
|
1603
1859
|
vars: {
|
|
1604
|
-
// Trouver le détail correspondant dans _details_temp par GUID
|
|
1605
1860
|
matchedDetail: {
|
|
1606
1861
|
$arrayElemAt: [
|
|
1607
1862
|
{
|
|
1608
1863
|
$filter: {
|
|
1609
|
-
input: '$' + fi.name + "_details_temp",
|
|
1610
|
-
as: "detailFile",
|
|
1864
|
+
input: '$' + fi.name + "_details_temp",
|
|
1865
|
+
as: "detailFile",
|
|
1611
1866
|
cond: {$eq: ["$$detailFile.guid", "$$originalGuidString"]}
|
|
1612
1867
|
}
|
|
1613
1868
|
},
|
|
1614
|
-
0
|
|
1869
|
+
0
|
|
1615
1870
|
]
|
|
1616
1871
|
}
|
|
1617
1872
|
},
|
|
1618
1873
|
in: {
|
|
1619
1874
|
$cond: {
|
|
1620
|
-
if: '$$matchedDetail',
|
|
1621
|
-
then: '$$matchedDetail',
|
|
1622
|
-
else: {
|
|
1623
|
-
guid: '$$originalGuidString',
|
|
1624
|
-
name: null,
|
|
1875
|
+
if: '$$matchedDetail',
|
|
1876
|
+
then: '$$matchedDetail',
|
|
1877
|
+
else: {
|
|
1878
|
+
guid: '$$originalGuidString',
|
|
1879
|
+
name: null,
|
|
1625
1880
|
_error: "File details not found"
|
|
1626
|
-
// Ou simplement: '$$originalGuidString' si vous voulez juste garder la chaîne
|
|
1627
1881
|
}
|
|
1628
1882
|
}
|
|
1629
1883
|
}
|
|
@@ -1631,13 +1885,13 @@ export const searchData = async (query, user) => {
|
|
|
1631
1885
|
}
|
|
1632
1886
|
}
|
|
1633
1887
|
},
|
|
1634
|
-
[]
|
|
1888
|
+
[]
|
|
1635
1889
|
]
|
|
1636
1890
|
}
|
|
1637
1891
|
}
|
|
1638
1892
|
},
|
|
1639
1893
|
{
|
|
1640
|
-
$project: {
|
|
1894
|
+
$project: {
|
|
1641
1895
|
[fi.name + "_details_temp"]: 0
|
|
1642
1896
|
}
|
|
1643
1897
|
}
|
|
@@ -1645,28 +1899,19 @@ export const searchData = async (query, user) => {
|
|
|
1645
1899
|
} else if (fi.type === 'calculated' && fi.calculation && fi.calculation.pipeline && fi.calculation.final) {
|
|
1646
1900
|
const calcPipelineAbstract = fi.calculation.pipeline;
|
|
1647
1901
|
const calcFinalFieldName = fi.calculation.final;
|
|
1648
|
-
const tempLookupsForThisCalcField = [];
|
|
1902
|
+
const tempLookupsForThisCalcField = [];
|
|
1649
1903
|
|
|
1650
|
-
// Ajouter les étapes $lookup définies par le calcul
|
|
1651
1904
|
if (calcPipelineAbstract.lookups && calcPipelineAbstract.lookups.length > 0) {
|
|
1652
1905
|
for (const lookupDef of calcPipelineAbstract.lookups) {
|
|
1653
|
-
// ... (votre logique existante de vérification de foreignModel et localField) ...
|
|
1654
|
-
// Assurez-vous que cette logique est robuste comme discuté précédemment.
|
|
1655
|
-
// Si une erreur se produit ici (foreignModel non trouvé, etc.),
|
|
1656
|
-
// vous ajoutez déjà un $addFields pour initialiser lookupDef.as à null/[]
|
|
1657
|
-
// et vous faites 'continue'. C'est bien.
|
|
1658
|
-
|
|
1659
|
-
// Si tout va bien, on construit le lookup :
|
|
1660
|
-
const targetCollectionName = await getUserCollectionName(user);
|
|
1661
|
-
const localFieldValueInPipeline = `$${lookupDef.localField}`;
|
|
1662
|
-
|
|
1663
|
-
// Vérification basique du localField (déjà présente dans votre code précédent)
|
|
1664
1906
|
if (!lookupDef.localField || typeof lookupDef.localField !== 'string' || lookupDef.localField.trim() === '') {
|
|
1665
1907
|
logger.warn(`[Calculated Field Error] ... localField ... invalide ...`);
|
|
1666
1908
|
pipelinesLookups.push({$addFields: {[lookupDef.as]: lookupDef.isMultiple ? [] : null}});
|
|
1667
1909
|
continue;
|
|
1668
1910
|
}
|
|
1669
1911
|
|
|
1912
|
+
const targetCollectionName = await getUserCollectionName(user);
|
|
1913
|
+
const localFieldValueInPipeline = `$${lookupDef.localField}`;
|
|
1914
|
+
|
|
1670
1915
|
const mongoLookupStage = {
|
|
1671
1916
|
$lookup: {
|
|
1672
1917
|
from: targetCollectionName,
|
|
@@ -1685,13 +1930,12 @@ export const searchData = async (query, user) => {
|
|
|
1685
1930
|
}
|
|
1686
1931
|
}
|
|
1687
1932
|
}
|
|
1688
|
-
// Optionnel: Projeter uniquement les champs nécessaires
|
|
1689
1933
|
],
|
|
1690
1934
|
as: lookupDef.as
|
|
1691
1935
|
}
|
|
1692
1936
|
};
|
|
1693
1937
|
pipelinesLookups.push(mongoLookupStage);
|
|
1694
|
-
tempLookupsForThisCalcField.push(lookupDef.as);
|
|
1938
|
+
tempLookupsForThisCalcField.push(lookupDef.as);
|
|
1695
1939
|
|
|
1696
1940
|
if (!lookupDef.isMultiple) {
|
|
1697
1941
|
pipelinesLookups.push({
|
|
@@ -1704,38 +1948,30 @@ export const searchData = async (query, user) => {
|
|
|
1704
1948
|
}
|
|
1705
1949
|
}
|
|
1706
1950
|
|
|
1707
|
-
// Ajouter l'étape $addFields pour les calculs eux-mêmes
|
|
1708
1951
|
if (calcPipelineAbstract.addFields && Object.keys(calcPipelineAbstract.addFields).length > 0) {
|
|
1709
1952
|
const addFields = Object.keys(calcPipelineAbstract.addFields).map(m => ({$addFields: {[m]: calcPipelineAbstract.addFields[m]}}));
|
|
1710
1953
|
pipelinesLookups = pipelinesLookups.concat(addFields);
|
|
1711
1954
|
}
|
|
1712
1955
|
|
|
1713
|
-
// S'assurer que le champ final du calcul (calcFinalFieldName) est bien accessible
|
|
1714
|
-
// sous le nom du champ du modèle (fi.name).
|
|
1715
1956
|
if (calcFinalFieldName !== fi.name) {
|
|
1716
1957
|
pipelinesLookups.push({$addFields: {[fi.name]: `$${calcFinalFieldName}`}});
|
|
1717
1958
|
}
|
|
1718
1959
|
|
|
1719
|
-
// --- NOUVEAU : Supprimer les champs de lookup temporaires pour CE champ calculé ---
|
|
1720
1960
|
if (tempLookupsForThisCalcField.length > 0) {
|
|
1721
1961
|
const unsetProjection = {};
|
|
1722
1962
|
for (const tempField of tempLookupsForThisCalcField) {
|
|
1723
|
-
|
|
1724
|
-
// empêche que outputAlias (et donc calcFinalFieldName, et donc fi.name)
|
|
1725
|
-
// soit un de ces champs temporaires.
|
|
1726
|
-
unsetProjection[tempField] = 0; // 0 signifie supprimer/exclure le champ
|
|
1963
|
+
unsetProjection[tempField] = 0;
|
|
1727
1964
|
}
|
|
1728
1965
|
if (Object.keys(unsetProjection).length > 0) {
|
|
1729
1966
|
pipelinesLookups.push({$project: unsetProjection});
|
|
1730
1967
|
}
|
|
1731
1968
|
}
|
|
1732
1969
|
} else if (fi.type === 'array') {
|
|
1733
|
-
// Handle array filtering here
|
|
1734
1970
|
if (data[fi.name]) {
|
|
1735
1971
|
pipelines.push({
|
|
1736
1972
|
$match: {
|
|
1737
1973
|
$expr: {
|
|
1738
|
-
$in: [data[fi.name], '$' + fi.name]
|
|
1974
|
+
$in: [data[fi.name], '$' + fi.name]
|
|
1739
1975
|
}
|
|
1740
1976
|
}
|
|
1741
1977
|
});
|
|
@@ -1753,64 +1989,256 @@ export const searchData = async (query, user) => {
|
|
|
1753
1989
|
}
|
|
1754
1990
|
}
|
|
1755
1991
|
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
return pipelines.concat([{$match: {'_pack': pack ? pack : {$exists: false}}}, {$match: {$expr: dataNoRelation}}], pipelinesLookups, addFields, [{$match: {$expr: {$and: dataRelationF}}}]);
|
|
1992
|
+
return pipelines.concat(
|
|
1993
|
+
[
|
|
1994
|
+
{$match: {'_pack': pack ? pack : {$exists: false}}},
|
|
1995
|
+
{$match: {$expr: dataNoRelation}}
|
|
1996
|
+
],
|
|
1997
|
+
customPipelines, // ← INTÉGRATION DES PIPELINES PERSONNALISÉES
|
|
1998
|
+
pipelinesLookups,
|
|
1999
|
+
[{$match: {$expr: {$and: dataRelationF}}}]
|
|
2000
|
+
);
|
|
1766
2001
|
};
|
|
1767
2002
|
|
|
1768
2003
|
let pipelines = [];
|
|
1769
|
-
if (allIds.length) {
|
|
1770
|
-
const id = {$in: ["$_id", allIds.map(m => new ObjectId(m))]};
|
|
1771
|
-
pipelines.push({
|
|
1772
|
-
$match: {$expr: id}
|
|
1773
|
-
});
|
|
1774
2004
|
|
|
1775
|
-
|
|
2005
|
+
// --- START: Modified pipeline construction for special operators ---
|
|
2006
|
+
if (specialFilterOps.$geoNear && (Object.values(specialFilterOps).some(v => v?.$nearSphere) || specialFilterOps.$text)) {
|
|
2007
|
+
throw new Error("A $geoNear stage cannot be combined with $nearSphere or $text operators in the same query.");
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// --- Strategy ---
|
|
2011
|
+
// 1. If a $nearSphere operator is found, convert it to a $geoNear stage. This must be the first stage.
|
|
2012
|
+
// Other special filters (like $regex) will be moved into the `query` part of the $geoNear stage.
|
|
2013
|
+
// 2. If a user-provided $geoNear stage exists, use it. It must be the first stage.
|
|
2014
|
+
// 3. If a $text operator is found, it must be in the first $match stage. It is mutually exclusive with geo-queries.
|
|
2015
|
+
|
|
2016
|
+
let nearSphereField = null;
|
|
2017
|
+
let nearSphereKey = null;
|
|
2018
|
+
for (const key in specialFilterOps) {
|
|
2019
|
+
if (isPlainObject(specialFilterOps[key]) && specialFilterOps[key].$nearSphere) {
|
|
2020
|
+
if (nearSphereField) {
|
|
2021
|
+
throw new Error("Query cannot contain multiple $nearSphere operators. Use a single $geoNear stage for complex geo-queries.");
|
|
2022
|
+
}
|
|
2023
|
+
nearSphereField = specialFilterOps[key].$nearSphere;
|
|
2024
|
+
nearSphereKey = key;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
1776
2027
|
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
2028
|
+
if (nearSphereField) {
|
|
2029
|
+
// A $geoNear stage must be the first stage.
|
|
2030
|
+
if (specialFilterOps.$geoNear || specialFilterOps.$text) {
|
|
2031
|
+
throw new Error("Cannot use $nearSphere with a $geoNear stage or a $text operator.");
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
const geoNearStage = {
|
|
2035
|
+
near: nearSphereField.$geometry,
|
|
2036
|
+
distanceField: "distance", // Default distance field name
|
|
2037
|
+
key: nearSphereKey, // The field to perform the search on
|
|
2038
|
+
spherical: true,
|
|
2039
|
+
query: { // Base query for model and user
|
|
2040
|
+
_model: modelElement.name,
|
|
2041
|
+
_user: user.username
|
|
2042
|
+
}
|
|
2043
|
+
};
|
|
2044
|
+
|
|
2045
|
+
// Conditionally add distance fields to avoid passing 'undefined' to MongoDB
|
|
2046
|
+
if (nearSphereField.$maxDistance !== undefined) {
|
|
2047
|
+
geoNearStage.maxDistance = nearSphereField.$maxDistance;
|
|
2048
|
+
}
|
|
2049
|
+
if (nearSphereField.$minDistance !== undefined) {
|
|
2050
|
+
geoNearStage.minDistance = nearSphereField.$minDistance;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Remove the processed nearSphere operator from specialFilterOps
|
|
2054
|
+
delete specialFilterOps[nearSphereKey];
|
|
2055
|
+
|
|
2056
|
+
// Add any other special filters (like $regex) to the $geoNear query
|
|
2057
|
+
Object.assign(geoNearStage.query, specialFilterOps);
|
|
2058
|
+
|
|
2059
|
+
if (allIds.length > 0) {
|
|
2060
|
+
geoNearStage.query._id = { $in: allIds };
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
pipelines.push({ $geoNear: geoNearStage });
|
|
2064
|
+
|
|
2065
|
+
// Clear specialFilterOps as they've all been moved into the $geoNear query
|
|
2066
|
+
Object.keys(specialFilterOps).forEach(key => delete specialFilterOps[key]);
|
|
2067
|
+
|
|
2068
|
+
} else if (specialFilterOps.$geoNear) {
|
|
2069
|
+
// Handle a user-provided $geoNear stage
|
|
2070
|
+
const geoNearStage = { ...specialFilterOps.$geoNear };
|
|
2071
|
+
geoNearStage.query = {
|
|
2072
|
+
...(geoNearStage.query || {}),
|
|
2073
|
+
_model: modelElement.name,
|
|
2074
|
+
_user: user.username
|
|
2075
|
+
};
|
|
2076
|
+
if (allIds.length > 0) {
|
|
2077
|
+
geoNearStage.query._id = { $in: allIds };
|
|
2078
|
+
}
|
|
2079
|
+
pipelines.push({ $geoNear: geoNearStage });
|
|
2080
|
+
delete specialFilterOps.$geoNear;
|
|
2081
|
+
|
|
2082
|
+
} else if (Object.keys(specialFilterOps).length > 0) {
|
|
2083
|
+
// Handle other special operators like $text and $regex if no geo-query was present.
|
|
2084
|
+
const standardMatchQueries = {};
|
|
2085
|
+
if (specialFilterOps.$text) {
|
|
2086
|
+
standardMatchQueries.$text = specialFilterOps.$text;
|
|
2087
|
+
delete specialFilterOps.$text;
|
|
2088
|
+
}
|
|
2089
|
+
Object.assign(standardMatchQueries, specialFilterOps);
|
|
2090
|
+
|
|
2091
|
+
standardMatchQueries._model = modelElement.name;
|
|
2092
|
+
standardMatchQueries._user = user.username;
|
|
2093
|
+
if (allIds.length > 0) {
|
|
2094
|
+
standardMatchQueries._id = { $in: allIds };
|
|
2095
|
+
}
|
|
2096
|
+
pipelines.push({ $match: standardMatchQueries });
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// Add the original initial match logic, but only if no pipeline stages have been created yet.
|
|
2100
|
+
if (pipelines.length === 0) {
|
|
2101
|
+
if (allIds.length) {
|
|
2102
|
+
// Note: allIds are already ObjectIds from the start of the function.
|
|
2103
|
+
const id = {$in: ["$_id", allIds]};
|
|
2104
|
+
pipelines.push({
|
|
2105
|
+
$match: {$expr: id}
|
|
2106
|
+
});
|
|
2107
|
+
} else {
|
|
2108
|
+
pipelines.push(
|
|
2109
|
+
{
|
|
2110
|
+
$match: {
|
|
2111
|
+
$expr: {
|
|
2112
|
+
$and: [
|
|
2113
|
+
{$eq: ["$_model", modelElement.name]},
|
|
2114
|
+
{$eq: ["$_user", user.username]}
|
|
2115
|
+
]
|
|
2116
|
+
}
|
|
1783
2117
|
}
|
|
1784
2118
|
}
|
|
1785
|
-
|
|
1786
|
-
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
// --- END: Modified pipeline construction ---
|
|
2123
|
+
|
|
2124
|
+
// Intégration des pipelines personnalisés au début si nécessaire
|
|
2125
|
+
if (customPipelines.length > 0 && pipelinesPosition === 'start') {
|
|
2126
|
+
pipelines = pipelines.concat(customPipelines);
|
|
1787
2127
|
}
|
|
1788
2128
|
|
|
1789
2129
|
pipelines = pipelines.concat(await recursiveLookup(model, filter, 1, []));
|
|
2130
|
+
|
|
2131
|
+
// Intégration des pipelines personnalisés à la fin si nécessaire
|
|
2132
|
+
if (customPipelines.length > 0 && pipelinesPosition !== 'start') {
|
|
2133
|
+
pipelines = pipelines.concat(customPipelines);
|
|
2134
|
+
}
|
|
2135
|
+
|
|
1790
2136
|
if (depthParam) {
|
|
1791
2137
|
pipelines.push({$project: {_user: 0}});
|
|
1792
2138
|
pipelines.push({$project: {_model: 0}});
|
|
1793
2139
|
}
|
|
1794
2140
|
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
// 4. Exécuter la pipeline
|
|
1798
|
-
const ts = parseInt(timeout, 10) / 2.0 || searchRequestTimeout;
|
|
2141
|
+
const mt = Config.Get('searchRequestTimeout', searchRequestTimeout);
|
|
2142
|
+
const ts = parseInt(timeout, 10) / 2.0 || mt;
|
|
1799
2143
|
const count = await collection.aggregate([...pipelines, {$count: "count"}]).maxTimeMS(ts).toArray();
|
|
1800
2144
|
let prom = collection.aggregate(pipelines).maxTimeMS(ts);
|
|
1801
2145
|
|
|
1802
|
-
|
|
2146
|
+
// Apply sort logic:
|
|
2147
|
+
// 1. If a user-defined sort exists, use it.
|
|
2148
|
+
// 2. If not, and if there's no $geoNear stage (which has implicit sort), apply a default sort.
|
|
2149
|
+
if (sortObj) {
|
|
1803
2150
|
prom.sort(sortObj);
|
|
2151
|
+
} else if (!pipelines.some(stage => stage.$geoNear)) {
|
|
2152
|
+
const defaultSort = { [modelElement.fields[0]?.name || '_id']: ['datetime', 'date'].includes(modelElement.fields[0].type) ? -1 : 1 };
|
|
2153
|
+
prom.sort(defaultSort);
|
|
1804
2154
|
}
|
|
1805
2155
|
prom.skip(p ? (p - 1) * l : 0).limit(l);
|
|
1806
2156
|
let data = await prom.toArray();
|
|
1807
2157
|
data = await handleFields(modelElement, data, user);
|
|
1808
2158
|
|
|
1809
2159
|
const res = {data, count: count[0]?.count || 0};
|
|
2160
|
+
|
|
2161
|
+
// Mettre en cache le résultat
|
|
2162
|
+
searchCache.set(cacheKey, res);
|
|
2163
|
+
|
|
2164
|
+
// Stocker les clés de cache associées aux modèles et IDs pour l'invalidation
|
|
2165
|
+
storeCacheRelations(cacheKey, model, data.map(item => item._id.toString()));
|
|
2166
|
+
|
|
1810
2167
|
const plugin = await Event.Trigger("OnDataSearched", "event", "system", engine, {data, count: count[0]?.count});
|
|
1811
2168
|
await Event.Trigger("OnDataSearched", "event", "user", plugin || {data, count: count[0]?.count});
|
|
1812
2169
|
return plugin || res;
|
|
1813
2170
|
}
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
|
+
// Map pour stocker les relations entre les données et les clés de cache
|
|
2174
|
+
const cacheRelations = new Map();
|
|
2175
|
+
|
|
2176
|
+
// Stocker les relations cache-données
|
|
2177
|
+
const storeCacheRelations = (cacheKey, model, dataIds) => {
|
|
2178
|
+
const relationKey = `model:${model}`;
|
|
2179
|
+
|
|
2180
|
+
if (!cacheRelations.has(relationKey)) {
|
|
2181
|
+
cacheRelations.set(relationKey, new Set());
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
const modelCacheKeys = cacheRelations.get(relationKey);
|
|
2185
|
+
modelCacheKeys.add(cacheKey);
|
|
2186
|
+
|
|
2187
|
+
// Stocker aussi les relations par ID individuel
|
|
2188
|
+
dataIds.forEach(id => {
|
|
2189
|
+
const idKey = `id:${model}:${id}`;
|
|
2190
|
+
if (!cacheRelations.has(idKey)) {
|
|
2191
|
+
cacheRelations.set(idKey, new Set());
|
|
2192
|
+
}
|
|
2193
|
+
cacheRelations.get(idKey).add(cacheKey);
|
|
2194
|
+
});
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
// Invalider le cache pour un modèle spécifique
|
|
2198
|
+
export const invalidateModelCache = (model) => {
|
|
2199
|
+
const relationKey = `model:${model}`;
|
|
2200
|
+
|
|
2201
|
+
if (cacheRelations.has(relationKey)) {
|
|
2202
|
+
const cacheKeys = cacheRelations.get(relationKey);
|
|
2203
|
+
cacheKeys.forEach(key => searchCache.del(key));
|
|
2204
|
+
cacheRelations.delete(relationKey);
|
|
2205
|
+
|
|
2206
|
+
console.log(`Invalidated cache for model: ${model}`);
|
|
2207
|
+
}
|
|
2208
|
+
};
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* Vide entièrement le cache de recherche. Utile lors de la modification de la structure d'un modèle.
|
|
2212
|
+
*/
|
|
2213
|
+
export const flushSearchCache = () => {
|
|
2214
|
+
const stats = searchCache.getStats();
|
|
2215
|
+
searchCache.flushAll();
|
|
2216
|
+
cacheRelations.clear();
|
|
2217
|
+
logger.info(`[Cache] Flushed all search cache. Removed ${stats.keys} keys.`);
|
|
2218
|
+
};
|
|
2219
|
+
// Invalider le cache pour des IDs spécifiques
|
|
2220
|
+
const invalidateIdsCache = (model, ids) => {
|
|
2221
|
+
if (!Array.isArray(ids)) {
|
|
2222
|
+
ids = [ids];
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
ids.forEach(id => {
|
|
2226
|
+
const idKey = `id:${model}:${id}`;
|
|
2227
|
+
|
|
2228
|
+
if (cacheRelations.has(idKey)) {
|
|
2229
|
+
const cacheKeys = cacheRelations.get(idKey);
|
|
2230
|
+
cacheKeys.forEach(key => searchCache.del(key));
|
|
2231
|
+
cacheRelations.delete(idKey);
|
|
2232
|
+
|
|
2233
|
+
console.log(`Invalidated cache for ID: ${id} in model: ${model}`);
|
|
2234
|
+
}
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
// Invalider aussi le cache du modèle entier (optionnel, plus agressif)
|
|
2238
|
+
// invalidateModelCache(model);
|
|
2239
|
+
};
|
|
2240
|
+
|
|
2241
|
+
|
|
1814
2242
|
export const importData = async (options, files, user) => {
|
|
1815
2243
|
|
|
1816
2244
|
if (!(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_IMPORT_DATA"], user)) {
|
|
@@ -2384,7 +2812,21 @@ export async function installPack(packIdentifier, user = null, lang = 'en', opti
|
|
|
2384
2812
|
} else if (typeof packIdentifier === 'object' && packIdentifier !== null) {
|
|
2385
2813
|
// New behavior - use provided pack object directly
|
|
2386
2814
|
pack = packIdentifier;
|
|
2815
|
+
let totalEntries = 0;
|
|
2816
|
+
for (const langKey in pack) {
|
|
2817
|
+
const langData = pack[langKey];
|
|
2818
|
+
for (const modelKey in langData) {
|
|
2819
|
+
if (Array.isArray(langData[modelKey])) {
|
|
2820
|
+
totalEntries += langData[modelKey].length;
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2387
2824
|
|
|
2825
|
+
const m = Config.Get('maxPackData',maxPackData);
|
|
2826
|
+
if (totalEntries > m) {
|
|
2827
|
+
// Cette erreur sera retournée à l'utilisateur via l'API.
|
|
2828
|
+
throw new Error(`Pack installation failed: The pack contains ${totalEntries} data entries, which exceeds the limit of ${m}. Please split the pack into smaller ones.`);
|
|
2829
|
+
}
|
|
2388
2830
|
// Validate basic pack structure
|
|
2389
2831
|
if (!pack.name || (!pack.models && !pack.data)) {
|
|
2390
2832
|
throw new Error('Invalid pack structure - must contain at least name and models or data');
|