@wowoengine/sawitdb 2.5.0 → 2.6.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 +72 -16
- package/bin/sawit-server.js +5 -1
- package/cli/benchmark.js +292 -119
- package/cli/local.js +26 -11
- package/cli/remote.js +26 -11
- package/cli/test.js +110 -72
- package/cli/test_security.js +140 -0
- package/docs/DB Event.md +32 -0
- package/docs/index.html +125 -17
- package/package.json +10 -7
- package/src/SawitClient.js +122 -98
- package/src/SawitServer.js +77 -464
- package/src/SawitWorker.js +55 -0
- package/src/WowoEngine.js +245 -824
- package/src/modules/BTreeIndex.js +54 -24
- package/src/modules/ClusterManager.js +78 -0
- package/src/modules/Env.js +33 -0
- package/src/modules/Pager.js +12 -9
- package/src/modules/QueryParser.js +233 -48
- package/src/modules/ThreadManager.js +84 -0
- package/src/modules/ThreadPool.js +154 -0
- package/src/server/DatabaseRegistry.js +92 -0
- package/src/server/auth/AuthManager.js +92 -0
- package/src/server/router/RequestRouter.js +278 -0
- package/src/server/session/ClientSession.js +19 -0
- package/src/services/IndexManager.js +183 -0
- package/src/services/QueryExecutor.js +11 -0
- package/src/services/TableManager.js +162 -0
- package/src/services/event/DBEvent.js +61 -0
- package/src/services/event/DBEventHandler.js +39 -0
- package/src/services/executors/AggregateExecutor.js +153 -0
- package/src/services/executors/DeleteExecutor.js +134 -0
- package/src/services/executors/InsertExecutor.js +113 -0
- package/src/services/executors/SelectExecutor.js +130 -0
- package/src/services/executors/UpdateExecutor.js +156 -0
- package/src/services/logic/ConditionEvaluator.js +75 -0
- package/src/services/logic/JoinProcessor.js +230 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const SawitDB = require('../WowoEngine');
|
|
4
|
+
|
|
5
|
+
class DatabaseRegistry {
|
|
6
|
+
constructor(dataDir, config) {
|
|
7
|
+
this.dataDir = dataDir;
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.databases = new Map(); // name -> SawitDB instance
|
|
10
|
+
this.walConfig = config.wal || { enabled: false };
|
|
11
|
+
|
|
12
|
+
// Ensure data directory exists
|
|
13
|
+
if (!fs.existsSync(this.dataDir)) {
|
|
14
|
+
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
validateName(name) {
|
|
19
|
+
if (!name || typeof name !== 'string') {
|
|
20
|
+
throw new Error("Database name required");
|
|
21
|
+
}
|
|
22
|
+
// Prevent Path Traversal and illegal chars
|
|
23
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
24
|
+
throw new Error("Invalid database name. Only alphanumeric, hyphen, and underscore allowed.");
|
|
25
|
+
}
|
|
26
|
+
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
27
|
+
throw new Error("Invalid database name (Path Traversal attempt).");
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get(name) {
|
|
33
|
+
this.validateName(name);
|
|
34
|
+
return this.getOrCreate(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getOrCreate(name) {
|
|
38
|
+
if (!this.databases.has(name)) {
|
|
39
|
+
const dbPath = path.join(this.dataDir, `${name}.sawit`);
|
|
40
|
+
const db = new SawitDB(dbPath, { wal: this.walConfig });
|
|
41
|
+
this.databases.set(name, db);
|
|
42
|
+
}
|
|
43
|
+
return this.databases.get(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
exists(name) {
|
|
47
|
+
const dbPath = path.join(this.dataDir, `${name}.sawit`);
|
|
48
|
+
return fs.existsSync(dbPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
create(name) {
|
|
52
|
+
this.validateName(name);
|
|
53
|
+
return this.getOrCreate(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
drop(name) {
|
|
57
|
+
this.validateName(name);
|
|
58
|
+
const dbPath = path.join(this.dataDir, `${name}.sawit`);
|
|
59
|
+
if (!fs.existsSync(dbPath)) {
|
|
60
|
+
throw new Error(`Database '${name}' does not exist`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Close if open
|
|
64
|
+
if (this.databases.has(name)) {
|
|
65
|
+
this.databases.get(name).close();
|
|
66
|
+
this.databases.delete(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Delete file
|
|
70
|
+
fs.unlinkSync(dbPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
list() {
|
|
74
|
+
return fs.readdirSync(this.dataDir)
|
|
75
|
+
.filter((file) => file.endsWith('.sawit'))
|
|
76
|
+
.map((file) => file.replace('.sawit', ''));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
closeAll() {
|
|
80
|
+
for (const [name, db] of this.databases) {
|
|
81
|
+
try {
|
|
82
|
+
console.log(`[Server] Closing database: ${name}`);
|
|
83
|
+
db.close();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.error(`[Server] Error closing database ${name}:`, e.message);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
this.databases.clear();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = DatabaseRegistry;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
class AuthManager {
|
|
4
|
+
constructor(server) {
|
|
5
|
+
this.server = server;
|
|
6
|
+
this.authConfig = server.config.auth || null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
isEnabled() {
|
|
10
|
+
return !!this.authConfig;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hash a password using SHA-256 with salt
|
|
15
|
+
* @param {string} password - Plain text password
|
|
16
|
+
* @param {string} salt - Optional salt (generated if not provided)
|
|
17
|
+
* @returns {string} - Format: salt:hash
|
|
18
|
+
*/
|
|
19
|
+
static hashPassword(password, salt = null) {
|
|
20
|
+
salt = salt || crypto.randomBytes(16).toString('hex');
|
|
21
|
+
const hash = crypto
|
|
22
|
+
.createHash('sha256')
|
|
23
|
+
.update(salt + password)
|
|
24
|
+
.digest('hex');
|
|
25
|
+
return `${salt}:${hash}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Verify a password against a stored hash using timing-safe comparison
|
|
30
|
+
* @param {string} password - Plain text password to verify
|
|
31
|
+
* @param {string} storedHash - Stored hash in format "salt:hash" or plain text
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
verifyPassword(password, storedHash) {
|
|
35
|
+
// Support both hashed (salt:hash) and legacy plaintext passwords
|
|
36
|
+
if (storedHash.includes(':')) {
|
|
37
|
+
const [salt, hash] = storedHash.split(':');
|
|
38
|
+
const computedHash = crypto
|
|
39
|
+
.createHash('sha256')
|
|
40
|
+
.update(salt + password)
|
|
41
|
+
.digest('hex');
|
|
42
|
+
// Timing-safe comparison to prevent timing attacks
|
|
43
|
+
try {
|
|
44
|
+
return crypto.timingSafeEqual(
|
|
45
|
+
Buffer.from(hash, 'hex'),
|
|
46
|
+
Buffer.from(computedHash, 'hex')
|
|
47
|
+
);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
// Legacy plaintext comparison with timing-safe method
|
|
53
|
+
// Pad both strings to same length to prevent length-based timing attacks
|
|
54
|
+
const maxLen = Math.max(password.length, storedHash.length);
|
|
55
|
+
const paddedInput = password.padEnd(maxLen, '\0');
|
|
56
|
+
const paddedStored = storedHash.padEnd(maxLen, '\0');
|
|
57
|
+
try {
|
|
58
|
+
return crypto.timingSafeEqual(
|
|
59
|
+
Buffer.from(paddedInput),
|
|
60
|
+
Buffer.from(paddedStored)
|
|
61
|
+
);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
handleAuth(socket, payload, session) {
|
|
69
|
+
const { username, password } = payload;
|
|
70
|
+
|
|
71
|
+
if (!this.isEnabled()) {
|
|
72
|
+
session.setAuth(true);
|
|
73
|
+
return this.server.sendResponse(socket, {
|
|
74
|
+
type: 'auth_success',
|
|
75
|
+
message: 'No authentication required'
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const storedPassword = this.authConfig[username];
|
|
80
|
+
if (storedPassword && this.verifyPassword(password, storedPassword)) {
|
|
81
|
+
session.setAuth(true);
|
|
82
|
+
this.server.sendResponse(socket, {
|
|
83
|
+
type: 'auth_success',
|
|
84
|
+
message: 'Authentication successful'
|
|
85
|
+
});
|
|
86
|
+
} else {
|
|
87
|
+
this.server.sendError(socket, 'Invalid credentials');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = AuthManager;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
class RequestRouter {
|
|
2
|
+
constructor(server) {
|
|
3
|
+
this.server = server;
|
|
4
|
+
this.dbRegistry = server.dbRegistry;
|
|
5
|
+
this.authManager = server.authManager;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
handle(socket, request, session) {
|
|
9
|
+
const { type, payload } = request;
|
|
10
|
+
|
|
11
|
+
// Authentication check
|
|
12
|
+
if (this.authManager.isEnabled() && !session.authenticated && type !== 'auth') {
|
|
13
|
+
return this.server.sendError(socket, 'Authentication required');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
switch (type) {
|
|
17
|
+
case 'auth':
|
|
18
|
+
this.authManager.handleAuth(socket, payload, session);
|
|
19
|
+
break;
|
|
20
|
+
|
|
21
|
+
case 'use':
|
|
22
|
+
this.handleUseDatabase(socket, payload, session);
|
|
23
|
+
break;
|
|
24
|
+
|
|
25
|
+
case 'query':
|
|
26
|
+
this.handleQuery(socket, payload, session);
|
|
27
|
+
break;
|
|
28
|
+
|
|
29
|
+
case 'ping':
|
|
30
|
+
this.server.sendResponse(socket, { type: 'pong', timestamp: Date.now() });
|
|
31
|
+
break;
|
|
32
|
+
|
|
33
|
+
case 'list_databases':
|
|
34
|
+
this.handleListDatabases(socket);
|
|
35
|
+
break;
|
|
36
|
+
|
|
37
|
+
case 'drop_database':
|
|
38
|
+
this.handleDropDatabase(socket, payload, session);
|
|
39
|
+
break;
|
|
40
|
+
|
|
41
|
+
case 'stats':
|
|
42
|
+
this.handleStats(socket);
|
|
43
|
+
break;
|
|
44
|
+
|
|
45
|
+
default:
|
|
46
|
+
this.server.sendError(socket, `Unknown request type: ${type}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
handleUseDatabase(socket, payload, session) {
|
|
51
|
+
const { database } = payload;
|
|
52
|
+
|
|
53
|
+
if (!database || typeof database !== 'string') {
|
|
54
|
+
return this.server.sendError(socket, 'Invalid database name');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Validate database name (alphanumeric, underscore, dash)
|
|
58
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(database)) {
|
|
59
|
+
return this.server.sendError(
|
|
60
|
+
socket,
|
|
61
|
+
'Database name can only contain letters, numbers, underscore, and dash'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
this.dbRegistry.getOrCreate(database);
|
|
67
|
+
session.setDatabase(database);
|
|
68
|
+
this.server.sendResponse(socket, {
|
|
69
|
+
type: 'use_success',
|
|
70
|
+
database,
|
|
71
|
+
message: `Switched to database '${database}'`
|
|
72
|
+
});
|
|
73
|
+
} catch (err) {
|
|
74
|
+
this.server.sendError(socket, `Failed to use database: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async handleQuery(socket, payload, session) {
|
|
79
|
+
const { query, params } = payload;
|
|
80
|
+
const startTime = Date.now();
|
|
81
|
+
|
|
82
|
+
// --- Intercept Server-Level Commands (Wilayah Management) ---
|
|
83
|
+
const qUpper = query.trim().toUpperCase();
|
|
84
|
+
|
|
85
|
+
// 1. LIHAT WILAYAH
|
|
86
|
+
if (qUpper === 'LIHAT WILAYAH' || qUpper === 'SHOW DATABASES') {
|
|
87
|
+
try {
|
|
88
|
+
const list = this.dbRegistry.list()
|
|
89
|
+
.map((f) => `- ${f}`)
|
|
90
|
+
.join('\n');
|
|
91
|
+
const result = `Daftar Wilayah:\n${list}`;
|
|
92
|
+
|
|
93
|
+
return this.server.sendResponse(socket, {
|
|
94
|
+
type: 'query_result',
|
|
95
|
+
result,
|
|
96
|
+
query,
|
|
97
|
+
executionTime: Date.now() - startTime
|
|
98
|
+
});
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return this.server.sendError(socket, `Gagal melihat wilayah: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. BUKA WILAYAH / CREATE DATABASE
|
|
105
|
+
if (qUpper.startsWith('BUKA WILAYAH') || qUpper.startsWith('CREATE DATABASE')) {
|
|
106
|
+
const parts = query.trim().split(/\s+/);
|
|
107
|
+
// Index 2 is name (BUKA WILAYAH name OR CREATE DATABASE name)
|
|
108
|
+
if (parts.length < 3) {
|
|
109
|
+
return this.server.sendError(socket, 'Syntax: BUKA WILAYAH [nama]');
|
|
110
|
+
}
|
|
111
|
+
const dbName = parts[2];
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(dbName)) {
|
|
115
|
+
return this.server.sendError(socket, 'Nama wilayah hanya boleh huruf, angka, _ dan -');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this.dbRegistry.exists(dbName)) {
|
|
119
|
+
return this.server.sendResponse(socket, {
|
|
120
|
+
type: 'query_result',
|
|
121
|
+
result: `Wilayah '${dbName}' sudah ada.`,
|
|
122
|
+
query,
|
|
123
|
+
executionTime: Date.now() - startTime
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.dbRegistry.create(dbName);
|
|
128
|
+
return this.server.sendResponse(socket, {
|
|
129
|
+
type: 'query_result',
|
|
130
|
+
result: `Wilayah '${dbName}' berhasil dibuka.`,
|
|
131
|
+
query,
|
|
132
|
+
executionTime: Date.now() - startTime
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return this.server.sendError(socket, `Gagal membuka wilayah: ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 3. MASUK WILAYAH / USE
|
|
140
|
+
if (qUpper.startsWith('MASUK WILAYAH') || qUpper.startsWith('USE ')) {
|
|
141
|
+
const parts = query.trim().split(/\s+/);
|
|
142
|
+
const dbName = parts[1] === 'WILAYAH' ? parts[2] : parts[1]; // Handle MASUK WILAYAH vs USE
|
|
143
|
+
|
|
144
|
+
if (!dbName) return this.server.sendError(socket, 'Syntax: MASUK WILAYAH [nama]');
|
|
145
|
+
|
|
146
|
+
if (!this.dbRegistry.exists(dbName)) {
|
|
147
|
+
return this.server.sendError(socket, `Wilayah '${dbName}' tidak ditemukan.`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
session.setDatabase(dbName);
|
|
151
|
+
return this.server.sendResponse(socket, {
|
|
152
|
+
type: 'query_result',
|
|
153
|
+
result: `Selamat datang di wilayah '${dbName}'.`,
|
|
154
|
+
query,
|
|
155
|
+
executionTime: Date.now() - startTime
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 4. BAKAR WILAYAH / DROP DATABASE
|
|
160
|
+
if (qUpper.startsWith('BAKAR WILAYAH') || qUpper.startsWith('DROP DATABASE')) {
|
|
161
|
+
const parts = query.trim().split(/\s+/);
|
|
162
|
+
const dbName = parts[1] === 'WILAYAH' ? parts[2] : parts[parts.length - 1]; // BAKAR WILAYAH name vs DROP DATABASE name
|
|
163
|
+
// Simple split logic might fail on extra spaces, but good enough for now.
|
|
164
|
+
// Strict parsing:
|
|
165
|
+
const nameIndex = qUpper.startsWith('BAKAR') ? 2 : 2;
|
|
166
|
+
const targetName = parts[nameIndex];
|
|
167
|
+
|
|
168
|
+
if (!targetName) return this.server.sendError(socket, 'Syntax: BAKAR WILAYAH [nama]');
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
this.dbRegistry.drop(targetName);
|
|
172
|
+
if (session.currentDatabase === targetName) {
|
|
173
|
+
session.setDatabase(null);
|
|
174
|
+
}
|
|
175
|
+
return this.server.sendResponse(socket, {
|
|
176
|
+
type: 'query_result',
|
|
177
|
+
result: `Wilayah '${targetName}' telah hangus terbakar.`,
|
|
178
|
+
query,
|
|
179
|
+
executionTime: Date.now() - startTime
|
|
180
|
+
});
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return this.server.sendError(socket, `Gagal membakar wilayah: ${err.message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- End Intercept ---
|
|
187
|
+
|
|
188
|
+
if (!session.currentDatabase) {
|
|
189
|
+
return this.server.sendError(
|
|
190
|
+
socket,
|
|
191
|
+
'Anda belum masuk wilayah manapun. Gunakan: MASUK WILAYAH [nama]'
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
let result;
|
|
197
|
+
if (this.server.threadPool) {
|
|
198
|
+
// Offload to Worker Thread (assuming ThreadPool interface matches)
|
|
199
|
+
const dbPath = this.dbRegistry.get(session.currentDatabase).pager.filePath; // Hack to get path or use Registry
|
|
200
|
+
// But Registry creates path.
|
|
201
|
+
// Let's rely on Registry.get(name) returning db instance.
|
|
202
|
+
// But ThreadPool needs path.
|
|
203
|
+
const fullPath = require('path').join(this.dbRegistry.dataDir, `${session.currentDatabase}.sawit`);
|
|
204
|
+
result = await this.server.threadPool.execute(fullPath, query, this.server.dbRegistry.walConfig);
|
|
205
|
+
} else {
|
|
206
|
+
// Local Execution
|
|
207
|
+
const db = this.dbRegistry.get(session.currentDatabase);
|
|
208
|
+
result = await Promise.resolve(db.query(query, params));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const duration = Date.now() - startTime;
|
|
212
|
+
this.server.stats.totalQueries++;
|
|
213
|
+
this.server.log('debug', `Query: ${query}`, { duration });
|
|
214
|
+
|
|
215
|
+
this.server.sendResponse(socket, {
|
|
216
|
+
type: 'query_result',
|
|
217
|
+
result,
|
|
218
|
+
query,
|
|
219
|
+
executionTime: duration
|
|
220
|
+
});
|
|
221
|
+
} catch (err) {
|
|
222
|
+
this.server.log('error', `Query failed: ${err.message}`);
|
|
223
|
+
this.server.stats.errors++;
|
|
224
|
+
this.server.sendError(socket, `Query error: ${err.message}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
handleListDatabases(socket) {
|
|
229
|
+
try {
|
|
230
|
+
const databases = this.dbRegistry.list();
|
|
231
|
+
this.server.sendResponse(socket, {
|
|
232
|
+
type: 'database_list',
|
|
233
|
+
databases,
|
|
234
|
+
count: databases.length
|
|
235
|
+
});
|
|
236
|
+
} catch (err) {
|
|
237
|
+
this.server.sendError(socket, `Failed to list databases: ${err.message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
handleDropDatabase(socket, payload, session) {
|
|
242
|
+
const { database } = payload;
|
|
243
|
+
if (!database) return this.server.sendError(socket, 'Database name required');
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
this.dbRegistry.drop(database);
|
|
247
|
+
if (session.currentDatabase === database) {
|
|
248
|
+
session.setDatabase(null);
|
|
249
|
+
}
|
|
250
|
+
this.server.sendResponse(socket, {
|
|
251
|
+
type: 'drop_success',
|
|
252
|
+
database,
|
|
253
|
+
message: `Database '${database}' has been burned (dropped)`
|
|
254
|
+
});
|
|
255
|
+
} catch (err) {
|
|
256
|
+
this.server.sendError(socket, `Failed to drop database: ${err.message}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
handleStats(socket) {
|
|
261
|
+
const uptime = Date.now() - this.server.stats.startTime;
|
|
262
|
+
const stats = {
|
|
263
|
+
...this.server.stats,
|
|
264
|
+
uptime,
|
|
265
|
+
uptimeFormatted: this.server.formatUptime(uptime),
|
|
266
|
+
databases: this.dbRegistry.databases.size,
|
|
267
|
+
memoryUsage: process.memoryUsage(),
|
|
268
|
+
workers: this.server.threadPool ? this.server.threadPool.getStats() : null,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
this.server.sendResponse(socket, {
|
|
272
|
+
type: 'stats',
|
|
273
|
+
stats
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = RequestRouter;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class ClientSession {
|
|
2
|
+
constructor(socket, clientId) {
|
|
3
|
+
this.socket = socket;
|
|
4
|
+
this.clientId = clientId;
|
|
5
|
+
this.authenticated = false;
|
|
6
|
+
this.currentDatabase = null;
|
|
7
|
+
this.connectedAt = Date.now();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
setAuth(isAuthenticated) {
|
|
11
|
+
this.authenticated = isAuthenticated;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
setDatabase(databaseName) {
|
|
15
|
+
this.currentDatabase = databaseName;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = ClientSession;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const BTreeIndex = require('../modules/BTreeIndex');
|
|
2
|
+
|
|
3
|
+
class IndexManager {
|
|
4
|
+
constructor(db) {
|
|
5
|
+
this.db = db;
|
|
6
|
+
// Indexes are stored in db.indexes for now to maintain state
|
|
7
|
+
// access via db.indexes
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
get indexes() {
|
|
11
|
+
return this.db.indexes;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
createIndex(table, field) {
|
|
15
|
+
const entry = this.db.tableManager
|
|
16
|
+
? this.db.tableManager.findTableEntry(table)
|
|
17
|
+
: this.db._findTableEntry(table);
|
|
18
|
+
|
|
19
|
+
if (!entry) throw new Error(`Kebun '${table}' tidak ditemukan.`);
|
|
20
|
+
|
|
21
|
+
const indexKey = `${table}.${field}`;
|
|
22
|
+
if (this.indexes.has(indexKey)) {
|
|
23
|
+
return `Indeks pada '${table}.${field}' sudah ada.`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Create index
|
|
27
|
+
const index = new BTreeIndex();
|
|
28
|
+
index.name = indexKey;
|
|
29
|
+
index.keyField = field;
|
|
30
|
+
|
|
31
|
+
// Build index from existing data
|
|
32
|
+
// We need a way to scan the table.
|
|
33
|
+
// If TableManager has no scan capability yet, use db._select or db._scanTable fallback
|
|
34
|
+
// Ideally this logic belongs here or uses a Scanner service.
|
|
35
|
+
// For now, assuming db._select or similar is available or we replicate scan logic.
|
|
36
|
+
// Actually, db._select depends on QueryExecutor now?
|
|
37
|
+
// Let's rely on db._select being available (maybe delegating to SelectExecutor!)
|
|
38
|
+
// Circular dependency risk: SelectExecutor needs IndexManager, IndexManager needs SelectExecutor logic?
|
|
39
|
+
// Index building needs a simple table scan.
|
|
40
|
+
// Let's imply we can use db._scanTable directly if available.
|
|
41
|
+
|
|
42
|
+
let allRecords = [];
|
|
43
|
+
if (this.db._scanTable) {
|
|
44
|
+
// Use low-level scan if available, WITH HINTS to capture _pageId
|
|
45
|
+
allRecords = this.db._scanTable(entry, null, null, true);
|
|
46
|
+
} else if (this.db.query) {
|
|
47
|
+
// Use high level query? Risky.
|
|
48
|
+
// Assume _scanTable is preserved or moved to a Scanner.
|
|
49
|
+
// For now, let's assume WowoEngine keeps _scanTable or we move it to TableScanner.
|
|
50
|
+
// Let's use `db._scanTable` assuming it exists.
|
|
51
|
+
allRecords = this.db._scanTable(entry, null, null, true);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const record of allRecords) {
|
|
55
|
+
if (record.hasOwnProperty(field)) {
|
|
56
|
+
index.insert(record[field], record);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.indexes.set(indexKey, index);
|
|
61
|
+
|
|
62
|
+
// PERSISTENCE: Save to _indexes table
|
|
63
|
+
try {
|
|
64
|
+
// Use db._insert to persist request.
|
|
65
|
+
// If InsertExecutor delegates to db._insert, or db._insert uses InsertExecutor?
|
|
66
|
+
// Safer to allow db._insert if it resolves to the executor.
|
|
67
|
+
this.db._insert('_indexes', { table, field });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.error("Failed to persist index definition", e);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return `Indeks dibuat pada '${table}.${field}' (${allRecords.length} records indexed)`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
updateIndexes(table, newObj, oldObj) {
|
|
76
|
+
// If oldObj is null, it's an INSERT. If newObj is null, it's a DELETE. Both? Update.
|
|
77
|
+
|
|
78
|
+
for (const [indexKey, index] of this.indexes) {
|
|
79
|
+
const [tbl, field] = indexKey.split('.');
|
|
80
|
+
if (tbl !== table) continue; // Wrong table
|
|
81
|
+
|
|
82
|
+
// 1. Remove old value from index (if exists and changed)
|
|
83
|
+
if (oldObj && oldObj.hasOwnProperty(field)) {
|
|
84
|
+
// Only remove if value changed OR it's a delete (newObj is null)
|
|
85
|
+
// If update, check if value diff
|
|
86
|
+
if (!newObj || newObj[field] !== oldObj[field]) {
|
|
87
|
+
index.delete(oldObj[field]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2. Insert new value (if exists)
|
|
92
|
+
if (newObj && newObj.hasOwnProperty(field)) {
|
|
93
|
+
// Only insert if it's new OR value changed
|
|
94
|
+
if (!oldObj || newObj[field] !== oldObj[field]) {
|
|
95
|
+
index.insert(newObj[field], newObj);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
removeFromIndexes(table, data) {
|
|
102
|
+
for (const [indexKey, index] of this.indexes) {
|
|
103
|
+
const [tbl, field] = indexKey.split('.');
|
|
104
|
+
if (tbl === table && data.hasOwnProperty(field)) {
|
|
105
|
+
index.delete(data[field]); // Basic deletion from B-Tree
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
showIndexes(table) {
|
|
111
|
+
if (table) {
|
|
112
|
+
const indexes = [];
|
|
113
|
+
for (const [key, index] of this.indexes) {
|
|
114
|
+
if (key.startsWith(table + '.')) {
|
|
115
|
+
indexes.push(index.stats());
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return indexes.length > 0 ? indexes : `Tidak ada indeks pada '${table}'`;
|
|
119
|
+
} else {
|
|
120
|
+
const allIndexes = [];
|
|
121
|
+
for (const index of this.indexes.values()) {
|
|
122
|
+
allIndexes.push(index.stats());
|
|
123
|
+
}
|
|
124
|
+
return allIndexes;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Initial loader
|
|
129
|
+
loadIndexes() {
|
|
130
|
+
if (!this.db._select) return; // Wait until ready?
|
|
131
|
+
|
|
132
|
+
// Re-implement load indexes to include Hints
|
|
133
|
+
// We need to read _indexes.
|
|
134
|
+
// db._select might use SelectExecutor, which uses IndexManager...
|
|
135
|
+
// Bootstrapping issue.
|
|
136
|
+
// We should use low-level scan for bootstrapping.
|
|
137
|
+
|
|
138
|
+
let indexRecords = [];
|
|
139
|
+
try {
|
|
140
|
+
// Manual scan of _indexes
|
|
141
|
+
const entry = this.db.tableManager
|
|
142
|
+
? this.db.tableManager.findTableEntry('_indexes')
|
|
143
|
+
: this.db._findTableEntry('_indexes');
|
|
144
|
+
|
|
145
|
+
if (entry && this.db._scanTable) {
|
|
146
|
+
indexRecords = this.db._scanTable(entry, null);
|
|
147
|
+
}
|
|
148
|
+
} catch (e) { return; }
|
|
149
|
+
|
|
150
|
+
for (const rec of indexRecords) {
|
|
151
|
+
const table = rec.table;
|
|
152
|
+
const field = rec.field;
|
|
153
|
+
const indexKey = `${table}.${field}`;
|
|
154
|
+
|
|
155
|
+
if (!this.indexes.has(indexKey)) {
|
|
156
|
+
const index = new BTreeIndex();
|
|
157
|
+
index.name = indexKey;
|
|
158
|
+
index.keyField = field;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
// Fetch all records with Hints
|
|
162
|
+
const entry = this.db.tableManager
|
|
163
|
+
? this.db.tableManager.findTableEntry(table)
|
|
164
|
+
: this.db._findTableEntry(table);
|
|
165
|
+
|
|
166
|
+
if (entry && this.db._scanTable) {
|
|
167
|
+
const allRecords = this.db._scanTable(entry, null, null, true); // true for Hints
|
|
168
|
+
for (const record of allRecords) {
|
|
169
|
+
if (record.hasOwnProperty(field)) {
|
|
170
|
+
index.insert(record[field], record);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
this.indexes.set(indexKey, index);
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.error(`Failed to rebuild index ${indexKey}: ${e.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = IndexManager;
|