PyMkDB 0.1.0__py3-none-any.whl

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 (54) hide show
  1. pymkdb/__init__.py +6 -0
  2. pymkdb/cli.py +57 -0
  3. pymkdb-0.1.0.dist-info/METADATA +86 -0
  4. pymkdb-0.1.0.dist-info/RECORD +54 -0
  5. pymkdb-0.1.0.dist-info/WHEEL +5 -0
  6. pymkdb-0.1.0.dist-info/entry_points.txt +2 -0
  7. pymkdb-0.1.0.dist-info/top_level.txt +3 -0
  8. sdk/__init__.py +1 -0
  9. sdk/connection.py +225 -0
  10. sdk/delta.py +19 -0
  11. sdk/http_connection.py +180 -0
  12. sdk/mkdb_client.py +226 -0
  13. sdk/responses.py +154 -0
  14. src/__init__.py +1 -0
  15. src/config/db.py +227 -0
  16. src/config/server.py +52 -0
  17. src/db/__init__.py +207 -0
  18. src/db/cache/__init__.py +1 -0
  19. src/db/cache/ram_cache.py +144 -0
  20. src/db/cache/write_queue.py +156 -0
  21. src/db/maintenance/__init__.py +0 -0
  22. src/db/maintenance/compactor.py +118 -0
  23. src/db/maintenance/task_scheduler.py +73 -0
  24. src/db/objects/store.py +283 -0
  25. src/db/parity/__init__.py +0 -0
  26. src/db/parity/parity_manager.py +196 -0
  27. src/db/query/__init__.py +1 -0
  28. src/db/query/full_text_index.py +168 -0
  29. src/db/query/numeric_index.py +196 -0
  30. src/db/query/query_engine.py +308 -0
  31. src/db/query/tokenizer.py +48 -0
  32. src/db/query_workers/__init__.py +16 -0
  33. src/db/query_workers/dispatcher.py +339 -0
  34. src/db/query_workers/task.py +78 -0
  35. src/db/query_workers/worker.py +292 -0
  36. src/db/requesting/main.py +0 -0
  37. src/db/storage/__init__.py +1 -0
  38. src/db/storage/blob_store.py +47 -0
  39. src/db/storage/index_manager.py +92 -0
  40. src/db/storage/log_manager.py +119 -0
  41. src/db/storage/serializer.py +38 -0
  42. src/filing/__init__.py +31 -0
  43. src/objects/__init__.py +190 -0
  44. src/runtime/__init__.py +15 -0
  45. src/server/__init__.py +0 -0
  46. src/server/coms/actions.py +209 -0
  47. src/server/coms/http.py +46 -0
  48. src/server/coms/http_handlers.py +445 -0
  49. src/server/coms/metrics.py +231 -0
  50. src/server/coms/socket.py +461 -0
  51. src/server/coms/socket_protocol.py +54 -0
  52. src/server/control/api/actions.py +1001 -0
  53. src/server/control/server.py +404 -0
  54. src/server/event_log.py +58 -0
