data-primals-engine 1.7.2 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +160 -160
  2. package/client/package-lock.json +8430 -8430
  3. package/client/src/APIInfo.jsx +1 -1
  4. package/client/src/App.jsx +2 -1
  5. package/client/src/App.scss +1635 -1626
  6. package/client/src/AssistantChat.jsx +1 -0
  7. package/client/src/CalendarView.jsx +1 -0
  8. package/client/src/ContentView.jsx +3 -3
  9. package/client/src/Dashboard.jsx +5 -2
  10. package/client/src/DashboardChart.jsx +1 -0
  11. package/client/src/DashboardFlexViewItem.jsx +1 -0
  12. package/client/src/DashboardHtmlViewItem.jsx +1 -0
  13. package/client/src/DashboardView.jsx +2 -0
  14. package/client/src/DataEditor.jsx +0 -1
  15. package/client/src/DataImporter.jsx +489 -468
  16. package/client/src/DataLayout.jsx +6 -3
  17. package/client/src/DataTable.jsx +4 -3
  18. package/client/src/Dialog.jsx +92 -90
  19. package/client/src/Dialog.scss +122 -116
  20. package/client/src/DisplayFlexNodeRenderer.jsx +1 -0
  21. package/client/src/FlexBuilderControls.jsx +1 -0
  22. package/client/src/FlexBuilderPreview.jsx +1 -1
  23. package/client/src/FlexNode.jsx +1 -1
  24. package/client/src/HistoryDialog.jsx +3 -2
  25. package/client/src/KPIWidget.jsx +1 -1
  26. package/client/src/KanbanView.jsx +1 -0
  27. package/client/src/ModelCreator.jsx +4 -0
  28. package/client/src/ModelList.jsx +1 -0
  29. package/client/src/PackGallery.jsx +5 -4
  30. package/client/src/RTETrans.jsx +1 -1
  31. package/client/src/RelationField.jsx +2 -0
  32. package/client/src/RelationSelectorWidget.jsx +2 -0
  33. package/client/src/RelationValue.jsx +1 -0
  34. package/client/src/RestoreDialog.jsx +1 -0
  35. package/client/src/WorkflowEditor.jsx +3 -0
  36. package/client/src/contexts/CommandContext.jsx +2 -1
  37. package/client/src/contexts/ModelContext.jsx +3 -3
  38. package/client/src/hooks/data.js +1 -0
  39. package/client/src/hooks/useValidation.js +1 -0
  40. package/client/src/translations.js +24 -24
  41. package/doc/AI-assistance.md +87 -63
  42. package/doc/Concepts.md +122 -0
  43. package/doc/Custom-Endpoints.md +31 -0
  44. package/doc/Event-system.md +13 -14
  45. package/doc/Home.md +33 -0
  46. package/doc/Modules.md +83 -0
  47. package/doc/Packs-gallery.md +8 -23
  48. package/doc/Workflows.md +32 -0
  49. package/doc/automation-workflows.md +141 -102
  50. package/doc/dashboards-kpis-charts.md +47 -49
  51. package/doc/data-management.md +126 -120
  52. package/doc/data-models.md +68 -75
  53. package/doc/roles-permissions.md +144 -43
  54. package/doc/sharding-replication.md +158 -0
  55. package/doc/users.md +54 -30
  56. package/package.json +1 -1
  57. package/server.js +37 -37
  58. package/src/constants.js +7 -8
  59. package/src/data.js +521 -520
  60. package/src/email.js +157 -154
  61. package/src/engine.js +117 -7
  62. package/src/gameObject.js +6 -0
  63. package/src/i18n.js +0 -1
  64. package/src/modules/auth-google/index.js +53 -50
  65. package/src/modules/bucket.js +346 -339
  66. package/src/modules/data/data.backup.js +400 -376
  67. package/src/modules/data/data.cluster.js +410 -42
  68. package/src/modules/data/data.operations.js +3666 -3635
  69. package/src/modules/data/data.replication.js +106 -64
  70. package/src/modules/data/data.routes.js +87 -64
  71. package/src/modules/data/data.scheduling.js +2 -1
  72. package/src/modules/file.js +248 -247
  73. package/src/modules/user.js +11 -1
  74. package/src/sso.js +91 -25
  75. package/test/cluster.test.js +221 -0
  76. package/test/core.test.js +0 -2
  77. package/test/replication.test.js +163 -0
  78. package/doc/core-concepts.md +0 -33
