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.
- package/README.md +160 -160
- package/client/package-lock.json +8430 -8430
- package/client/src/APIInfo.jsx +1 -1
- package/client/src/App.jsx +2 -1
- package/client/src/App.scss +1635 -1626
- package/client/src/AssistantChat.jsx +1 -0
- package/client/src/CalendarView.jsx +1 -0
- package/client/src/ContentView.jsx +3 -3
- package/client/src/Dashboard.jsx +5 -2
- package/client/src/DashboardChart.jsx +1 -0
- package/client/src/DashboardFlexViewItem.jsx +1 -0
- package/client/src/DashboardHtmlViewItem.jsx +1 -0
- package/client/src/DashboardView.jsx +2 -0
- package/client/src/DataEditor.jsx +0 -1
- package/client/src/DataImporter.jsx +489 -468
- package/client/src/DataLayout.jsx +6 -3
- package/client/src/DataTable.jsx +4 -3
- package/client/src/Dialog.jsx +92 -90
- package/client/src/Dialog.scss +122 -116
- package/client/src/DisplayFlexNodeRenderer.jsx +1 -0
- package/client/src/FlexBuilderControls.jsx +1 -0
- package/client/src/FlexBuilderPreview.jsx +1 -1
- package/client/src/FlexNode.jsx +1 -1
- package/client/src/HistoryDialog.jsx +3 -2
- package/client/src/KPIWidget.jsx +1 -1
- package/client/src/KanbanView.jsx +1 -0
- package/client/src/ModelCreator.jsx +4 -0
- package/client/src/ModelList.jsx +1 -0
- package/client/src/PackGallery.jsx +5 -4
- package/client/src/RTETrans.jsx +1 -1
- package/client/src/RelationField.jsx +2 -0
- package/client/src/RelationSelectorWidget.jsx +2 -0
- package/client/src/RelationValue.jsx +1 -0
- package/client/src/RestoreDialog.jsx +1 -0
- package/client/src/WorkflowEditor.jsx +3 -0
- package/client/src/contexts/CommandContext.jsx +2 -1
- package/client/src/contexts/ModelContext.jsx +3 -3
- package/client/src/hooks/data.js +1 -0
- package/client/src/hooks/useValidation.js +1 -0
- package/client/src/translations.js +24 -24
- package/doc/AI-assistance.md +87 -63
- package/doc/Concepts.md +122 -0
- package/doc/Custom-Endpoints.md +31 -0
- package/doc/Event-system.md +13 -14
- package/doc/Home.md +33 -0
- package/doc/Modules.md +83 -0
- package/doc/Packs-gallery.md +8 -23
- package/doc/Workflows.md +32 -0
- package/doc/automation-workflows.md +141 -102
- package/doc/dashboards-kpis-charts.md +47 -49
- package/doc/data-management.md +126 -120
- package/doc/data-models.md +68 -75
- package/doc/roles-permissions.md +144 -43
- package/doc/sharding-replication.md +158 -0
- package/doc/users.md +54 -30
- package/package.json +1 -1
- package/server.js +37 -37
- package/src/constants.js +7 -8
- package/src/data.js +521 -520
- package/src/email.js +157 -154
- package/src/engine.js +117 -7
- package/src/gameObject.js +6 -0
- package/src/i18n.js +0 -1
- package/src/modules/auth-google/index.js +53 -50
- package/src/modules/bucket.js +346 -339
- package/src/modules/data/data.backup.js +400 -376
- package/src/modules/data/data.cluster.js +410 -42
- package/src/modules/data/data.operations.js +3666 -3635
- package/src/modules/data/data.replication.js +106 -64
- package/src/modules/data/data.routes.js +87 -64
- package/src/modules/data/data.scheduling.js +2 -1
- package/src/modules/file.js +248 -247
- package/src/modules/user.js +11 -1
- package/src/sso.js +91 -25
- package/test/cluster.test.js +221 -0
- package/test/core.test.js +0 -2
- package/test/replication.test.js +163 -0
- 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
|
|
57
|
-
const
|
|
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 (
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
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) {
|
|
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
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
@@ -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
|
+
});
|
package/doc/core-concepts.md
DELETED
|
@@ -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**
|