gramobase 1.0.9 → 1.0.11

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,2790 @@
1
+ 'use strict';
2
+
3
+ var http = require('http');
4
+ var fs = require('fs');
5
+ var path = require('path');
6
+ var zod = require('zod');
7
+ var TelegramBot = require('node-telegram-bot-api');
8
+ var PQueue = require('p-queue');
9
+ var pRetry = require('p-retry');
10
+ var EventEmitter = require('eventemitter3');
11
+ var lruCache = require('lru-cache');
12
+ var crypto = require('crypto');
13
+ var jwt = require('jsonwebtoken');
14
+ var bcrypt = require('bcryptjs');
15
+
16
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
17
+
18
+ function _interopNamespace(e) {
19
+ if (e && e.__esModule) return e;
20
+ var n = Object.create(null);
21
+ if (e) {
22
+ Object.keys(e).forEach(function (k) {
23
+ if (k !== 'default') {
24
+ var d = Object.getOwnPropertyDescriptor(e, k);
25
+ Object.defineProperty(n, k, d.get ? d : {
26
+ enumerable: true,
27
+ get: function () { return e[k]; }
28
+ });
29
+ }
30
+ });
31
+ }
32
+ n.default = e;
33
+ return Object.freeze(n);
34
+ }
35
+
36
+ var http__namespace = /*#__PURE__*/_interopNamespace(http);
37
+ var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
38
+ var path__namespace = /*#__PURE__*/_interopNamespace(path);
39
+ var TelegramBot__default = /*#__PURE__*/_interopDefault(TelegramBot);
40
+ var PQueue__default = /*#__PURE__*/_interopDefault(PQueue);
41
+ var pRetry__default = /*#__PURE__*/_interopDefault(pRetry);
42
+ var EventEmitter__default = /*#__PURE__*/_interopDefault(EventEmitter);
43
+ var jwt__namespace = /*#__PURE__*/_interopNamespace(jwt);
44
+ var bcrypt__namespace = /*#__PURE__*/_interopNamespace(bcrypt);
45
+
46
+ // gramobase — Telegram as your free, infinite backend
47
+
48
+
49
+ // src/studio/ui.ts
50
+ var ico = {
51
+ gauge: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 2v4"/><path d="m4.93 4.93 2.83 2.83"/><path d="M22 12h-4"/><path d="m15 9 3-3"/><circle cx="12" cy="12" r="3"/></svg>`,
52
+ radio: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.2 19.1 19.1"/></svg>`,
53
+ table: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/></svg>`,
54
+ bot: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>`,
55
+ layers: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>`,
56
+ zap: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
57
+ hdd: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" y1="16" x2="6.01" y2="16"/><line x1="10" y1="16" x2="10.01" y2="16"/></svg>`,
58
+ hash: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>`,
59
+ grid: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>`,
60
+ refresh: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>`,
61
+ trash: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
62
+ x: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
63
+ xCircle: `<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
64
+ inbox: `<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`,
65
+ activity: `<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`,
66
+ sortAsc: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`,
67
+ sortDesc: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
68
+ chevrL: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`,
69
+ chevrR: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`,
70
+ logoDb: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/></svg>`,
71
+ check: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
72
+ close2: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`
73
+ };
74
+ function getStudioHTML() {
75
+ return `<!DOCTYPE html>
76
+ <html lang="en">
77
+ <head>
78
+ <meta charset="UTF-8" />
79
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
80
+ <title>gramobase studio</title>
81
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
82
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
83
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
84
+ <style>
85
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
86
+
87
+ :root {
88
+ --bg: #0a0b0e;
89
+ --bg2: #111318;
90
+ --bg3: #181b22;
91
+ --bg4: #1e2230;
92
+ --border: rgba(255,255,255,0.07);
93
+ --border2: rgba(255,255,255,0.12);
94
+ --accent: #3b82f6;
95
+ --accent2: #60a5fa;
96
+ --accent-glow:rgba(59,130,246,0.25);
97
+ --green: #22c55e;
98
+ --red: #ef4444;
99
+ --yellow: #f59e0b;
100
+ --purple: #a78bfa;
101
+ --teal: #2dd4bf;
102
+ --text: #e2e8f0;
103
+ --text2: #94a3b8;
104
+ --text3: #475569;
105
+ --radius: 10px;
106
+ --font-mono: 'JetBrains Mono', monospace;
107
+ --sidebar-w: 240px;
108
+ }
109
+
110
+ html, body { height: 100%; background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; font-size: 14px; }
111
+
112
+ /* \u2500\u2500 LAYOUT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
113
+ #app { display: flex; height: 100vh; overflow: hidden; }
114
+
115
+ /* \u2500\u2500 SIDEBAR \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
116
+ #sidebar {
117
+ width: var(--sidebar-w); min-width: var(--sidebar-w);
118
+ background: var(--bg2);
119
+ border-right: 1px solid var(--border);
120
+ display: flex; flex-direction: column;
121
+ overflow: hidden;
122
+ }
123
+ #sidebar-header {
124
+ padding: 18px 16px 14px;
125
+ border-bottom: 1px solid var(--border);
126
+ display: flex; align-items: center; gap: 10px;
127
+ }
128
+ .logo-icon {
129
+ width: 28px; height: 28px; border-radius: 7px;
130
+ background: linear-gradient(135deg, var(--accent), var(--purple));
131
+ display: flex; align-items: center; justify-content: center;
132
+ flex-shrink: 0;
133
+ box-shadow: 0 0 16px var(--accent-glow);
134
+ }
135
+ .logo-icon svg { display: block; }
136
+ .logo-text { font-weight: 700; font-size: 15px; letter-spacing: -0.3px; }
137
+ .logo-badge {
138
+ font-size: 9px; font-weight: 600; padding: 1px 5px; border-radius: 4px;
139
+ background: var(--accent-glow); color: var(--accent2);
140
+ border: 1px solid var(--accent); margin-left: auto;
141
+ }
142
+ #sidebar-nav { flex: 1; overflow-y: auto; padding: 10px 0; }
143
+ .nav-section { padding: 6px 16px 4px; font-size: 10px; font-weight: 600; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text3); }
144
+ .nav-item {
145
+ display: flex; align-items: center; gap: 9px;
146
+ padding: 7px 16px; cursor: pointer; border-radius: 0;
147
+ color: var(--text2); font-size: 13px; transition: all 0.15s;
148
+ border-left: 2px solid transparent;
149
+ }
150
+ .nav-item:hover { background: var(--bg3); color: var(--text); }
151
+ .nav-item.active { background: var(--bg4); color: var(--accent2); border-left-color: var(--accent); }
152
+ .nav-item svg { flex-shrink: 0; opacity: 0.7; }
153
+ .nav-item.active svg, .nav-item:hover svg { opacity: 1; }
154
+ .nav-item-count {
155
+ margin-left: auto; font-size: 10px; padding: 1px 6px; border-radius: 10px;
156
+ background: var(--bg4); color: var(--text3); font-family: var(--font-mono);
157
+ }
158
+ #sidebar-footer {
159
+ padding: 12px 16px;
160
+ border-top: 1px solid var(--border);
161
+ font-size: 11px; color: var(--text3);
162
+ display: flex; align-items: center; gap: 6px;
163
+ }
164
+ .status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); flex-shrink: 0; box-shadow: 0 0 6px var(--green); }
165
+ .status-dot.red { background: var(--red); box-shadow: 0 0 6px var(--red); }
166
+
167
+ /* \u2500\u2500 MAIN \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
168
+ #main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
169
+
170
+ /* \u2500\u2500 TOPBAR \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
171
+ #topbar {
172
+ height: 52px; display: flex; align-items: center; gap: 12px;
173
+ padding: 0 20px;
174
+ background: var(--bg2); border-bottom: 1px solid var(--border);
175
+ flex-shrink: 0;
176
+ }
177
+ #topbar-title { font-weight: 600; font-size: 15px; }
178
+ #topbar-sub { font-size: 12px; color: var(--text3); }
179
+ #topbar-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
180
+ .btn {
181
+ display: inline-flex; align-items: center; gap: 6px;
182
+ padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border2);
183
+ background: var(--bg3); color: var(--text2); cursor: pointer;
184
+ font-size: 12px; font-family: 'Inter', sans-serif;
185
+ transition: all 0.15s;
186
+ }
187
+ .btn:hover { background: var(--bg4); color: var(--text); border-color: var(--accent); }
188
+ .btn svg { flex-shrink: 0; }
189
+
190
+ /* \u2500\u2500 CONTENT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
191
+ #content { flex: 1; overflow: hidden; position: relative; }
192
+ .panel { display: none; height: 100%; overflow-y: auto; }
193
+ .panel.active { display: block; }
194
+
195
+ /* \u2500\u2500 OVERVIEW PANEL \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
196
+ #panel-overview { padding: 24px; }
197
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-bottom: 28px; }
198
+ .stat-card {
199
+ background: var(--bg2); border: 1px solid var(--border);
200
+ border-radius: var(--radius); padding: 18px;
201
+ transition: border-color 0.2s;
202
+ }
203
+ .stat-card:hover { border-color: var(--border2); }
204
+ .stat-icon-wrap {
205
+ width: 34px; height: 34px; border-radius: 8px;
206
+ display: flex; align-items: center; justify-content: center;
207
+ margin-bottom: 12px; flex-shrink: 0;
208
+ }
209
+ .stat-icon-wrap.blue { background: rgba(59,130,246,0.12); color: var(--accent2); }
210
+ .stat-icon-wrap.purple{ background: rgba(167,139,250,0.12); color: var(--purple); }
211
+ .stat-icon-wrap.green { background: rgba(34,197,94,0.12); color: var(--green); }
212
+ .stat-icon-wrap.teal { background: rgba(45,212,191,0.12); color: var(--teal); }
213
+ .stat-icon-wrap.yellow{ background: rgba(245,158,11,0.12); color: var(--yellow); }
214
+ .stat-label { font-size: 11px; color: var(--text3); font-weight: 500; letter-spacing: 0.5px; text-transform: uppercase; margin-bottom: 6px; }
215
+ .stat-value { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
216
+ .stat-sub { font-size: 11px; color: var(--text3); margin-top: 4px; }
217
+ .stat-value.green { color: var(--green); }
218
+ .stat-value.blue { color: var(--accent2); }
219
+ .stat-value.purple { color: var(--purple); }
220
+ .stat-value.yellow { color: var(--yellow); }
221
+ .stat-value.teal { color: var(--teal); }
222
+
223
+ .section-title { font-size: 13px; font-weight: 600; color: var(--text2); margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
224
+ .section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
225
+
226
+ /* \u2500\u2500 BOT STATUS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
227
+ .info-card {
228
+ background: var(--bg2); border: 1px solid var(--border);
229
+ border-radius: var(--radius); padding: 16px; margin-bottom: 14px;
230
+ }
231
+ .info-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); }
232
+ .info-row:last-child { border-bottom: none; }
233
+ .info-key { font-size: 12px; color: var(--text3); width: 130px; flex-shrink: 0; }
234
+ .info-val { font-size: 13px; font-family: var(--font-mono); color: var(--text); }
235
+ .badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 500; }
236
+ .badge.green { background: rgba(34,197,94,0.12); color: var(--green); border: 1px solid rgba(34,197,94,0.2); }
237
+ .badge.blue { background: rgba(59,130,246,0.12); color: var(--accent2);border: 1px solid rgba(59,130,246,0.2); }
238
+ .badge.online-dot::before { content: ''; display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: var(--green); }
239
+
240
+ /* worker pool bars */
241
+ .worker-bars { display: flex; flex-direction: column; gap: 8px; }
242
+ .worker-row { display: flex; align-items: center; gap: 10px; }
243
+ .worker-label { font-size: 11px; color: var(--text3); width: 60px; flex-shrink: 0; font-family: var(--font-mono); }
244
+ .progress-bar { flex: 1; height: 6px; background: var(--bg4); border-radius: 3px; overflow: hidden; }
245
+ .progress-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, var(--accent), var(--purple)); transition: width 0.4s; }
246
+ .worker-stat { font-size: 11px; color: var(--text3); width: 60px; text-align: right; font-family: var(--font-mono); }
247
+
248
+ /* \u2500\u2500 COLLECTION PANEL \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
249
+ #panel-collection { display: none; flex-direction: column; height: 100%; }
250
+ #panel-collection.active { display: flex; }
251
+
252
+ #collection-toolbar {
253
+ padding: 12px 20px; border-bottom: 1px solid var(--border);
254
+ display: flex; align-items: center; gap: 10px; flex-shrink: 0;
255
+ background: var(--bg2);
256
+ }
257
+ .filter-wrap {
258
+ display: flex; align-items: center; gap: 6px;
259
+ flex: 1; max-width: 320px;
260
+ background: var(--bg3); border: 1px solid var(--border2);
261
+ border-radius: 6px; padding: 0 10px;
262
+ transition: border-color 0.15s;
263
+ }
264
+ .filter-wrap:focus-within { border-color: var(--accent); }
265
+ .filter-wrap svg { color: var(--text3); flex-shrink: 0; }
266
+ #filter-input {
267
+ flex: 1; border: none; background: transparent;
268
+ padding: 6px 0;
269
+ color: var(--text); font-size: 13px; font-family: 'Inter', sans-serif;
270
+ outline: none;
271
+ }
272
+ #filter-input::placeholder { color: var(--text3); }
273
+ select.sort-select {
274
+ background: var(--bg3); border: 1px solid var(--border2);
275
+ border-radius: 6px; padding: 6px 10px;
276
+ color: var(--text); font-size: 13px; font-family: 'Inter', sans-serif;
277
+ outline: none; cursor: pointer;
278
+ }
279
+
280
+ /* \u2500\u2500 TABLE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
281
+ #table-wrap { flex: 1; overflow: auto; }
282
+ table { width: 100%; border-collapse: collapse; }
283
+ thead { position: sticky; top: 0; z-index: 2; }
284
+ th {
285
+ background: var(--bg2); color: var(--text3);
286
+ font-size: 11px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase;
287
+ padding: 10px 16px; text-align: left;
288
+ border-bottom: 1px solid var(--border);
289
+ cursor: pointer; white-space: nowrap;
290
+ user-select: none;
291
+ }
292
+ th .th-inner { display: flex; align-items: center; gap: 4px; }
293
+ th:hover { color: var(--text); }
294
+ th.sorted { color: var(--accent2); }
295
+ td {
296
+ padding: 9px 16px; border-bottom: 1px solid var(--border);
297
+ font-size: 13px; max-width: 250px;
298
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
299
+ vertical-align: middle;
300
+ }
301
+ tr { cursor: pointer; transition: background 0.1s; }
302
+ tr:hover td { background: var(--bg3); }
303
+ tr.selected td { background: rgba(59,130,246,0.08); }
304
+ .cell-id { font-family: var(--font-mono); font-size: 11px; color: var(--text3); }
305
+ .cell-bool { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; }
306
+ .cell-bool.t { color: var(--green); }
307
+ .cell-bool.f { color: var(--red); }
308
+ .cell-num { color: var(--purple); font-family: var(--font-mono); font-size: 12px; }
309
+ .cell-date { color: var(--teal); font-size: 11px; }
310
+ .cell-null { color: var(--text3); font-style: italic; font-size: 11px; }
311
+ .cell-obj { color: var(--text3); font-size: 11px; font-family: var(--font-mono); }
312
+
313
+ /* \u2500\u2500 PAGINATION \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
314
+ #pagination {
315
+ padding: 10px 20px; border-top: 1px solid var(--border);
316
+ display: flex; align-items: center; gap: 10px;
317
+ background: var(--bg2); flex-shrink: 0;
318
+ }
319
+ #pagination-info { font-size: 12px; color: var(--text3); }
320
+ #pagination-controls { margin-left: auto; display: flex; gap: 6px; }
321
+ .page-btn {
322
+ width: 28px; height: 28px; border-radius: 5px;
323
+ background: var(--bg3); border: 1px solid var(--border);
324
+ color: var(--text2); cursor: pointer; font-size: 12px;
325
+ display: flex; align-items: center; justify-content: center;
326
+ transition: all 0.15s;
327
+ }
328
+ .page-btn:hover:not(:disabled) { background: var(--bg4); border-color: var(--accent); color: var(--accent2); }
329
+ .page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
330
+ .page-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
331
+
332
+ /* \u2500\u2500 REALTIME PANEL \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
333
+ #panel-realtime { display: none; flex-direction: column; height: 100%; }
334
+ #panel-realtime.active { display: flex; }
335
+ #events-toolbar {
336
+ padding: 12px 20px; border-bottom: 1px solid var(--border);
337
+ display: flex; align-items: center; gap: 10px;
338
+ background: var(--bg2); flex-shrink: 0;
339
+ }
340
+ .event-indicator { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text3); }
341
+ .pulse { width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 1.5s infinite; }
342
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
343
+ #events-log {
344
+ flex: 1; overflow-y: auto; padding: 12px 20px;
345
+ display: flex; flex-direction: column; gap: 6px;
346
+ }
347
+ .event-item {
348
+ display: flex; align-items: flex-start; gap: 12px;
349
+ padding: 10px 14px; border-radius: 8px;
350
+ background: var(--bg2); border: 1px solid var(--border);
351
+ animation: fadeIn 0.3s ease;
352
+ }
353
+ @keyframes fadeIn { from { opacity:0; transform: translateY(-4px); } to { opacity:1; transform: translateY(0); } }
354
+ .event-type {
355
+ font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 4px;
356
+ text-transform: uppercase; letter-spacing: 0.5px; flex-shrink: 0; margin-top: 1px;
357
+ }
358
+ .event-type.insert { background: rgba(34,197,94,0.15); color: var(--green); }
359
+ .event-type.update { background: rgba(245,158,11,0.15); color: var(--yellow); }
360
+ .event-type.delete { background: rgba(239,68,68,0.15); color: var(--red); }
361
+ .event-type.system { background: rgba(167,139,250,0.15);color: var(--purple); }
362
+ .event-body { flex: 1; min-width: 0; }
363
+ .event-title { font-size: 13px; font-weight: 500; margin-bottom: 2px; }
364
+ .event-detail{ font-size: 11px; color: var(--text3); font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
365
+ .event-time { font-size: 10px; color: var(--text3); flex-shrink: 0; font-family: var(--font-mono); }
366
+
367
+ /* \u2500\u2500 INSPECTOR MODAL \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
368
+ #inspector-overlay {
369
+ display: none; position: fixed; inset: 0; z-index: 100;
370
+ background: rgba(0,0,0,0.7); backdrop-filter: blur(4px);
371
+ align-items: flex-start; justify-content: flex-end;
372
+ }
373
+ #inspector-overlay.open { display: flex; }
374
+ #inspector-panel {
375
+ width: min(560px, 90vw); height: 100vh; overflow-y: auto;
376
+ background: var(--bg2); border-left: 1px solid var(--border);
377
+ padding: 20px; display: flex; flex-direction: column; gap: 14px;
378
+ animation: slideIn 0.25s ease;
379
+ }
380
+ @keyframes slideIn { from { transform: translateX(40px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
381
+ #inspector-header { display: flex; align-items: center; justify-content: space-between; }
382
+ #inspector-title { font-size: 15px; font-weight: 600; }
383
+ #inspector-close {
384
+ width: 28px; height: 28px; border-radius: 6px;
385
+ background: var(--bg3); border: 1px solid var(--border);
386
+ cursor: pointer; color: var(--text3);
387
+ display: flex; align-items: center; justify-content: center;
388
+ transition: all 0.15s;
389
+ }
390
+ #inspector-close:hover { color: var(--red); border-color: var(--red); background: rgba(239,68,68,0.1); }
391
+ #inspector-body { flex: 1; }
392
+ #inspector-json {
393
+ background: var(--bg); border: 1px solid var(--border);
394
+ border-radius: 8px; padding: 16px;
395
+ font-family: var(--font-mono); font-size: 12px; line-height: 1.7;
396
+ white-space: pre-wrap; word-break: break-all; overflow-x: auto;
397
+ color: var(--text);
398
+ }
399
+ .j-key { color: #93c5fd; }
400
+ .j-str { color: #86efac; }
401
+ .j-num { color: #c4b5fd; }
402
+ .j-bool { color: #f97316; }
403
+ .j-null { color: var(--text3); }
404
+
405
+ /* \u2500\u2500 EMPTY / LOADING \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
406
+ .loading-spinner {
407
+ width: 30px; height: 30px; border: 3px solid var(--border2);
408
+ border-top-color: var(--accent); border-radius: 50%;
409
+ animation: spin 0.7s linear infinite; margin: 60px auto;
410
+ }
411
+ @keyframes spin { to { transform: rotate(360deg); } }
412
+ .empty-state { text-align: center; padding: 60px 20px; color: var(--text3); }
413
+ .empty-state .empty-icon { display: flex; justify-content: center; margin-bottom: 14px; opacity: 0.4; }
414
+ .empty-state p { font-size: 14px; margin-bottom: 6px; color: var(--text2); }
415
+ .empty-state small { font-size: 12px; }
416
+
417
+ /* \u2500\u2500 SCROLLBAR \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
418
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
419
+ ::-webkit-scrollbar-track { background: transparent; }
420
+ ::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 10px; }
421
+ ::-webkit-scrollbar-thumb:hover { background: var(--text3); }
422
+
423
+ /* \u2500\u2500 TOAST \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
424
+ #toast {
425
+ position: fixed; bottom: 20px; right: 20px;
426
+ background: var(--bg3); border: 1px solid var(--border2);
427
+ border-radius: 8px; padding: 10px 16px; font-size: 13px;
428
+ display: none; z-index: 200; animation: toastIn 0.2s ease;
429
+ }
430
+ @keyframes toastIn { from { opacity:0; transform: translateY(10px); } to { opacity:1; transform: translateY(0); } }
431
+
432
+ /* \u2500\u2500 SEARCH ICON \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
433
+ .search-icon { color: var(--text3); pointer-events: none; }
434
+ </style>
435
+ </head>
436
+ <body>
437
+ <div id="app">
438
+
439
+ <!-- \u2500\u2500 SIDEBAR \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->
440
+ <nav id="sidebar">
441
+ <div id="sidebar-header">
442
+ <div class="logo-icon">${ico.logoDb}</div>
443
+ <span class="logo-text">gramobase</span>
444
+ <span class="logo-badge">STUDIO</span>
445
+ </div>
446
+ <div id="sidebar-nav">
447
+ <div class="nav-section">Dashboard</div>
448
+ <div class="nav-item active" data-panel="overview" onclick="switchPanel('overview')">
449
+ ${ico.gauge} Overview
450
+ </div>
451
+ <div class="nav-item" data-panel="realtime" onclick="switchPanel('realtime')">
452
+ ${ico.radio} Realtime Feed
453
+ </div>
454
+
455
+ <div class="nav-section" style="margin-top:10px">Collections</div>
456
+ <div id="collection-list">
457
+ <div style="padding:12px 16px;font-size:12px;color:var(--text3)">Loading...</div>
458
+ </div>
459
+ </div>
460
+ <div id="sidebar-footer">
461
+ <span class="status-dot" id="conn-dot"></span>
462
+ <span id="conn-label">Connecting...</span>
463
+ </div>
464
+ </nav>
465
+
466
+ <!-- \u2500\u2500 MAIN \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->
467
+ <div id="main">
468
+ <div id="topbar">
469
+ <span id="topbar-title">Overview</span>
470
+ <span id="topbar-sub"></span>
471
+ <div id="topbar-actions">
472
+ <button class="btn" onclick="refreshCurrent()">${ico.refresh} Refresh</button>
473
+ </div>
474
+ </div>
475
+
476
+ <div id="content">
477
+
478
+ <!-- Overview Panel -->
479
+ <div id="panel-overview" class="panel active">
480
+ <div id="overview-inner" style="padding:24px">
481
+ <div style="text-align:center;padding:60px 0"><div class="loading-spinner"></div></div>
482
+ </div>
483
+ </div>
484
+
485
+ <!-- Collection Panel -->
486
+ <div id="panel-collection" class="panel">
487
+ <div id="collection-toolbar">
488
+ <div class="filter-wrap">
489
+ <svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
490
+ <input id="filter-input" type="text" placeholder="Filter: field:value or text" oninput="debounceFilter()" />
491
+ </div>
492
+ <select class="sort-select" id="sort-field" onchange="loadCollection()">
493
+ <option value="">Sort by...</option>
494
+ </select>
495
+ <select class="sort-select" id="sort-dir" onchange="loadCollection()" style="width:90px">
496
+ <option value="-1">DESC</option>
497
+ <option value="1">ASC</option>
498
+ </select>
499
+ <span style="font-size:12px;color:var(--text3);margin-left:4px" id="coll-count"></span>
500
+ </div>
501
+ <div id="table-wrap">
502
+ <div style="text-align:center;padding:60px 0"><div class="loading-spinner"></div></div>
503
+ </div>
504
+ <div id="pagination">
505
+ <span id="pagination-info"></span>
506
+ <div id="pagination-controls"></div>
507
+ </div>
508
+ </div>
509
+
510
+ <!-- Realtime Panel -->
511
+ <div id="panel-realtime" class="panel">
512
+ <div id="events-toolbar">
513
+ <div class="event-indicator"><div class="pulse"></div> Live events</div>
514
+ <button class="btn" style="margin-left:auto" onclick="clearEvents()">${ico.trash} Clear</button>
515
+ </div>
516
+ <div id="events-log">
517
+ <div class="empty-state" id="events-empty">
518
+ <div class="empty-icon">${ico.activity}</div>
519
+ <p>Waiting for events...</p>
520
+ <small>Insert, update, or delete documents to see events here.</small>
521
+ </div>
522
+ </div>
523
+ </div>
524
+
525
+ </div>
526
+ </div>
527
+ </div>
528
+
529
+ <!-- Inspector Drawer -->
530
+ <div id="inspector-overlay" onclick="closeInspector(event)">
531
+ <div id="inspector-panel">
532
+ <div id="inspector-header">
533
+ <span id="inspector-title">Document</span>
534
+ <div id="inspector-close" onclick="closeInspector()">${ico.x}</div>
535
+ </div>
536
+ <div id="inspector-body">
537
+ <pre id="inspector-json"></pre>
538
+ </div>
539
+ </div>
540
+ </div>
541
+
542
+ <!-- Toast -->
543
+ <div id="toast"></div>
544
+
545
+ <script>
546
+ /* \u2500\u2500\u2500 Icon snippets for dynamic HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
547
+ const SVG = {
548
+ bot: \`${ico.bot}\`,
549
+ layers: \`${ico.layers}\`,
550
+ zap: \`${ico.zap}\`,
551
+ hdd: \`${ico.hdd}\`,
552
+ hash: \`${ico.hash}\`,
553
+ grid: \`${ico.grid}\`,
554
+ table: \`${ico.table}\`,
555
+ xCirc: \`${ico.xCircle}\`,
556
+ inbox: \`${ico.inbox}\`,
557
+ asc: \`${ico.sortAsc}\`,
558
+ desc: \`${ico.sortDesc}\`,
559
+ chevrL: \`${ico.chevrL}\`,
560
+ chevrR: \`${ico.chevrR}\`,
561
+ check: \`${ico.check}\`,
562
+ close2: \`${ico.close2}\`,
563
+ };
564
+
565
+ /* \u2500\u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
566
+ let currentPanel = 'overview';
567
+ let currentCollection = null;
568
+ let currentPage = 1;
569
+ let pageSize = 25;
570
+ let totalDocs = 0;
571
+ let sortField = '';
572
+ let sortDir = -1;
573
+ let filterText = '';
574
+ let filterTimer = null;
575
+ let columns = [];
576
+ let eventSource = null;
577
+ let eventCount = 0;
578
+
579
+ /* \u2500\u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
580
+ async function init() {
581
+ await loadStatus();
582
+ await loadCollections();
583
+ connectSSE();
584
+ }
585
+
586
+ /* \u2500\u2500\u2500 Status / Overview \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
587
+ async function loadStatus() {
588
+ try {
589
+ const data = await api('/api/status');
590
+ setConnected(true, '@' + (data.bot?.username || 'unknown'));
591
+ renderOverview(data);
592
+ } catch (e) {
593
+ setConnected(false, 'Connection failed');
594
+ renderOverviewError(e.message);
595
+ }
596
+ }
597
+
598
+ function renderOverview(data) {
599
+ const cache = data.cache || {};
600
+ const workers = data.workers || {};
601
+ const tokens = workers.tokens || [];
602
+
603
+ const hitPct = cache.hits + cache.misses > 0
604
+ ? Math.round((cache.hits / (cache.hits + cache.misses)) * 100)
605
+ : 0;
606
+ const cacheUsedMb = ((cache.bytes || 0) / 1024 / 1024).toFixed(1);
607
+ const cacheMaxMb = ((cache.maxBytes || 1) / 1024 / 1024).toFixed(0);
608
+ const hitColor = hitPct > 70 ? 'green' : hitPct > 40 ? 'yellow' : 'blue';
609
+
610
+ document.getElementById('overview-inner').innerHTML = \`
611
+ <div class="stats-grid">
612
+ <div class="stat-card">
613
+ <div class="stat-icon-wrap blue">\${SVG.bot}</div>
614
+ <div class="stat-label">Bot</div>
615
+ <div class="stat-value blue" style="font-size:18px">@\${escH(data.bot?.username || '\u2014')}</div>
616
+ <div class="stat-sub">\${escH(data.bot?.firstName || '')}</div>
617
+ </div>
618
+ <div class="stat-card">
619
+ <div class="stat-icon-wrap purple">\${SVG.layers}</div>
620
+ <div class="stat-label">Token Pool</div>
621
+ <div class="stat-value purple">\${tokens.length || 1}</div>
622
+ <div class="stat-sub">\${(tokens.length||1) * 30} req/s capacity</div>
623
+ </div>
624
+ <div class="stat-card">
625
+ <div class="stat-icon-wrap \${hitColor}">\${SVG.zap}</div>
626
+ <div class="stat-label">Cache Hit Rate</div>
627
+ <div class="stat-value \${hitColor}">\${hitPct}%</div>
628
+ <div class="stat-sub">\${cache.hits||0} hits / \${cache.misses||0} misses</div>
629
+ </div>
630
+ <div class="stat-card">
631
+ <div class="stat-icon-wrap teal">\${SVG.hdd}</div>
632
+ <div class="stat-label">Cache Usage</div>
633
+ <div class="stat-value teal">\${cacheUsedMb} MB</div>
634
+ <div class="stat-sub">of \${cacheMaxMb} MB limit</div>
635
+ </div>
636
+ <div class="stat-card">
637
+ <div class="stat-icon-wrap blue">\${SVG.hash}</div>
638
+ <div class="stat-label">Channel</div>
639
+ <div class="stat-value blue" style="font-size:15px;font-family:var(--font-mono)">\${escH(data.channelId || '\u2014')}</div>
640
+ <div class="stat-sub">Primary channel</div>
641
+ </div>
642
+ <div class="stat-card">
643
+ <div class="stat-icon-wrap green">\${SVG.grid}</div>
644
+ <div class="stat-label">Collections</div>
645
+ <div class="stat-value green">\${(data.collections||[]).length}</div>
646
+ <div class="stat-sub">discovered</div>
647
+ </div>
648
+ </div>
649
+
650
+ <div class="section-title">Bot Connection</div>
651
+ <div class="info-card">
652
+ <div class="info-row"><span class="info-key">Status</span><span class="info-val"><span class="badge green online-dot"> Online</span></span></div>
653
+ <div class="info-row"><span class="info-key">Username</span><span class="info-val">@\${escH(data.bot?.username||'\u2014')}</span></div>
654
+ <div class="info-row"><span class="info-key">First Name</span><span class="info-val">\${escH(data.bot?.firstName||'\u2014')}</span></div>
655
+ <div class="info-row"><span class="info-key">Channel ID</span><span class="info-val">\${escH(data.channelId||'\u2014')}</span></div>
656
+ <div class="info-row"><span class="info-key">Token Count</span><span class="info-val"><span class="badge blue">\${tokens.length||1} token\${(tokens.length||1)>1?'s':''}</span></span></div>
657
+ </div>
658
+
659
+ \${tokens.length > 0 ? \`
660
+ <div class="section-title" style="margin-top:20px">Worker Pool</div>
661
+ <div class="info-card">
662
+ <div class="worker-bars">
663
+ \${tokens.map((t,i) => \`
664
+ <div class="worker-row">
665
+ <span class="worker-label">Token \${i+1}</span>
666
+ <div class="progress-bar"><div class="progress-fill" style="width:\${Math.min(100,(t.activeRequests||0)/25*100)}%"></div></div>
667
+ <span class="worker-stat">\${t.activeRequests||0}/25</span>
668
+ </div>
669
+ \`).join('')}
670
+ </div>
671
+ </div>
672
+ \` : ''}
673
+ \`;
674
+ }
675
+
676
+ function renderOverviewError(msg) {
677
+ document.getElementById('overview-inner').innerHTML = \`
678
+ <div class="empty-state">
679
+ <div class="empty-icon">\${SVG.xCirc}</div>
680
+ <p>Could not connect to gramobase</p>
681
+ <small>\${escH(msg)}</small>
682
+ </div>
683
+ \`;
684
+ }
685
+
686
+ /* \u2500\u2500\u2500 Collections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
687
+ async function loadCollections() {
688
+ try {
689
+ const data = await api('/api/collections');
690
+ const list = data.collections || [];
691
+ const nav = document.getElementById('collection-list');
692
+ if (list.length === 0) {
693
+ nav.innerHTML = '<div style="padding:10px 16px;font-size:12px;color:var(--text3)">No collections yet</div>';
694
+ return;
695
+ }
696
+ nav.innerHTML = list.map(c => \`
697
+ <div class="nav-item" data-panel="collection" data-col="\${escH(c.name)}" onclick="openCollection('\${escH(c.name)}')">
698
+ \${SVG.table}
699
+ <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">\${escH(c.name)}</span>
700
+ <span class="nav-item-count">\${c.count||'?'}</span>
701
+ </div>
702
+ \`).join('');
703
+ } catch(e) {}
704
+ }
705
+
706
+ async function openCollection(name) {
707
+ currentCollection = name;
708
+ currentPage = 1;
709
+ filterText = '';
710
+ sortField = '';
711
+ document.getElementById('filter-input').value = '';
712
+ document.getElementById('sort-field').innerHTML = '<option value="">Sort by...</option>';
713
+ switchPanel('collection');
714
+ document.getElementById('topbar-title').textContent = name;
715
+ document.getElementById('topbar-sub').textContent = 'collection';
716
+ await loadCollection();
717
+ }
718
+
719
+ async function loadCollection() {
720
+ if (!currentCollection) return;
721
+ sortField = document.getElementById('sort-field').value;
722
+ sortDir = parseInt(document.getElementById('sort-dir').value, 10);
723
+ const params = new URLSearchParams({
724
+ page: currentPage, limit: pageSize, filter: filterText, sortField, sortDir,
725
+ });
726
+ setTableLoading();
727
+ try {
728
+ const data = await api('/api/collection/' + encodeURIComponent(currentCollection) + '?' + params);
729
+ totalDocs = data.total || 0;
730
+ columns = data.columns || [];
731
+ renderTable(data.docs || [], data.columns || []);
732
+ renderPagination();
733
+ updateSortSelect(data.columns || []);
734
+ document.getElementById('coll-count').textContent = totalDocs + ' docs';
735
+ document.querySelectorAll('.nav-item[data-col]').forEach(item => {
736
+ if (item.dataset.col === currentCollection) {
737
+ const cnt = item.querySelector('.nav-item-count');
738
+ if (cnt) cnt.textContent = totalDocs;
739
+ }
740
+ });
741
+ } catch(e) {
742
+ document.getElementById('table-wrap').innerHTML = \`<div class="empty-state"><div class="empty-icon">\${SVG.xCirc}</div><p>\${escH(e.message)}</p></div>\`;
743
+ }
744
+ }
745
+
746
+ function setTableLoading() {
747
+ document.getElementById('table-wrap').innerHTML = '<div style="text-align:center;padding:60px 0"><div class="loading-spinner"></div></div>';
748
+ }
749
+
750
+ function updateSortSelect(cols) {
751
+ const sel = document.getElementById('sort-field');
752
+ const cur = sel.value;
753
+ sel.innerHTML = '<option value="">Sort by...</option>' +
754
+ cols.map(c => \`<option value="\${escH(c)}" \${c===cur?'selected':''}>\${escH(c)}</option>\`).join('');
755
+ }
756
+
757
+ function renderTable(docs, cols) {
758
+ if (docs.length === 0) {
759
+ document.getElementById('table-wrap').innerHTML = \`
760
+ <div class="empty-state">
761
+ <div class="empty-icon">\${SVG.inbox}</div>
762
+ <p>No documents found</p>
763
+ <small>Try adjusting your filter or add some data.</small>
764
+ </div>\`;
765
+ return;
766
+ }
767
+ const allCols = ['_id', ...cols.filter(c => !c.startsWith('_') && c !== '_id'), '_createdAt', '_updatedAt'];
768
+ const sortIcon = (c) => sortField === c ? (sortDir === -1 ? SVG.desc : SVG.asc) : '';
769
+ const html = \`<table>
770
+ <thead><tr>
771
+ \${allCols.map(c => \`<th class="\${sortField===c?'sorted':''}" onclick="sortBy('\${escH(c)}')"><div class="th-inner">\${escH(c)}\${sortIcon(c)}</div></th>\`).join('')}
772
+ </tr></thead>
773
+ <tbody>
774
+ \${docs.map((doc, i) => \`
775
+ <tr onclick="inspectDoc(\${i})" data-idx="\${i}">
776
+ \${allCols.map(c => \`<td>\${renderCell(doc[c], c)}</td>\`).join('')}
777
+ </tr>\`).join('')}
778
+ </tbody>
779
+ </table>\`;
780
+ document.getElementById('table-wrap').innerHTML = html;
781
+ window._currentDocs = docs;
782
+ }
783
+
784
+ function renderCell(val, col) {
785
+ if (val === undefined || val === null) return '<span class="cell-null">\u2014</span>';
786
+ if (col === '_id' || col === '_collection') return \`<span class="cell-id">\${escH(String(val).slice(0,12))}\u2026</span>\`;
787
+ if (col === '_createdAt' || col === '_updatedAt') return \`<span class="cell-date">\${escH(fmtDate(val))}</span>\`;
788
+ if (typeof val === 'boolean') return val
789
+ ? \`<span class="cell-bool t">\${SVG.check} true</span>\`
790
+ : \`<span class="cell-bool f">\${SVG.close2} false</span>\`;
791
+ if (typeof val === 'number') return \`<span class="cell-num">\${escH(String(val))}</span>\`;
792
+ if (typeof val === 'object') return '<span class="cell-obj">{\u2026}</span>';
793
+ const s = String(val);
794
+ return \`<span title="\${escH(s)}">\${escH(s.length > 40 ? s.slice(0,40)+'\u2026' : s)}</span>\`;
795
+ }
796
+
797
+ function sortBy(col) {
798
+ if (sortField === col) { sortDir = sortDir === -1 ? 1 : -1; }
799
+ else { sortField = col; sortDir = -1; }
800
+ document.getElementById('sort-field').value = sortField;
801
+ document.getElementById('sort-dir').value = sortDir;
802
+ loadCollection();
803
+ }
804
+
805
+ function inspectDoc(idx) {
806
+ const doc = window._currentDocs?.[idx];
807
+ if (!doc) return;
808
+ document.getElementById('inspector-title').textContent = 'Document: ' + (doc._id?.slice(0,8)||'') + '\u2026';
809
+ document.getElementById('inspector-json').innerHTML = syntaxHL(JSON.stringify(doc, null, 2));
810
+ document.getElementById('inspector-overlay').classList.add('open');
811
+ document.querySelectorAll('tr').forEach(r => r.classList.toggle('selected', r.dataset.idx == idx));
812
+ }
813
+
814
+ function closeInspector(e) {
815
+ if (e && e.target !== document.getElementById('inspector-overlay')) return;
816
+ document.getElementById('inspector-overlay').classList.remove('open');
817
+ document.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
818
+ }
819
+
820
+ /* \u2500\u2500\u2500 Pagination \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
821
+ function renderPagination() {
822
+ const totalPages = Math.max(1, Math.ceil(totalDocs / pageSize));
823
+ const start = (currentPage - 1) * pageSize + 1;
824
+ const end = Math.min(currentPage * pageSize, totalDocs);
825
+ document.getElementById('pagination-info').textContent = totalDocs > 0 ? \`\${start}\u2013\${end} of \${totalDocs}\` : '0 results';
826
+
827
+ let btns = '';
828
+ btns += \`<button class="page-btn" onclick="goPage(\${currentPage-1})" \${currentPage<=1?'disabled':''}>\${SVG.chevrL}</button>\`;
829
+ const pages = pageRange(currentPage, totalPages);
830
+ pages.forEach(p => {
831
+ if (p === '\u2026') btns += \`<span style="color:var(--text3);padding:0 4px;line-height:28px">\u2026</span>\`;
832
+ else btns += \`<button class="page-btn \${p===currentPage?'active':''}" onclick="goPage(\${p})">\${p}</button>\`;
833
+ });
834
+ btns += \`<button class="page-btn" onclick="goPage(\${currentPage+1})" \${currentPage>=totalPages?'disabled':''}>\${SVG.chevrR}</button>\`;
835
+ document.getElementById('pagination-controls').innerHTML = btns;
836
+ }
837
+
838
+ function pageRange(cur, total) {
839
+ if (total <= 7) return Array.from({length:total},(_,i)=>i+1);
840
+ const pages = [1];
841
+ if (cur > 3) pages.push('\u2026');
842
+ for (let i = Math.max(2,cur-1); i <= Math.min(total-1,cur+1); i++) pages.push(i);
843
+ if (cur < total-2) pages.push('\u2026');
844
+ pages.push(total);
845
+ return pages;
846
+ }
847
+
848
+ function goPage(p) {
849
+ const totalPages = Math.ceil(totalDocs / pageSize);
850
+ if (p < 1 || p > totalPages) return;
851
+ currentPage = p;
852
+ loadCollection();
853
+ }
854
+
855
+ /* \u2500\u2500\u2500 Filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
856
+ function debounceFilter() {
857
+ clearTimeout(filterTimer);
858
+ filterTimer = setTimeout(() => {
859
+ filterText = document.getElementById('filter-input').value;
860
+ currentPage = 1;
861
+ loadCollection();
862
+ }, 400);
863
+ }
864
+
865
+ /* \u2500\u2500\u2500 Realtime SSE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
866
+ function connectSSE() {
867
+ if (eventSource) eventSource.close();
868
+ eventSource = new EventSource('/api/events');
869
+ eventSource.onmessage = (e) => {
870
+ try { addEvent(JSON.parse(e.data)); } catch(_) {}
871
+ };
872
+ eventSource.onerror = () => {};
873
+ }
874
+
875
+ function addEvent(ev) {
876
+ eventCount++;
877
+ const log = document.getElementById('events-log');
878
+ const empty = document.getElementById('events-empty');
879
+ if (empty) empty.remove();
880
+
881
+ const typeMap = { insert:'insert', update:'update', delete:'delete', 'worker:rotate':'system', 'wal:flush':'system' };
882
+ const type = typeMap[ev.type] || 'system';
883
+ const title = ev.type === 'insert' ? \`New doc in \${ev.collection}\`
884
+ : ev.type === 'update' ? \`Updated \${ev.id?.slice(0,8)}\u2026 in \${ev.collection}\`
885
+ : ev.type === 'delete' ? \`Deleted \${ev.id?.slice(0,8)}\u2026 from \${ev.collection}\`
886
+ : ev.type === 'worker:rotate' ? 'Worker rotated token #' + ev.tokenIndex
887
+ : ev.type === 'wal:flush' ? \`WAL flushed \${ev.entries} entries\` : ev.type;
888
+ const detail = ev.doc ? JSON.stringify(ev.doc).slice(0,80) : ev.changes ? JSON.stringify(ev.changes).slice(0,80) : '';
889
+
890
+ const item = document.createElement('div');
891
+ item.className = 'event-item';
892
+ item.innerHTML = \`
893
+ <div class="event-type \${type}">\${ev.type}</div>
894
+ <div class="event-body">
895
+ <div class="event-title">\${escH(title)}</div>
896
+ \${detail ? \`<div class="event-detail">\${escH(detail)}</div>\` : ''}
897
+ </div>
898
+ <div class="event-time">\${fmtTime(new Date())}</div>
899
+ \`;
900
+ log.insertBefore(item, log.firstChild);
901
+ while (log.children.length > 200) log.removeChild(log.lastChild);
902
+ if ((ev.collection === currentCollection) && currentPanel === 'collection') loadCollection();
903
+ }
904
+
905
+ function clearEvents() {
906
+ document.getElementById('events-log').innerHTML = \`
907
+ <div class="empty-state" id="events-empty">
908
+ <div class="empty-icon">\${SVG.xCirc}</div>
909
+ <p>Waiting for events...</p>
910
+ <small>Insert, update, or delete documents to see events here.</small>
911
+ </div>\`;
912
+ eventCount = 0;
913
+ }
914
+
915
+ /* \u2500\u2500\u2500 Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
916
+ function switchPanel(name) {
917
+ currentPanel = name;
918
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
919
+ document.getElementById('panel-' + name).classList.add('active');
920
+ document.querySelectorAll('.nav-item').forEach(i => {
921
+ i.classList.toggle('active', i.dataset.panel === name && !i.dataset.col);
922
+ });
923
+ if (name === 'collection') {
924
+ document.querySelectorAll('.nav-item[data-col]').forEach(i => {
925
+ i.classList.toggle('active', i.dataset.col === currentCollection);
926
+ });
927
+ }
928
+ if (name === 'overview') {
929
+ document.getElementById('topbar-title').textContent = 'Overview';
930
+ document.getElementById('topbar-sub').textContent = '';
931
+ loadStatus(); loadCollections();
932
+ }
933
+ if (name === 'realtime') {
934
+ document.getElementById('topbar-title').textContent = 'Realtime Feed';
935
+ document.getElementById('topbar-sub').textContent = 'live events';
936
+ }
937
+ }
938
+
939
+ function refreshCurrent() {
940
+ if (currentPanel === 'overview') { loadStatus(); loadCollections(); }
941
+ else if (currentPanel === 'collection') loadCollection();
942
+ }
943
+
944
+ /* \u2500\u2500\u2500 Utilities \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
945
+ function setConnected(ok, label) {
946
+ document.getElementById('conn-dot').className = 'status-dot' + (ok ? '' : ' red');
947
+ document.getElementById('conn-label').textContent = label;
948
+ }
949
+
950
+ async function api(url) {
951
+ const r = await fetch(url);
952
+ if (!r.ok) {
953
+ const j = await r.json().catch(() => ({}));
954
+ throw new Error(j.error || r.statusText);
955
+ }
956
+ return r.json();
957
+ }
958
+
959
+ function escH(s) {
960
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
961
+ }
962
+
963
+ function fmtDate(v) {
964
+ if (!v) return '';
965
+ try { return new Date(v).toLocaleString(); } catch(_) { return String(v); }
966
+ }
967
+
968
+ function fmtTime(d) { return d.toTimeString().slice(0,8); }
969
+
970
+ function syntaxHL(json) {
971
+ return json
972
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
973
+ .replace(/"(\\\\.|[^"\\\\])*"\\s*:/g, s => \`<span class="j-key">\${s}</span>\`)
974
+ .replace(/:\\s*"(\\\\.|[^"\\\\])*"/g, s => {
975
+ const col = s.indexOf('"');
976
+ return s.slice(0,col) + \`<span class="j-str">\${s.slice(col)}</span>\`;
977
+ })
978
+ .replace(/:\\s*(-?\\d+\\.?\\d*)/g, (m,n) => m.replace(n, \`<span class="j-num">\${n}</span>\`))
979
+ .replace(/:\\s*(true|false)/g, (m,b) => m.replace(b, \`<span class="j-bool">\${b}</span>\`))
980
+ .replace(/:\\s*(null)/g, (m,n) => m.replace(n, \`<span class="j-null">\${n}</span>\`));
981
+ }
982
+
983
+ init();
984
+ </script>
985
+ </body>
986
+ </html>`;
987
+ }
988
+ var BotWorkerPool = class extends EventEmitter__default.default {
989
+ bots = [];
990
+ queues = [];
991
+ stats = [];
992
+ currentIndex = 0;
993
+ debug;
994
+ constructor(tokens, concurrency = 25, debug = false) {
995
+ super();
996
+ this.debug = debug;
997
+ if (tokens.length === 0) throw new Error("[gramobase] At least one bot token required");
998
+ for (let i = 0; i < tokens.length; i++) {
999
+ const token = tokens[i];
1000
+ this.bots.push(new TelegramBot__default.default(token, { polling: false }));
1001
+ this.queues.push(new PQueue__default.default({ concurrency, intervalCap: 25, interval: 1e3 }));
1002
+ this.stats.push({
1003
+ tokenIndex: i,
1004
+ requestCount: 0,
1005
+ errorCount: 0,
1006
+ lastUsed: 0,
1007
+ rateLimitHits: 0
1008
+ });
1009
+ }
1010
+ }
1011
+ /**
1012
+ * Execute a Telegram API call through the pool with automatic retry
1013
+ * and token rotation on rate limits.
1014
+ */
1015
+ async execute(fn, priority = 5) {
1016
+ const idx = this.pickWorker();
1017
+ const queue = this.queues[idx];
1018
+ const bot = this.bots[idx];
1019
+ const stat = this.stats[idx];
1020
+ return queue.add(
1021
+ () => pRetry__default.default(
1022
+ async () => {
1023
+ stat.requestCount++;
1024
+ stat.lastUsed = Date.now();
1025
+ try {
1026
+ const result = await fn(bot);
1027
+ return result;
1028
+ } catch (err) {
1029
+ stat.errorCount++;
1030
+ if (this.isFloodError(err)) {
1031
+ stat.rateLimitHits++;
1032
+ this.emit("worker:rotate", idx);
1033
+ const retryAfter = this.extractRetryAfter(err) * 1e3;
1034
+ if (this.debug) {
1035
+ console.warn(`[gramobase] Worker ${idx} flood limited, retrying after ${retryAfter}ms`);
1036
+ }
1037
+ await this.sleep(retryAfter);
1038
+ throw err;
1039
+ }
1040
+ if (this.isRetryableError(err)) {
1041
+ throw err;
1042
+ }
1043
+ throw new pRetry.AbortError(err instanceof Error ? err : new Error(String(err)));
1044
+ }
1045
+ },
1046
+ {
1047
+ retries: 5,
1048
+ factor: 2,
1049
+ minTimeout: 1e3,
1050
+ maxTimeout: 3e4,
1051
+ onFailedAttempt: (error) => {
1052
+ if (this.debug) {
1053
+ console.warn(`[gramobase] Attempt ${error.attemptNumber} failed:`, error.message);
1054
+ }
1055
+ }
1056
+ }
1057
+ ),
1058
+ { priority }
1059
+ );
1060
+ }
1061
+ /**
1062
+ * Round-robin with recency bias — prefer the worker that was least recently used.
1063
+ */
1064
+ pickWorker() {
1065
+ if (this.bots.length === 1) return 0;
1066
+ let bestIdx = 0;
1067
+ let oldestTime = Infinity;
1068
+ for (let i = 0; i < this.stats.length; i++) {
1069
+ const stat = this.stats[i];
1070
+ if (stat.lastUsed < oldestTime) {
1071
+ oldestTime = stat.lastUsed;
1072
+ bestIdx = i;
1073
+ }
1074
+ }
1075
+ this.currentIndex = bestIdx;
1076
+ return bestIdx;
1077
+ }
1078
+ isFloodError(err) {
1079
+ if (err && typeof err === "object") {
1080
+ const e = err;
1081
+ return e.code === "ETELEGRAM" && e.response?.statusCode === 429;
1082
+ }
1083
+ return false;
1084
+ }
1085
+ isRetryableError(err) {
1086
+ if (!err) return false;
1087
+ if (err instanceof Error) {
1088
+ if (err.name === "TypeError" || err.name === "ReferenceError" || err.name === "ValidationError") {
1089
+ return false;
1090
+ }
1091
+ }
1092
+ if (typeof err === "object") {
1093
+ const e = err;
1094
+ if (e.code === "ETELEGRAM" && e.response?.statusCode === 429) {
1095
+ return true;
1096
+ }
1097
+ if (e.response?.statusCode && e.response.statusCode >= 500) {
1098
+ return true;
1099
+ }
1100
+ const retryableCodes = ["ETIMEDOUT", "ECONNRESET", "ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"];
1101
+ if (e.code && retryableCodes.includes(e.code)) {
1102
+ return true;
1103
+ }
1104
+ }
1105
+ return false;
1106
+ }
1107
+ extractRetryAfter(err) {
1108
+ if (err && typeof err === "object") {
1109
+ const e = err;
1110
+ return e.response?.body?.parameters?.retry_after ?? 5;
1111
+ }
1112
+ return 5;
1113
+ }
1114
+ getBot(index = 0) {
1115
+ return this.bots[index] ?? this.bots[0];
1116
+ }
1117
+ getStats() {
1118
+ return [...this.stats];
1119
+ }
1120
+ getQueueSizes() {
1121
+ return this.queues.map((q) => q.size);
1122
+ }
1123
+ sleep(ms) {
1124
+ return new Promise((r) => setTimeout(r, ms));
1125
+ }
1126
+ async destroy() {
1127
+ await Promise.all(this.queues.map((q) => q.onIdle()));
1128
+ for (const bot of this.bots) {
1129
+ await bot.stopPolling();
1130
+ }
1131
+ }
1132
+ };
1133
+ var HotCache = class extends EventEmitter__default.default {
1134
+ cache;
1135
+ collectionIndexes = /* @__PURE__ */ new Map();
1136
+ stats = { hits: 0, misses: 0, evictions: 0 };
1137
+ constructor(maxBytes = 64 * 1024 * 1024, ttlMs = 6e4) {
1138
+ super();
1139
+ this.cache = new lruCache.LRUCache({
1140
+ maxSize: maxBytes,
1141
+ sizeCalculation: (val) => JSON.stringify(val).length * 2,
1142
+ // rough UTF-16 byte estimate
1143
+ ttl: ttlMs,
1144
+ dispose: () => {
1145
+ this.stats.evictions++;
1146
+ }
1147
+ });
1148
+ }
1149
+ // ─── Document cache ──────────────────────────────────────────────────────
1150
+ get(collection, id) {
1151
+ const key = this.docKey(collection, id);
1152
+ const entry = this.cache.get(key);
1153
+ if (entry) {
1154
+ this.stats.hits++;
1155
+ this.emit("cache:hit", { collection, key: id });
1156
+ return entry.data;
1157
+ }
1158
+ this.stats.misses++;
1159
+ this.emit("cache:miss", { collection, key: id });
1160
+ return void 0;
1161
+ }
1162
+ set(collection, id, data) {
1163
+ const key = this.docKey(collection, id);
1164
+ this.cache.set(key, { data, collection, cachedAt: Date.now() });
1165
+ }
1166
+ delete(collection, id) {
1167
+ this.cache.delete(this.docKey(collection, id));
1168
+ }
1169
+ invalidateCollection(collection) {
1170
+ for (const key of this.cache.keys()) {
1171
+ if (key.startsWith(`doc:${collection}:`)) {
1172
+ this.cache.delete(key);
1173
+ }
1174
+ }
1175
+ this.collectionIndexes.delete(collection);
1176
+ }
1177
+ // ─── Query cache ─────────────────────────────────────────────────────────
1178
+ getQuery(queryHash) {
1179
+ const entry = this.cache.get(`query:${queryHash}`);
1180
+ return entry?.data;
1181
+ }
1182
+ setQuery(queryHash, results) {
1183
+ this.cache.set(`query:${queryHash}`, {
1184
+ data: results,
1185
+ collection: "__query__",
1186
+ cachedAt: Date.now()
1187
+ });
1188
+ }
1189
+ invalidateQuery(collection) {
1190
+ for (const key of this.cache.keys()) {
1191
+ if (key.startsWith(`query:${collection}:`)) {
1192
+ this.cache.delete(key);
1193
+ }
1194
+ }
1195
+ }
1196
+ // ─── Collection index ─────────────────────────────────────────────────────
1197
+ // Maps document _id → Telegram message ID for O(1) lookup
1198
+ getIndex(collection) {
1199
+ return this.collectionIndexes.get(collection);
1200
+ }
1201
+ setIndex(collection, index) {
1202
+ this.collectionIndexes.set(collection, index);
1203
+ }
1204
+ updateIndexEntry(collection, id, msgId) {
1205
+ const idx = this.collectionIndexes.get(collection);
1206
+ if (idx) {
1207
+ idx.set(id, msgId);
1208
+ } else {
1209
+ this.collectionIndexes.set(collection, /* @__PURE__ */ new Map([[id, msgId]]));
1210
+ }
1211
+ }
1212
+ deleteIndexEntry(collection, id) {
1213
+ this.collectionIndexes.get(collection)?.delete(id);
1214
+ }
1215
+ getMsgId(collection, id) {
1216
+ return this.collectionIndexes.get(collection)?.get(id);
1217
+ }
1218
+ // ─── Bulk read ────────────────────────────────────────────────────────────
1219
+ getMany(collection, ids) {
1220
+ const result = /* @__PURE__ */ new Map();
1221
+ for (const id of ids) {
1222
+ const val = this.get(collection, id);
1223
+ if (val !== void 0) result.set(id, val);
1224
+ }
1225
+ return result;
1226
+ }
1227
+ // ─── Stats ────────────────────────────────────────────────────────────────
1228
+ getStats() {
1229
+ return {
1230
+ ...this.stats,
1231
+ size: this.cache.size,
1232
+ hitRate: this.stats.hits + this.stats.misses > 0 ? this.stats.hits / (this.stats.hits + this.stats.misses) : 0
1233
+ };
1234
+ }
1235
+ clear() {
1236
+ this.cache.clear();
1237
+ this.collectionIndexes.clear();
1238
+ }
1239
+ docKey(collection, id) {
1240
+ return `doc:${collection}:${id}`;
1241
+ }
1242
+ };
1243
+ var INDEX_TAG = "__GRAMOBASE_INDEX__";
1244
+ var DOC_TAG = "__GRAMOBASE_DOC__";
1245
+ var MAX_MSG_BYTES = 4e3;
1246
+ var TelegramStorage = class {
1247
+ constructor(pool, defaultChannelId, encryptionKey, debug = false) {
1248
+ this.pool = pool;
1249
+ this.defaultChannelId = defaultChannelId;
1250
+ this.debug = debug;
1251
+ if (encryptionKey) {
1252
+ this.encryptionKey = crypto.createHash("sha256").update(encryptionKey).digest();
1253
+ }
1254
+ }
1255
+ pool;
1256
+ defaultChannelId;
1257
+ debug;
1258
+ encryptionKey = null;
1259
+ // collection → pinned index message ID
1260
+ indexMsgIds = /* @__PURE__ */ new Map();
1261
+ // ─── Index management ─────────────────────────────────────────────────────
1262
+ async loadIndex(collection, channelId) {
1263
+ const channel = channelId ?? this.defaultChannelId;
1264
+ try {
1265
+ const chat = await this.pool.execute((bot) => bot.getChat(channel));
1266
+ if (chat.pinned_message?.text?.startsWith(INDEX_TAG)) {
1267
+ const json = chat.pinned_message.text.replace(INDEX_TAG + "\n", "");
1268
+ const parsed = JSON.parse(json);
1269
+ this.indexMsgIds.set(collection, chat.pinned_message.message_id);
1270
+ return parsed;
1271
+ }
1272
+ } catch {
1273
+ }
1274
+ return {
1275
+ collection,
1276
+ entries: {},
1277
+ walSeq: 0,
1278
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1279
+ };
1280
+ }
1281
+ async saveIndex(index, channelId) {
1282
+ const channel = channelId ?? this.defaultChannelId;
1283
+ const text = `${INDEX_TAG}
1284
+ ${JSON.stringify(index)}`;
1285
+ const existingMsgId = this.indexMsgIds.get(index.collection);
1286
+ if (existingMsgId) {
1287
+ try {
1288
+ await this.pool.execute(
1289
+ (bot) => bot.editMessageText(text, {
1290
+ chat_id: channel,
1291
+ message_id: existingMsgId
1292
+ })
1293
+ );
1294
+ return;
1295
+ } catch {
1296
+ }
1297
+ }
1298
+ const msg = await this.pool.execute(
1299
+ (bot) => bot.sendMessage(channel, text, { disable_notification: true })
1300
+ );
1301
+ this.indexMsgIds.set(index.collection, msg.message_id);
1302
+ await this.pool.execute(
1303
+ (bot) => bot.pinChatMessage(channel, msg.message_id, { disable_notification: true })
1304
+ );
1305
+ }
1306
+ // ─── Document CRUD ────────────────────────────────────────────────────────
1307
+ async writeDocument(doc, channelId) {
1308
+ const channel = channelId ?? this.defaultChannelId;
1309
+ let text = JSON.stringify({ [DOC_TAG]: true, ...doc });
1310
+ if (this.encryptionKey) {
1311
+ text = this.encrypt(text);
1312
+ }
1313
+ if (Buffer.byteLength(text, "utf8") > MAX_MSG_BYTES) {
1314
+ return this.writeChunked(text, channel);
1315
+ }
1316
+ const msg = await this.pool.execute(
1317
+ (bot) => bot.sendMessage(channel, text, { disable_notification: true })
1318
+ );
1319
+ return msg.message_id;
1320
+ }
1321
+ async readDocument(msgId, channelId) {
1322
+ const channel = channelId ?? this.defaultChannelId;
1323
+ try {
1324
+ const msgs = await this.pool.execute(
1325
+ (bot) => bot.forwardMessages(channel, channel, [msgId])
1326
+ );
1327
+ const msg = Array.isArray(msgs) ? msgs[0] : msgs;
1328
+ if (!msg?.text) return null;
1329
+ let text = msg.text;
1330
+ if (this.encryptionKey && text.startsWith("ENC:")) {
1331
+ text = this.decrypt(text);
1332
+ }
1333
+ if (text.startsWith("CHUNK:")) {
1334
+ text = await this.readChunked(text, channel);
1335
+ }
1336
+ const parsed = JSON.parse(text);
1337
+ delete parsed[DOC_TAG];
1338
+ return parsed;
1339
+ } catch {
1340
+ return null;
1341
+ }
1342
+ }
1343
+ async deleteDocument(msgId, channelId) {
1344
+ const channel = channelId ?? this.defaultChannelId;
1345
+ await this.pool.execute(
1346
+ (bot) => bot.deleteMessage(channel, msgId)
1347
+ );
1348
+ }
1349
+ async updateDocument(msgId, doc, channelId) {
1350
+ await this.deleteDocument(msgId, channelId);
1351
+ return this.writeDocument(doc, channelId);
1352
+ }
1353
+ // ─── Chunked large documents ──────────────────────────────────────────────
1354
+ async writeChunked(text, channel) {
1355
+ const chunks = [];
1356
+ for (let i = 0; i < text.length; i += MAX_MSG_BYTES) {
1357
+ chunks.push(text.slice(i, i + MAX_MSG_BYTES));
1358
+ }
1359
+ const msgIds = [];
1360
+ for (const chunk of chunks) {
1361
+ const msg = await this.pool.execute(
1362
+ (bot) => bot.sendMessage(channel, chunk, { disable_notification: true })
1363
+ );
1364
+ msgIds.push(msg.message_id);
1365
+ }
1366
+ const header = `CHUNK:${JSON.stringify(msgIds)}`;
1367
+ const headerMsg = await this.pool.execute(
1368
+ (bot) => bot.sendMessage(channel, header, { disable_notification: true })
1369
+ );
1370
+ return headerMsg.message_id;
1371
+ }
1372
+ async readChunked(headerText, channel) {
1373
+ const msgIds = JSON.parse(headerText.replace("CHUNK:", ""));
1374
+ const parts = [];
1375
+ for (const id of msgIds) {
1376
+ const msgs = await this.pool.execute(
1377
+ (bot) => bot.forwardMessages(channel, channel, [id])
1378
+ );
1379
+ const msg = Array.isArray(msgs) ? msgs[0] : msgs;
1380
+ if (msg?.text) parts.push(msg.text);
1381
+ }
1382
+ return parts.join("");
1383
+ }
1384
+ // ─── File storage ─────────────────────────────────────────────────────────
1385
+ async uploadFile(data, fileName, mimeType, channelId) {
1386
+ const channel = channelId ?? this.defaultChannelId;
1387
+ const isImage = mimeType.startsWith("image/");
1388
+ let msg;
1389
+ if (isImage) {
1390
+ msg = await this.pool.execute(
1391
+ (bot) => bot.sendPhoto(channel, data, {
1392
+ caption: fileName,
1393
+ disable_notification: true
1394
+ })
1395
+ );
1396
+ } else {
1397
+ msg = await this.pool.execute(
1398
+ (bot) => bot.sendDocument(channel, data, {
1399
+ caption: fileName,
1400
+ disable_notification: true
1401
+ })
1402
+ );
1403
+ }
1404
+ const fileId = isImage ? msg.photo?.[msg.photo.length - 1]?.file_id : msg.document?.file_id;
1405
+ return { fileId, msgId: msg.message_id };
1406
+ }
1407
+ async getFileUrl(fileId) {
1408
+ return this.pool.execute((bot) => bot.getFileLink(fileId));
1409
+ }
1410
+ // ─── Encryption ───────────────────────────────────────────────────────────
1411
+ encrypt(text) {
1412
+ if (!this.encryptionKey) return text;
1413
+ const iv = crypto.randomBytes(16);
1414
+ const cipher = crypto.createCipheriv("aes-256-cbc", this.encryptionKey, iv);
1415
+ const encrypted = Buffer.concat([
1416
+ cipher.update(text, "utf8"),
1417
+ cipher.final()
1418
+ ]);
1419
+ return `ENC:${iv.toString("hex")}:${encrypted.toString("base64")}`;
1420
+ }
1421
+ decrypt(text) {
1422
+ if (!this.encryptionKey) return text;
1423
+ const [, ivHex, encB64] = text.split(":");
1424
+ if (!ivHex || !encB64) throw new Error("Invalid encrypted payload");
1425
+ const iv = Buffer.from(ivHex, "hex");
1426
+ const encBuf = Buffer.from(encB64, "base64");
1427
+ const decipher = crypto.createDecipheriv("aes-256-cbc", this.encryptionKey, iv);
1428
+ return Buffer.concat([decipher.update(encBuf), decipher.final()]).toString("utf8");
1429
+ }
1430
+ };
1431
+ var WAL_HEADER = "__WAL__";
1432
+ var WAL_SEQ_TAG = "__WAL_SEQ__";
1433
+ var WriteAheadLog = class {
1434
+ constructor(pool, walChannelId, debug = false) {
1435
+ this.pool = pool;
1436
+ this.walChannelId = walChannelId;
1437
+ this.debug = debug;
1438
+ }
1439
+ pool;
1440
+ walChannelId;
1441
+ debug;
1442
+ seq = 0;
1443
+ buffer = [];
1444
+ flushTimer = null;
1445
+ FLUSH_INTERVAL_MS = 2e3;
1446
+ BUFFER_LIMIT = 50;
1447
+ async init() {
1448
+ try {
1449
+ const msgs = await this.pool.execute(
1450
+ (bot) => bot.getChatHistory(this.walChannelId, { limit: 10 })
1451
+ );
1452
+ for (const msg of msgs.reverse()) {
1453
+ if (msg.text?.includes(WAL_SEQ_TAG)) {
1454
+ const match = msg.text.match(/__WAL_SEQ__:(\d+)/);
1455
+ if (match) {
1456
+ this.seq = parseInt(match[1], 10);
1457
+ break;
1458
+ }
1459
+ }
1460
+ }
1461
+ } catch {
1462
+ }
1463
+ if (this.debug) console.log(`[WAL] Initialized at seq=${this.seq}`);
1464
+ }
1465
+ /**
1466
+ * Append an entry to the WAL buffer. Flushes immediately if buffer is full.
1467
+ */
1468
+ async append(op, collection, id, data) {
1469
+ this.seq++;
1470
+ const entry = {
1471
+ seq: this.seq,
1472
+ op,
1473
+ collection,
1474
+ id,
1475
+ data,
1476
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1477
+ checksum: ""
1478
+ };
1479
+ entry.checksum = this.checksum(entry);
1480
+ this.buffer.push(entry);
1481
+ if (this.buffer.length >= this.BUFFER_LIMIT) {
1482
+ await this.flush();
1483
+ } else {
1484
+ this.scheduleFlush();
1485
+ }
1486
+ return entry;
1487
+ }
1488
+ /**
1489
+ * Flush buffered WAL entries to Telegram.
1490
+ */
1491
+ async flush() {
1492
+ if (this.buffer.length === 0) return;
1493
+ if (this.flushTimer) {
1494
+ clearTimeout(this.flushTimer);
1495
+ this.flushTimer = null;
1496
+ }
1497
+ const batch = [...this.buffer];
1498
+ this.buffer = [];
1499
+ const payload = JSON.stringify({
1500
+ __wal: true,
1501
+ [WAL_SEQ_TAG]: batch[batch.length - 1].seq,
1502
+ entries: batch
1503
+ });
1504
+ const chunks = this.chunk(payload, 4e3);
1505
+ for (const chunk of chunks) {
1506
+ await this.pool.execute(
1507
+ (bot) => bot.sendMessage(this.walChannelId, `${WAL_HEADER}
1508
+ ${chunk}`, {
1509
+ disable_notification: true
1510
+ })
1511
+ );
1512
+ }
1513
+ if (this.debug) console.log(`[WAL] Flushed ${batch.length} entries`);
1514
+ this.pool.emit("wal:flush", batch.length);
1515
+ }
1516
+ /**
1517
+ * Replay all WAL entries since a given sequence number.
1518
+ * Returns entries in order — the caller applies them to restore state.
1519
+ */
1520
+ async replay(sinceSeq = 0) {
1521
+ const entries = [];
1522
+ try {
1523
+ const msgs = await this.pool.execute(
1524
+ (bot) => bot.getChatHistory(this.walChannelId, { limit: 100 })
1525
+ );
1526
+ for (const msg of msgs) {
1527
+ if (!msg.text?.startsWith(WAL_HEADER)) continue;
1528
+ const jsonStr = msg.text.replace(WAL_HEADER + "\n", "");
1529
+ try {
1530
+ const parsed = JSON.parse(jsonStr);
1531
+ if (parsed.__wal && Array.isArray(parsed.entries)) {
1532
+ for (const e of parsed.entries) {
1533
+ if (e.seq > sinceSeq && this.verifyChecksum(e)) {
1534
+ entries.push(e);
1535
+ }
1536
+ }
1537
+ }
1538
+ } catch {
1539
+ }
1540
+ }
1541
+ } catch {
1542
+ }
1543
+ return entries.sort((a, b) => a.seq - b.seq);
1544
+ }
1545
+ scheduleFlush() {
1546
+ if (this.flushTimer) return;
1547
+ this.flushTimer = setTimeout(() => this.flush(), this.FLUSH_INTERVAL_MS);
1548
+ }
1549
+ checksum(entry) {
1550
+ const str = `${entry.seq}:${entry.op}:${entry.collection}:${entry.id}:${JSON.stringify(entry.data)}`;
1551
+ return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
1552
+ }
1553
+ verifyChecksum(entry) {
1554
+ const { checksum, ...rest } = entry;
1555
+ return checksum === this.checksum(rest);
1556
+ }
1557
+ chunk(str, size) {
1558
+ const chunks = [];
1559
+ for (let i = 0; i < str.length; i += size) {
1560
+ chunks.push(str.slice(i, i + size));
1561
+ }
1562
+ return chunks;
1563
+ }
1564
+ getCurrentSeq() {
1565
+ return this.seq;
1566
+ }
1567
+ };
1568
+ var REGISTRY_TAG = "__GRAMOBASE_REGISTRY__";
1569
+ var LEASE_TTL_MS = 3e4;
1570
+ var HEARTBEAT_MS = 1e4;
1571
+ var Registry = class {
1572
+ constructor(pool, channelId, debug = false) {
1573
+ this.pool = pool;
1574
+ this.channelId = channelId;
1575
+ this.debug = debug;
1576
+ this.instanceId = crypto.randomUUID();
1577
+ this.state = {
1578
+ activeLease: null,
1579
+ instanceId: this.instanceId,
1580
+ registryMsgId: null
1581
+ };
1582
+ }
1583
+ pool;
1584
+ channelId;
1585
+ debug;
1586
+ state;
1587
+ instanceId;
1588
+ async acquireWriteLease() {
1589
+ const existing = await this.readRegistryMessage();
1590
+ if (existing?.activeLease) {
1591
+ const lease2 = existing.activeLease;
1592
+ if (lease2.instanceId !== this.instanceId && Date.now() < lease2.expiresAt) {
1593
+ throw new Error(
1594
+ `[gramobase Registry] Another instance (${lease2.instanceId}) holds the write lease until ${new Date(lease2.expiresAt).toISOString()}. Use Registry.forceRelease() to break a stale lease.`
1595
+ );
1596
+ }
1597
+ }
1598
+ const lease = {
1599
+ instanceId: this.instanceId,
1600
+ acquiredAt: Date.now(),
1601
+ expiresAt: Date.now() + LEASE_TTL_MS,
1602
+ heartbeatInterval: null
1603
+ };
1604
+ await this.writeRegistryMessage({ activeLease: lease });
1605
+ this.state.activeLease = lease;
1606
+ lease.heartbeatInterval = setInterval(
1607
+ () => this.heartbeat(),
1608
+ HEARTBEAT_MS
1609
+ );
1610
+ if (this.debug) console.log(`[Registry] Acquired write lease: ${this.instanceId}`);
1611
+ return lease;
1612
+ }
1613
+ async releaseWriteLease() {
1614
+ if (!this.state.activeLease) return;
1615
+ if (this.state.activeLease.heartbeatInterval) {
1616
+ clearInterval(this.state.activeLease.heartbeatInterval);
1617
+ }
1618
+ await this.writeRegistryMessage({ activeLease: null });
1619
+ this.state.activeLease = null;
1620
+ if (this.debug) console.log(`[Registry] Released write lease: ${this.instanceId}`);
1621
+ }
1622
+ async forceRelease() {
1623
+ await this.writeRegistryMessage({ activeLease: null });
1624
+ this.state.activeLease = null;
1625
+ if (this.debug) console.log("[Registry] Forced lease release");
1626
+ }
1627
+ async isWriteLeaseHeld() {
1628
+ const state = await this.readRegistryMessage();
1629
+ if (!state?.activeLease) return false;
1630
+ const { activeLease } = state;
1631
+ return activeLease.instanceId === this.instanceId && Date.now() < activeLease.expiresAt;
1632
+ }
1633
+ async heartbeat() {
1634
+ if (!this.state.activeLease) return;
1635
+ this.state.activeLease.expiresAt = Date.now() + LEASE_TTL_MS;
1636
+ await this.writeRegistryMessage({ activeLease: this.state.activeLease });
1637
+ if (this.debug) console.log("[Registry] Heartbeat sent");
1638
+ }
1639
+ async readRegistryMessage() {
1640
+ try {
1641
+ const chat = await this.pool.execute(
1642
+ (bot) => bot.getChat(this.channelId)
1643
+ );
1644
+ if (chat.pinned_message?.text?.startsWith(REGISTRY_TAG)) {
1645
+ this.state.registryMsgId = chat.pinned_message.message_id;
1646
+ const json = chat.pinned_message.text.replace(REGISTRY_TAG + "\n", "");
1647
+ return JSON.parse(json);
1648
+ }
1649
+ } catch {
1650
+ }
1651
+ return null;
1652
+ }
1653
+ async writeRegistryMessage(data) {
1654
+ const text = `${REGISTRY_TAG}
1655
+ ${JSON.stringify(data, null, 0)}`;
1656
+ if (this.state.registryMsgId) {
1657
+ try {
1658
+ await this.pool.execute(
1659
+ (bot) => bot.editMessageText(text, {
1660
+ chat_id: this.channelId,
1661
+ message_id: this.state.registryMsgId
1662
+ })
1663
+ );
1664
+ return;
1665
+ } catch {
1666
+ }
1667
+ }
1668
+ const msg = await this.pool.execute(
1669
+ (bot) => bot.sendMessage(this.channelId, text, { disable_notification: true })
1670
+ );
1671
+ this.state.registryMsgId = msg.message_id;
1672
+ await this.pool.execute(
1673
+ (bot) => bot.pinChatMessage(this.channelId, msg.message_id, {
1674
+ disable_notification: true
1675
+ })
1676
+ );
1677
+ }
1678
+ getInstanceId() {
1679
+ return this.instanceId;
1680
+ }
1681
+ getCurrentLease() {
1682
+ return this.state.activeLease;
1683
+ }
1684
+ };
1685
+ var Collection = class {
1686
+ name;
1687
+ config;
1688
+ cache;
1689
+ storage;
1690
+ wal;
1691
+ channelId;
1692
+ indexLoaded = false;
1693
+ constructor(name, config, cache, storage, wal, defaultChannelId) {
1694
+ this.name = name;
1695
+ this.config = config;
1696
+ this.cache = cache;
1697
+ this.storage = storage;
1698
+ this.wal = wal;
1699
+ this.channelId = config.channelId ?? defaultChannelId;
1700
+ }
1701
+ // ─── Init ──────────────────────────────────────────────────────────────
1702
+ async ensureIndexLoaded() {
1703
+ if (this.indexLoaded) return;
1704
+ const idx = await this.storage.loadIndex(this.name, this.channelId);
1705
+ this.cache.setIndex(this.name, new Map(Object.entries(idx.entries).map(([k, v]) => [k, v])));
1706
+ this.indexLoaded = true;
1707
+ }
1708
+ // ─── Insert ────────────────────────────────────────────────────────────
1709
+ async insertOne(data) {
1710
+ const validated = this.config.schema.parse(data);
1711
+ const doc = {
1712
+ ...validated,
1713
+ _id: crypto.randomUUID(),
1714
+ _collection: this.name,
1715
+ _msgId: 0,
1716
+ _createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1717
+ _updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1718
+ };
1719
+ await this.wal.append("INSERT", this.name, doc._id, doc);
1720
+ const msgId = await this.storage.writeDocument(doc, this.channelId);
1721
+ doc._msgId = msgId;
1722
+ this.cache.set(this.name, doc._id, doc);
1723
+ this.cache.updateIndexEntry(this.name, doc._id, msgId);
1724
+ this.cache.invalidateQuery(this.name);
1725
+ await this.flushIndex();
1726
+ return doc;
1727
+ }
1728
+ async insertMany(items) {
1729
+ return Promise.all(items.map((item) => this.insertOne(item)));
1730
+ }
1731
+ // ─── Find ──────────────────────────────────────────────────────────────
1732
+ async findById(id) {
1733
+ const cached = this.cache.get(this.name, id);
1734
+ if (cached) return cached;
1735
+ await this.ensureIndexLoaded();
1736
+ const msgId = this.cache.getMsgId(this.name, id);
1737
+ if (!msgId) return null;
1738
+ const doc = await this.storage.readDocument(msgId, this.channelId);
1739
+ if (!doc) return null;
1740
+ this.cache.set(this.name, id, doc);
1741
+ return doc;
1742
+ }
1743
+ async findOne(filter = {}) {
1744
+ const results = await this.find({ filter, limit: 1 });
1745
+ return results[0] ?? null;
1746
+ }
1747
+ async find(options = {}) {
1748
+ const { filter = {}, sort, limit, skip = 0, projection, useCache = true } = options;
1749
+ const queryHash = this.hashQuery(filter, sort, limit, skip);
1750
+ if (useCache) {
1751
+ const cached = this.cache.getQuery(queryHash);
1752
+ if (cached) return cached;
1753
+ }
1754
+ await this.ensureIndexLoaded();
1755
+ const index = this.cache.getIndex(this.name);
1756
+ if (!index) return [];
1757
+ const docs = [];
1758
+ const ids = [...index.keys()];
1759
+ const uncachedIds = [];
1760
+ for (const id of ids) {
1761
+ const cached = this.cache.get(this.name, id);
1762
+ if (cached) {
1763
+ docs.push(cached);
1764
+ } else {
1765
+ uncachedIds.push(id);
1766
+ }
1767
+ }
1768
+ await Promise.all(
1769
+ uncachedIds.map(async (id) => {
1770
+ const doc = await this.findById(id);
1771
+ if (doc) docs.push(doc);
1772
+ })
1773
+ );
1774
+ let results = docs.filter((doc) => this.matchesFilter(doc, filter));
1775
+ if (sort) {
1776
+ results = this.applySort(results, sort);
1777
+ }
1778
+ results = results.slice(skip, limit ? skip + limit : void 0);
1779
+ if (projection) {
1780
+ results = results.map((doc) => this.applyProjection(doc, projection));
1781
+ }
1782
+ if (useCache) {
1783
+ this.cache.setQuery(queryHash, results);
1784
+ }
1785
+ return results;
1786
+ }
1787
+ async count(filter = {}) {
1788
+ const results = await this.find({ filter, useCache: true });
1789
+ return results.length;
1790
+ }
1791
+ // ─── Update ────────────────────────────────────────────────────────────
1792
+ async updateOne(filter, update) {
1793
+ const doc = await this.findOne(filter);
1794
+ if (!doc) return null;
1795
+ return this.applyUpdate(doc, update);
1796
+ }
1797
+ async updateMany(filter, update) {
1798
+ const docs = await this.find({ filter });
1799
+ return Promise.all(docs.map((doc) => this.applyUpdate(doc, update)));
1800
+ }
1801
+ async findByIdAndUpdate(id, update) {
1802
+ const doc = await this.findById(id);
1803
+ if (!doc) return null;
1804
+ return this.applyUpdate(doc, update);
1805
+ }
1806
+ async applyUpdate(doc, update) {
1807
+ const updated = { ...doc, _updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1808
+ if (update.$set) Object.assign(updated, update.$set);
1809
+ if (update.$unset) {
1810
+ for (const key of Object.keys(update.$unset)) {
1811
+ delete updated[key];
1812
+ }
1813
+ }
1814
+ if (update.$inc) {
1815
+ for (const [key, val] of Object.entries(update.$inc)) {
1816
+ updated[key] = (updated[key] ?? 0) + val;
1817
+ }
1818
+ }
1819
+ if (update.$push) {
1820
+ for (const [key, val] of Object.entries(update.$push)) {
1821
+ const arr = updated[key];
1822
+ updated[key] = Array.isArray(arr) ? [...arr, val] : [val];
1823
+ }
1824
+ }
1825
+ this.config.schema.parse(updated);
1826
+ await this.wal.append("UPDATE", this.name, updated._id, updated);
1827
+ const newMsgId = await this.storage.updateDocument(
1828
+ updated._msgId,
1829
+ updated,
1830
+ this.channelId
1831
+ );
1832
+ updated._msgId = newMsgId;
1833
+ this.cache.set(this.name, updated._id, updated);
1834
+ this.cache.updateIndexEntry(this.name, updated._id, newMsgId);
1835
+ this.cache.invalidateQuery(this.name);
1836
+ await this.flushIndex();
1837
+ return updated;
1838
+ }
1839
+ // ─── Delete ────────────────────────────────────────────────────────────
1840
+ async deleteOne(filter) {
1841
+ const doc = await this.findOne(filter);
1842
+ if (!doc) return false;
1843
+ return this.deleteById(doc._id);
1844
+ }
1845
+ async deleteMany(filter) {
1846
+ const docs = await this.find({ filter });
1847
+ await Promise.all(docs.map((doc) => this.deleteById(doc._id)));
1848
+ return docs.length;
1849
+ }
1850
+ async deleteById(id) {
1851
+ const msgId = this.cache.getMsgId(this.name, id);
1852
+ if (!msgId) return false;
1853
+ await this.wal.append("DELETE", this.name, id);
1854
+ await this.storage.deleteDocument(msgId, this.channelId);
1855
+ this.cache.delete(this.name, id);
1856
+ this.cache.deleteIndexEntry(this.name, id);
1857
+ this.cache.invalidateQuery(this.name);
1858
+ await this.flushIndex();
1859
+ return true;
1860
+ }
1861
+ // ─── Index flush ──────────────────────────────────────────────────────
1862
+ async flushIndex() {
1863
+ const index = this.cache.getIndex(this.name);
1864
+ if (!index) return;
1865
+ await this.storage.saveIndex(
1866
+ {
1867
+ collection: this.name,
1868
+ entries: Object.fromEntries(index.entries()),
1869
+ walSeq: this.wal.getCurrentSeq(),
1870
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1871
+ },
1872
+ this.channelId
1873
+ );
1874
+ }
1875
+ // ─── Filter engine ─────────────────────────────────────────────────────
1876
+ matchesFilter(doc, filter) {
1877
+ for (const [key, condition] of Object.entries(filter)) {
1878
+ if (key === "$and") {
1879
+ if (!condition.every((f) => this.matchesFilter(doc, f)))
1880
+ return false;
1881
+ continue;
1882
+ }
1883
+ if (key === "$or") {
1884
+ if (!condition.some((f) => this.matchesFilter(doc, f)))
1885
+ return false;
1886
+ continue;
1887
+ }
1888
+ if (key === "$not") {
1889
+ if (this.matchesFilter(doc, condition)) return false;
1890
+ continue;
1891
+ }
1892
+ const val = doc[key];
1893
+ if (condition === null || typeof condition !== "object" || condition instanceof RegExp) {
1894
+ if (Array.isArray(val)) {
1895
+ if (condition instanceof RegExp) {
1896
+ if (!val.some((item) => condition.test(String(item)))) return false;
1897
+ } else {
1898
+ if (!val.includes(condition)) return false;
1899
+ }
1900
+ } else {
1901
+ if (condition instanceof RegExp) {
1902
+ if (!condition.test(String(val))) return false;
1903
+ } else {
1904
+ if (val !== condition) return false;
1905
+ }
1906
+ }
1907
+ continue;
1908
+ }
1909
+ const ops = condition;
1910
+ if ("$eq" in ops) {
1911
+ const eqVal = ops["$eq"];
1912
+ if (Array.isArray(val)) {
1913
+ if (Array.isArray(eqVal)) {
1914
+ if (val.length !== eqVal.length || !val.every((v, i) => v === eqVal[i])) return false;
1915
+ } else {
1916
+ if (!val.includes(eqVal)) return false;
1917
+ }
1918
+ } else {
1919
+ if (val !== eqVal) return false;
1920
+ }
1921
+ }
1922
+ if ("$ne" in ops) {
1923
+ const neVal = ops["$ne"];
1924
+ if (Array.isArray(val)) {
1925
+ if (Array.isArray(neVal)) {
1926
+ if (val.length === neVal.length && val.every((v, i) => v === neVal[i])) return false;
1927
+ } else {
1928
+ if (val.includes(neVal)) return false;
1929
+ }
1930
+ } else {
1931
+ if (val === neVal) return false;
1932
+ }
1933
+ }
1934
+ if ("$gt" in ops && !(val > ops["$gt"])) return false;
1935
+ if ("$gte" in ops && !(val >= ops["$gte"])) return false;
1936
+ if ("$lt" in ops && !(val < ops["$lt"])) return false;
1937
+ if ("$lte" in ops && !(val <= ops["$lte"])) return false;
1938
+ if ("$in" in ops) {
1939
+ const inList = ops["$in"];
1940
+ if (Array.isArray(val)) {
1941
+ const hasIntersection = val.some((v) => inList.includes(v));
1942
+ const hasExactArray = inList.some(
1943
+ (item) => Array.isArray(item) && item.length === val.length && item.every((v, i) => v === val[i])
1944
+ );
1945
+ if (!hasIntersection && !hasExactArray) return false;
1946
+ } else {
1947
+ if (!inList.includes(val)) return false;
1948
+ }
1949
+ }
1950
+ if ("$nin" in ops) {
1951
+ const ninList = ops["$nin"];
1952
+ if (Array.isArray(val)) {
1953
+ const hasIntersection = val.some((v) => ninList.includes(v));
1954
+ const hasExactArray = ninList.some(
1955
+ (item) => Array.isArray(item) && item.length === val.length && item.every((v, i) => v === val[i])
1956
+ );
1957
+ if (hasIntersection || hasExactArray) return false;
1958
+ } else {
1959
+ if (ninList.includes(val)) return false;
1960
+ }
1961
+ }
1962
+ if ("$exists" in ops) {
1963
+ const exists = val !== void 0 && val !== null;
1964
+ if (exists !== ops["$exists"]) return false;
1965
+ }
1966
+ if ("$regex" in ops) {
1967
+ const re = ops["$regex"] instanceof RegExp ? ops["$regex"] : new RegExp(ops["$regex"]);
1968
+ if (Array.isArray(val)) {
1969
+ if (!val.some((item) => re.test(String(item)))) return false;
1970
+ } else {
1971
+ if (!re.test(String(val))) return false;
1972
+ }
1973
+ }
1974
+ }
1975
+ return true;
1976
+ }
1977
+ applySort(docs, sort) {
1978
+ return [...docs].sort((a, b) => {
1979
+ for (const [key, dir] of Object.entries(sort)) {
1980
+ const av = a[key];
1981
+ const bv = b[key];
1982
+ if (av === bv) continue;
1983
+ if (av == null) return dir;
1984
+ if (bv == null) return -dir;
1985
+ return av < bv ? -dir : dir;
1986
+ }
1987
+ return 0;
1988
+ });
1989
+ }
1990
+ applyProjection(doc, projection) {
1991
+ const result = {};
1992
+ const isInclusive = Object.values(projection).some((v) => v === 1);
1993
+ for (const [key, val] of Object.entries(doc)) {
1994
+ if (isInclusive) {
1995
+ if (projection[key] === 1 || key.startsWith("_")) result[key] = val;
1996
+ } else {
1997
+ if (projection[key] !== 0) result[key] = val;
1998
+ }
1999
+ }
2000
+ return result;
2001
+ }
2002
+ hashQuery(...args) {
2003
+ return `${this.name}:` + crypto.createHash("md5").update(JSON.stringify(args)).digest("hex");
2004
+ }
2005
+ getName() {
2006
+ return this.name;
2007
+ }
2008
+ };
2009
+ zod.z.object({
2010
+ email: zod.z.string().email(),
2011
+ passwordHash: zod.z.string(),
2012
+ roles: zod.z.array(zod.z.string()).default(["user"]),
2013
+ metadata: zod.z.record(zod.z.unknown()).optional(),
2014
+ createdAt: zod.z.string(),
2015
+ updatedAt: zod.z.string()
2016
+ });
2017
+ function resolveJwtSecret(configSecret) {
2018
+ if (configSecret) return configSecret;
2019
+ if (process.env["JWT_SECRET"]) return process.env["JWT_SECRET"];
2020
+ const secretFile = "./jwt_secret.txt";
2021
+ if (fs.existsSync(secretFile)) return fs.readFileSync(secretFile, "utf-8").trim();
2022
+ const ephemeral = crypto.randomBytes(32).toString("hex");
2023
+ console.warn(
2024
+ "[gramobase Auth] WARNING: No JWT_SECRET provided. Using an ephemeral random secret. Tokens will be invalidated on restart. Set JWT_SECRET env variable for production!"
2025
+ );
2026
+ return ephemeral;
2027
+ }
2028
+ function validatePasswordStrength(password) {
2029
+ if (password.length < 8) {
2030
+ throw new Error("[Auth] Password must be at least 8 characters long");
2031
+ }
2032
+ }
2033
+ var GramoBaseAuth = class {
2034
+ constructor(users, config) {
2035
+ this.users = users;
2036
+ this.config = config;
2037
+ this.resolvedSecret = resolveJwtSecret(config.jwtSecret);
2038
+ }
2039
+ users;
2040
+ config;
2041
+ DEFAULT_ROUNDS = 12;
2042
+ resolvedSecret;
2043
+ // ─── Registration ─────────────────────────────────────────────────────
2044
+ async register(email, password, roles = ["user"], metadata) {
2045
+ if (!email || typeof email !== "string") {
2046
+ throw new Error("[Auth] Invalid email");
2047
+ }
2048
+ validatePasswordStrength(password);
2049
+ const existing = await this.users.findOne({ email: { $eq: email } });
2050
+ if (existing) throw new Error("[Auth] Email already registered");
2051
+ const passwordHash = await bcrypt__namespace.hash(
2052
+ password,
2053
+ this.config.bcryptRounds ?? this.DEFAULT_ROUNDS
2054
+ );
2055
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2056
+ const doc = await this.users.insertOne({
2057
+ email,
2058
+ passwordHash,
2059
+ roles,
2060
+ metadata,
2061
+ createdAt: now,
2062
+ updatedAt: now
2063
+ });
2064
+ const user = doc;
2065
+ const session = this.createSession(user);
2066
+ await this.config.onSignIn?.(user);
2067
+ return { user, session };
2068
+ }
2069
+ // ─── Login ────────────────────────────────────────────────────────────
2070
+ async login(email, password) {
2071
+ const doc = await this.users.findOne({ email: { $eq: email } });
2072
+ if (!doc) {
2073
+ await bcrypt__namespace.compare(password, "$2a$12$invalidhashtopreventtimingattacks");
2074
+ throw new Error("[Auth] Invalid credentials");
2075
+ }
2076
+ const user = doc;
2077
+ const valid = await bcrypt__namespace.compare(password, user.passwordHash);
2078
+ if (!valid) throw new Error("[Auth] Invalid credentials");
2079
+ const session = this.createSession(user);
2080
+ await this.config.onSignIn?.(user);
2081
+ return { user, session };
2082
+ }
2083
+ // ─── Token verification ───────────────────────────────────────────────
2084
+ verifyToken(token) {
2085
+ try {
2086
+ const payload = jwt__namespace.verify(token, this.resolvedSecret, {
2087
+ algorithms: ["HS256"]
2088
+ });
2089
+ return payload;
2090
+ } catch {
2091
+ throw new Error("[Auth] Invalid or expired token");
2092
+ }
2093
+ }
2094
+ // ─── Role-based access ────────────────────────────────────────────────
2095
+ requireRole(session, role) {
2096
+ if (!session.roles.includes(role) && !session.roles.includes("admin")) {
2097
+ throw new Error(`[Auth] Requires role: ${role}`);
2098
+ }
2099
+ }
2100
+ requireAnyRole(session, roles) {
2101
+ const hasRole = roles.some(
2102
+ (r) => session.roles.includes(r) || session.roles.includes("admin")
2103
+ );
2104
+ if (!hasRole) {
2105
+ throw new Error(`[Auth] Requires one of roles: ${roles.join(", ")}`);
2106
+ }
2107
+ }
2108
+ // ─── Password management ──────────────────────────────────────────────
2109
+ async changePassword(userId, oldPassword, newPassword) {
2110
+ const doc = await this.users.findById(userId);
2111
+ if (!doc) throw new Error("[Auth] User not found");
2112
+ const user = doc;
2113
+ const valid = await bcrypt__namespace.compare(oldPassword, user.passwordHash);
2114
+ if (!valid) throw new Error("[Auth] Old password incorrect");
2115
+ validatePasswordStrength(newPassword);
2116
+ const newHash = await bcrypt__namespace.hash(
2117
+ newPassword,
2118
+ this.config.bcryptRounds ?? this.DEFAULT_ROUNDS
2119
+ );
2120
+ await this.users.findByIdAndUpdate(userId, {
2121
+ $set: { passwordHash: newHash, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
2122
+ });
2123
+ }
2124
+ async resetPassword(userId, newPassword) {
2125
+ validatePasswordStrength(newPassword);
2126
+ const newHash = await bcrypt__namespace.hash(
2127
+ newPassword,
2128
+ this.config.bcryptRounds ?? this.DEFAULT_ROUNDS
2129
+ );
2130
+ await this.users.findByIdAndUpdate(userId, {
2131
+ $set: { passwordHash: newHash, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
2132
+ });
2133
+ }
2134
+ // ─── User management ─────────────────────────────────────────────────
2135
+ async getUserById(id) {
2136
+ const doc = await this.users.findById(id);
2137
+ return doc ? doc : null;
2138
+ }
2139
+ async getUserByEmail(email) {
2140
+ const doc = await this.users.findOne({ email: { $eq: email } });
2141
+ return doc ? doc : null;
2142
+ }
2143
+ async updateRoles(userId, roles) {
2144
+ await this.users.findByIdAndUpdate(userId, {
2145
+ $set: { roles, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
2146
+ });
2147
+ }
2148
+ async deleteUser(userId) {
2149
+ await this.users.deleteById(userId);
2150
+ await this.config.onSignOut?.(userId);
2151
+ }
2152
+ // ─── Session helpers ──────────────────────────────────────────────────
2153
+ createSession(user) {
2154
+ const expiresIn = this.config.jwtExpiresIn ?? "7d";
2155
+ const payload = {
2156
+ sub: user._id,
2157
+ userId: user._id,
2158
+ roles: user.roles,
2159
+ expiresAt: Date.now() + this.parseExpiry(expiresIn)
2160
+ };
2161
+ const token = jwt__namespace.sign(payload, this.resolvedSecret, {
2162
+ expiresIn,
2163
+ algorithm: "HS256"
2164
+ });
2165
+ return { ...payload, token };
2166
+ }
2167
+ parseExpiry(s) {
2168
+ const n = parseInt(s);
2169
+ if (s.endsWith("d")) return n * 864e5;
2170
+ if (s.endsWith("h")) return n * 36e5;
2171
+ if (s.endsWith("m")) return n * 6e4;
2172
+ return n * 1e3;
2173
+ }
2174
+ // ─── Middleware factory (Express/Fastify compatible) ──────────────────
2175
+ middleware() {
2176
+ return (req, res, next) => {
2177
+ const auth = req.headers["authorization"];
2178
+ if (!auth?.startsWith("Bearer ")) {
2179
+ return res.status(401).json({ error: "Missing token" });
2180
+ }
2181
+ try {
2182
+ req.session = this.verifyToken(auth.slice(7));
2183
+ next();
2184
+ } catch {
2185
+ res.status(401).json({ error: "Unauthorized" });
2186
+ }
2187
+ };
2188
+ }
2189
+ requireRoleMiddleware(role) {
2190
+ return (req, res, next) => {
2191
+ try {
2192
+ this.requireRole(req.session, role);
2193
+ next();
2194
+ } catch {
2195
+ res.status(403).json({ error: "Forbidden" });
2196
+ }
2197
+ };
2198
+ }
2199
+ };
2200
+ var RealtimeManager = class extends EventEmitter__default.default {
2201
+ constructor(pool, webhookUrl, debug = false) {
2202
+ super();
2203
+ this.pool = pool;
2204
+ this.webhookUrl = webhookUrl;
2205
+ this.debug = debug;
2206
+ }
2207
+ pool;
2208
+ webhookUrl;
2209
+ debug;
2210
+ pollingActive = false;
2211
+ pollingInterval = null;
2212
+ lastUpdateId = 0;
2213
+ async start() {
2214
+ if (this.webhookUrl) {
2215
+ await this.setupWebhook();
2216
+ } else {
2217
+ this.startPolling();
2218
+ }
2219
+ }
2220
+ async stop() {
2221
+ if (this.pollingInterval) {
2222
+ clearInterval(this.pollingInterval);
2223
+ this.pollingInterval = null;
2224
+ }
2225
+ this.pollingActive = false;
2226
+ }
2227
+ // ─── Subscribe helpers ────────────────────────────────────────────────
2228
+ onInsert(collection, cb) {
2229
+ const handler = (ev) => {
2230
+ if (ev.type === "insert" && ev.collection === collection) {
2231
+ cb(ev.doc);
2232
+ }
2233
+ };
2234
+ this.on("event", handler);
2235
+ return () => this.off("event", handler);
2236
+ }
2237
+ onUpdate(collection, cb) {
2238
+ const handler = (ev) => {
2239
+ if (ev.type === "update" && ev.collection === collection) {
2240
+ cb(ev.id, ev.changes, ev.doc);
2241
+ }
2242
+ };
2243
+ this.on("event", handler);
2244
+ return () => this.off("event", handler);
2245
+ }
2246
+ onDelete(collection, cb) {
2247
+ const handler = (ev) => {
2248
+ if (ev.type === "delete" && ev.collection === collection) {
2249
+ cb(ev.id);
2250
+ }
2251
+ };
2252
+ this.on("event", handler);
2253
+ return () => this.off("event", handler);
2254
+ }
2255
+ onAny(cb) {
2256
+ const handler = (ev) => cb(ev);
2257
+ this.on("event", handler);
2258
+ return () => this.off("event", handler);
2259
+ }
2260
+ // ─── SSE adapter ──────────────────────────────────────────────────────
2261
+ // Usage: app.get('/events', db.realtime.sseHandler())
2262
+ sseHandler(collection) {
2263
+ return (req, res) => {
2264
+ res.setHeader("Content-Type", "text/event-stream");
2265
+ res.setHeader("Cache-Control", "no-cache, no-store");
2266
+ res.setHeader("Connection", "keep-alive");
2267
+ res.setHeader("X-Content-Type-Options", "nosniff");
2268
+ res.flushHeaders?.();
2269
+ const send = (ev) => {
2270
+ if (!collection || "collection" in ev && ev.collection === collection) {
2271
+ res.write(`data: ${JSON.stringify(ev)}
2272
+
2273
+ `);
2274
+ }
2275
+ };
2276
+ this.on("event", send);
2277
+ const keepalive = setInterval(() => res.write(": ping\n\n"), 25e3);
2278
+ req.on("close", () => {
2279
+ this.off("event", send);
2280
+ clearInterval(keepalive);
2281
+ });
2282
+ };
2283
+ }
2284
+ // ─── Internal event dispatch ──────────────────────────────────────────
2285
+ dispatch(event) {
2286
+ this.emit("event", event);
2287
+ if (this.debug) {
2288
+ console.log("[Realtime]", event.type, "collection" in event ? event.collection : "");
2289
+ }
2290
+ }
2291
+ // ─── Webhook setup ────────────────────────────────────────────────────
2292
+ async setupWebhook() {
2293
+ const bot = this.pool.getBot();
2294
+ await bot.setWebHook(this.webhookUrl);
2295
+ if (this.debug) console.log("[Realtime] Webhook set");
2296
+ }
2297
+ // ─── Long polling fallback ────────────────────────────────────────────
2298
+ startPolling() {
2299
+ this.pollingActive = true;
2300
+ const POLL_INTERVAL = 2e3;
2301
+ this.pollingInterval = setInterval(async () => {
2302
+ try {
2303
+ const updates = await this.pool.execute(
2304
+ (bot) => bot.getUpdates({ offset: this.lastUpdateId + 1, limit: 100, timeout: 0 })
2305
+ );
2306
+ for (const update of updates) {
2307
+ this.lastUpdateId = Math.max(this.lastUpdateId, update.update_id);
2308
+ this.processUpdate(update);
2309
+ }
2310
+ } catch {
2311
+ }
2312
+ }, POLL_INTERVAL);
2313
+ }
2314
+ processUpdate(update) {
2315
+ const msg = update.channel_post ?? update.message;
2316
+ if (!msg?.text?.includes('"__gramobase"')) return;
2317
+ try {
2318
+ const payload = JSON.parse(msg.text);
2319
+ if (payload.__event) {
2320
+ this.dispatch(payload.__event);
2321
+ }
2322
+ } catch {
2323
+ }
2324
+ }
2325
+ };
2326
+
2327
+ // src/migrations/MigrationRunner.ts
2328
+ var MIGRATION_TAG = "__GRAMOBASE_MIGRATIONS__";
2329
+ var MigrationRunner = class {
2330
+ constructor(pool, channelId, debug = false) {
2331
+ this.pool = pool;
2332
+ this.channelId = channelId;
2333
+ this.debug = debug;
2334
+ }
2335
+ pool;
2336
+ channelId;
2337
+ debug;
2338
+ historyMsgId = null;
2339
+ async run(migrations, db) {
2340
+ const applied = await this.loadHistory();
2341
+ const appliedVersions = new Set(applied.map((m) => m.version));
2342
+ const pending = migrations.filter((m) => !appliedVersions.has(m.version)).sort((a, b) => a.version - b.version);
2343
+ if (pending.length === 0) {
2344
+ if (this.debug) console.log("[Migrations] Nothing to run");
2345
+ return;
2346
+ }
2347
+ for (const migration of pending) {
2348
+ console.log(`[Migrations] Running: v${migration.version} \u2014 ${migration.name}`);
2349
+ await migration.up(db);
2350
+ applied.push({
2351
+ version: migration.version,
2352
+ name: migration.name,
2353
+ appliedAt: (/* @__PURE__ */ new Date()).toISOString()
2354
+ });
2355
+ await this.saveHistory(applied);
2356
+ console.log(`[Migrations] \u2713 v${migration.version}`);
2357
+ }
2358
+ }
2359
+ async rollback(migrations, db, steps = 1) {
2360
+ const applied = await this.loadHistory();
2361
+ const toRollback = applied.sort((a, b) => b.version - a.version).slice(0, steps);
2362
+ for (const record of toRollback) {
2363
+ const migration = migrations.find((m) => m.version === record.version);
2364
+ if (!migration) throw new Error(`Migration v${record.version} not found`);
2365
+ console.log(`[Migrations] Rolling back: v${record.version} \u2014 ${record.name}`);
2366
+ await migration.down(db);
2367
+ applied.splice(applied.indexOf(record), 1);
2368
+ await this.saveHistory(applied);
2369
+ console.log(`[Migrations] \u2713 Rolled back v${record.version}`);
2370
+ }
2371
+ }
2372
+ async status(migrations) {
2373
+ const applied = await this.loadHistory();
2374
+ const appliedVersions = new Set(applied.map((m) => m.version));
2375
+ console.log("\n gramobase migration status\n");
2376
+ for (const m of migrations.sort((a, b) => a.version - b.version)) {
2377
+ const status = appliedVersions.has(m.version) ? "\u2713" : "\u25CB";
2378
+ const appliedAt = applied.find((a) => a.version === m.version)?.appliedAt ?? "";
2379
+ console.log(` ${status} v${m.version} ${m.name.padEnd(40)} ${appliedAt}`);
2380
+ }
2381
+ console.log();
2382
+ }
2383
+ async loadHistory() {
2384
+ try {
2385
+ const chat = await this.pool.execute((bot) => bot.getChat(this.channelId));
2386
+ for (const msg of chat.pinned_messages ?? []) {
2387
+ if (msg.text?.startsWith(MIGRATION_TAG)) {
2388
+ this.historyMsgId = msg.message_id;
2389
+ return JSON.parse(msg.text.replace(MIGRATION_TAG + "\n", ""));
2390
+ }
2391
+ }
2392
+ } catch {
2393
+ }
2394
+ return [];
2395
+ }
2396
+ async saveHistory(records) {
2397
+ const text = `${MIGRATION_TAG}
2398
+ ${JSON.stringify(records)}`;
2399
+ if (this.historyMsgId) {
2400
+ try {
2401
+ await this.pool.execute(
2402
+ (bot) => bot.editMessageText(text, {
2403
+ chat_id: this.channelId,
2404
+ message_id: this.historyMsgId
2405
+ })
2406
+ );
2407
+ return;
2408
+ } catch {
2409
+ }
2410
+ }
2411
+ const msg = await this.pool.execute(
2412
+ (bot) => bot.sendMessage(this.channelId, text, { disable_notification: true })
2413
+ );
2414
+ this.historyMsgId = msg.message_id;
2415
+ }
2416
+ };
2417
+ var GramoBase = class {
2418
+ pool;
2419
+ cache;
2420
+ storage;
2421
+ wal;
2422
+ registry;
2423
+ realtime;
2424
+ migrations;
2425
+ collections = /* @__PURE__ */ new Map();
2426
+ initialized = false;
2427
+ config;
2428
+ constructor(config) {
2429
+ this.config = config;
2430
+ const tokens = Array.isArray(config.botToken) ? config.botToken : [config.botToken];
2431
+ this.pool = new BotWorkerPool(tokens, config.concurrency ?? 25, config.debug ?? false);
2432
+ this.cache = new HotCache(config.cacheMaxBytes, config.cacheTtlMs);
2433
+ this.storage = new TelegramStorage(
2434
+ this.pool,
2435
+ config.channelId,
2436
+ config.encryptionKey,
2437
+ config.debug ?? false
2438
+ );
2439
+ this.wal = new WriteAheadLog(
2440
+ this.pool,
2441
+ config.walChannelId ?? config.channelId,
2442
+ config.debug ?? false
2443
+ );
2444
+ this.registry = new Registry(
2445
+ this.pool,
2446
+ config.indexChannelId ?? config.channelId,
2447
+ config.debug ?? false
2448
+ );
2449
+ this.realtime = new RealtimeManager(
2450
+ this.pool,
2451
+ config.webhookUrl,
2452
+ config.debug ?? false
2453
+ );
2454
+ this.migrations = new MigrationRunner(
2455
+ this.pool,
2456
+ config.channelId,
2457
+ config.debug ?? false
2458
+ );
2459
+ this.pool.on("worker:rotate", (idx) => {
2460
+ this.realtime.dispatch({ type: "worker:rotate", tokenIndex: idx });
2461
+ });
2462
+ this.pool.on("wal:flush", (count) => {
2463
+ this.realtime.dispatch({ type: "wal:flush", entries: count });
2464
+ });
2465
+ }
2466
+ // ─── Lifecycle ────────────────────────────────────────────────────────
2467
+ async connect() {
2468
+ if (this.initialized) return this;
2469
+ await this.wal.init();
2470
+ await this.registry.acquireWriteLease();
2471
+ await this.realtime.start();
2472
+ const walEntries = await this.wal.replay();
2473
+ if (walEntries.length > 0 && this.config.debug) {
2474
+ console.log(`[gramobase] Replaying ${walEntries.length} WAL entries`);
2475
+ }
2476
+ this.initialized = true;
2477
+ if (this.config.debug) console.log("[gramobase] Connected \u2713");
2478
+ return this;
2479
+ }
2480
+ async disconnect() {
2481
+ await this.wal.flush();
2482
+ await this.registry.releaseWriteLease();
2483
+ await this.realtime.stop();
2484
+ await this.pool.destroy();
2485
+ this.initialized = false;
2486
+ }
2487
+ // ─── Collection factory ───────────────────────────────────────────────
2488
+ collection(name, config) {
2489
+ if (this.collections.has(name)) {
2490
+ return this.collections.get(name);
2491
+ }
2492
+ const col = new Collection(
2493
+ name,
2494
+ config,
2495
+ this.cache,
2496
+ this.storage,
2497
+ this.wal,
2498
+ this.config.channelId
2499
+ );
2500
+ this.collections.set(name, col);
2501
+ return col;
2502
+ }
2503
+ // ─── Auth factory ─────────────────────────────────────────────────────
2504
+ createAuth(config) {
2505
+ const UserSchema2 = zod.z.object({
2506
+ email: zod.z.string().email(),
2507
+ passwordHash: zod.z.string(),
2508
+ roles: zod.z.array(zod.z.string()).default(["user"]),
2509
+ metadata: zod.z.record(zod.z.unknown()).optional(),
2510
+ createdAt: zod.z.string(),
2511
+ updatedAt: zod.z.string()
2512
+ });
2513
+ const users = this.collection("__gramobase_users__", { schema: UserSchema2 });
2514
+ return new GramoBaseAuth(users, config);
2515
+ }
2516
+ // ─── File storage ─────────────────────────────────────────────────────
2517
+ async uploadFile(data, options = {}) {
2518
+ const { fileName = "file", mimeType = "application/octet-stream", metadata } = options;
2519
+ const channelId = this.config.channelId;
2520
+ const { fileId, msgId } = await this.storage.uploadFile(data, fileName, mimeType, channelId);
2521
+ const url = await this.storage.getFileUrl(fileId);
2522
+ const record = {
2523
+ _id: crypto.randomUUID(),
2524
+ fileId,
2525
+ fileName,
2526
+ mimeType,
2527
+ sizeBytes: data.length,
2528
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
2529
+ ...url !== void 0 ? { url } : {},
2530
+ ...metadata !== void 0 ? { metadata } : {}
2531
+ };
2532
+ const files = this.collection("__gramobase_files__", {
2533
+ schema: zod.z.object({
2534
+ _id: zod.z.string(),
2535
+ fileId: zod.z.string(),
2536
+ fileName: zod.z.string(),
2537
+ mimeType: zod.z.string(),
2538
+ sizeBytes: zod.z.number(),
2539
+ url: zod.z.string().optional(),
2540
+ uploadedAt: zod.z.string(),
2541
+ metadata: zod.z.record(zod.z.unknown()).optional()
2542
+ })
2543
+ });
2544
+ await files.insertOne(record);
2545
+ return record;
2546
+ }
2547
+ async getFileUrl(fileId) {
2548
+ return this.storage.getFileUrl(fileId);
2549
+ }
2550
+ // ─── Migrations ───────────────────────────────────────────────────────
2551
+ async migrate(migrations) {
2552
+ await this.migrations.run(migrations, this);
2553
+ }
2554
+ async rollback(migrations, steps = 1) {
2555
+ await this.migrations.rollback(migrations, this, steps);
2556
+ }
2557
+ async migrationStatus(migrations) {
2558
+ await this.migrations.status(migrations);
2559
+ }
2560
+ // ─── State helpers ────────────────────────────────────────────────────
2561
+ getCacheStats() {
2562
+ return this.cache.getStats();
2563
+ }
2564
+ getWorkerStats() {
2565
+ return this.pool.getStats();
2566
+ }
2567
+ getRegistryInstanceId() {
2568
+ return this.registry.getInstanceId();
2569
+ }
2570
+ /**
2571
+ * Warm up the cache by pre-loading all collection indexes.
2572
+ */
2573
+ async warmCache() {
2574
+ for (const col of this.collections.values()) {
2575
+ await col.ensureIndexLoaded();
2576
+ }
2577
+ }
2578
+ };
2579
+ function createClient(config) {
2580
+ return new GramoBase(config);
2581
+ }
2582
+
2583
+ // src/studio/server.ts
2584
+ function loadEnvFile(cwd) {
2585
+ const envPath = path__namespace.join(cwd, ".env");
2586
+ const env = {};
2587
+ if (!fs__namespace.existsSync(envPath)) return env;
2588
+ const lines = fs__namespace.readFileSync(envPath, "utf-8").split("\n");
2589
+ for (const raw of lines) {
2590
+ const line = raw.trim();
2591
+ if (!line || line.startsWith("#")) continue;
2592
+ const eq = line.indexOf("=");
2593
+ if (eq === -1) continue;
2594
+ const key = line.slice(0, eq).trim();
2595
+ const val = line.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
2596
+ env[key] = val;
2597
+ }
2598
+ return env;
2599
+ }
2600
+ function resolveConfig(cwd) {
2601
+ const env = loadEnvFile(cwd);
2602
+ const get = (k) => process.env[k] || env[k];
2603
+ const tokens = [];
2604
+ const single = get("GRAMOBASE_BOT_TOKEN");
2605
+ if (single) tokens.push(single);
2606
+ let i = 1;
2607
+ while (true) {
2608
+ const t = get(`GRAMOBASE_BOT_TOKEN_${i}`);
2609
+ if (!t) break;
2610
+ if (!tokens.includes(t)) tokens.push(t);
2611
+ i++;
2612
+ }
2613
+ const channelId = get("GRAMOBASE_CHANNEL_ID");
2614
+ if (tokens.length === 0 || !channelId) return null;
2615
+ const encKey = get("GRAMOBASE_ENCRYPTION_KEY");
2616
+ return {
2617
+ botToken: tokens.length === 1 ? tokens[0] : tokens,
2618
+ channelId,
2619
+ ...encKey !== void 0 ? { encryptionKey: encKey } : {},
2620
+ cacheMaxBytes: 64 * 1024 * 1024,
2621
+ cacheTtlMs: 6e4,
2622
+ concurrency: 25,
2623
+ debug: false
2624
+ };
2625
+ }
2626
+ function jsonRes(res, status, body) {
2627
+ const payload = JSON.stringify(body);
2628
+ res.writeHead(status, {
2629
+ "Content-Type": "application/json",
2630
+ "Content-Length": Buffer.byteLength(payload),
2631
+ "Access-Control-Allow-Origin": "*"
2632
+ });
2633
+ res.end(payload);
2634
+ }
2635
+ function parseUrl(rawUrl) {
2636
+ const base = "http://localhost";
2637
+ const url = new URL(rawUrl, base);
2638
+ return { pathname: url.pathname, query: url.searchParams };
2639
+ }
2640
+ async function startStudio(port, cwd = process.cwd()) {
2641
+ const config = resolveConfig(cwd);
2642
+ let db = null;
2643
+ let botInfo = null;
2644
+ if (config) {
2645
+ try {
2646
+ db = await createClient(config).connect();
2647
+ const token = Array.isArray(config.botToken) ? config.botToken[0] : config.botToken;
2648
+ try {
2649
+ const r = await fetch(`https://api.telegram.org/bot${token}/getMe`);
2650
+ const j = await r.json();
2651
+ if (j.ok) botInfo = { username: j.result.username, firstName: j.result.first_name };
2652
+ } catch (_) {
2653
+ }
2654
+ } catch (e) {
2655
+ db = null;
2656
+ }
2657
+ }
2658
+ const sseClients = /* @__PURE__ */ new Set();
2659
+ if (db) {
2660
+ const forward = (ev) => {
2661
+ const data = JSON.stringify(ev);
2662
+ sseClients.forEach((client) => {
2663
+ try {
2664
+ client.write(`data: ${data}
2665
+
2666
+ `);
2667
+ } catch (_) {
2668
+ sseClients.delete(client);
2669
+ }
2670
+ });
2671
+ };
2672
+ db.realtime.onInsert("*", (doc) => forward({ type: "insert", collection: doc._collection, doc }));
2673
+ db.realtime.onUpdate("*", (id, changes, doc) => forward({ type: "update", collection: doc?._collection, id, changes, doc }));
2674
+ db.realtime.onDelete("*", (id) => forward({ type: "delete", id }));
2675
+ }
2676
+ const server = http__namespace.createServer(async (req, res) => {
2677
+ const { pathname, query } = parseUrl(req.url || "/");
2678
+ if (pathname === "/" || pathname === "/index.html") {
2679
+ const html = getStudioHTML();
2680
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2681
+ res.end(html);
2682
+ return;
2683
+ }
2684
+ if (pathname === "/api/events") {
2685
+ res.writeHead(200, {
2686
+ "Content-Type": "text/event-stream",
2687
+ "Cache-Control": "no-cache",
2688
+ "Connection": "keep-alive",
2689
+ "Access-Control-Allow-Origin": "*"
2690
+ });
2691
+ res.write(":\n\n");
2692
+ sseClients.add(res);
2693
+ req.on("close", () => sseClients.delete(res));
2694
+ return;
2695
+ }
2696
+ if (pathname === "/api/status") {
2697
+ if (!db || !config) {
2698
+ return jsonRes(res, 503, { error: "Not connected. Check your .env file." });
2699
+ }
2700
+ const cacheStats = db.getCacheStats();
2701
+ const workerStats = db.getWorkerStats();
2702
+ const colRes = await safeListCollections(db);
2703
+ return jsonRes(res, 200, {
2704
+ bot: botInfo,
2705
+ channelId: config.channelId,
2706
+ cache: cacheStats,
2707
+ workers: workerStats,
2708
+ collections: colRes
2709
+ });
2710
+ }
2711
+ if (pathname === "/api/collections") {
2712
+ if (!db) return jsonRes(res, 503, { error: "Not connected" });
2713
+ const cols = await safeListCollections(db);
2714
+ return jsonRes(res, 200, { collections: cols });
2715
+ }
2716
+ const colMatch = pathname.match(/^\/api\/collection\/(.+)$/);
2717
+ if (colMatch) {
2718
+ if (!db) return jsonRes(res, 503, { error: "Not connected" });
2719
+ const name = decodeURIComponent(colMatch[1] ?? "");
2720
+ if (!/^[a-zA-Z0-9_\-]+$/.test(name)) {
2721
+ return jsonRes(res, 400, { error: "Invalid collection name" });
2722
+ }
2723
+ const page = Math.max(1, parseInt(query.get("page") || "1", 10));
2724
+ const limit = Math.min(100, Math.max(1, parseInt(query.get("limit") || "25", 10)));
2725
+ const skip = (page - 1) * limit;
2726
+ const sortFieldRaw = query.get("sortField") || "_createdAt";
2727
+ const sortDirRaw = parseInt(query.get("sortDir") || "-1", 10);
2728
+ const sortField = /^[a-zA-Z0-9_.]+$/.test(sortFieldRaw) ? sortFieldRaw : "_createdAt";
2729
+ const sortDir = sortDirRaw === 1 ? 1 : -1;
2730
+ const filterText = (query.get("filter") || "").trim();
2731
+ let filter = {};
2732
+ if (filterText) {
2733
+ const kv = filterText.match(/^([a-zA-Z0-9_.]+):(.+)$/);
2734
+ if (kv) {
2735
+ const [, k, v] = kv;
2736
+ const key = String(k ?? "unknown");
2737
+ const num = Number(v ?? "");
2738
+ const filterVal = v === "true" ? true : v === "false" ? false : !isNaN(num) ? num : v ?? "";
2739
+ filter = Object.fromEntries([[key, { $eq: filterVal }]]);
2740
+ } else {
2741
+ filter = { $or: [{ text: { $regex: filterText } }, { name: { $regex: filterText } }, { title: { $regex: filterText } }] };
2742
+ }
2743
+ }
2744
+ try {
2745
+ const { z: z3 } = await import('zod');
2746
+ const col = db.collection(name, { schema: z3.record(z3.unknown()) });
2747
+ const sort = {};
2748
+ sort[sortField] = sortDir;
2749
+ const [docs, allDocs] = await Promise.all([
2750
+ col.find({ filter, sort, limit, skip }),
2751
+ col.count(filter)
2752
+ ]);
2753
+ const colSet = /* @__PURE__ */ new Set();
2754
+ docs.slice(0, 10).forEach((d) => Object.keys(d).forEach((k) => colSet.add(k)));
2755
+ const columns = Array.from(colSet).filter((c) => !["_id", "_collection", "_msgId", "_createdAt", "_updatedAt"].includes(c));
2756
+ return jsonRes(res, 200, { docs, total: allDocs, page, limit, columns });
2757
+ } catch (e) {
2758
+ return jsonRes(res, 500, { error: e.message || "Query failed" });
2759
+ }
2760
+ }
2761
+ jsonRes(res, 404, { error: "Not found" });
2762
+ });
2763
+ server.listen(port, "127.0.0.1", () => {
2764
+ });
2765
+ return server;
2766
+ }
2767
+ async function safeListCollections(db) {
2768
+ try {
2769
+ const registry = db.collections;
2770
+ if (!registry) return [];
2771
+ const result = [];
2772
+ for (const [name] of registry) {
2773
+ if (name.startsWith("__")) continue;
2774
+ try {
2775
+ const col = registry.get(name);
2776
+ const count = await col.count({});
2777
+ result.push({ name, count });
2778
+ } catch (_) {
2779
+ result.push({ name, count: 0 });
2780
+ }
2781
+ }
2782
+ return result;
2783
+ } catch (_) {
2784
+ return [];
2785
+ }
2786
+ }
2787
+
2788
+ exports.startStudio = startStudio;
2789
+ //# sourceMappingURL=server.cjs.map
2790
+ //# sourceMappingURL=server.cjs.map