package/src/sso.js CHANGED
@@ -5,6 +5,11 @@ import { cookiesSecret } from "./constants.js";
5
5
  import {UserProvider} from "./providers.js";
6
6
  import {getCollection} from "./modules/mongodb.js";
7
7
  import process from "process";
8
+ import {Event} from "./events.js"
9
+ import { randomBytes } from 'crypto';
10
+ import bcrypt from 'bcrypt';
11
+ import { sendEmail } from './email.js';
12
+ import {getInternalSmtpConfig, getSmtpConfig} from "./modules/user.js";
8
13
 
9
14
  /**
10
15
  * @class SSOUserProvider
@@ -53,35 +58,70 @@ export class SSOUserProvider extends UserProvider {
53
58
 
54
59
  const email = profile.emails[0].value;
55
60
 
56
- // 1. Chercher un utilisateur existant avec cet email.
57
- const existingUser = await this.usersCollection.findOne({ email: email });
61
+ // 1. Chercher un contact existant avec cet email.
62
+ const contactCollection = getCollection("contact");
63
+ const existingContact = await contactCollection.findOne({ email: email });
58
64
 
59
- if (existingUser) {
60
- // TODO: Mettre à jour les informations de l'utilisateur si nécessaire (ex: nom, photo de profil)
61
- // await this.usersCollection.updateOne({ _id: existingUser._id }, { $set: { lastLogin: new Date() } });
62
- return existingUser;
65
+ if (existingContact) {
66
+ // 2. Si le contact existe, chercher l'utilisateur qui lui est lié.
67
+ const existingUser = await this.usersCollection.findOne({ contact: existingContact._id.toString() });
68
+ if (existingUser) {
69
+ // TODO: Mettre à jour les informations de l'utilisateur/contact si nécessaire (ex: nom, photo de profil)
70
+ return existingUser;
71
+ }
63
72
  }
64
73
 
65
- // 2. Si aucun utilisateur n'est trouvé, en créer un nouveau.
74
+ // 3. Si aucun utilisateur n'est trouvé, en créer un nouveau avec son contact.
75
+ const [firstName, ...lastNameParts] = (profile.displayName || email).split(' ');
76
+ const lastName = lastNameParts.join(' ');
77
+
78
+ const newContact = {
79
+ _model: 'contact',
80
+ firstName: firstName,
81
+ lastName: lastName,
82
+ email: email,
83
+ // On pourrait ajouter le _user ici si on a un utilisateur "système" pour les créations SSO
84
+ };
85
+ const contactResult = await contactCollection.insertOne(newContact);
86
+
87
+ // Générer un mot de passe aléatoire et sécurisé que l'utilisateur ne connaîtra pas.
88
+ // Il pourra utiliser le flux "mot de passe oublié" s'il souhaite se connecter localement.
89
+ const randomPassword = randomBytes(32).toString('hex');
90
+ const saltRounds = 10;
91
+ const hashedPassword = await bcrypt.hash(randomPassword, saltRounds);
92
+
66
93
  const newUser = {
94
+ _model: 'user',
67
95
  username: email, // Utiliser l'email comme nom d'utilisateur par défaut
68
- email: email,
69
- name: profile.displayName || email,
96
+ contact: contactResult.insertedId.toString(),
70
97
  provider: profile.provider, // ex: 'google', 'saml'
71
98
  providerId: profile.id,
99
+ hash: hashedPassword, // Mot de passe haché pour une éventuelle connexion locale
72
100
  userPlan: 'free', // Assigner un plan par défaut
73
101
  createdAt: new Date()
74
102
  };
75
103
 
76
- const result = await this.usersCollection.insertOne(newUser);
77
- return await this.findUserById(result.insertedId);
104
+ const userResult = await this.usersCollection.insertOne(newUser);
105
+ const finalUser = await this.findUserById(userResult.insertedId);
106
+
107
+ // Envoyer un e-mail de bienvenue au nouvel utilisateur
108
+ const smtpConfig = getInternalSmtpConfig(finalUser);
109
+ sendEmail(email, { title: "Bienvenue !", content: "Votre compte a été créé avec succès." }, smtpConfig).catch(console.error);
110
+
111
+ return finalUser;
78
112
  }
79
113
 
80
114
  // Les autres méthodes comme validatePassword ne sont pas pertinentes pour le SSO.
81
115
  // On peut les laisser vides ou les faire lever une erreur.
82
116
  async validatePassword(user, password) { return false; }
83
117
  async findUserByUsername(username) { return await this.usersCollection.findOne({ username }); }
84
- async initiateUser(req) { /* Géré par Passport */ }
118
+ async initiateUser(req) {
119
+ const username = req.query._user || req.headers._user || req.cookies.username;
120
+ const user = await this.findUserByUsername(username);
121
+ if (user) {
122
+ req.me = user; // Attache l'utilisateur à la requête
123
+ }
124
+ }
85
125
  }
