@wowoengine/sawitdb 2.4.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.
@@ -0,0 +1,602 @@
1
+ const net = require('net');
2
+ const SawitDB = require('./WowoEngine');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ /**
7
+ * SawitDB Server - Network Database Server
8
+ * Supports sawitdb:// protocol connections
9
+ */
10
+ class SawitServer {
11
+ constructor(config = {}) {
12
+ // Validate and set configuration
13
+ this.port = this._validatePort(config.port || 7878);
14
+ this.host = config.host || '0.0.0.0';
15
+ this.dataDir = config.dataDir || path.join(__dirname, '../data');
16
+ this.databases = new Map(); // Map of database name -> SawitDB instance
17
+ this.clients = new Set();
18
+ this.server = null;
19
+ this.auth = config.auth || null; // { username: 'password' }
20
+ this.maxConnections = config.maxConnections || 100;
21
+ this.queryTimeout = config.queryTimeout || 30000; // 30 seconds
22
+ this.logLevel = config.logLevel || 'info'; // 'debug', 'info', 'warn', 'error'
23
+ this.stats = {
24
+ totalConnections: 0,
25
+ activeConnections: 0,
26
+ totalQueries: 0,
27
+ errors: 0,
28
+ startTime: Date.now()
29
+ };
30
+
31
+ // Ensure data directory exists
32
+ if (!fs.existsSync(this.dataDir)) {
33
+ fs.mkdirSync(this.dataDir, { recursive: true });
34
+ }
35
+
36
+ this._log('info', `Data directory: ${this.dataDir}`);
37
+ this._log('info', `Max connections: ${this.maxConnections}`);
38
+ }
39
+
40
+ _validatePort(port) {
41
+ const p = parseInt(port);
42
+ if (isNaN(p) || p < 1 || p > 65535) {
43
+ throw new Error(`Invalid port: ${port}. Must be between 1-65535`);
44
+ }
45
+ return p;
46
+ }
47
+
48
+ _log(level, message, data = null) {
49
+ const levels = { debug: 0, info: 1, warn: 2, error: 3 };
50
+ const currentLevel = levels[this.logLevel] || 1;
51
+ const msgLevel = levels[level] || 1;
52
+
53
+ if (msgLevel >= currentLevel) {
54
+ const timestamp = new Date().toISOString();
55
+ const prefix = level.toUpperCase().padEnd(5);
56
+ console.log(`[${timestamp}] [${prefix}] ${message}`);
57
+ if (data && this.logLevel === 'debug') {
58
+ console.log(JSON.stringify(data, null, 2));
59
+ }
60
+ }
61
+ }
62
+
63
+ start() {
64
+ this.server = net.createServer((socket) => this._handleConnection(socket));
65
+
66
+ this.server.listen(this.port, this.host, () => {
67
+ console.log(`╔══════════════════════════════════════════════════╗`);
68
+ console.log(`║ 🌴 SawitDB Server - Version 2.4 ║`);
69
+ console.log(`╚══════════════════════════════════════════════════╝`);
70
+ console.log(`[Server] Listening on ${this.host}:${this.port}`);
71
+ console.log(`[Server] Protocol: sawitdb://${this.host}:${this.port}/[database]`);
72
+ console.log(`[Server] Ready to accept connections...`);
73
+ });
74
+
75
+ this.server.on('error', (err) => {
76
+ console.error('[Server] Error:', err.message);
77
+ });
78
+ }
79
+
80
+ stop() {
81
+ console.log('[Server] Shutting down...');
82
+
83
+ // Close all client connections
84
+ for (const client of this.clients) {
85
+ client.destroy();
86
+ }
87
+
88
+ // Close server
89
+ if (this.server) {
90
+ this.server.close(() => {
91
+ console.log('[Server] Server stopped.');
92
+ });
93
+ }
94
+ }
95
+
96
+ _handleConnection(socket) {
97
+ const clientId = `${socket.remoteAddress}:${socket.remotePort}`;
98
+
99
+ // Check connection limit
100
+ if (this.clients.size >= this.maxConnections) {
101
+ this._log('warn', `Connection limit reached. Rejecting ${clientId}`);
102
+ socket.write(JSON.stringify({
103
+ type: 'error',
104
+ error: 'Server connection limit reached. Please try again later.'
105
+ }) + '\n');
106
+ socket.end();
107
+ return;
108
+ }
109
+
110
+ this._log('info', `Client connected: ${clientId}`);
111
+ this.stats.totalConnections++;
112
+ this.stats.activeConnections++;
113
+
114
+ this.clients.add(socket);
115
+ socket.clientId = clientId;
116
+ socket.connectedAt = Date.now();
117
+
118
+ let authenticated = !this.auth; // If no auth required, auto-authenticate
119
+ let currentDatabase = null;
120
+ let buffer = '';
121
+
122
+ socket.on('data', (data) => {
123
+ buffer += data.toString();
124
+
125
+ // Prevent buffer overflow attacks
126
+ if (buffer.length > 1048576) { // 1MB limit
127
+ this._log('warn', `Buffer overflow attempt from ${clientId}`);
128
+ this._sendError(socket, 'Request too large');
129
+ socket.destroy();
130
+ return;
131
+ }
132
+
133
+ // Process complete messages (delimited by newline)
134
+ let newlineIndex;
135
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
136
+ const message = buffer.substring(0, newlineIndex);
137
+ buffer = buffer.substring(newlineIndex + 1);
138
+
139
+ try {
140
+ const request = JSON.parse(message);
141
+ this._log('debug', `Request from ${clientId}`, request);
142
+
143
+ // Handle with timeout
144
+ this._handleRequest(socket, request, {
145
+ authenticated,
146
+ currentDatabase,
147
+ setAuth: (val) => { authenticated = val; },
148
+ setDatabase: (db) => { currentDatabase = db; }
149
+ });
150
+ } catch (err) {
151
+ this._log('error', `Invalid request from ${clientId}: ${err.message}`);
152
+ this.stats.errors++;
153
+ this._sendError(socket, `Invalid request format: ${err.message}`);
154
+ }
155
+ }
156
+ });
157
+
158
+ socket.on('end', () => {
159
+ const duration = Date.now() - (socket.connectedAt || Date.now());
160
+ this._log('info', `Client disconnected: ${clientId} (duration: ${duration}ms)`);
161
+ this.clients.delete(socket);
162
+ this.stats.activeConnections--;
163
+ });
164
+
165
+ socket.on('error', (err) => {
166
+ this._log('error', `Client error: ${clientId} - ${err.message}`);
167
+ this.clients.delete(socket);
168
+ this.stats.activeConnections--;
169
+ this.stats.errors++;
170
+ });
171
+
172
+ socket.on('timeout', () => {
173
+ this._log('warn', `Client timeout: ${clientId}`);
174
+ socket.destroy();
175
+ });
176
+
177
+ // Set socket timeout
178
+ socket.setTimeout(this.queryTimeout);
179
+
180
+ // Send welcome message
181
+ this._sendResponse(socket, {
182
+ type: 'welcome',
183
+ message: 'SawitDB Server',
184
+ version: '2.4',
185
+ protocol: 'sawitdb'
186
+ });
187
+ }
188
+
189
+ _handleRequest(socket, request, context) {
190
+ const { type, payload } = request;
191
+
192
+ // Authentication check
193
+ if (this.auth && !context.authenticated && type !== 'auth') {
194
+ return this._sendError(socket, 'Authentication required');
195
+ }
196
+
197
+ switch (type) {
198
+ case 'auth':
199
+ this._handleAuth(socket, payload, context);
200
+ break;
201
+
202
+ case 'use':
203
+ this._handleUseDatabase(socket, payload, context);
204
+ break;
205
+
206
+ case 'query':
207
+ this._handleQuery(socket, payload, context);
208
+ break;
209
+
210
+ case 'ping':
211
+ this._sendResponse(socket, { type: 'pong', timestamp: Date.now() });
212
+ break;
213
+
214
+ case 'list_databases':
215
+ this._handleListDatabases(socket);
216
+ break;
217
+
218
+ case 'drop_database':
219
+ this._handleDropDatabase(socket, payload, context);
220
+ break;
221
+
222
+ case 'stats':
223
+ this._handleStats(socket);
224
+ break;
225
+
226
+ default:
227
+ this._sendError(socket, `Unknown request type: ${type}`);
228
+ }
229
+ }
230
+
231
+ _handleStats(socket) {
232
+ const uptime = Date.now() - this.stats.startTime;
233
+ const stats = {
234
+ ...this.stats,
235
+ uptime,
236
+ uptimeFormatted: this._formatUptime(uptime),
237
+ databases: this.databases.size,
238
+ memoryUsage: process.memoryUsage()
239
+ };
240
+
241
+ this._sendResponse(socket, {
242
+ type: 'stats',
243
+ stats
244
+ });
245
+ }
246
+
247
+ _formatUptime(ms) {
248
+ const seconds = Math.floor(ms / 1000);
249
+ const minutes = Math.floor(seconds / 60);
250
+ const hours = Math.floor(minutes / 60);
251
+ const days = Math.floor(hours / 24);
252
+
253
+ if (days > 0) return `${days}d ${hours % 24}h`;
254
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
255
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
256
+ return `${seconds}s`;
257
+ }
258
+
259
+ _handleAuth(socket, payload, context) {
260
+ const { username, password } = payload;
261
+
262
+ if (!this.auth) {
263
+ context.setAuth(true);
264
+ return this._sendResponse(socket, { type: 'auth_success', message: 'No authentication required' });
265
+ }
266
+
267
+ if (this.auth[username] === password) {
268
+ context.setAuth(true);
269
+ this._sendResponse(socket, { type: 'auth_success', message: 'Authentication successful' });
270
+ } else {
271
+ this._sendError(socket, 'Invalid credentials');
272
+ }
273
+ }
274
+
275
+ _handleUseDatabase(socket, payload, context) {
276
+ const { database } = payload;
277
+
278
+ if (!database || typeof database !== 'string') {
279
+ return this._sendError(socket, 'Invalid database name');
280
+ }
281
+
282
+ // Validate database name (alphanumeric, underscore, dash)
283
+ if (!/^[a-zA-Z0-9_-]+$/.test(database)) {
284
+ return this._sendError(socket, 'Database name can only contain letters, numbers, underscore, and dash');
285
+ }
286
+
287
+ try {
288
+ const db = this._getOrCreateDatabase(database);
289
+ context.setDatabase(database);
290
+ this._sendResponse(socket, {
291
+ type: 'use_success',
292
+ database,
293
+ message: `Switched to database '${database}'`
294
+ });
295
+ } catch (err) {
296
+ this._sendError(socket, `Failed to use database: ${err.message}`);
297
+ }
298
+ }
299
+
300
+ _handleQuery(socket, payload, context) {
301
+ const { query, params } = payload;
302
+ const startTime = Date.now();
303
+
304
+ // --- Intercept Server-Level Commands (Wilayah Management) ---
305
+
306
+ const qUpper = query.trim().toUpperCase();
307
+
308
+ // 1. LIHAT WILAYAH
309
+ if (qUpper === 'LIHAT WILAYAH') {
310
+ try {
311
+ const databases = fs.readdirSync(this.dataDir)
312
+ .filter(file => file.endsWith('.sawit'));
313
+
314
+ // Format nicely as a table-like string or JSON
315
+ const list = databases.map(f => `- ${f.replace('.sawit', '')}`).join('\n');
316
+ const result = `Daftar Wilayah:\n${list}`;
317
+
318
+ return this._sendResponse(socket, {
319
+ type: 'query_result',
320
+ result,
321
+ query,
322
+ executionTime: Date.now() - startTime
323
+ });
324
+ } catch (err) {
325
+ return this._sendError(socket, `Gagal melihat wilayah: ${err.message}`);
326
+ }
327
+ }
328
+
329
+ // 2. BUKA WILAYAH [nama]
330
+ if (qUpper.startsWith('BUKA WILAYAH')) {
331
+ const parts = query.trim().split(/\s+/);
332
+ if (parts.length < 3) {
333
+ return this._sendError(socket, 'Syntax: BUKA WILAYAH [nama_wilayah]');
334
+ }
335
+ const dbName = parts[2];
336
+ // Reuse logic from internal handler if possible, otherwise implement here
337
+ // Implementing directly to match the "create empty sawit file" logic
338
+ try {
339
+ // Validation
340
+ if (!/^[a-zA-Z0-9_-]+$/.test(dbName)) {
341
+ return this._sendError(socket, 'Nama wilayah hanya boleh huruf, angka, _ dan -');
342
+ }
343
+
344
+ const dbPath = path.join(this.dataDir, `${dbName}.sawit`);
345
+ if (fs.existsSync(dbPath)) {
346
+ // It's technically fine if it exists, just say it's opened/available
347
+ // But strict "Create" usually expects new. Let's follow "Open/Create" semantics of key-value stores or similar
348
+ // Design doc says: "Membuat file ... kosong".
349
+ // If exists, let's just say it exists.
350
+ return this._sendResponse(socket, {
351
+ type: 'query_result',
352
+ result: `Wilayah '${dbName}' sudah ada.`,
353
+ query,
354
+ executionTime: Date.now() - startTime
355
+ });
356
+ }
357
+
358
+ // Create empty file (via SawitDB constructor which initializes it)
359
+ new SawitDB(dbPath);
360
+
361
+ return this._sendResponse(socket, {
362
+ type: 'query_result',
363
+ result: `Wilayah '${dbName}' berhasil dibuka.`,
364
+ query,
365
+ executionTime: Date.now() - startTime
366
+ });
367
+ } catch (err) {
368
+ return this._sendError(socket, `Gagal membuka wilayah: ${err.message}`);
369
+ }
370
+ }
371
+
372
+ // 3. MASUK WILAYAH [nama]
373
+ if (qUpper.startsWith('MASUK WILAYAH')) {
374
+ const parts = query.trim().split(/\s+/);
375
+ if (parts.length < 3) {
376
+ return this._sendError(socket, 'Syntax: MASUK WILAYAH [nama_wilayah]');
377
+ }
378
+ const dbName = parts[2];
379
+
380
+ // Allow "MASUK WILAYAH DEFAULT" case-insensitive match for file? No, usually case sensitive filesystems.
381
+ // But let's assume case-sensitive for ID.
382
+
383
+ const dbPath = path.join(this.dataDir, `${dbName}.sawit`);
384
+ if (!fs.existsSync(dbPath)) {
385
+ return this._sendError(socket, `Wilayah '${dbName}' tidak ditemukan.`);
386
+ }
387
+
388
+ context.setDatabase(dbName);
389
+ return this._sendResponse(socket, {
390
+ type: 'query_result', // Return as query result so CLI prints it normally
391
+ result: `Selamat datang di wilayah '${dbName}'.`,
392
+ query,
393
+ executionTime: Date.now() - startTime
394
+ });
395
+ }
396
+
397
+ // 4. BAKAR WILAYAH [nama]
398
+ if (qUpper.startsWith('BAKAR WILAYAH')) {
399
+ const parts = query.trim().split(/\s+/);
400
+ if (parts.length < 3) {
401
+ return this._sendError(socket, 'Syntax: BAKAR WILAYAH [nama_wilayah]');
402
+ }
403
+ const dbName = parts[2];
404
+
405
+ // Reuse existing logic
406
+ const payload = { database: dbName };
407
+ // We need to adapt the existing _handleDropDatabase to generic usage or call it.
408
+ // _handleDropDatabase sends its own response. We want query_result format preferably for consistency in CLI?
409
+ // The existing _handleDropDatabase sends 'drop_success'. CLI might not print that as a query result string.
410
+ // Let's reimplement logic here for 'query' flow to ensure 'query_result' type.
411
+
412
+ try {
413
+ const dbPath = path.join(this.dataDir, `${dbName}.sawit`);
414
+ if (!fs.existsSync(dbPath)) {
415
+ return this._sendError(socket, `Wilayah '${dbName}' tidak ditemukan.`);
416
+ }
417
+
418
+ if (this.databases.has(dbName)) {
419
+ this.databases.delete(dbName);
420
+ }
421
+
422
+ try { fs.unlinkSync(dbPath); } catch (e) { }
423
+
424
+ if (context.currentDatabase === dbName) {
425
+ context.setDatabase(null);
426
+ }
427
+
428
+ return this._sendResponse(socket, {
429
+ type: 'query_result',
430
+ result: `Wilayah '${dbName}' telah hangus terbakar.`,
431
+ query,
432
+ executionTime: Date.now() - startTime
433
+ });
434
+
435
+ } catch (err) {
436
+ return this._sendError(socket, `Gagal membakar wilayah: ${err.message}`);
437
+ }
438
+ }
439
+
440
+ // --- Generic Syntax Aliases (Server Level) ---
441
+
442
+ // 5. CREATE DATABASE [name] -> BUKA WILAYAH
443
+ if (qUpper.startsWith('CREATE DATABASE')) {
444
+ const parts = query.trim().split(/\s+/);
445
+ if (parts.length < 3) return this._sendError(socket, 'Syntax: CREATE DATABASE [name]');
446
+ // Recruit BUKA WILAYAH logic
447
+ const newQuery = `BUKA WILAYAH ${parts[2]}`;
448
+ return this._handleQuery(socket, { ...payload, query: newQuery }, context);
449
+ }
450
+
451
+ // 6. USE [name] -> MASUK WILAYAH
452
+ if (qUpper.startsWith('USE ')) {
453
+ const parts = query.trim().split(/\s+/);
454
+ if (parts.length < 2) return this._sendError(socket, 'Syntax: USE [name]');
455
+ // Recruit MASUK WILAYAH logic
456
+ const newQuery = `MASUK WILAYAH ${parts[1]}`;
457
+ return this._handleQuery(socket, { ...payload, query: newQuery }, context);
458
+ }
459
+
460
+ // 7. SHOW DATABASES -> LIHAT WILAYAH
461
+ if (qUpper === 'SHOW DATABASES') {
462
+ const newQuery = `LIHAT WILAYAH`;
463
+ return this._handleQuery(socket, { ...payload, query: newQuery }, context);
464
+ }
465
+
466
+ // 8. DROP DATABASE [name] -> BAKAR WILAYAH
467
+ if (qUpper.startsWith('DROP DATABASE')) {
468
+ const parts = query.trim().split(/\s+/);
469
+ if (parts.length < 3) return this._sendError(socket, 'Syntax: DROP DATABASE [name]');
470
+ // Recruit BAKAR WILAYAH logic
471
+ const newQuery = `BAKAR WILAYAH ${parts[2]}`;
472
+ return this._handleQuery(socket, { ...payload, query: newQuery }, context);
473
+ }
474
+
475
+ // --- End Intercept ---
476
+
477
+ if (!context.currentDatabase) {
478
+ return this._sendError(socket, 'Anda belum masuk wilayah manapun. Gunakan: MASUK WILAYAH [nama]');
479
+ }
480
+
481
+ try {
482
+ const db = this._getOrCreateDatabase(context.currentDatabase);
483
+ const result = db.query(query, params);
484
+ const duration = Date.now() - startTime;
485
+
486
+ this.stats.totalQueries++;
487
+ this._log('debug', `Query executed in ${duration}ms: ${query.substring(0, 50)}...`);
488
+
489
+ this._sendResponse(socket, {
490
+ type: 'query_result',
491
+ result,
492
+ query,
493
+ executionTime: duration
494
+ });
495
+ } catch (err) {
496
+ this._log('error', `Query failed: ${err.message} - Query: ${query}`);
497
+ this.stats.errors++;
498
+ this._sendError(socket, `Query error: ${err.message}`);
499
+ }
500
+ }
501
+
502
+ _handleListDatabases(socket) {
503
+ try {
504
+ const databases = fs.readdirSync(this.dataDir)
505
+ .filter(file => file.endsWith('.sawit'))
506
+ .map(file => file.replace('.sawit', ''));
507
+
508
+ this._sendResponse(socket, {
509
+ type: 'database_list',
510
+ databases,
511
+ count: databases.length
512
+ });
513
+ } catch (err) {
514
+ this._sendError(socket, `Failed to list databases: ${err.message}`);
515
+ }
516
+ }
517
+
518
+ _handleDropDatabase(socket, payload, context) {
519
+ const { database } = payload;
520
+
521
+ if (!database) {
522
+ return this._sendError(socket, 'Database name required');
523
+ }
524
+
525
+ try {
526
+ const dbPath = path.join(this.dataDir, `${database}.sawit`);
527
+
528
+ if (!fs.existsSync(dbPath)) {
529
+ return this._sendError(socket, `Database '${database}' does not exist`);
530
+ }
531
+
532
+ // Close database if open
533
+ if (this.databases.has(database)) {
534
+ this.databases.delete(database);
535
+ }
536
+
537
+ // Delete file
538
+ fs.unlinkSync(dbPath);
539
+
540
+ // Clear current database if it was dropped
541
+ if (context.currentDatabase === database) {
542
+ context.setDatabase(null);
543
+ }
544
+
545
+ this._sendResponse(socket, {
546
+ type: 'drop_success',
547
+ database,
548
+ message: `Database '${database}' has been burned (dropped)`
549
+ });
550
+ } catch (err) {
551
+ this._sendError(socket, `Failed to drop database: ${err.message}`);
552
+ }
553
+ }
554
+
555
+ _getOrCreateDatabase(name) {
556
+ if (!this.databases.has(name)) {
557
+ const dbPath = path.join(this.dataDir, `${name}.sawit`);
558
+ const db = new SawitDB(dbPath);
559
+ this.databases.set(name, db);
560
+ }
561
+ return this.databases.get(name);
562
+ }
563
+
564
+ _sendResponse(socket, data) {
565
+ try {
566
+ socket.write(JSON.stringify(data) + '\n');
567
+ } catch (err) {
568
+ console.error('[Server] Failed to send response:', err.message);
569
+ }
570
+ }
571
+
572
+ _sendError(socket, message) {
573
+ this._sendResponse(socket, { type: 'error', error: message });
574
+ }
575
+ }
576
+
577
+ module.exports = SawitServer;
578
+
579
+ // Allow running as standalone server
580
+ if (require.main === module) {
581
+ const server = new SawitServer({
582
+ port: process.env.SAWIT_PORT || 7878,
583
+ host: process.env.SAWIT_HOST || '0.0.0.0',
584
+ dataDir: process.env.SAWIT_DATA_DIR || path.join(__dirname, '../data'),
585
+ // Optional auth: { username: 'petani', password: 'sawit123' }
586
+ });
587
+
588
+ server.start();
589
+
590
+ // Graceful shutdown
591
+ process.on('SIGINT', () => {
592
+ console.log('\n[Server] Received SIGINT, shutting down gracefully...');
593
+ server.stop();
594
+ process.exit(0);
595
+ });
596
+
597
+ process.on('SIGTERM', () => {
598
+ console.log('\n[Server] Received SIGTERM, shutting down gracefully...');
599
+ server.stop();
600
+ process.exit(0);
601
+ });
602
+ }