@@ -0,0 +1,1001 @@
1
+ from src.db import mkdb
2
+ from src.config.db import mkdb_config
3
+
4
+ def api_create_store(database: mkdb, data: dict):
5
+ """Create a new store in the config."""
6
+ store_name:str = data.get("name", "").strip()
7
+ description:str = data.get("description", "").strip()
8
+
9
+ if not store_name:
10
+ raise ValueError("Store name is required")
11
+
12
+ if database is None:
13
+ raise RuntimeError("Database not initialized")
14
+
15
+ try:
16
+ database.config.new_store(store_name, description)
17
+ database.setup()
18
+ return {
19
+ "store_name": store_name,
20
+ "message": f"Store '{store_name}' created successfully"
21
+ }
22
+ except ValueError as e:
23
+ raise e
24
+
25
+ def api_delete_store(database: mkdb, data: dict):
26
+ """Delete a store from the config."""
27
+ store_name:str = data.get("name", "").strip()
28
+
29
+ if not store_name:
30
+ raise ValueError("Store name is required")
31
+
32
+ if database is None:
33
+ raise RuntimeError("Database not initialized")
34
+
35
+ if store_name not in database.config.stores:
36
+ raise ValueError(f"Store '{store_name}' does not exist")
37
+
38
+ del database.config.stores[store_name]
39
+
40
+ return {
41
+ "store_name": store_name,
42
+ "message": f"Store '{store_name}' deleted successfully"
43
+ }
44
+
45
+ def api_list_stores(database: mkdb, data: dict):
46
+ """List all stores from the config."""
47
+ if database is None:
48
+ raise RuntimeError("Database not initialized")
49
+
50
+ return {
51
+ "stores": [{"name": x, "description": database.config.stores[x].description} for x in database.config.stores],
52
+ "count": len(database.config.stores)
53
+ }
54
+
55
+ def api_update_store(database: mkdb, data: dict):
56
+ """Update a store's description."""
57
+ store_name: str = data.get("name", "").strip()
58
+ description: str = data.get("description", "").strip()
59
+
60
+ if not store_name:
61
+ raise ValueError("Store name is required")
62
+
63
+ if database is None:
64
+ raise RuntimeError("Database not initialized")
65
+
66
+ if store_name not in database.config.stores:
67
+ raise ValueError(f"Store '{store_name}' does not exist")
68
+
69
+ database.config.stores[store_name].description = description
70
+ database.config.save()
71
+
72
+ return {
73
+ "store_name": store_name,
74
+ "description": description,
75
+ "message": f"Store '{store_name}' updated successfully"
76
+ }
77
+
78
+ def api_generate_id(database: mkdb, data: dict):
79
+ """Generate one or more unique record IDs for a store.
80
+
81
+ Required: name (store name)
82
+ Optional: count (int, 1–100, default 1)
83
+
84
+ Delegates to store.generate_id() which respects entity_config.token_chars,
85
+ entity_config.token_length, and entity_config.auto_expand.
86
+ """
87
+ store_name: str = data.get("name", "").strip()
88
+ count: int = max(1, min(100, int(data.get("count", 1))))
89
+
90
+ if not store_name:
91
+ raise ValueError("Store name is required")
92
+ if database is None:
93
+ raise RuntimeError("Database not initialized")
94
+ if store_name not in database.stores:
95
+ raise ValueError(f"Store '{store_name}' does not exist")
96
+
97
+ store_obj = database.stores[store_name]
98
+ ids: list[str] = []
99
+ for _ in range(count):
100
+ ids.append(store_obj.generate_id())
101
+
102
+ return {"ids": ids, "count": len(ids)}
103
+
104
+ def api_get_store_config(database: mkdb, data: dict):
105
+ """Get detailed configuration for a store."""
106
+ store_name: str = data.get("name", "").strip()
107
+
108
+ if not store_name:
109
+ raise ValueError("Store name is required")
110
+
111
+ if database is None:
112
+ raise RuntimeError("Database not initialized")
113
+
114
+ if store_name not in database.config.stores:
115
+ raise ValueError(f"Store '{store_name}' does not exist")
116
+
117
+ store = database.config.stores[store_name]
118
+ result = dict(store.json)
119
+ result["entity_health"] = store.entity_config.health
120
+ return result
121
+
122
+ def api_update_store_config(database: mkdb, data: dict):
123
+ """Update detailed configuration for a store."""
124
+ store_name: str = data.get("name", "").strip()
125
+
126
+ if not store_name:
127
+ raise ValueError("Store name is required")
128
+
129
+ if database is None:
130
+ raise RuntimeError("Database not initialized")
131
+
132
+ if store_name not in database.config.stores:
133
+ raise ValueError(f"Store '{store_name}' does not exist")
134
+
135
+ store = database.config.stores[store_name]
136
+
137
+ # Update sub-configs using their constructors from JSON
138
+ if "file_config" in data:
139
+ from src.config.db import file_config
140
+ store.file_config = file_config(data["file_config"])
141
+ if "entity_config" in data:
142
+ from src.config.db import entity_config
143
+ store.entity_config = entity_config(data["entity_config"])
144
+ if "ram_config" in data:
145
+ from src.config.db import ram_config
146
+ store.ram_config = ram_config(data["ram_config"])
147
+ if "rate_limit" in data:
148
+ from src.config.db import store_rate_limit
149
+ store.rate_limit = store_rate_limit(data["rate_limit"])
150
+ if "slow_query_threshold_ms" in data:
151
+ store.slow_query_threshold_ms = float(data["slow_query_threshold_ms"])
152
+ if "client_id_header" in data:
153
+ store.client_id_header = str(data["client_id_header"]).strip()
154
+ if "protect_reads" in data:
155
+ store.protect_reads = bool(data["protect_reads"])
156
+ if "description" in data:
157
+ store.description = data["description"]
158
+ if "schema_config" in data:
159
+ from src.config.db import schema_config, field_schema
160
+ raw_fields = data["schema_config"].get("fields", {})
161
+ new_schema = schema_config({})
162
+ new_schema.fields = {
163
+ k: field_schema(v) if isinstance(v, dict) else v
164
+ for k, v in raw_fields.items()
165
+ }
166
+ store.schema_config = new_schema
167
+
168
+ database.config.save()
169
+
170
+ # If schema changed, rebuild live query engine indexes for the running store so
171
+ # changes take effect immediately without needing a server restart.
172
+ if "schema_config" in data and store_name in database.stores:
173
+ import threading as _threading
174
+ store_obj = database.stores[store_name]
175
+ qe = getattr(store_obj, "query_engine", None)
176
+ if qe is not None:
177
+ def _do_rebuild():
178
+ for field_name, fs in store.schema_config.fields.items():
179
+ q = getattr(fs, "queryable", False)
180
+ if q:
181
+ try:
182
+ qe.rebuild_index(field_name)
183
+ except Exception as _exc:
184
+ import logging as _log
185
+ _log.getLogger(__name__).warning(
186
+ "schema update: rebuild_index('%s') failed: %s", field_name, _exc
187
+ )
188
+ _threading.Thread(target=_do_rebuild, daemon=True,
189
+ name=f"schema-rebuild-{store_name}").start()
190
+
191
+ response: dict = {"store_name": store_name, "message": f"Store '{store_name}' config updated successfully"}
192
+ if "entity_config" in data:
193
+ from src.config.db import TOKEN_HARD_LIMIT
194
+ tl = store.entity_config.token_length
195
+ if tl > TOKEN_HARD_LIMIT:
196
+ response["warning"] = (
197
+ f"Token length {tl} exceeds the recommended maximum of "
198
+ f"{TOKEN_HARD_LIMIT} characters. Performance may degrade."
199
+ )
200
+ return response
201
+
202
+ def api_export_store_json(database: mkdb, data: dict):
203
+ """Export a store's complete configuration as JSON."""
204
+ store_name: str = data.get("name", "").strip()
205
+
206
+ if not store_name:
207
+ raise ValueError("Store name is required")
208
+
209
+ if database is None:
210
+ raise RuntimeError("Database not initialized")
211
+
212
+ if store_name not in database.config.stores:
213
+ raise ValueError(f"Store '{store_name}' does not exist")
214
+
215
+ return database.config.stores[store_name].json
216
+
217
+ # ── Database info ─────────────────────────────────────────────────────────────
218
+
219
+ def api_get_db_info(database: mkdb, data: dict):
220
+ """Return basic database metadata for the dashboard."""
221
+ if database is None:
222
+ raise RuntimeError("Database not initialized")
223
+ from src.config.db import TOKEN_HARD_LIMIT, TOKEN_SOFT_LIMIT
224
+ db_health = "ok"
225
+ for sc in database.config.stores.values():
226
+ h = sc.entity_config.health["status"]
227
+ if h == "degraded":
228
+ db_health = "degraded"
229
+ break
230
+ if h == "low" and db_health == "ok":
231
+ db_health = "low"
232
+ return {
233
+ "name": database.config.name,
234
+ "base_path": database.config.base_path,
235
+ "store_count": len(database.config.stores),
236
+ "health": db_health,
237
+ }
238
+
239
+
240
+ def api_get_dashboard(database: mkdb, data: dict):
241
+ """Aggregate all dashboard data in one call: db info, server status, store metrics, event log."""
242
+ if database is None:
243
+ raise RuntimeError("Database not initialized")
244
+
245
+ import time as _time
246
+ from src.server.coms.metrics import get_store_metrics
247
+ from src.server.event_log import get_events
248
+
249
+ # ── DB info ───────────────────────────────────────────────────────────────
250
+ db_health = "ok"
251
+ for sc in database.config.stores.values():
252
+ h = sc.entity_config.health["status"]
253
+ if h == "degraded":
254
+ db_health = "degraded"
255
+ break
256
+ if h == "low" and db_health == "ok":
257
+ db_health = "low"
258
+
259
+ # ── Server status ─────────────────────────────────────────────────────────
260
+ srv_raw = database.get_server_status()
261
+
262
+ # ── Per-store metrics + totals ────────────────────────────────────────────
263
+ stores_data = []
264
+ total_reads = total_writes = total_queries = total_errors = total_entries = 0
265
+ total_bytes_in = total_bytes_out = 0
266
+
267
+ for name, store_obj in database.stores.items():
268
+ m = get_store_metrics(name)
269
+ rc = getattr(store_obj, "_ram_cache", None)
270
+ entries = rc.size() if rc else 0
271
+ est_bytes = rc.estimated_bytes() if rc else 0
272
+ max_size = rc.max_size if rc else 0
273
+
274
+ r = m.get("reads", 0)
275
+ w = m.get("writes", 0)
276
+ q = m.get("queries", 0)
277
+ e = m.get("errors", 0)
278
+ bi = m.get("bytes_in", 0)
279
+ bo = m.get("bytes_out", 0)
280
+
281
+ total_reads += r
282
+ total_writes += w
283
+ total_queries += q
284
+ total_errors += e
285
+ total_bytes_in += bi
286
+ total_bytes_out += bo
287
+ total_entries += entries
288
+
289
+ stores_data.append({
290
+ "name": name,
291
+ "reads": r,
292
+ "writes": w,
293
+ "deletes": m.get("deletes", 0),
294
+ "queries": m.get("queries", 0),
295
+ "errors": e,
296
+ "bytes_in": bi,
297
+ "bytes_out": bo,
298
+ "ram": {
299
+ "entries": entries,
300
+ "est_bytes": est_bytes,
301
+ "max_size": max_size,
302
+ "pct": round(entries / max_size * 100, 1) if max_size else 0,
303
+ },
304
+ })
305
+
306
+ return {
307
+ "db": {
308
+ "name": database.config.name,
309
+ "base_path": database.config.base_path,
310
+ "store_count": len(database.config.stores),
311
+ "health": db_health,
312
+ },
313
+ "servers": {
314
+ "http": {"running": bool(srv_raw.get("http"))},
315
+ "socket": {"running": bool(srv_raw.get("socket"))},
316
+ "control": {"running": bool(srv_raw.get("control"))},
317
+ },
318
+ "totals": {
319
+ "reads": total_reads,
320
+ "writes": total_writes,
321
+ "queries": total_queries,
322
+ "errors": total_errors,
323
+ "bytes_in": total_bytes_in,
324
+ "bytes_out": total_bytes_out,
325
+ "ram_entries": total_entries,
326
+ },
327
+ "stores": stores_data,
328
+ "events": get_events(limit=40),
329
+ "started_at": database._started_at,
330
+ "ts": round(_time.time(), 3),
331
+ }
332
+
333
+
334
+ def api_get_event_log(database: mkdb, data: dict):
335
+ """Return recent system events. Optional filter: level=info|warning|error|recovery."""
336
+ from src.server.event_log import get_events
337
+ limit = max(1, min(200, int(data.get("limit", 50))))
338
+ level = str(data.get("level", "")).strip()
339
+ return {"events": get_events(limit=limit, level=level)}
340
+
341
+
342
+ # ── Auth management ───────────────────────────────────────────────────────────
343
+
344
+ def api_get_auth_status(database: mkdb, data: dict):
345
+ """Return current control-server auth configuration."""
346
+ if database is None:
347
+ raise RuntimeError("Database not initialized")
348
+ from src.server.control.server import active_session_count
349
+ auth = database.config.servers.control_server.auth
350
+ return {
351
+ "enabled": auth.enabled,
352
+ "has_password": bool(auth.password_hash),
353
+ "session_ttl": auth.session_ttl,
354
+ "active_sessions": active_session_count(),
355
+ }
356
+
357
+ def api_set_auth_password(database: mkdb, data: dict):
358
+ """Set or change the control-server password and enable auth."""
359
+ if database is None:
360
+ raise RuntimeError("Database not initialized")
361
+ from src.server.control.server import hash_password
362
+ password = data.get("password", "").strip()
363
+ if not password:
364
+ raise ValueError("Password cannot be empty")
365
+ auth = database.config.servers.control_server.auth
366
+ auth.password_hash = hash_password(password)
367
+ auth.enabled = True
368
+ database.config.save()
369
+ return {"message": "Password updated and authentication enabled"}
370
+
371
+ def api_enable_auth(database: mkdb, data: dict):
372
+ """Enable authentication on the control server."""
373
+ if database is None:
374
+ raise RuntimeError("Database not initialized")
375
+ auth = database.config.servers.control_server.auth
376
+ if not auth.password_hash:
377
+ raise ValueError("Cannot enable auth: set a password first")
378
+ auth.enabled = True
379
+ database.config.save()
380
+ return {"message": "Authentication enabled"}
381
+
382
+ def api_disable_auth(database: mkdb, data: dict):
383
+ """Disable authentication on the control server."""
384
+ if database is None:
385
+ raise RuntimeError("Database not initialized")
386
+ auth = database.config.servers.control_server.auth
387
+ auth.enabled = False
388
+ database.config.save()
389
+ return {"message": "Authentication disabled"}
390
+
391
+ def api_set_auth_session_ttl(database: mkdb, data: dict):
392
+ """Set session TTL in seconds (minimum 60)."""
393
+ if database is None:
394
+ raise RuntimeError("Database not initialized")
395
+ ttl = data.get("ttl", 3600)
396
+ if not isinstance(ttl, int) or ttl < 60:
397
+ raise ValueError("TTL must be an integer ≥ 60 seconds")
398
+ auth = database.config.servers.control_server.auth
399
+ auth.session_ttl = ttl
400
+ database.config.save()
401
+ return {"message": f"Session TTL set to {ttl} seconds"}
402
+
403
+ def api_invalidate_all_sessions(database: mkdb, data: dict):
404
+ """Force-expire every active session token."""
405
+ from src.server.control.server import invalidate_all_sessions
406
+ count = invalidate_all_sessions()
407
+ return {"message": f"Invalidated {count} session(s)"}
408
+
409
+ def api_query_store(database: mkdb, data: dict):
410
+ """
411
+ Execute a query against a store's query engine.
412
+
413
+ Request data keys:
414
+ store (str, required) — store name
415
+ filter (dict, required) — query filter dict
416
+ hydrate (bool, optional, default False) — return full records instead of just IDs
417
+ """
418
+ store_name = str(data.get("store", "")).strip()
419
+ filter_dict = data.get("filter", {})
420
+ hydrate = bool(data.get("hydrate", False))
421
+
422
+ if not store_name:
423
+ return {"success": False, "error": "'store' is required"}
424
+ if not isinstance(filter_dict, dict):
425
+ return {"success": False, "error": "'filter' must be a JSON object"}
426
+
427
+ if store_name not in database.stores:
428
+ return {"success": False, "error": f"Store '{store_name}' not found"}
429
+
430
+ store_obj = database.stores[store_name]
431
+ qe = getattr(store_obj, "query_engine", None)
432
+ if qe is None:
433
+ return {"success": False, "error": f"Store '{store_name}' has no query engine"}
434
+
435
+ try:
436
+ ids = qe.query(filter_dict)
437
+ except Exception as exc:
438
+ return {"success": False, "error": str(exc)}
439
+
440
+ if hydrate:
441
+ records = [store_obj.read(rid) for rid in ids]
442
+ return {"success": True, "count": len(records), "records": records}
443
+
444
+ return {"success": True, "count": len(ids), "ids": ids}
445
+
446
+
447
+ # ── Database settings ─────────────────────────────────────────────────────────
448
+
449
+ def api_get_db_settings(database: mkdb, data: dict):
450
+ """Return all editable database and server settings."""
451
+ if database is None:
452
+ raise RuntimeError("Database not initialized")
453
+ cfg = database.config
454
+ return {
455
+ "name": cfg.name,
456
+ "base_path": cfg.base_path,
457
+ "storage_nodes": cfg.storage_nodes,
458
+ "store_count": len(cfg.stores),
459
+ "servers": {
460
+ "control": {
461
+ "enabled": cfg.servers.control_server.enabled,
462
+ "host": cfg.servers.control_server.address.host,
463
+ "port": cfg.servers.control_server.address.port,
464
+ },
465
+ "socket": {
466
+ "enabled": cfg.servers.socket_server.enabled,
467
+ "host": cfg.servers.socket_server.address.host,
468
+ "port": cfg.servers.socket_server.address.port,
469
+ "heartbeat_interval": cfg.servers.socket_server.heartbeat_interval,
470
+ "max_clients": cfg.servers.socket_server.max_clients,
471
+ "recv_timeout": cfg.servers.socket_server.recv_timeout,
472
+ },
473
+ "http": {
474
+ "enabled": cfg.servers.http_server.enabled,
475
+ "host": cfg.servers.http_server.address.host,
476
+ "port": cfg.servers.http_server.address.port,
477
+ "max_body_size": cfg.servers.http_server.max_body_size,
478
+ "cors_enabled": cfg.servers.http_server.cors_enabled,
479
+ "max_requests_per_second": cfg.servers.http_server.max_requests_per_second,
480
+ },
481
+ },
482
+ }
483
+
484
+
485
+ def api_update_db_settings(database: mkdb, data: dict):
486
+ """Update top-level database settings (name, storage_nodes) and save."""
487
+ if database is None:
488
+ raise RuntimeError("Database not initialized")
489
+ changed = False
490
+ if "name" in data:
491
+ new_name = str(data["name"]).strip()
492
+ if not new_name:
493
+ raise ValueError("Database name cannot be empty")
494
+ database.config.name = new_name
495
+ changed = True
496
+ if "storage_nodes" in data:
497
+ nodes = data["storage_nodes"]
498
+ if not isinstance(nodes, list):
499
+ raise ValueError("storage_nodes must be a list")
500
+ database.config.storage_nodes = [str(n) for n in nodes]
501
+ changed = True
502
+ if changed:
503
+ database.config.save()
504
+ return {"saved": changed, "message": "Settings saved." if changed else "No changes."}
505
+
506
+
507
+ # ── Server lifecycle ──────────────────────────────────────────────────────────
508
+
509
+ def api_get_server_status(database: mkdb, data: dict):
510
+ """Return running/stopped status for all three servers."""
511
+ if database is None:
512
+ raise RuntimeError("Database not initialized")
513
+ raw = database.get_server_status()
514
+ return {
515
+ "control": {"running": bool(raw.get("control"))},
516
+ "socket": {"running": bool(raw.get("socket"))},
517
+ "http": {"running": bool(raw.get("http"))},
518
+ }
519
+
520
+
521
+ def api_update_server_config(database: mkdb, data: dict):
522
+ """Persist host/port/enabled changes for a server to config.json.
523
+
524
+ Required: server = "socket" | "http" | "control"
525
+ Does NOT restart — call api_server_control afterwards if needed.
526
+ """
527
+ if database is None:
528
+ raise RuntimeError("Database not initialized")
529
+ server_name = str(data.get("server", "")).strip()
530
+ if server_name not in ("socket", "http", "control"):
531
+ raise ValueError("server must be one of: socket, http, control")
532
+
533
+ cfg_map = {
534
+ "socket": database.config.servers.socket_server,
535
+ "http": database.config.servers.http_server,
536
+ "control": database.config.servers.control_server,
537
+ }
538
+ srv_cfg = cfg_map[server_name]
539
+
540
+ if "host" in data:
541
+ srv_cfg.address.host = str(data["host"]).strip()
542
+ if "port" in data:
543
+ port = int(data["port"])
544
+ if not (1 <= port <= 65535):
545
+ raise ValueError("port must be between 1 and 65535")
546
+ srv_cfg.address.port = port
547
+ if "enabled" in data:
548
+ srv_cfg.enabled = bool(data["enabled"])
549
+
550
+ if server_name == "socket":
551
+ if "heartbeat_interval" in data:
552
+ srv_cfg.heartbeat_interval = float(data["heartbeat_interval"])
553
+ if "max_clients" in data:
554
+ srv_cfg.max_clients = int(data["max_clients"])
555
+ if "recv_timeout" in data:
556
+ srv_cfg.recv_timeout = float(data["recv_timeout"])
557
+ elif server_name == "http":
558
+ if "max_body_size" in data:
559
+ srv_cfg.max_body_size = int(data["max_body_size"])
560
+ if "cors_enabled" in data:
561
+ srv_cfg.cors_enabled = bool(data["cors_enabled"])
562
+ if "max_requests_per_second" in data:
563
+ srv_cfg.max_requests_per_second = int(data["max_requests_per_second"])
564
+
565
+ database.config.save()
566
+ return {"message": f"{server_name} server config saved", "server": server_name}
567
+
568
+
569
+ def api_server_control(database: mkdb, data: dict):
570
+ """Start, stop, or restart a named server.
571
+
572
+ Required:
573
+ server = "socket" | "http" | "control"
574
+ action = "start" | "stop" | "restart"
575
+
576
+ Control-server stop/restart is deferred 1.5 s so the response
577
+ is delivered before the socket closes.
578
+ """
579
+ import threading
580
+ if database is None:
581
+ raise RuntimeError("Database not initialized")
582
+ server_name = str(data.get("server", "")).strip()
583
+ action = str(data.get("op", "")).strip() # 'op' avoids collision with the top-level 'action' envelope key
584
+ if server_name not in ("socket", "http", "control"):
585
+ raise ValueError("server must be one of: socket, http, control")
586
+ if action not in ("start", "stop", "restart"):
587
+ raise ValueError("op must be start, stop, or restart")
588
+
589
+ def _do():
590
+ if action == "start":
591
+ database.start_server(server_name)
592
+ elif action == "stop":
593
+ database.stop_server(server_name)
594
+ elif action == "restart":
595
+ database.restart_server(server_name)
596
+
597
+ if server_name == "control" and action in ("stop", "restart"):
598
+ threading.Timer(1.5, _do).start()
599
+ return {"message": f"Control server {action} scheduled in 1.5 s", "deferred": True}
600
+
601
+ _do()
602
+ return {"message": f"{server_name} server {action}ed", "deferred": False}
603
+
604
+
605
+ # ── User management ───────────────────────────────────────────────────────────
606
+
607
+ def api_list_users(database: mkdb, data: dict):
608
+ """List all database users and their store permissions."""
609
+ if database is None:
610
+ raise RuntimeError("Database not initialized")
611
+ return [
612
+ {
613
+ "username": uname,
614
+ "has_password": bool(user.password_hash),
615
+ "stores": {
616
+ sname: {"read": perm.read, "write": perm.write}
617
+ for sname, perm in user.stores.items()
618
+ },
619
+ }
620
+ for uname, user in database.config.users.items()
621
+ ]
622
+
623
+
624
+ def api_create_user(database: mkdb, data: dict):
625
+ """Create a new data-plane user."""
626
+ if database is None:
627
+ raise RuntimeError("Database not initialized")
628
+ from src.server.control.server import hash_password
629
+ from src.config.db import db_user
630
+
631
+ username = str(data.get("username", "")).strip()
632
+ password = str(data.get("password", "")).strip()
633
+ if not username:
634
+ raise ValueError("Username cannot be empty")
635
+ if not password:
636
+ raise ValueError("Password cannot be empty")
637
+ if len(password) < 6:
638
+ raise ValueError("Password must be at least 6 characters")
639
+ if username in database.config.users:
640
+ raise ValueError(f"User '{username}' already exists")
641
+
642
+ u = db_user({})
643
+ u.username = username
644
+ u.password_hash = hash_password(password)
645
+ database.config.users[username] = u
646
+ database.config.save()
647
+ return {"message": f"User '{username}' created"}
648
+
649
+
650
+ def api_delete_user(database: mkdb, data: dict):
651
+ """Delete a data-plane user."""
652
+ if database is None:
653
+ raise RuntimeError("Database not initialized")
654
+ username = str(data.get("username", "")).strip()
655
+ if not username:
656
+ raise ValueError("Username cannot be empty")
657
+ if username not in database.config.users:
658
+ raise ValueError(f"User '{username}' does not exist")
659
+ del database.config.users[username]
660
+ database.config.save()
661
+ return {"message": f"User '{username}' deleted"}
662
+
663
+
664
+ def api_set_user_password(database: mkdb, data: dict):
665
+ """Set or change a user's data-plane password."""
666
+ if database is None:
667
+ raise RuntimeError("Database not initialized")
668
+ from src.server.control.server import hash_password
669
+ username = str(data.get("username", "")).strip()
670
+ password = str(data.get("password", "")).strip()
671
+ if not username:
672
+ raise ValueError("Username cannot be empty")
673
+ if not password:
674
+ raise ValueError("Password cannot be empty")
675
+ if len(password) < 6:
676
+ raise ValueError("Password must be at least 6 characters")
677
+ user = database.config.users.get(username)
678
+ if user is None:
679
+ raise ValueError(f"User '{username}' does not exist")
680
+ user.password_hash = hash_password(password)
681
+ database.config.save()
682
+ return {"message": f"Password updated for '{username}'"}
683
+
684
+
685
+ def api_set_user_store_access(database: mkdb, data: dict):
686
+ """Grant or update a user's access to a store.
687
+ Required: username, store
688
+ Optional: read (bool, default True), write (bool, default False)
689
+ """
690
+ if database is None:
691
+ raise RuntimeError("Database not initialized")
692
+ from src.config.db import store_permission
693
+ username = str(data.get("username", "")).strip()
694
+ store_name = str(data.get("store", "")).strip()
695
+ if not username:
696
+ raise ValueError("Username cannot be empty")
697
+ if not store_name:
698
+ raise ValueError("Store name cannot be empty")
699
+ user = database.config.users.get(username)
700
+ if user is None:
701
+ raise ValueError(f"User '{username}' does not exist")
702
+ if store_name not in database.config.stores:
703
+ raise ValueError(f"Store '{store_name}' does not exist")
704
+ perm = user.stores.get(store_name) or store_permission({})
705
+ perm.read = bool(data.get("read", perm.read))
706
+ perm.write = bool(data.get("write", perm.write))
707
+ user.stores[store_name] = perm
708
+ database.config.save()
709
+ return {"message": f"Access updated for '{username}' on '{store_name}'"}
710
+
711
+
712
+ def api_remove_user_store_access(database: mkdb, data: dict):
713
+ """Remove a user's access to a specific store."""
714
+ if database is None:
715
+ raise RuntimeError("Database not initialized")
716
+ username = str(data.get("username", "")).strip()
717
+ store_name = str(data.get("store", "")).strip()
718
+ if not username or not store_name:
719
+ raise ValueError("Username and store name are required")
720
+ user = database.config.users.get(username)
721
+ if user is None:
722
+ raise ValueError(f"User '{username}' does not exist")
723
+ user.stores.pop(store_name, None)
724
+ database.config.save()
725
+ return {"message": f"Store access '{store_name}' removed from '{username}'"}
726
+
727
+
728
+ def api_get_rate_limit_log(database: mkdb, data: dict):
729
+ """Return recent rate-limit events from logs/rate_limit.jsonl.
730
+ Optional: limit (int, default 200)
731
+ """
732
+ import os, json as _json
733
+ if database is None:
734
+ raise RuntimeError("Database not initialized")
735
+ limit = max(1, int(data.get("limit", 200)))
736
+ log_path = os.path.join(database.config.base_path, "logs", "rate_limit.jsonl")
737
+ if not os.path.exists(log_path):
738
+ return {"events": [], "total": 0}
739
+ with open(log_path, "r", encoding="utf-8") as f:
740
+ lines = f.readlines()
741
+ events = []
742
+ for line in lines[-limit:]:
743
+ try:
744
+ events.append(_json.loads(line.strip()))
745
+ except Exception:
746
+ pass
747
+ return {"events": list(reversed(events)), "total": len(lines)}
748
+
749
+
750
+ # ── Data security settings ────────────────────────────────────────────────────
751
+
752
+ def api_get_data_security(database: mkdb, data: dict):
753
+ """Return the data-plane security settings."""
754
+ if database is None:
755
+ raise RuntimeError("Database not initialized")
756
+ ds = database.config.data_security
757
+ return {"protect_reads": ds.protect_reads}
758
+
759
+
760
+ def api_set_data_security(database: mkdb, data: dict):
761
+ """Update data-plane security settings.
762
+ Optional: protect_reads (bool)
763
+ """
764
+ if database is None:
765
+ raise RuntimeError("Database not initialized")
766
+ if "protect_reads" in data:
767
+ database.config.data_security.protect_reads = bool(data["protect_reads"])
768
+ database.config.save()
769
+ return {"protect_reads": database.config.data_security.protect_reads}
770
+
771
+
772
+ # ── Control-plane user management (RBAC) ─────────────────────────────────────
773
+
774
+ def api_list_control_users(database: mkdb, data: dict):
775
+ """List all control/SDK users and their roles."""
776
+ if database is None:
777
+ raise RuntimeError("Database not initialized")
778
+ from src.config.db import CONTROL_ROLES
779
+ return [
780
+ {"username": uname, "role": cu.role, "has_password": bool(cu.password_hash)}
781
+ for uname, cu in database.config.control_users.items()
782
+ ]
783
+
784
+
785
+ def api_create_control_user(database: mkdb, data: dict):
786
+ """Create a control/SDK user.
787
+ Required: username, password, role ('viewer' | 'operator' | 'admin')
788
+ """
789
+ if database is None:
790
+ raise RuntimeError("Database not initialized")
791
+ from src.server.control.server import hash_password
792
+ from src.config.db import control_user, CONTROL_ROLES
793
+ username = str(data.get("username", "")).strip()
794
+ password = str(data.get("password", "")).strip()
795
+ role = str(data.get("role", "viewer")).strip()
796
+ if not username:
797
+ raise ValueError("Username cannot be empty")
798
+ if not password or len(password) < 6:
799
+ raise ValueError("Password must be at least 6 characters")
800
+ if role not in CONTROL_ROLES:
801
+ raise ValueError(f"Role must be one of: {', '.join(CONTROL_ROLES)}")
802
+ if username in database.config.control_users:
803
+ raise ValueError(f"Control user '{username}' already exists")
804
+ cu = control_user({})
805
+ cu.username = username
806
+ cu.password_hash = hash_password(password)
807
+ cu.role = role
808
+ database.config.control_users[username] = cu
809
+ database.config.save()
810
+ return {"message": f"Control user '{username}' created with role '{role}'"}
811
+
812
+
813
+ def api_delete_control_user(database: mkdb, data: dict):
814
+ """Delete a control/SDK user."""
815
+ if database is None:
816
+ raise RuntimeError("Database not initialized")
817
+ username = str(data.get("username", "")).strip()
818
+ if not username:
819
+ raise ValueError("Username cannot be empty")
820
+ if username not in database.config.control_users:
821
+ raise ValueError(f"Control user '{username}' does not exist")
822
+ del database.config.control_users[username]
823
+ database.config.save()
824
+ return {"message": f"Control user '{username}' deleted"}
825
+
826
+
827
+ def api_set_control_user_role(database: mkdb, data: dict):
828
+ """Change a control user's role."""
829
+ if database is None:
830
+ raise RuntimeError("Database not initialized")
831
+ from src.config.db import CONTROL_ROLES
832
+ username = str(data.get("username", "")).strip()
833
+ role = str(data.get("role", "")).strip()
834
+ if not username:
835
+ raise ValueError("Username cannot be empty")
836
+ if role not in CONTROL_ROLES:
837
+ raise ValueError(f"Role must be one of: {', '.join(CONTROL_ROLES)}")
838
+ cu = database.config.control_users.get(username)
839
+ if cu is None:
840
+ raise ValueError(f"Control user '{username}' does not exist")
841
+ cu.role = role
842
+ database.config.save()
843
+ return {"message": f"Role for '{username}' updated to '{role}'"}
844
+
845
+
846
+ def api_set_control_user_password(database: mkdb, data: dict):
847
+ """Set or change a control user's password."""
848
+ if database is None:
849
+ raise RuntimeError("Database not initialized")
850
+ from src.server.control.server import hash_password
851
+ username = str(data.get("username", "")).strip()
852
+ password = str(data.get("password", "")).strip()
853
+ if not username:
854
+ raise ValueError("Username cannot be empty")
855
+ if not password or len(password) < 6:
856
+ raise ValueError("Password must be at least 6 characters")
857
+ cu = database.config.control_users.get(username)
858
+ if cu is None:
859
+ raise ValueError(f"Control user '{username}' does not exist")
860
+ cu.password_hash = hash_password(password)
861
+ database.config.save()
862
+ return {"message": f"Password updated for control user '{username}'"}
863
+
864
+
865
+ def api_get_store_metrics(database: mkdb, data: dict):
866
+ """
867
+ Return live request, bandwidth, RAM-cache, and per-client metrics for a store.
868
+
869
+ Request: {"name": "store_name"}
870
+ Response: {
871
+ "reads": int, "writes": int, "deletes": int, "queries": int,
872
+ "errors": int, "rate_limited": int,
873
+ "bytes_in": int, "bytes_out": int,
874
+ "started_at": float,
875
+ "transport": {"http": {...}, "socket": {...}},
876
+ "clients": [ {"key": str, "reads": int, ...}, ... ],
877
+ "rate_limited_ips": [ {"ip": str, "count": int}, ... ],
878
+ "ram_cache": {"entries": int, "estimated_bytes": int, "max_size": int},
879
+ }
880
+ """
881
+ if database is None:
882
+ raise RuntimeError("Database not initialized")
883
+ import time
884
+ from src.server.coms.metrics import get_store_metrics
885
+
886
+ name = str(data.get("name", "")).strip()
887
+ if not name:
888
+ raise ValueError("'name' is required")
889
+
890
+ m = get_store_metrics(name)
891
+
892
+ # Flatten clients dict into a sorted list (most active first)
893
+ clients_list = sorted(
894
+ [{"key": k, **v} for k, v in m.get("clients", {}).items()],
895
+ key=lambda c: c.get("reads", 0) + c.get("writes", 0) + c.get("queries", 0),
896
+ reverse=True,
897
+ )
898
+
899
+ # Flatten rate_limited_ips into a sorted list
900
+ rl_ips = sorted(
901
+ [{"ip": ip, "count": cnt} for ip, cnt in m.get("rate_limited_ips", {}).items()],
902
+ key=lambda x: x["count"],
903
+ reverse=True,
904
+ )
905
+
906
+ # RAM cache stats from the live store object
907
+ ram_info = {"entries": 0, "estimated_bytes": 0, "max_size": 0}
908
+ store_obj = database.stores.get(name)
909
+ if store_obj is not None:
910
+ rc = getattr(store_obj, "_ram_cache", None)
911
+ if rc is not None:
912
+ ram_info["entries"] = rc.size()
913
+ ram_info["estimated_bytes"] = rc.estimated_bytes()
914
+ ram_info["max_size"] = rc.max_size
915
+ ram_info["ttl"] = getattr(getattr(store_obj, "_ram_cache", None), "ttl", 0)
916
+ ram_info["clean_type"] = getattr(getattr(store_obj, "_ram_cache", None), "clean_type", "") #type: ignore
917
+
918
+ # Slow query log — newest first, convert deque to plain list for JSON
919
+ slow_log_raw = m.get("slow_query_log", [])
920
+ slow_query_log = list(reversed(list(slow_log_raw)))
921
+
922
+ # Error log — newest first
923
+ error_log_raw = m.get("error_log", [])
924
+ error_log = list(reversed(list(error_log_raw)))
925
+
926
+ # Threshold from live config (so the UI shows the current setting)
927
+ slow_query_threshold_ms = 0.0
928
+ if store_obj is not None:
929
+ slow_query_threshold_ms = getattr(store_obj.config, "slow_query_threshold_ms", 0.0)
930
+
931
+ return {
932
+ "reads": m.get("reads", 0),
933
+ "writes": m.get("writes", 0),
934
+ "deletes": m.get("deletes", 0),
935
+ "queries": m.get("queries", 0),
936
+ "errors": m.get("errors", 0),
937
+ "slow_queries": m.get("slow_queries", 0),
938
+ "rate_limited": m.get("rate_limited", 0),
939
+ "bytes_in": m.get("bytes_in", 0),
940
+ "bytes_out": m.get("bytes_out", 0),
941
+ "started_at": m.get("started_at", time.time()),
942
+ "transport": m.get("transport", {}),
943
+ "clients": clients_list,
944
+ "rate_limited_ips": rl_ips,
945
+ "ram_cache": ram_info,
946
+ "slow_query_log": slow_query_log,
947
+ "slow_query_threshold_ms": slow_query_threshold_ms,
948
+ "error_log": error_log,
949
+ }
950
+
951
+
952
+ def api_reset_store_metrics(database: mkdb, data: dict):
953
+ """Clear all in-memory metrics for a store."""
954
+ if database is None:
955
+ raise RuntimeError("Database not initialized")
956
+ from src.server.coms.metrics import reset_store_metrics
957
+ name = str(data.get("name", "")).strip()
958
+ if not name:
959
+ raise ValueError("'name' is required")
960
+ reset_store_metrics(name)
961
+ return {"message": f"Metrics reset for store '{name}'"}
962
+
963
+
964
+ def api_get_all_store_metrics(database: mkdb, data: dict):
965
+ """
966
+ Return request counts, bandwidth, and live RAM-cache stats for every store.
967
+
968
+ Response: list of {
969
+ name, reads, writes, deletes, queries, errors, rate_limited,
970
+ bytes_in, bytes_out,
971
+ ram_cache: {entries, max_size, estimated_bytes, ttl, clean_type}
972
+ }
973
+ """
974
+ if database is None:
975
+ raise RuntimeError("Database not initialized")
976
+ from src.server.coms.metrics import get_store_metrics
977
+
978
+ result = []
979
+ for name, store_obj in database.stores.items():
980
+ m = get_store_metrics(name)
981
+ rc = getattr(store_obj, "_ram_cache", None)
982
+ ram_info = {
983
+ "entries": rc.size() if rc else 0,
984
+ "estimated_bytes": rc.estimated_bytes() if rc else 0,
985
+ "max_size": rc.max_size if rc else 0,
986
+ "ttl": rc.ttl if rc else 0,
987
+ "clean_type": rc.clean_type if rc else "",
988
+ }
989
+ result.append({
990
+ "name": name,
991
+ "reads": m.get("reads", 0),
992
+ "writes": m.get("writes", 0),
993
+ "deletes": m.get("deletes", 0),
994
+ "queries": m.get("queries", 0),
995
+ "errors": m.get("errors", 0),
996
+ "rate_limited": m.get("rate_limited", 0),
997
+ "bytes_in": m.get("bytes_in", 0),
998
+ "bytes_out": m.get("bytes_out", 0),
999
+ "ram_cache": ram_info,
1000
+ })
1001
+ return result