86
126
 
87
127
  /**
@@ -162,19 +202,45 @@ export class Sso extends Behaviour {
162
202
 
163
203
  const app = this.gameObject;
164
204
 
165
- // Créer la route d'initiation
166
- app.get(routes.authPath, passport.authenticate(name, options));
167
-
168
- // Créer la route de callback
169
- app.get(
170
- routes.callbackPath,
171
- passport.authenticate(name, { failureRedirect: '/login', failureMessage: true }),
172
- (req, res) => {
173
- // Redirection après une authentification réussie
174
- res.redirect(req.session.returnTo || '/dashboard');
175
- delete req.session.returnTo;
176
- }
177
- );
205
+ // --- ROUTE D'INITIATION AMÉLIORÉE ---
206
+ // Nous utilisons un middleware personnalisé pour préserver l'état (query params).
207
+ app.get(routes.authPath, (req, res, next) => {
208
+ this.#logger.debug(`[SSO] Initiating '${name}' auth. Full returnTo URL stored: '${req.session.returnTo}'`);
209
+
210
+ // On sauvegarde la session manuellement avant de rediriger vers le fournisseur SSO.
211
+ // Ceci garantit que `returnTo` est persisté.
212
+ req.session.save(() => {
213
+ passport.authenticate(name, options)(req, res, next);
214
+ });
215
+ });
216
+
217
+ // --- ROUTE DE CALLBACK AMÉLIORÉE ---
218
+ // Nous utilisons un callback personnalisé pour déclencher des événements.
219
+ app.get(routes.callbackPath, (req, res, next) => {
220
+ passport.authenticate(name, { failureRedirect: '/login', failureMessage: true }, async (err, user, info) => {
221
+ if (err) { return next(err); }
222
+ if (!user) { return res.redirect('/login'); }
223
+
224
+ // Sauvegarder l'URL de retour avant que req.logIn() ne régénère potentiellement la session.
225
+ const returnToUrl = req.session.returnTo;
226
+
227
+ req.logIn(user, async (loginErr) => {
228
+ if (loginErr) { return next(loginErr); }
229
+
230
+ // Restaurer l'URL de retour dans la nouvelle session.
231
+ req.session.returnTo = returnToUrl;
232
+
233
+ // *** DÉCLENCHEMENT DE L'ÉVÉNEMENT OnSSOLogin ***
234
+ await Event.Trigger("OnSsoLogin", "event", "system", { req, res, user, ssoProfile: info?.profile });
235
+
236
+ // Après que l'événement a mis à jour la session (par exemple, avec un nouveau token),
237
+ // nous pouvons rediriger l'utilisateur.
238
+ // On restaure une dernière fois au cas où la session aurait été régénérée.
239
+ const finalReturnTo = req.session.returnTo || '/';
240
+ res.redirect(finalReturnTo);
241
+ });
242
+ })(req, res, next);
243
+ });
178
244
 
179
245
  this.strategies.set(name, { strategy, routes, options });
180
246
  this.#logger.info(`Passport strategy '${name}' registered with routes: ${routes.authPath}, ${routes.callbackPath}`);
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { initializeCluster, getMemberList, stopClusterServices } from '../src/modules/data/data.cluster.js';
3
+ import * as fs from 'node:fs/promises';
4
+ import { Logger } from "../src/index.js";
5
+ import { Config } from '../src/config.js';
6
+
7
+ // Mock Logger to prevent console output
8
+ vi.mock('../src/gameObject.js', async (importOriginal) => {
9
+ const original = await importOriginal();
10
+ return {
11
+ ...original, // Keep original exports like 'Behaviour'
12
+ Logger: class {
13
+ info = vi.fn();
14
+ warn = vi.fn();
15
+ error = vi.fn();
16
+ debug = vi.fn();
17
+ },
18
+ DataHandler: class {} // Provide a mock DataHandler class
19
+ };
20
+ });
21
+
22
+ // Mock Config
23
+ vi.mock('../src/config.js', () => ({
24
+ Config: {
25
+ Get: vi.fn((key, defaultValue) => defaultValue)
26
+ }
27
+ }));
28
+
29
+ // Mock fs.promises pour contrôler statfs
30
+ vi.mock('node:fs/promises', async (importOriginal) => {
31
+ const actual = await importOriginal();
32
+ return {
33
+ ...actual,
34
+ statfs: vi.fn().mockResolvedValue({ bavail: 5000, bsize: 1024, blocks: 10000 }), // Mock de base
35
+ };
36
+ });
37
+
38
+ describe('Data Cluster & Gossip Logic', () => {
39
+ let mockEngine;
40
+ let postHandlers = {};
41
+ let mockDataHandler;
42
+ let mockClusterLeaseModel;
43
+
44
+ beforeEach(() => {
45
+ vi.useFakeTimers();
46
+
47
+ // Mock the engine
48
+ mockEngine = {
49
+ selfUrl: 'http://node-1:3000',
50
+ peers: [
51
+ { id: 'node-1', public_domain: 'http://node-1:3000', sharding: true, replica: true },
52
+ { id: 'node-2', public_domain: 'http://node-2:3000', sharding: true, replica: true },
53
+ { id: 'node-3', public_domain: 'http://node-3:3000', sharding: true, replica: true }
54
+ ],
55
+ getComponent: vi.fn(componentName => {
56
+ if (componentName === 'DataHandler') return mockDataHandler;
57
+ return new Logger('mock');
58
+ }),
59
+ sendToPeer: vi.fn(),
60
+ post: vi.fn((path, handler) => {
61
+ postHandlers[path] = handler;
62
+ }),
63
+ };
64
+
65
+ // Mock Mongoose/DataHandler for leasing
66
+ mockClusterLeaseModel = {
67
+ findOneAndUpdate: vi.fn(),
68
+ };
69
+ mockDataHandler = {
70
+ mongoose: {
71
+ Schema: class Schema {},
72
+ model: vi.fn().mockReturnValue(mockClusterLeaseModel),
73
+ }
74
+ };
75
+
76
+ // Reset post handlers for each test
77
+ postHandlers = {};
78
+ });
79
+
80
+ afterEach(() => {
81
+ stopClusterServices();
82
+ vi.restoreAllMocks();
83
+ vi.useRealTimers();
84
+ });
85
+
86
+ describe('Initialization (onInit)', () => {
87
+ it('should initialize member list from static peer configuration', async () => {
88
+ await initializeCluster(mockEngine);
89
+ const memberList = getMemberList();
90
+
91
+ expect(memberList).toHaveLength(3);
92
+ expect(memberList.find(m => m.id === 'node-1')).toBeDefined();
93
+ expect(memberList.find(m => m.id === 'node-2')).toBeDefined();
94
+ expect(memberList.find(m => m.id === 'node-3')).toBeDefined();
95
+
96
+ const node1 = memberList.find(m => m.id === 'node-1');
97
+ expect(node1.status).toBe('UP');
98
+ expect(node1.version).toBe(1);
99
+ });
100
+
101
+ it('should correctly identify selfId', async () => {
102
+ await initializeCluster(mockEngine);
103
+ expect(mockEngine.selfId).toBe('node-1');
104
+ });
105
+
106
+ it('should register the /api/internal/gossip endpoint', async () => {
107
+ await initializeCluster(mockEngine);
108
+ expect(mockEngine.post).toHaveBeenCalledWith('/api/internal/gossip', expect.any(Function));
109
+ expect(postHandlers['/api/internal/gossip']).toBeDefined();
110
+ });
111
+
112
+ it('should start the gossip interval', async () => {
113
+ const setIntervalSpy = vi.spyOn(global, 'setInterval');
114
+ await initializeCluster(mockEngine);
115
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 2000); // Default interval
116
+ });
117
+ });
118
+
119
+ describe('Gossip Execution', () => {
120
+ it('should send its member list to a random UP peer', async () => {
121
+ await initializeCluster(mockEngine);
122
+ mockEngine.sendToPeer.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) });
123
+
124
+ await vi.advanceTimersByTimeAsync(2000); // Trigger gossip
125
+
126
+ expect(mockEngine.sendToPeer).toHaveBeenCalledTimes(1);
127
+ const [peerId, path, payload] = mockEngine.sendToPeer.mock.calls[0];
128
+ expect(['node-2', 'node-3']).toContain(peerId);
129
+ expect(path).toBe('/api/internal/gossip');
130
+ expect(payload).toHaveLength(3);
131
+ expect(payload.find(p => p.id === 'node-1')).toBeDefined();
132
+ });
133
+
134
+ it('should mark a peer as SUSPECT if sendToPeer fails', async () => {
135
+ await initializeCluster(mockEngine);
136
+ mockEngine.sendToPeer.mockRejectedValue(new Error('Network error'));
137
+
138
+ await vi.advanceTimersByTimeAsync(2000); // Trigger gossip
139
+
140
+ expect(mockEngine.sendToPeer).toHaveBeenCalledTimes(1);
141
+ const failedPeerId = mockEngine.sendToPeer.mock.calls[0][0];
142
+
143
+ const memberList = getMemberList();
144
+ const failedPeer = memberList.find(m => m.id === failedPeerId);
145
+ expect(failedPeer.status).toBe('SUSPECT');
146
+ expect(failedPeer.version).toBe(2); // Version incremented
147
+ });
148
+
149
+ it('should merge the list received from a peer on successful gossip', async () => {
150
+ const remoteList = [
151
+ { id: 'node-1', public_domain: 'http://node-1:3000', status: 'UP', version: 1 },
152
+ { id: 'node-2', public_domain: 'http://node-2:3000', status: 'UP', version: 2 }, // Higher version
153
+ { id: 'node-4', public_domain: 'http://node-4:3000', status: 'UP', version: 1 } // New node
154
+ ];
155
+ mockEngine.sendToPeer.mockResolvedValue({ ok: true, json: () => Promise.resolve(remoteList) });
156
+ await initializeCluster(mockEngine);
157
+
158
+ await vi.advanceTimersByTimeAsync(2000); // Trigger gossip
159
+
160
+ const memberList = getMemberList();
161
+ expect(memberList).toHaveLength(4);
162
+ expect(memberList.find(m => m.id === 'node-4')).toBeDefined(); // New node added
163
+ expect(memberList.find(m => m.id === 'node-2').version).toBe(2); // Node updated
164
+ });
165
+ });
166
+
167
+ describe('Gossip Health Checks', () => {
168
+ it('should mark a SUSPECT peer as DOWN after a timeout', async () => {
169
+ Config.Get.mockImplementation((key, defaultValue) => {
170
+ if (key === 'gossipSuspectTimeout') return 5000; // 5s timeout for test
171
+ return defaultValue;
172
+ });
173
+
174
+ // Rendre le choix du pair déterministe en mockant Math.random
175
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
176
+
177
+ await initializeCluster(mockEngine);
178
+ mockEngine.sendToPeer.mockRejectedValue(new Error('Network error'));
179
+
180
+ // Le premier gossip échoue, node-2 devient SUSPECT
181
+ await vi.advanceTimersByTimeAsync(2000);
182
+ const memberListV1 = getMemberList();
183
+ expect(memberListV1.find(m => m.id === 'node-2')?.status).toBe('SUSPECT');
184
+
185
+ // On avance le temps au-delà du timeout + un autre cycle de gossip pour que checkSuspectNodes s'exécute.
186
+ await vi.advanceTimersByTimeAsync(8000); // 6000ms (timeout) + 2000ms (next gossip)
187
+
188
+ const memberListV2 = getMemberList();
189
+ expect(memberListV2.find(m => m.id === 'node-2')?.status).toBe('DOWN');
190
+
191
+ randomSpy.mockRestore(); // Nettoyer le spy
192
+ });
193
+ });
194
+
195
+ describe('Gossip Endpoint', () => {
196
+ it('should merge received list and respond with its own updated list', async () => {
197
+ await initializeCluster(mockEngine);
198
+ const gossipEndpointHandler = postHandlers['/api/internal/gossip'];
199
+
200
+ const remoteList = [
201
+ { id: 'node-3', public_domain: 'http://node-3:3000', status: 'SUSPECT', version: 5 },
202
+ { id: 'node-4', public_domain: 'http://node-4:3000', status: 'UP', version: 1 }
203
+ ];
204
+ const mockReq = { fields: remoteList };
205
+ const mockRes = { json: vi.fn() };
206
+
207
+ gossipEndpointHandler(mockReq, mockRes);
208
+
209
+ // Check if list was merged
210
+ const memberList = getMemberList();
211
+ expect(memberList).toHaveLength(4);
212
+ expect(memberList.find(m => m.id === 'node-4')).toBeDefined();
213
+ expect(memberList.find(m => m.id === 'node-3').status).toBe('SUSPECT');
214
+ expect(memberList.find(m => m.id === 'node-3').version).toBe(5);
215
+
216
+ // Check if it responded with the new list
217
+ expect(mockRes.json).toHaveBeenCalledWith(memberList);
218
+ });
219
+ });
220
+
221
+ });
package/test/core.test.js CHANGED
@@ -1,6 +1,4 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
2
  import {
5
3
  sleep,
6
4
  escapeRegex,
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { queueReplication, onInit, stopReplicationQueue } from '../src/modules/data/data.replication.js';
3
+ import * as clusterModule from '../src/modules/data/data.cluster.js';
4
+ import { Logger } from "../src/index.js"
5
+
6
+ // Mock Logger to prevent console output
7
+ vi.mock('../src/gameObject.js', () => ({
8
+ Logger: class {
9
+ info = vi.fn();
10
+ warn = vi.fn();
11
+ error = vi.fn();
12
+ debug = vi.fn();
13
+ }
14
+ }));
15
+
16
+ describe('Data Replication Logic', () => {
17
+ let mockEngine;
18
+ let getReplicaNodesForUserSpy;
19
+
20
+ beforeEach(() => {
21
+ vi.useFakeTimers();
22
+
23
+ // Mock the engine and its sendToPeer function
24
+ mockEngine = {
25
+ peers: [
26
+ { id: 'self', url: 'http://self:3000' },
27
+ { id: 'replica-1', url: 'http://replica-1:3000' },
28
+ { id: 'replica-2', url: 'http://replica-2:3000' }
29
+ ],
30
+ getComponent: vi.fn().mockReturnValue(new Logger('mock')),
31
+ sendToPeer: vi.fn().mockResolvedValue({ ok: true }),
32
+ };
33
+
34
+ // Spy on getReplicaNodesForUser to control its output
35
+ getReplicaNodesForUserSpy = vi.spyOn(clusterModule, 'getReplicaNodesForUser');
36
+
37
+ // Initialize the replication module with the mock engine
38
+ onInit(mockEngine);
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.useRealTimers();
43
+ vi.restoreAllMocks();
44
+ stopReplicationQueue();
45
+ // Clear the queue manually for test isolation
46
+ const replicationQueue = []; // This is a simplified way to clear it for tests
47
+ while (replicationQueue.length > 0) {
48
+ replicationQueue.pop();
49
+ }
50
+ });
51
+
52
+ it('should queue an operation correctly', async () => {
53
+ const operation = 'insert';
54
+ const modelName = 'testModel';
55
+ const user = { username: 'testuser' };
56
+ const payload = { data: { name: 'test' } };
57
+
58
+ // This test is tricky because replicationQueue is not exported.
59
+ // We'll test its effect indirectly via processReplicationQueue.
60
+ queueReplication(operation, modelName, user, payload);
61
+
62
+ // We can't directly inspect the queue, so we'll let the processing logic run
63
+ // and check if the mocks were called.
64
+ getReplicaNodesForUserSpy.mockReturnValue([{ id: 'replica-1' }]);
65
+
66
+ await vi.advanceTimersByTimeAsync(200); // Advance time to trigger processReplicationQueue
67
+
68
+ // Check that the processing function tried to get replicas
69
+ expect(getReplicaNodesForUserSpy).toHaveBeenCalledWith(user.username);
70
+ });
71
+
72
+ it('should process the queue and send operations in batches to correct replicas', async () => {
73
+ const user1 = { username: 'user1' };
74
+ const user2 = { username: 'user2' };
75
+
76
+ // Mock so user1 replicates to replica-1 and user2 to replica-2
77
+ getReplicaNodesForUserSpy.mockImplementation((username) => {
78
+ if (username === 'user1') return [{ id: 'replica-1' }];
79
+ if (username === 'user2') return [{ id: 'replica-2' }];
80
+ return [];
81
+ });
82
+
83
+ // Queue operations for different users
84
+ queueReplication('insert', 'modelA', user1, { data: { a: 1 } });
85
+ queueReplication('update', 'modelB', user2, { data: { b: 2 } });
86
+ queueReplication('delete', 'modelC', user1, { ids: ['123'] });
87
+
88
+ // Advance timers to trigger the processing
89
+ await vi.advanceTimersByTimeAsync(200);
90
+
91
+ // Assertions
92
+ expect(mockEngine.sendToPeer).toHaveBeenCalledTimes(2);
93
+
94
+ // Check call for replica-1
95
+ expect(mockEngine.sendToPeer).toHaveBeenCalledWith(
96
+ 'replica-1',
97
+ '/api/internal/replicate',
98
+ expect.objectContaining({
99
+ operations: expect.arrayContaining([
100
+ expect.objectContaining({ operation: 'insert', modelName: 'modelA', user: user1 }),
101
+ expect.objectContaining({ operation: 'delete', modelName: 'modelC', user: user1 })
102
+ ])
103
+ })
104
+ );
105
+
106
+ // Check call for replica-2
107
+ expect(mockEngine.sendToPeer).toHaveBeenCalledWith(
108
+ 'replica-2',
109
+ '/api/internal/replicate',
110
+ expect.objectContaining({
111
+ operations: expect.arrayContaining([
112
+ expect.objectContaining({ operation: 'update', modelName: 'modelB', user: user2 })
113
+ ])
114
+ })
115
+ );
116
+ });
117
+
118
+ it('should not process queue if CLUSTER_SHARED_DATABASE is true', async () => {
119
+ process.env.CLUSTER_SHARED_DATABASE = 'true';
120
+
121
+ queueReplication('insert', 'modelA', { username: 'user1' }, { data: { a: 1 } });
122
+
123
+ await vi.advanceTimersByTimeAsync(200);
124
+
125
+ expect(mockEngine.sendToPeer).not.toHaveBeenCalled();
126
+
127
+ delete process.env.CLUSTER_SHARED_DATABASE; // Cleanup
128
+ });
129
+
130
+ it('should queue a failed batch for retry and re-send it on the next cycle', async () => {
131
+ const user = { username: 'retry-user' };
132
+ const payload = { data: { name: 'important-data' } };
133
+
134
+ // Configurer le mock pour qu'il échoue la première fois et réussisse la seconde
135
+ mockEngine.sendToPeer
136
+ .mockRejectedValueOnce(new Error('Simulated Network Error'))
137
+ .mockResolvedValue({ ok: true });
138
+
139
+ // Toutes les opérations pour cet utilisateur vont vers 'replica-1'
140
+ getReplicaNodesForUserSpy.mockReturnValue([{ id: 'replica-1' }]);
141
+
142
+ // 1. Mettre une opération en file d'attente
143
+ queueReplication('insert', 'retryModel', user, payload);
144
+
145
+ // 2. Premier cycle : processReplicationQueue est appelé, sendToPeer échoue
146
+ // Advance just enough to trigger one processing cycle (assuming interval is 100ms)
147
+ await vi.advanceTimersByTimeAsync(100);
148
+
149
+ // Vérifier que l'échec a été loggué et que la tentative a eu lieu
150
+ expect(mockEngine.sendToPeer).toHaveBeenCalledTimes(1);
151
+
152
+ // 3. Deuxième cycle : retryFailedBatches est appelé, sendToPeer réussit
153
+ await vi.advanceTimersByTimeAsync(100);
154
+
155
+ // Vérifier que la nouvelle tentative a eu lieu
156
+ expect(mockEngine.sendToPeer).toHaveBeenCalledTimes(2);
157
+
158
+ // Vérifier que les deux appels étaient pour la même réplique avec les mêmes données
159
+ const firstCallArgs = mockEngine.sendToPeer.mock.calls[0];
160
+ const secondCallArgs = mockEngine.sendToPeer.mock.calls[1];
161
+ expect(firstCallArgs).toEqual(secondCallArgs);
162
+ });
163
+ });
@@ -1,33 +0,0 @@
1
- # Core Concepts: Data Modeling Fundamentals
2
-
3
- The `data-primals-engine` is built around a flexible and powerful data modeling system that allows you to define and manage your application's data structures without complex migrations. This section introduces the fundamental concepts of data modeling within the engine.
4
-
5
- ## Dynamic and Schema-less Nature
6
-
7
- At its core, `data-primals-engine` leverages **MongoDB**, a NoSQL database. This provides inherent flexibility, allowing for a schema-less approach. You can define and update your data models either through a user interface or by providing JSON definitions, and the system adapts dynamically without requiring traditional database migrations. This significantly accelerates development and iteration cycles.
8
-
9
- ## Models
10
-
11
- A **Model** is the blueprint for a collection of data. It defines the structure and characteristics of the documents you store. For example, a `User` model would define what properties a user object has (e.g., `username`, `email`, `roles`).
12
-
13
- Models are defined by a `name`, an optional `description`, an `icon`, and a list of `fields`. The engine comes with a set of default models for common entities like `user`, `product`, `workflow`, and more.
14
-
15
- ## Fields
16
-
17
- **Fields** are the individual attributes that make up a model. Each field has a `name`, a `type`, and various optional properties that define its behavior and constraints.
18
-
19
- Common field types include:
20
- - `string`: For text data.
21
- - `number`: For numerical data.
22
- - `boolean`: For true/false values.
23
- - `datetime`: For date and time values.
24
- - `relation`: To establish relationships between different models (e.g., a `product` model having a `category` field that relates to a `taxonomy` model).
25
- - `enum`: For fields with a predefined set of allowed values.
26
- - `file`: For handling file uploads (e.g., `profilePicture` in the `user` model).
27
- - `code`: For storing code snippets (e.g., `script` in an `endpoint` model).
28
-
29
- Fields can also have properties like `required`, `unique`, `min`, `max`, `default` values, and `hint` for user guidance.
30
-
31
- This dynamic and field-based approach to data modeling provides the flexibility needed for rapidly building complex data-driven applications.
32
-
33
- **Next: Data Models**