@topgunbuild/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +97 -0
- package/dist/index.d.mts +311 -0
- package/dist/index.d.ts +311 -0
- package/dist/index.js +2915 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2872 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +45 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2872 @@
|
|
|
1
|
+
// src/ServerCoordinator.ts
|
|
2
|
+
import { createServer as createHttpServer } from "http";
|
|
3
|
+
import { createServer as createHttpsServer } from "https";
|
|
4
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
5
|
+
import { WebSocketServer as WebSocketServer2, WebSocket as WebSocket2 } from "ws";
|
|
6
|
+
import { HLC, LWWMap as LWWMap2, ORMap as ORMap2, serialize as serialize2, deserialize, MessageSchema } from "@topgunbuild/core";
|
|
7
|
+
import * as jwt from "jsonwebtoken";
|
|
8
|
+
import * as crypto from "crypto";
|
|
9
|
+
|
|
10
|
+
// src/query/Matcher.ts
|
|
11
|
+
import { evaluatePredicate } from "@topgunbuild/core";
|
|
12
|
+
function matchesQuery(record, query) {
|
|
13
|
+
const data = record.value;
|
|
14
|
+
if (!data) return false;
|
|
15
|
+
if (record.ttlMs) {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
if (record.timestamp.millis + record.ttlMs < now) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (query.predicate) {
|
|
22
|
+
return evaluatePredicate(query.predicate, data);
|
|
23
|
+
}
|
|
24
|
+
if (!query.where) return true;
|
|
25
|
+
for (const [field, expected] of Object.entries(query.where)) {
|
|
26
|
+
const actual = data[field];
|
|
27
|
+
if (typeof expected === "object" && expected !== null && !Array.isArray(expected)) {
|
|
28
|
+
for (const [op, opValueRaw] of Object.entries(expected)) {
|
|
29
|
+
const opValue = opValueRaw;
|
|
30
|
+
switch (op) {
|
|
31
|
+
case "$gt":
|
|
32
|
+
if (!(actual > opValue)) return false;
|
|
33
|
+
break;
|
|
34
|
+
case "$gte":
|
|
35
|
+
if (!(actual >= opValue)) return false;
|
|
36
|
+
break;
|
|
37
|
+
case "$lt":
|
|
38
|
+
if (!(actual < opValue)) return false;
|
|
39
|
+
break;
|
|
40
|
+
case "$lte":
|
|
41
|
+
if (!(actual <= opValue)) return false;
|
|
42
|
+
break;
|
|
43
|
+
case "$ne":
|
|
44
|
+
if (!(actual !== opValue)) return false;
|
|
45
|
+
break;
|
|
46
|
+
// Add more operators as needed ($in, etc.)
|
|
47
|
+
default:
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
if (actual !== expected) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
function executeQuery(records, query) {
|
|
60
|
+
if (!query) {
|
|
61
|
+
query = {};
|
|
62
|
+
}
|
|
63
|
+
let results = [];
|
|
64
|
+
if (records instanceof Map) {
|
|
65
|
+
for (const [key, record] of records) {
|
|
66
|
+
if (matchesQuery(record, query)) {
|
|
67
|
+
results.push({ key, record });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
for (const record of records) {
|
|
72
|
+
if (matchesQuery(record, query)) {
|
|
73
|
+
results.push({ key: "?", record });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (query.sort) {
|
|
78
|
+
results.sort((a, b) => {
|
|
79
|
+
for (const [field, direction] of Object.entries(query.sort)) {
|
|
80
|
+
const valA = a.record.value[field];
|
|
81
|
+
const valB = b.record.value[field];
|
|
82
|
+
if (valA < valB) return direction === "asc" ? -1 : 1;
|
|
83
|
+
if (valA > valB) return direction === "asc" ? 1 : -1;
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (query.offset || query.limit) {
|
|
89
|
+
const offset = query.offset || 0;
|
|
90
|
+
const limit = query.limit || results.length;
|
|
91
|
+
results = results.slice(offset, offset + limit);
|
|
92
|
+
}
|
|
93
|
+
return results.map((r) => ({ key: r.key, value: r.record.value }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/query/QueryRegistry.ts
|
|
97
|
+
import { serialize } from "@topgunbuild/core";
|
|
98
|
+
|
|
99
|
+
// src/utils/logger.ts
|
|
100
|
+
import pino from "pino";
|
|
101
|
+
var logLevel = process.env.LOG_LEVEL || "info";
|
|
102
|
+
var logger = pino({
|
|
103
|
+
level: logLevel,
|
|
104
|
+
transport: process.env.NODE_ENV !== "production" ? {
|
|
105
|
+
target: "pino-pretty",
|
|
106
|
+
options: {
|
|
107
|
+
colorize: true,
|
|
108
|
+
translateTime: "SYS:standard",
|
|
109
|
+
ignore: "pid,hostname"
|
|
110
|
+
}
|
|
111
|
+
} : void 0,
|
|
112
|
+
formatters: {
|
|
113
|
+
level: (label) => {
|
|
114
|
+
return { level: label };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// src/query/QueryRegistry.ts
|
|
120
|
+
var ReverseQueryIndex = class {
|
|
121
|
+
constructor() {
|
|
122
|
+
// field -> value -> Set<Subscription>
|
|
123
|
+
this.equality = /* @__PURE__ */ new Map();
|
|
124
|
+
// field -> Set<Subscription>
|
|
125
|
+
this.interest = /* @__PURE__ */ new Map();
|
|
126
|
+
// catch-all
|
|
127
|
+
this.wildcard = /* @__PURE__ */ new Set();
|
|
128
|
+
}
|
|
129
|
+
add(sub) {
|
|
130
|
+
const query = sub.query;
|
|
131
|
+
let indexed = false;
|
|
132
|
+
const cleanupFns = [];
|
|
133
|
+
if (query.where) {
|
|
134
|
+
for (const [field, value] of Object.entries(query.where)) {
|
|
135
|
+
if (typeof value !== "object") {
|
|
136
|
+
this.addEquality(field, value, sub);
|
|
137
|
+
cleanupFns.push(() => this.removeEquality(field, value, sub));
|
|
138
|
+
indexed = true;
|
|
139
|
+
} else {
|
|
140
|
+
this.addInterest(field, sub);
|
|
141
|
+
cleanupFns.push(() => this.removeInterest(field, sub));
|
|
142
|
+
indexed = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (query.predicate) {
|
|
147
|
+
const visit = (node) => {
|
|
148
|
+
if (node.op === "eq" && node.attribute && node.value !== void 0) {
|
|
149
|
+
this.addEquality(node.attribute, node.value, sub);
|
|
150
|
+
cleanupFns.push(() => this.removeEquality(node.attribute, node.value, sub));
|
|
151
|
+
indexed = true;
|
|
152
|
+
} else if (node.attribute) {
|
|
153
|
+
this.addInterest(node.attribute, sub);
|
|
154
|
+
cleanupFns.push(() => this.removeInterest(node.attribute, sub));
|
|
155
|
+
indexed = true;
|
|
156
|
+
}
|
|
157
|
+
if (node.children) {
|
|
158
|
+
node.children.forEach(visit);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
visit(query.predicate);
|
|
162
|
+
}
|
|
163
|
+
if (query.sort) {
|
|
164
|
+
Object.keys(query.sort).forEach((k) => {
|
|
165
|
+
this.addInterest(k, sub);
|
|
166
|
+
cleanupFns.push(() => this.removeInterest(k, sub));
|
|
167
|
+
indexed = true;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (!indexed) {
|
|
171
|
+
this.wildcard.add(sub);
|
|
172
|
+
cleanupFns.push(() => this.wildcard.delete(sub));
|
|
173
|
+
}
|
|
174
|
+
sub._cleanup = () => cleanupFns.forEach((fn) => fn());
|
|
175
|
+
}
|
|
176
|
+
remove(sub) {
|
|
177
|
+
if (sub._cleanup) {
|
|
178
|
+
sub._cleanup();
|
|
179
|
+
sub._cleanup = void 0;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
getCandidates(changedFields, oldVal, newVal) {
|
|
183
|
+
const candidates = new Set(this.wildcard);
|
|
184
|
+
if (changedFields === "ALL") {
|
|
185
|
+
for (const set of this.interest.values()) {
|
|
186
|
+
for (const s of set) candidates.add(s);
|
|
187
|
+
}
|
|
188
|
+
for (const map of this.equality.values()) {
|
|
189
|
+
for (const set of map.values()) {
|
|
190
|
+
for (const s of set) candidates.add(s);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return candidates;
|
|
194
|
+
}
|
|
195
|
+
if (changedFields.size === 0) return candidates;
|
|
196
|
+
for (const field of changedFields) {
|
|
197
|
+
if (this.interest.has(field)) {
|
|
198
|
+
for (const sub of this.interest.get(field)) {
|
|
199
|
+
candidates.add(sub);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (this.equality.has(field)) {
|
|
203
|
+
const valMap = this.equality.get(field);
|
|
204
|
+
if (newVal && newVal[field] !== void 0 && valMap.has(newVal[field])) {
|
|
205
|
+
for (const sub of valMap.get(newVal[field])) {
|
|
206
|
+
candidates.add(sub);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (oldVal && oldVal[field] !== void 0 && valMap.has(oldVal[field])) {
|
|
210
|
+
for (const sub of valMap.get(oldVal[field])) {
|
|
211
|
+
candidates.add(sub);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return candidates;
|
|
217
|
+
}
|
|
218
|
+
addEquality(field, value, sub) {
|
|
219
|
+
if (!this.equality.has(field)) this.equality.set(field, /* @__PURE__ */ new Map());
|
|
220
|
+
const valMap = this.equality.get(field);
|
|
221
|
+
if (!valMap.has(value)) valMap.set(value, /* @__PURE__ */ new Set());
|
|
222
|
+
valMap.get(value).add(sub);
|
|
223
|
+
}
|
|
224
|
+
removeEquality(field, value, sub) {
|
|
225
|
+
const valMap = this.equality.get(field);
|
|
226
|
+
if (valMap) {
|
|
227
|
+
const set = valMap.get(value);
|
|
228
|
+
if (set) {
|
|
229
|
+
set.delete(sub);
|
|
230
|
+
if (set.size === 0) valMap.delete(value);
|
|
231
|
+
}
|
|
232
|
+
if (valMap.size === 0) this.equality.delete(field);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
addInterest(field, sub) {
|
|
236
|
+
if (!this.interest.has(field)) this.interest.set(field, /* @__PURE__ */ new Set());
|
|
237
|
+
this.interest.get(field).add(sub);
|
|
238
|
+
}
|
|
239
|
+
removeInterest(field, sub) {
|
|
240
|
+
const set = this.interest.get(field);
|
|
241
|
+
if (set) {
|
|
242
|
+
set.delete(sub);
|
|
243
|
+
if (set.size === 0) this.interest.delete(field);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
var QueryRegistry = class {
|
|
248
|
+
constructor() {
|
|
249
|
+
// MapName -> Set of Subscriptions (Legacy/Backup)
|
|
250
|
+
this.subscriptions = /* @__PURE__ */ new Map();
|
|
251
|
+
// MapName -> Reverse Index
|
|
252
|
+
this.indexes = /* @__PURE__ */ new Map();
|
|
253
|
+
}
|
|
254
|
+
register(sub) {
|
|
255
|
+
if (!this.subscriptions.has(sub.mapName)) {
|
|
256
|
+
this.subscriptions.set(sub.mapName, /* @__PURE__ */ new Set());
|
|
257
|
+
this.indexes.set(sub.mapName, new ReverseQueryIndex());
|
|
258
|
+
}
|
|
259
|
+
const interestedFields = this.analyzeQueryFields(sub.query);
|
|
260
|
+
sub.interestedFields = interestedFields;
|
|
261
|
+
this.subscriptions.get(sub.mapName).add(sub);
|
|
262
|
+
this.indexes.get(sub.mapName).add(sub);
|
|
263
|
+
logger.info({ clientId: sub.clientId, mapName: sub.mapName, query: sub.query }, "Client subscribed");
|
|
264
|
+
}
|
|
265
|
+
unregister(queryId) {
|
|
266
|
+
for (const [mapName, subs] of this.subscriptions) {
|
|
267
|
+
for (const sub of subs) {
|
|
268
|
+
if (sub.id === queryId) {
|
|
269
|
+
subs.delete(sub);
|
|
270
|
+
this.indexes.get(mapName)?.remove(sub);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
unsubscribeAll(clientId) {
|
|
277
|
+
for (const [mapName, subs] of this.subscriptions) {
|
|
278
|
+
for (const sub of subs) {
|
|
279
|
+
if (sub.clientId === clientId) {
|
|
280
|
+
subs.delete(sub);
|
|
281
|
+
this.indexes.get(mapName)?.remove(sub);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Refreshes all subscriptions for a given map.
|
|
288
|
+
* Useful when the map is bulk-loaded from storage.
|
|
289
|
+
*/
|
|
290
|
+
refreshSubscriptions(mapName, map) {
|
|
291
|
+
const subs = this.subscriptions.get(mapName);
|
|
292
|
+
if (!subs || subs.size === 0) return;
|
|
293
|
+
const allRecords = this.getMapRecords(map);
|
|
294
|
+
for (const sub of subs) {
|
|
295
|
+
const newResults = executeQuery(allRecords, sub.query);
|
|
296
|
+
const newResultKeys = new Set(newResults.map((r) => r.key));
|
|
297
|
+
for (const key of sub.previousResultKeys) {
|
|
298
|
+
if (!newResultKeys.has(key)) {
|
|
299
|
+
this.sendUpdate(sub, key, null, "REMOVE");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const res of newResults) {
|
|
303
|
+
this.sendUpdate(sub, res.key, res.value, "UPDATE");
|
|
304
|
+
}
|
|
305
|
+
sub.previousResultKeys = newResultKeys;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
getMapRecords(map) {
|
|
309
|
+
const recordsMap = /* @__PURE__ */ new Map();
|
|
310
|
+
const mapAny = map;
|
|
311
|
+
if (typeof mapAny.allKeys === "function" && typeof mapAny.getRecord === "function") {
|
|
312
|
+
for (const key of mapAny.allKeys()) {
|
|
313
|
+
const rec = mapAny.getRecord(key);
|
|
314
|
+
if (rec) {
|
|
315
|
+
recordsMap.set(key, rec);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
} else if (mapAny.items instanceof Map && typeof mapAny.get === "function") {
|
|
319
|
+
const items = mapAny.items;
|
|
320
|
+
for (const key of items.keys()) {
|
|
321
|
+
const values = mapAny.get(key);
|
|
322
|
+
if (values.length > 0) {
|
|
323
|
+
recordsMap.set(key, { value: values });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return recordsMap;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Processes a record change for all relevant subscriptions.
|
|
331
|
+
* Calculates diffs and sends updates.
|
|
332
|
+
*/
|
|
333
|
+
processChange(mapName, map, changeKey, changeRecord, oldRecord) {
|
|
334
|
+
const index = this.indexes.get(mapName);
|
|
335
|
+
if (!index) return;
|
|
336
|
+
const newVal = this.extractValue(changeRecord);
|
|
337
|
+
const oldVal = this.extractValue(oldRecord);
|
|
338
|
+
const changedFields = this.getChangedFields(oldVal, newVal);
|
|
339
|
+
if (changedFields !== "ALL" && changedFields.size === 0 && oldRecord && changeRecord) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const candidates = index.getCandidates(changedFields, oldVal, newVal);
|
|
343
|
+
if (candidates.size === 0) return;
|
|
344
|
+
let recordsMap = null;
|
|
345
|
+
const getRecordsMap = () => {
|
|
346
|
+
if (recordsMap) return recordsMap;
|
|
347
|
+
recordsMap = this.getMapRecords(map);
|
|
348
|
+
return recordsMap;
|
|
349
|
+
};
|
|
350
|
+
for (const sub of candidates) {
|
|
351
|
+
const dummyRecord = {
|
|
352
|
+
value: newVal,
|
|
353
|
+
timestamp: { millis: 0, counter: 0, nodeId: "" }
|
|
354
|
+
// Dummy timestamp for matchesQuery
|
|
355
|
+
};
|
|
356
|
+
const isMatch = matchesQuery(dummyRecord, sub.query);
|
|
357
|
+
const wasInResult = sub.previousResultKeys.has(changeKey);
|
|
358
|
+
if (!isMatch && !wasInResult) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const allRecords = getRecordsMap();
|
|
362
|
+
const newResults = executeQuery(allRecords, sub.query);
|
|
363
|
+
const newResultKeys = new Set(newResults.map((r) => r.key));
|
|
364
|
+
for (const key of sub.previousResultKeys) {
|
|
365
|
+
if (!newResultKeys.has(key)) {
|
|
366
|
+
this.sendUpdate(sub, key, null, "REMOVE");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const res of newResults) {
|
|
370
|
+
const key = res.key;
|
|
371
|
+
const isNew = !sub.previousResultKeys.has(key);
|
|
372
|
+
if (key === changeKey) {
|
|
373
|
+
this.sendUpdate(sub, key, res.value, "UPDATE");
|
|
374
|
+
} else if (isNew) {
|
|
375
|
+
this.sendUpdate(sub, key, res.value, "UPDATE");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
sub.previousResultKeys = newResultKeys;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
extractValue(record) {
|
|
382
|
+
if (!record) return null;
|
|
383
|
+
if (Array.isArray(record)) {
|
|
384
|
+
return record.map((r) => r.value);
|
|
385
|
+
}
|
|
386
|
+
return record.value;
|
|
387
|
+
}
|
|
388
|
+
sendUpdate(sub, key, value, type) {
|
|
389
|
+
if (sub.socket.readyState === 1) {
|
|
390
|
+
sub.socket.send(serialize({
|
|
391
|
+
type: "QUERY_UPDATE",
|
|
392
|
+
payload: {
|
|
393
|
+
queryId: sub.id,
|
|
394
|
+
key,
|
|
395
|
+
value,
|
|
396
|
+
type
|
|
397
|
+
}
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
analyzeQueryFields(query) {
|
|
402
|
+
const fields = /* @__PURE__ */ new Set();
|
|
403
|
+
try {
|
|
404
|
+
if (query.predicate) {
|
|
405
|
+
const extract = (node) => {
|
|
406
|
+
if (node.attribute) fields.add(node.attribute);
|
|
407
|
+
if (node.children) node.children.forEach(extract);
|
|
408
|
+
};
|
|
409
|
+
extract(query.predicate);
|
|
410
|
+
}
|
|
411
|
+
if (query.where) {
|
|
412
|
+
Object.keys(query.where).forEach((k) => fields.add(k));
|
|
413
|
+
}
|
|
414
|
+
if (query.sort) {
|
|
415
|
+
Object.keys(query.sort).forEach((k) => fields.add(k));
|
|
416
|
+
}
|
|
417
|
+
} catch (e) {
|
|
418
|
+
return "ALL";
|
|
419
|
+
}
|
|
420
|
+
return fields.size > 0 ? fields : "ALL";
|
|
421
|
+
}
|
|
422
|
+
getChangedFields(oldValue, newValue) {
|
|
423
|
+
if (Array.isArray(oldValue) || Array.isArray(newValue)) return "ALL";
|
|
424
|
+
if (oldValue === newValue) return /* @__PURE__ */ new Set();
|
|
425
|
+
if (!oldValue && !newValue) return /* @__PURE__ */ new Set();
|
|
426
|
+
if (!oldValue) return new Set(Object.keys(newValue || {}));
|
|
427
|
+
if (!newValue) return new Set(Object.keys(oldValue || {}));
|
|
428
|
+
const changes = /* @__PURE__ */ new Set();
|
|
429
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldValue), ...Object.keys(newValue)]);
|
|
430
|
+
for (const key of allKeys) {
|
|
431
|
+
if (oldValue[key] !== newValue[key]) {
|
|
432
|
+
changes.add(key);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return changes;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// src/topic/TopicManager.ts
|
|
440
|
+
var TopicManager = class {
|
|
441
|
+
// M1: Basic limit
|
|
442
|
+
constructor(config) {
|
|
443
|
+
this.subscribers = /* @__PURE__ */ new Map();
|
|
444
|
+
this.MAX_SUBSCRIPTIONS = 100;
|
|
445
|
+
this.cluster = config.cluster;
|
|
446
|
+
this.sendToClient = config.sendToClient;
|
|
447
|
+
}
|
|
448
|
+
validateTopic(topic) {
|
|
449
|
+
if (!topic || topic.length > 256 || !/^[\w\-.:/]+$/.test(topic)) {
|
|
450
|
+
throw new Error("Invalid topic name");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Subscribe a client to a topic
|
|
455
|
+
*/
|
|
456
|
+
subscribe(clientId, topic) {
|
|
457
|
+
this.validateTopic(topic);
|
|
458
|
+
let count = 0;
|
|
459
|
+
for (const subs of this.subscribers.values()) {
|
|
460
|
+
if (subs.has(clientId)) count++;
|
|
461
|
+
}
|
|
462
|
+
if (count >= this.MAX_SUBSCRIPTIONS) {
|
|
463
|
+
throw new Error("Subscription limit reached");
|
|
464
|
+
}
|
|
465
|
+
if (!this.subscribers.has(topic)) {
|
|
466
|
+
this.subscribers.set(topic, /* @__PURE__ */ new Set());
|
|
467
|
+
}
|
|
468
|
+
this.subscribers.get(topic).add(clientId);
|
|
469
|
+
logger.debug({ clientId, topic }, "Client subscribed to topic");
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Unsubscribe a client from a topic
|
|
473
|
+
*/
|
|
474
|
+
unsubscribe(clientId, topic) {
|
|
475
|
+
const subs = this.subscribers.get(topic);
|
|
476
|
+
if (subs) {
|
|
477
|
+
subs.delete(clientId);
|
|
478
|
+
if (subs.size === 0) {
|
|
479
|
+
this.subscribers.delete(topic);
|
|
480
|
+
}
|
|
481
|
+
logger.debug({ clientId, topic }, "Client unsubscribed from topic");
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Clean up all subscriptions for a client (e.g. on disconnect)
|
|
486
|
+
*/
|
|
487
|
+
unsubscribeAll(clientId) {
|
|
488
|
+
for (const [topic, subs] of this.subscribers) {
|
|
489
|
+
if (subs.has(clientId)) {
|
|
490
|
+
subs.delete(clientId);
|
|
491
|
+
if (subs.size === 0) {
|
|
492
|
+
this.subscribers.delete(topic);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Publish a message to a topic
|
|
499
|
+
* @param topic Topic name
|
|
500
|
+
* @param data Message data
|
|
501
|
+
* @param senderId Client ID of the publisher (optional)
|
|
502
|
+
* @param fromCluster Whether this message came from another cluster node
|
|
503
|
+
*/
|
|
504
|
+
publish(topic, data, senderId, fromCluster = false) {
|
|
505
|
+
this.validateTopic(topic);
|
|
506
|
+
const subs = this.subscribers.get(topic);
|
|
507
|
+
if (subs) {
|
|
508
|
+
const payload = {
|
|
509
|
+
topic,
|
|
510
|
+
data,
|
|
511
|
+
publisherId: senderId,
|
|
512
|
+
timestamp: Date.now()
|
|
513
|
+
};
|
|
514
|
+
const message = {
|
|
515
|
+
type: "TOPIC_MESSAGE",
|
|
516
|
+
payload
|
|
517
|
+
};
|
|
518
|
+
for (const clientId of subs) {
|
|
519
|
+
if (clientId !== senderId) {
|
|
520
|
+
this.sendToClient(clientId, message);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (!fromCluster) {
|
|
525
|
+
this.cluster.getMembers().forEach((nodeId) => {
|
|
526
|
+
if (!this.cluster.isLocal(nodeId)) {
|
|
527
|
+
this.cluster.send(nodeId, "CLUSTER_TOPIC_PUB", {
|
|
528
|
+
topic,
|
|
529
|
+
data,
|
|
530
|
+
originalSenderId: senderId
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// src/cluster/ClusterManager.ts
|
|
539
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
540
|
+
import { EventEmitter } from "events";
|
|
541
|
+
import * as dns from "dns";
|
|
542
|
+
import { readFileSync } from "fs";
|
|
543
|
+
import * as https from "https";
|
|
544
|
+
var ClusterManager = class extends EventEmitter {
|
|
545
|
+
constructor(config) {
|
|
546
|
+
super();
|
|
547
|
+
this.members = /* @__PURE__ */ new Map();
|
|
548
|
+
this.pendingConnections = /* @__PURE__ */ new Set();
|
|
549
|
+
this.reconnectIntervals = /* @__PURE__ */ new Map();
|
|
550
|
+
this._actualPort = 0;
|
|
551
|
+
this.config = config;
|
|
552
|
+
}
|
|
553
|
+
/** Get the actual port the cluster is listening on */
|
|
554
|
+
get port() {
|
|
555
|
+
return this._actualPort;
|
|
556
|
+
}
|
|
557
|
+
start() {
|
|
558
|
+
return new Promise((resolve) => {
|
|
559
|
+
logger.info({ port: this.config.port, tls: !!this.config.tls?.enabled }, "Starting Cluster Manager");
|
|
560
|
+
if (this.config.tls?.enabled) {
|
|
561
|
+
const tlsOptions = this.buildClusterTLSOptions();
|
|
562
|
+
const httpsServer = https.createServer(tlsOptions);
|
|
563
|
+
this.server = new WebSocketServer({ server: httpsServer });
|
|
564
|
+
httpsServer.listen(this.config.port, () => {
|
|
565
|
+
const addr = httpsServer.address();
|
|
566
|
+
this._actualPort = typeof addr === "object" && addr ? addr.port : this.config.port;
|
|
567
|
+
logger.info({ port: this._actualPort }, "Cluster Manager listening (TLS enabled)");
|
|
568
|
+
this.onServerReady(resolve);
|
|
569
|
+
});
|
|
570
|
+
} else {
|
|
571
|
+
this.server = new WebSocketServer({ port: this.config.port });
|
|
572
|
+
this.server.on("listening", () => {
|
|
573
|
+
const addr = this.server.address();
|
|
574
|
+
this._actualPort = typeof addr === "object" && addr ? addr.port : this.config.port;
|
|
575
|
+
logger.info({ port: this._actualPort }, "Cluster Manager listening");
|
|
576
|
+
this.onServerReady(resolve);
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
this.server?.on("connection", (ws, req) => {
|
|
580
|
+
logger.info({ remoteAddress: req.socket.remoteAddress }, "Incoming cluster connection");
|
|
581
|
+
this.handleSocket(ws, false);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
/** Called when server is ready - registers self and initiates peer connections */
|
|
586
|
+
onServerReady(resolve) {
|
|
587
|
+
this.members.set(this.config.nodeId, {
|
|
588
|
+
nodeId: this.config.nodeId,
|
|
589
|
+
host: this.config.host,
|
|
590
|
+
port: this._actualPort,
|
|
591
|
+
socket: null,
|
|
592
|
+
isSelf: true
|
|
593
|
+
});
|
|
594
|
+
if (this.config.discovery === "kubernetes" && this.config.serviceName) {
|
|
595
|
+
this.startDiscovery();
|
|
596
|
+
} else {
|
|
597
|
+
this.connectToPeers();
|
|
598
|
+
}
|
|
599
|
+
resolve(this._actualPort);
|
|
600
|
+
}
|
|
601
|
+
stop() {
|
|
602
|
+
logger.info({ port: this.config.port }, "Stopping Cluster Manager");
|
|
603
|
+
for (const timeout of this.reconnectIntervals.values()) {
|
|
604
|
+
clearTimeout(timeout);
|
|
605
|
+
}
|
|
606
|
+
this.reconnectIntervals.clear();
|
|
607
|
+
if (this.discoveryTimer) {
|
|
608
|
+
clearInterval(this.discoveryTimer);
|
|
609
|
+
this.discoveryTimer = void 0;
|
|
610
|
+
}
|
|
611
|
+
this.pendingConnections.clear();
|
|
612
|
+
for (const member of this.members.values()) {
|
|
613
|
+
if (member.socket) {
|
|
614
|
+
member.socket.terminate();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
this.members.clear();
|
|
618
|
+
if (this.server) {
|
|
619
|
+
this.server.close();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
connectToPeers() {
|
|
623
|
+
for (const peer of this.config.peers) {
|
|
624
|
+
this.connectToPeer(peer);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
startDiscovery() {
|
|
628
|
+
const runDiscovery = async () => {
|
|
629
|
+
if (!this.config.serviceName) return;
|
|
630
|
+
try {
|
|
631
|
+
const addresses = await dns.promises.resolve4(this.config.serviceName);
|
|
632
|
+
logger.debug({ addresses, serviceName: this.config.serviceName }, "DNS discovery results");
|
|
633
|
+
for (const ip of addresses) {
|
|
634
|
+
const targetPort = this._actualPort || this.config.port;
|
|
635
|
+
const peerAddress = `${ip}:${targetPort}`;
|
|
636
|
+
this.connectToPeer(peerAddress);
|
|
637
|
+
}
|
|
638
|
+
} catch (err) {
|
|
639
|
+
logger.error({ err: err.message, serviceName: this.config.serviceName }, "DNS discovery failed");
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
logger.info({ serviceName: this.config.serviceName }, "Starting Kubernetes DNS discovery");
|
|
643
|
+
runDiscovery();
|
|
644
|
+
this.discoveryTimer = setInterval(runDiscovery, this.config.discoveryInterval || 1e4);
|
|
645
|
+
}
|
|
646
|
+
scheduleReconnect(peerAddress, attempt = 0) {
|
|
647
|
+
if (this.reconnectIntervals.has(peerAddress)) return;
|
|
648
|
+
const delay = Math.min(5e3 * Math.pow(2, attempt), 6e4);
|
|
649
|
+
const timeout = setTimeout(() => {
|
|
650
|
+
this.reconnectIntervals.delete(peerAddress);
|
|
651
|
+
this.connectToPeerWithBackoff(peerAddress, attempt + 1);
|
|
652
|
+
}, delay);
|
|
653
|
+
this.reconnectIntervals.set(peerAddress, timeout);
|
|
654
|
+
}
|
|
655
|
+
// Helper to track attempts
|
|
656
|
+
connectToPeerWithBackoff(peerAddress, attempt) {
|
|
657
|
+
this._connectToPeerInternal(peerAddress, attempt);
|
|
658
|
+
}
|
|
659
|
+
connectToPeer(peerAddress) {
|
|
660
|
+
this._connectToPeerInternal(peerAddress, 0);
|
|
661
|
+
}
|
|
662
|
+
_connectToPeerInternal(peerAddress, attempt) {
|
|
663
|
+
if (this.pendingConnections.has(peerAddress)) return;
|
|
664
|
+
for (const member of this.members.values()) {
|
|
665
|
+
if (`${member.host}:${member.port}` === peerAddress) return;
|
|
666
|
+
}
|
|
667
|
+
logger.info({ peerAddress, attempt, tls: !!this.config.tls?.enabled }, "Connecting to peer");
|
|
668
|
+
this.pendingConnections.add(peerAddress);
|
|
669
|
+
try {
|
|
670
|
+
let ws;
|
|
671
|
+
if (this.config.tls?.enabled) {
|
|
672
|
+
const protocol = "wss://";
|
|
673
|
+
const wsOptions = {
|
|
674
|
+
rejectUnauthorized: this.config.tls.rejectUnauthorized !== false
|
|
675
|
+
};
|
|
676
|
+
if (this.config.tls.certPath && this.config.tls.keyPath) {
|
|
677
|
+
wsOptions.cert = readFileSync(this.config.tls.certPath);
|
|
678
|
+
wsOptions.key = readFileSync(this.config.tls.keyPath);
|
|
679
|
+
if (this.config.tls.passphrase) {
|
|
680
|
+
wsOptions.passphrase = this.config.tls.passphrase;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (this.config.tls.caCertPath) {
|
|
684
|
+
wsOptions.ca = readFileSync(this.config.tls.caCertPath);
|
|
685
|
+
}
|
|
686
|
+
ws = new WebSocket(`${protocol}${peerAddress}`, wsOptions);
|
|
687
|
+
} else {
|
|
688
|
+
ws = new WebSocket(`ws://${peerAddress}`);
|
|
689
|
+
}
|
|
690
|
+
ws.on("open", () => {
|
|
691
|
+
this.pendingConnections.delete(peerAddress);
|
|
692
|
+
logger.info({ peerAddress }, "Connected to peer");
|
|
693
|
+
this.handleSocket(ws, true, peerAddress);
|
|
694
|
+
});
|
|
695
|
+
ws.on("error", (err) => {
|
|
696
|
+
logger.error({ peerAddress, err: err.message }, "Connection error to peer");
|
|
697
|
+
this.pendingConnections.delete(peerAddress);
|
|
698
|
+
this.scheduleReconnect(peerAddress, attempt);
|
|
699
|
+
});
|
|
700
|
+
ws.on("close", () => {
|
|
701
|
+
this.pendingConnections.delete(peerAddress);
|
|
702
|
+
});
|
|
703
|
+
} catch (e) {
|
|
704
|
+
this.pendingConnections.delete(peerAddress);
|
|
705
|
+
this.scheduleReconnect(peerAddress, attempt);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
handleSocket(ws, initiated, peerAddress) {
|
|
709
|
+
const helloMsg = {
|
|
710
|
+
type: "HELLO",
|
|
711
|
+
senderId: this.config.nodeId,
|
|
712
|
+
payload: {
|
|
713
|
+
host: this.config.host,
|
|
714
|
+
port: this._actualPort || this.config.port
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
ws.send(JSON.stringify(helloMsg));
|
|
718
|
+
let remoteNodeId = null;
|
|
719
|
+
ws.on("message", (data) => {
|
|
720
|
+
try {
|
|
721
|
+
const msg = JSON.parse(data.toString());
|
|
722
|
+
if (msg.type === "HELLO") {
|
|
723
|
+
remoteNodeId = msg.senderId;
|
|
724
|
+
const { host, port } = msg.payload;
|
|
725
|
+
logger.info({ nodeId: remoteNodeId, host, port }, "Peer identified");
|
|
726
|
+
const myId = this.config.nodeId;
|
|
727
|
+
const otherId = remoteNodeId;
|
|
728
|
+
const initiatorId = initiated ? myId : otherId;
|
|
729
|
+
const receiverId = initiated ? otherId : myId;
|
|
730
|
+
if (this.members.has(remoteNodeId)) {
|
|
731
|
+
logger.warn({ nodeId: remoteNodeId }, "Duplicate valid connection. Replacing.");
|
|
732
|
+
}
|
|
733
|
+
this.members.set(remoteNodeId, {
|
|
734
|
+
nodeId: remoteNodeId,
|
|
735
|
+
host,
|
|
736
|
+
port,
|
|
737
|
+
socket: ws,
|
|
738
|
+
isSelf: false
|
|
739
|
+
});
|
|
740
|
+
this.emit("memberJoined", remoteNodeId);
|
|
741
|
+
} else {
|
|
742
|
+
this.emit("message", msg);
|
|
743
|
+
}
|
|
744
|
+
} catch (err) {
|
|
745
|
+
logger.error({ err }, "Failed to parse cluster message");
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
ws.on("close", () => {
|
|
749
|
+
if (remoteNodeId) {
|
|
750
|
+
const current = this.members.get(remoteNodeId);
|
|
751
|
+
if (current && current.socket === ws) {
|
|
752
|
+
logger.info({ nodeId: remoteNodeId }, "Peer disconnected");
|
|
753
|
+
this.members.delete(remoteNodeId);
|
|
754
|
+
this.emit("memberLeft", remoteNodeId);
|
|
755
|
+
if (initiated && peerAddress) {
|
|
756
|
+
this.scheduleReconnect(peerAddress, 0);
|
|
757
|
+
}
|
|
758
|
+
} else {
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
send(nodeId, type, payload) {
|
|
764
|
+
const member = this.members.get(nodeId);
|
|
765
|
+
if (member && member.socket && member.socket.readyState === WebSocket.OPEN) {
|
|
766
|
+
const msg = {
|
|
767
|
+
type,
|
|
768
|
+
senderId: this.config.nodeId,
|
|
769
|
+
payload
|
|
770
|
+
};
|
|
771
|
+
member.socket.send(JSON.stringify(msg));
|
|
772
|
+
} else {
|
|
773
|
+
logger.warn({ nodeId }, "Cannot send to node: not connected");
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
sendToNode(nodeId, message) {
|
|
777
|
+
this.send(nodeId, "OP_FORWARD", message);
|
|
778
|
+
}
|
|
779
|
+
getMembers() {
|
|
780
|
+
return Array.from(this.members.keys());
|
|
781
|
+
}
|
|
782
|
+
isLocal(nodeId) {
|
|
783
|
+
return nodeId === this.config.nodeId;
|
|
784
|
+
}
|
|
785
|
+
buildClusterTLSOptions() {
|
|
786
|
+
const config = this.config.tls;
|
|
787
|
+
const options = {
|
|
788
|
+
cert: readFileSync(config.certPath),
|
|
789
|
+
key: readFileSync(config.keyPath),
|
|
790
|
+
minVersion: config.minVersion || "TLSv1.2"
|
|
791
|
+
};
|
|
792
|
+
if (config.caCertPath) {
|
|
793
|
+
options.ca = readFileSync(config.caCertPath);
|
|
794
|
+
}
|
|
795
|
+
if (config.requireClientCert) {
|
|
796
|
+
options.requestCert = true;
|
|
797
|
+
options.rejectUnauthorized = true;
|
|
798
|
+
}
|
|
799
|
+
if (config.passphrase) {
|
|
800
|
+
options.passphrase = config.passphrase;
|
|
801
|
+
}
|
|
802
|
+
return options;
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
// src/cluster/PartitionService.ts
|
|
807
|
+
import { hashString } from "@topgunbuild/core";
|
|
808
|
+
var PartitionService = class {
|
|
809
|
+
// Standard Hazelcast default
|
|
810
|
+
constructor(cluster) {
|
|
811
|
+
// partitionId -> { owner, backups }
|
|
812
|
+
this.partitions = /* @__PURE__ */ new Map();
|
|
813
|
+
this.PARTITION_COUNT = 271;
|
|
814
|
+
this.BACKUP_COUNT = 1;
|
|
815
|
+
this.cluster = cluster;
|
|
816
|
+
this.cluster.on("memberJoined", () => this.rebalance());
|
|
817
|
+
this.cluster.on("memberLeft", () => this.rebalance());
|
|
818
|
+
this.rebalance();
|
|
819
|
+
}
|
|
820
|
+
getPartitionId(key) {
|
|
821
|
+
return Math.abs(hashString(key)) % this.PARTITION_COUNT;
|
|
822
|
+
}
|
|
823
|
+
getDistribution(key) {
|
|
824
|
+
const pId = this.getPartitionId(key);
|
|
825
|
+
return this.partitions.get(pId) || {
|
|
826
|
+
owner: this.cluster.config.nodeId,
|
|
827
|
+
backups: []
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
getOwner(key) {
|
|
831
|
+
return this.getDistribution(key).owner;
|
|
832
|
+
}
|
|
833
|
+
isLocalOwner(key) {
|
|
834
|
+
return this.getOwner(key) === this.cluster.config.nodeId;
|
|
835
|
+
}
|
|
836
|
+
isLocalBackup(key) {
|
|
837
|
+
const dist = this.getDistribution(key);
|
|
838
|
+
return dist.backups.includes(this.cluster.config.nodeId);
|
|
839
|
+
}
|
|
840
|
+
isRelated(key) {
|
|
841
|
+
return this.isLocalOwner(key) || this.isLocalBackup(key);
|
|
842
|
+
}
|
|
843
|
+
rebalance() {
|
|
844
|
+
let allMembers = this.cluster.getMembers().sort();
|
|
845
|
+
if (allMembers.length === 0) {
|
|
846
|
+
allMembers = [this.cluster.config.nodeId];
|
|
847
|
+
}
|
|
848
|
+
logger.info({ memberCount: allMembers.length, members: allMembers }, "Rebalancing partitions");
|
|
849
|
+
for (let i = 0; i < this.PARTITION_COUNT; i++) {
|
|
850
|
+
const ownerIndex = i % allMembers.length;
|
|
851
|
+
const owner = allMembers[ownerIndex];
|
|
852
|
+
const backups = [];
|
|
853
|
+
if (allMembers.length > 1) {
|
|
854
|
+
for (let b = 1; b <= this.BACKUP_COUNT; b++) {
|
|
855
|
+
const backupIndex = (ownerIndex + b) % allMembers.length;
|
|
856
|
+
backups.push(allMembers[backupIndex]);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
this.partitions.set(i, { owner, backups });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
// src/cluster/LockManager.ts
|
|
865
|
+
import { EventEmitter as EventEmitter2 } from "events";
|
|
866
|
+
var _LockManager = class _LockManager extends EventEmitter2 {
|
|
867
|
+
// 5 minutes
|
|
868
|
+
constructor() {
|
|
869
|
+
super();
|
|
870
|
+
this.locks = /* @__PURE__ */ new Map();
|
|
871
|
+
this.checkInterval = setInterval(() => this.cleanupExpiredLocks(), 1e3);
|
|
872
|
+
}
|
|
873
|
+
stop() {
|
|
874
|
+
clearInterval(this.checkInterval);
|
|
875
|
+
}
|
|
876
|
+
acquire(name, clientId, requestId, ttl) {
|
|
877
|
+
const safeTtl = Math.max(_LockManager.MIN_TTL, Math.min(ttl || _LockManager.MIN_TTL, _LockManager.MAX_TTL));
|
|
878
|
+
let lock = this.locks.get(name);
|
|
879
|
+
if (!lock) {
|
|
880
|
+
lock = {
|
|
881
|
+
name,
|
|
882
|
+
owner: "",
|
|
883
|
+
fencingToken: 0,
|
|
884
|
+
expiry: 0,
|
|
885
|
+
queue: []
|
|
886
|
+
};
|
|
887
|
+
this.locks.set(name, lock);
|
|
888
|
+
}
|
|
889
|
+
const now = Date.now();
|
|
890
|
+
if (!lock.owner || lock.expiry < now) {
|
|
891
|
+
this.grantLock(lock, clientId, safeTtl);
|
|
892
|
+
return { granted: true, fencingToken: lock.fencingToken };
|
|
893
|
+
}
|
|
894
|
+
if (lock.owner === clientId) {
|
|
895
|
+
lock.expiry = Math.max(lock.expiry, now + safeTtl);
|
|
896
|
+
logger.info({ name, clientId, fencingToken: lock.fencingToken }, "Lock lease extended");
|
|
897
|
+
return { granted: true, fencingToken: lock.fencingToken };
|
|
898
|
+
}
|
|
899
|
+
lock.queue.push({ clientId, requestId, ttl: safeTtl, timestamp: now });
|
|
900
|
+
logger.info({ name, clientId, queueLength: lock.queue.length }, "Lock queued");
|
|
901
|
+
return { granted: false };
|
|
902
|
+
}
|
|
903
|
+
release(name, clientId, fencingToken) {
|
|
904
|
+
const lock = this.locks.get(name);
|
|
905
|
+
if (!lock) return false;
|
|
906
|
+
if (lock.owner !== clientId) {
|
|
907
|
+
logger.warn({ name, clientId, owner: lock.owner }, "Release failed: Not owner");
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
if (lock.fencingToken !== fencingToken) {
|
|
911
|
+
logger.warn({ name, clientId, sentToken: fencingToken, actualToken: lock.fencingToken }, "Release failed: Token mismatch");
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
this.processNext(lock);
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
handleClientDisconnect(clientId) {
|
|
918
|
+
for (const lock of this.locks.values()) {
|
|
919
|
+
if (lock.owner === clientId) {
|
|
920
|
+
logger.info({ name: lock.name, clientId }, "Releasing lock due to disconnect");
|
|
921
|
+
this.processNext(lock);
|
|
922
|
+
} else {
|
|
923
|
+
const initialLen = lock.queue.length;
|
|
924
|
+
lock.queue = lock.queue.filter((req) => req.clientId !== clientId);
|
|
925
|
+
if (lock.queue.length < initialLen) {
|
|
926
|
+
logger.info({ name: lock.name, clientId }, "Removed from lock queue due to disconnect");
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
grantLock(lock, clientId, ttl) {
|
|
932
|
+
lock.owner = clientId;
|
|
933
|
+
lock.expiry = Date.now() + ttl;
|
|
934
|
+
lock.fencingToken++;
|
|
935
|
+
logger.info({ name: lock.name, clientId, fencingToken: lock.fencingToken }, "Lock granted");
|
|
936
|
+
}
|
|
937
|
+
processNext(lock) {
|
|
938
|
+
const now = Date.now();
|
|
939
|
+
lock.owner = "";
|
|
940
|
+
lock.expiry = 0;
|
|
941
|
+
while (lock.queue.length > 0) {
|
|
942
|
+
const next = lock.queue.shift();
|
|
943
|
+
this.grantLock(lock, next.clientId, next.ttl);
|
|
944
|
+
this.emit("lockGranted", {
|
|
945
|
+
clientId: next.clientId,
|
|
946
|
+
requestId: next.requestId,
|
|
947
|
+
name: lock.name,
|
|
948
|
+
fencingToken: lock.fencingToken
|
|
949
|
+
});
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (lock.queue.length === 0) {
|
|
953
|
+
this.locks.delete(lock.name);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
cleanupExpiredLocks() {
|
|
957
|
+
const now = Date.now();
|
|
958
|
+
const lockNames = Array.from(this.locks.keys());
|
|
959
|
+
for (const name of lockNames) {
|
|
960
|
+
const lock = this.locks.get(name);
|
|
961
|
+
if (!lock) continue;
|
|
962
|
+
if (lock.owner && lock.expiry < now) {
|
|
963
|
+
logger.info({ name: lock.name, owner: lock.owner }, "Lock expired, processing next");
|
|
964
|
+
this.processNext(lock);
|
|
965
|
+
} else if (!lock.owner && lock.queue.length === 0) {
|
|
966
|
+
this.locks.delete(name);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
_LockManager.MIN_TTL = 1e3;
|
|
972
|
+
// 1 second
|
|
973
|
+
_LockManager.MAX_TTL = 3e5;
|
|
974
|
+
var LockManager = _LockManager;
|
|
975
|
+
|
|
976
|
+
// src/security/SecurityManager.ts
|
|
977
|
+
var SecurityManager = class {
|
|
978
|
+
constructor(policies = []) {
|
|
979
|
+
this.policies = [];
|
|
980
|
+
this.policies = policies;
|
|
981
|
+
}
|
|
982
|
+
addPolicy(policy) {
|
|
983
|
+
this.policies.push(policy);
|
|
984
|
+
}
|
|
985
|
+
checkPermission(principal, mapName, action) {
|
|
986
|
+
if (principal.roles.includes("ADMIN")) {
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
if (mapName.startsWith("$sys/")) {
|
|
990
|
+
logger.warn({ userId: principal.userId, mapName }, "Access Denied: System Map requires ADMIN role");
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
for (const policy of this.policies) {
|
|
994
|
+
const hasRole = this.hasRole(principal, policy.role);
|
|
995
|
+
const matchesMap = this.matchesMap(mapName, policy.mapNamePattern, principal);
|
|
996
|
+
if (hasRole && matchesMap) {
|
|
997
|
+
if (policy.actions.includes("ALL") || policy.actions.includes(action)) {
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
} else {
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
logger.warn({
|
|
1004
|
+
userId: principal.userId,
|
|
1005
|
+
roles: principal.roles,
|
|
1006
|
+
mapName,
|
|
1007
|
+
action,
|
|
1008
|
+
policyCount: this.policies.length
|
|
1009
|
+
}, "SecurityManager: Access Denied - No matching policy found");
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
filterObject(object, principal, mapName) {
|
|
1013
|
+
if (!object || typeof object !== "object") return object;
|
|
1014
|
+
if (principal.roles.includes("ADMIN")) return object;
|
|
1015
|
+
if (Array.isArray(object)) {
|
|
1016
|
+
return object.map((item) => this.filterObject(item, principal, mapName));
|
|
1017
|
+
}
|
|
1018
|
+
let allowedFields = null;
|
|
1019
|
+
let accessGranted = false;
|
|
1020
|
+
for (const policy of this.policies) {
|
|
1021
|
+
if (this.hasRole(principal, policy.role) && this.matchesMap(mapName, policy.mapNamePattern, principal)) {
|
|
1022
|
+
if (policy.actions.includes("ALL") || policy.actions.includes("READ")) {
|
|
1023
|
+
accessGranted = true;
|
|
1024
|
+
if (!policy.allowedFields || policy.allowedFields.length === 0 || policy.allowedFields.includes("*")) {
|
|
1025
|
+
return object;
|
|
1026
|
+
}
|
|
1027
|
+
if (allowedFields === null) allowedFields = /* @__PURE__ */ new Set();
|
|
1028
|
+
policy.allowedFields.forEach((f) => allowedFields.add(f));
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (!accessGranted) return null;
|
|
1033
|
+
if (allowedFields === null) return object;
|
|
1034
|
+
const filtered = {};
|
|
1035
|
+
for (const key of Object.keys(object)) {
|
|
1036
|
+
if (allowedFields.has(key)) {
|
|
1037
|
+
filtered[key] = object[key];
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return filtered;
|
|
1041
|
+
}
|
|
1042
|
+
hasRole(principal, role) {
|
|
1043
|
+
return principal.roles.includes(role);
|
|
1044
|
+
}
|
|
1045
|
+
matchesMap(mapName, pattern, principal) {
|
|
1046
|
+
let finalPattern = pattern;
|
|
1047
|
+
if (pattern.includes("{userId}") && principal) {
|
|
1048
|
+
finalPattern = pattern.replace("{userId}", principal.userId);
|
|
1049
|
+
}
|
|
1050
|
+
if (finalPattern === "*") return true;
|
|
1051
|
+
if (finalPattern === mapName) return true;
|
|
1052
|
+
if (finalPattern.endsWith("*")) {
|
|
1053
|
+
const prefix = finalPattern.slice(0, -1);
|
|
1054
|
+
return mapName.startsWith(prefix);
|
|
1055
|
+
}
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
// src/monitoring/MetricsService.ts
|
|
1061
|
+
import { Registry, Gauge, Counter, collectDefaultMetrics } from "prom-client";
|
|
1062
|
+
var MetricsService = class {
|
|
1063
|
+
constructor() {
|
|
1064
|
+
this.registry = new Registry();
|
|
1065
|
+
collectDefaultMetrics({ register: this.registry, prefix: "topgun_" });
|
|
1066
|
+
this.connectedClients = new Gauge({
|
|
1067
|
+
name: "topgun_connected_clients",
|
|
1068
|
+
help: "Number of currently connected clients",
|
|
1069
|
+
registers: [this.registry]
|
|
1070
|
+
});
|
|
1071
|
+
this.mapSizeItems = new Gauge({
|
|
1072
|
+
name: "topgun_map_size_items",
|
|
1073
|
+
help: "Number of items in a map",
|
|
1074
|
+
labelNames: ["map"],
|
|
1075
|
+
registers: [this.registry]
|
|
1076
|
+
});
|
|
1077
|
+
this.opsTotal = new Counter({
|
|
1078
|
+
name: "topgun_ops_total",
|
|
1079
|
+
help: "Total number of operations",
|
|
1080
|
+
labelNames: ["type", "map"],
|
|
1081
|
+
registers: [this.registry]
|
|
1082
|
+
});
|
|
1083
|
+
this.memoryUsage = new Gauge({
|
|
1084
|
+
name: "topgun_memory_usage_bytes",
|
|
1085
|
+
help: "Current memory usage in bytes",
|
|
1086
|
+
registers: [this.registry],
|
|
1087
|
+
collect() {
|
|
1088
|
+
this.set(process.memoryUsage().heapUsed);
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
this.clusterMembers = new Gauge({
|
|
1092
|
+
name: "topgun_cluster_members",
|
|
1093
|
+
help: "Number of active cluster members",
|
|
1094
|
+
registers: [this.registry]
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
destroy() {
|
|
1098
|
+
this.registry.clear();
|
|
1099
|
+
}
|
|
1100
|
+
setConnectedClients(count) {
|
|
1101
|
+
this.connectedClients.set(count);
|
|
1102
|
+
}
|
|
1103
|
+
setMapSize(mapName, size) {
|
|
1104
|
+
this.mapSizeItems.set({ map: mapName }, size);
|
|
1105
|
+
}
|
|
1106
|
+
incOp(type, mapName) {
|
|
1107
|
+
this.opsTotal.inc({ type, map: mapName });
|
|
1108
|
+
}
|
|
1109
|
+
setClusterMembers(count) {
|
|
1110
|
+
this.clusterMembers.set(count);
|
|
1111
|
+
}
|
|
1112
|
+
async getMetrics() {
|
|
1113
|
+
return this.registry.metrics();
|
|
1114
|
+
}
|
|
1115
|
+
async getMetricsJson() {
|
|
1116
|
+
const metrics = await this.registry.getMetricsAsJSON();
|
|
1117
|
+
const result = {};
|
|
1118
|
+
for (const metric of metrics) {
|
|
1119
|
+
if (metric.values.length === 1) {
|
|
1120
|
+
result[metric.name] = metric.values[0].value;
|
|
1121
|
+
} else {
|
|
1122
|
+
result[metric.name] = metric.values;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return result;
|
|
1126
|
+
}
|
|
1127
|
+
getContentType() {
|
|
1128
|
+
return this.registry.contentType;
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
// src/system/SystemManager.ts
|
|
1133
|
+
var SystemManager = class {
|
|
1134
|
+
constructor(cluster, metrics, getMap) {
|
|
1135
|
+
this.cluster = cluster;
|
|
1136
|
+
this.metrics = metrics;
|
|
1137
|
+
this.getMap = getMap;
|
|
1138
|
+
}
|
|
1139
|
+
start() {
|
|
1140
|
+
this.setupClusterMap();
|
|
1141
|
+
this.setupStatsMap();
|
|
1142
|
+
this.setupMapsMap();
|
|
1143
|
+
this.statsInterval = setInterval(() => this.updateStats(), 5e3);
|
|
1144
|
+
this.cluster.on("memberJoined", () => this.updateClusterMap());
|
|
1145
|
+
this.cluster.on("memberLeft", () => this.updateClusterMap());
|
|
1146
|
+
this.updateClusterMap();
|
|
1147
|
+
this.updateStats();
|
|
1148
|
+
}
|
|
1149
|
+
stop() {
|
|
1150
|
+
if (this.statsInterval) {
|
|
1151
|
+
clearInterval(this.statsInterval);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
notifyMapCreated(mapName) {
|
|
1155
|
+
if (mapName.startsWith("$sys/")) return;
|
|
1156
|
+
this.updateMapsMap(mapName);
|
|
1157
|
+
}
|
|
1158
|
+
setupClusterMap() {
|
|
1159
|
+
this.getMap("$sys/cluster");
|
|
1160
|
+
}
|
|
1161
|
+
setupStatsMap() {
|
|
1162
|
+
this.getMap("$sys/stats");
|
|
1163
|
+
}
|
|
1164
|
+
setupMapsMap() {
|
|
1165
|
+
this.getMap("$sys/maps");
|
|
1166
|
+
}
|
|
1167
|
+
updateClusterMap() {
|
|
1168
|
+
try {
|
|
1169
|
+
const map = this.getMap("$sys/cluster");
|
|
1170
|
+
const members = this.cluster.getMembers();
|
|
1171
|
+
for (const memberId of members) {
|
|
1172
|
+
const isLocal = this.cluster.isLocal(memberId);
|
|
1173
|
+
map.set(memberId, {
|
|
1174
|
+
id: memberId,
|
|
1175
|
+
status: "UP",
|
|
1176
|
+
isLocal,
|
|
1177
|
+
lastUpdated: Date.now()
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
} catch (err) {
|
|
1181
|
+
logger.error({ err }, "Failed to update $sys/cluster");
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
async updateStats() {
|
|
1185
|
+
try {
|
|
1186
|
+
const map = this.getMap("$sys/stats");
|
|
1187
|
+
const metrics = await this.metrics.getMetricsJson();
|
|
1188
|
+
map.set(this.cluster.config.nodeId, {
|
|
1189
|
+
...metrics,
|
|
1190
|
+
timestamp: Date.now()
|
|
1191
|
+
});
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
logger.error({ err }, "Failed to update $sys/stats");
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
updateMapsMap(mapName) {
|
|
1197
|
+
try {
|
|
1198
|
+
const map = this.getMap("$sys/maps");
|
|
1199
|
+
map.set(mapName, {
|
|
1200
|
+
name: mapName,
|
|
1201
|
+
createdAt: Date.now()
|
|
1202
|
+
});
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
logger.error({ err }, "Failed to update $sys/maps");
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
// src/ServerCoordinator.ts
|
|
1210
|
+
var GC_INTERVAL_MS = 60 * 60 * 1e3;
|
|
1211
|
+
var GC_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
1212
|
+
var ServerCoordinator = class {
|
|
1213
|
+
constructor(config) {
|
|
1214
|
+
this.clients = /* @__PURE__ */ new Map();
|
|
1215
|
+
// Interceptors
|
|
1216
|
+
this.interceptors = [];
|
|
1217
|
+
// In-memory storage (partitioned later)
|
|
1218
|
+
this.maps = /* @__PURE__ */ new Map();
|
|
1219
|
+
this.pendingClusterQueries = /* @__PURE__ */ new Map();
|
|
1220
|
+
// GC Consensus State
|
|
1221
|
+
this.gcReports = /* @__PURE__ */ new Map();
|
|
1222
|
+
// Track map loading state to avoid returning empty results during async load
|
|
1223
|
+
this.mapLoadingPromises = /* @__PURE__ */ new Map();
|
|
1224
|
+
this._actualPort = 0;
|
|
1225
|
+
this._actualClusterPort = 0;
|
|
1226
|
+
this._readyPromise = new Promise((resolve) => {
|
|
1227
|
+
this._readyResolve = resolve;
|
|
1228
|
+
});
|
|
1229
|
+
this.hlc = new HLC(config.nodeId);
|
|
1230
|
+
this.storage = config.storage;
|
|
1231
|
+
const rawSecret = config.jwtSecret || process.env.JWT_SECRET || "topgun-secret-dev";
|
|
1232
|
+
this.jwtSecret = rawSecret.replace(/\\n/g, "\n");
|
|
1233
|
+
this.queryRegistry = new QueryRegistry();
|
|
1234
|
+
this.securityManager = new SecurityManager(config.securityPolicies || []);
|
|
1235
|
+
this.interceptors = config.interceptors || [];
|
|
1236
|
+
this.metricsService = new MetricsService();
|
|
1237
|
+
if (config.tls?.enabled) {
|
|
1238
|
+
const tlsOptions = this.buildTLSOptions(config.tls);
|
|
1239
|
+
this.httpServer = createHttpsServer(tlsOptions, (_req, res) => {
|
|
1240
|
+
res.writeHead(200);
|
|
1241
|
+
res.end("TopGun Server Running (Secure)");
|
|
1242
|
+
});
|
|
1243
|
+
logger.info("TLS enabled for client connections");
|
|
1244
|
+
} else {
|
|
1245
|
+
this.httpServer = createHttpServer((_req, res) => {
|
|
1246
|
+
res.writeHead(200);
|
|
1247
|
+
res.end("TopGun Server Running");
|
|
1248
|
+
});
|
|
1249
|
+
if (process.env.NODE_ENV === "production") {
|
|
1250
|
+
logger.warn("\u26A0\uFE0F TLS is disabled! Client connections are NOT encrypted.");
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const metricsPort = config.metricsPort !== void 0 ? config.metricsPort : 9090;
|
|
1254
|
+
this.metricsServer = createHttpServer(async (req, res) => {
|
|
1255
|
+
if (req.url === "/metrics") {
|
|
1256
|
+
try {
|
|
1257
|
+
res.setHeader("Content-Type", this.metricsService.getContentType());
|
|
1258
|
+
res.end(await this.metricsService.getMetrics());
|
|
1259
|
+
} catch (err) {
|
|
1260
|
+
res.statusCode = 500;
|
|
1261
|
+
res.end("Internal Server Error");
|
|
1262
|
+
}
|
|
1263
|
+
} else {
|
|
1264
|
+
res.statusCode = 404;
|
|
1265
|
+
res.end();
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
this.metricsServer.listen(metricsPort, () => {
|
|
1269
|
+
logger.info({ port: metricsPort }, "Metrics server listening");
|
|
1270
|
+
});
|
|
1271
|
+
this.metricsServer.on("error", (err) => {
|
|
1272
|
+
logger.error({ err, port: metricsPort }, "Metrics server failed to start");
|
|
1273
|
+
});
|
|
1274
|
+
this.wss = new WebSocketServer2({ server: this.httpServer });
|
|
1275
|
+
this.wss.on("connection", (ws) => this.handleConnection(ws));
|
|
1276
|
+
this.httpServer.listen(config.port, () => {
|
|
1277
|
+
const addr = this.httpServer.address();
|
|
1278
|
+
this._actualPort = typeof addr === "object" && addr ? addr.port : config.port;
|
|
1279
|
+
logger.info({ port: this._actualPort }, "Server Coordinator listening");
|
|
1280
|
+
const clusterPort = config.clusterPort ?? 0;
|
|
1281
|
+
const peers = config.resolvePeers ? config.resolvePeers() : config.peers || [];
|
|
1282
|
+
this.cluster = new ClusterManager({
|
|
1283
|
+
nodeId: config.nodeId,
|
|
1284
|
+
host: config.host || "localhost",
|
|
1285
|
+
port: clusterPort,
|
|
1286
|
+
peers,
|
|
1287
|
+
discovery: config.discovery,
|
|
1288
|
+
serviceName: config.serviceName,
|
|
1289
|
+
discoveryInterval: config.discoveryInterval,
|
|
1290
|
+
tls: config.clusterTls
|
|
1291
|
+
});
|
|
1292
|
+
this.partitionService = new PartitionService(this.cluster);
|
|
1293
|
+
this.lockManager = new LockManager();
|
|
1294
|
+
this.lockManager.on("lockGranted", (evt) => this.handleLockGranted(evt));
|
|
1295
|
+
this.topicManager = new TopicManager({
|
|
1296
|
+
cluster: this.cluster,
|
|
1297
|
+
sendToClient: (clientId, message) => {
|
|
1298
|
+
const client = this.clients.get(clientId);
|
|
1299
|
+
if (client && client.socket.readyState === WebSocket2.OPEN) {
|
|
1300
|
+
client.socket.send(serialize2(message));
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
this.systemManager = new SystemManager(
|
|
1305
|
+
this.cluster,
|
|
1306
|
+
this.metricsService,
|
|
1307
|
+
(name) => this.getMap(name)
|
|
1308
|
+
);
|
|
1309
|
+
this.setupClusterListeners();
|
|
1310
|
+
this.cluster.start().then((actualClusterPort) => {
|
|
1311
|
+
this._actualClusterPort = actualClusterPort;
|
|
1312
|
+
this.metricsService.setClusterMembers(this.cluster.getMembers().length);
|
|
1313
|
+
logger.info({ clusterPort: this._actualClusterPort }, "Cluster started");
|
|
1314
|
+
this.systemManager.start();
|
|
1315
|
+
this._readyResolve();
|
|
1316
|
+
}).catch((err) => {
|
|
1317
|
+
this._actualClusterPort = clusterPort;
|
|
1318
|
+
this.metricsService.setClusterMembers(this.cluster.getMembers().length);
|
|
1319
|
+
logger.info({ clusterPort: this._actualClusterPort }, "Cluster started (sync)");
|
|
1320
|
+
this.systemManager.start();
|
|
1321
|
+
this._readyResolve();
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
if (this.storage) {
|
|
1325
|
+
this.storage.initialize().then(() => {
|
|
1326
|
+
logger.info("Storage adapter initialized");
|
|
1327
|
+
}).catch((err) => {
|
|
1328
|
+
logger.error({ err }, "Failed to initialize storage");
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
this.startGarbageCollection();
|
|
1332
|
+
}
|
|
1333
|
+
/** Wait for server to be fully ready (ports assigned) */
|
|
1334
|
+
ready() {
|
|
1335
|
+
return this._readyPromise;
|
|
1336
|
+
}
|
|
1337
|
+
/** Get the actual port the server is listening on */
|
|
1338
|
+
get port() {
|
|
1339
|
+
return this._actualPort;
|
|
1340
|
+
}
|
|
1341
|
+
/** Get the actual cluster port */
|
|
1342
|
+
get clusterPort() {
|
|
1343
|
+
return this._actualClusterPort;
|
|
1344
|
+
}
|
|
1345
|
+
async shutdown() {
|
|
1346
|
+
logger.info("Shutting down Server Coordinator...");
|
|
1347
|
+
this.httpServer.close();
|
|
1348
|
+
if (this.metricsServer) {
|
|
1349
|
+
this.metricsServer.close();
|
|
1350
|
+
}
|
|
1351
|
+
this.metricsService.destroy();
|
|
1352
|
+
this.wss.close();
|
|
1353
|
+
logger.info(`Closing ${this.clients.size} client connections...`);
|
|
1354
|
+
const shutdownMsg = serialize2({ type: "SHUTDOWN_PENDING", retryAfter: 5e3 });
|
|
1355
|
+
for (const client of this.clients.values()) {
|
|
1356
|
+
try {
|
|
1357
|
+
if (client.socket.readyState === WebSocket2.OPEN) {
|
|
1358
|
+
client.socket.send(shutdownMsg);
|
|
1359
|
+
client.socket.close(1001, "Server Shutdown");
|
|
1360
|
+
}
|
|
1361
|
+
} catch (e) {
|
|
1362
|
+
logger.error({ err: e, clientId: client.id }, "Error closing client connection");
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
this.clients.clear();
|
|
1366
|
+
if (this.cluster) {
|
|
1367
|
+
this.cluster.stop();
|
|
1368
|
+
}
|
|
1369
|
+
if (this.storage) {
|
|
1370
|
+
logger.info("Closing storage connection...");
|
|
1371
|
+
try {
|
|
1372
|
+
await this.storage.close();
|
|
1373
|
+
logger.info("Storage closed successfully.");
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
logger.error({ err }, "Error closing storage");
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
if (this.gcInterval) {
|
|
1379
|
+
clearInterval(this.gcInterval);
|
|
1380
|
+
this.gcInterval = void 0;
|
|
1381
|
+
}
|
|
1382
|
+
if (this.lockManager) {
|
|
1383
|
+
this.lockManager.stop();
|
|
1384
|
+
}
|
|
1385
|
+
if (this.systemManager) {
|
|
1386
|
+
this.systemManager.stop();
|
|
1387
|
+
}
|
|
1388
|
+
logger.info("Server Coordinator shutdown complete.");
|
|
1389
|
+
}
|
|
1390
|
+
async handleConnection(ws) {
|
|
1391
|
+
const clientId = crypto.randomUUID();
|
|
1392
|
+
logger.info({ clientId }, "Client connected (pending auth)");
|
|
1393
|
+
const connection = {
|
|
1394
|
+
id: clientId,
|
|
1395
|
+
socket: ws,
|
|
1396
|
+
isAuthenticated: false,
|
|
1397
|
+
subscriptions: /* @__PURE__ */ new Set(),
|
|
1398
|
+
lastActiveHlc: this.hlc.now()
|
|
1399
|
+
// Initialize with current time
|
|
1400
|
+
};
|
|
1401
|
+
this.clients.set(clientId, connection);
|
|
1402
|
+
this.metricsService.setConnectedClients(this.clients.size);
|
|
1403
|
+
try {
|
|
1404
|
+
const context = {
|
|
1405
|
+
clientId: connection.id,
|
|
1406
|
+
socket: connection.socket,
|
|
1407
|
+
isAuthenticated: connection.isAuthenticated,
|
|
1408
|
+
principal: connection.principal
|
|
1409
|
+
};
|
|
1410
|
+
for (const interceptor of this.interceptors) {
|
|
1411
|
+
if (interceptor.onConnection) {
|
|
1412
|
+
await interceptor.onConnection(context);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
logger.error({ clientId, err }, "Interceptor rejected connection");
|
|
1417
|
+
ws.close(4e3, "Connection Rejected");
|
|
1418
|
+
this.clients.delete(clientId);
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
ws.on("message", (message) => {
|
|
1422
|
+
try {
|
|
1423
|
+
let data;
|
|
1424
|
+
let buf;
|
|
1425
|
+
if (Buffer.isBuffer(message)) {
|
|
1426
|
+
buf = message;
|
|
1427
|
+
} else if (message instanceof ArrayBuffer) {
|
|
1428
|
+
buf = new Uint8Array(message);
|
|
1429
|
+
} else if (Array.isArray(message)) {
|
|
1430
|
+
buf = Buffer.concat(message);
|
|
1431
|
+
} else {
|
|
1432
|
+
buf = Buffer.from(message);
|
|
1433
|
+
}
|
|
1434
|
+
try {
|
|
1435
|
+
data = deserialize(buf);
|
|
1436
|
+
} catch (e) {
|
|
1437
|
+
try {
|
|
1438
|
+
const text = Buffer.isBuffer(buf) ? buf.toString() : new TextDecoder().decode(buf);
|
|
1439
|
+
data = JSON.parse(text);
|
|
1440
|
+
} catch (jsonErr) {
|
|
1441
|
+
throw e;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
this.handleMessage(connection, data);
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
logger.error({ err }, "Invalid message format");
|
|
1447
|
+
ws.close(1002, "Protocol Error");
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
ws.on("close", () => {
|
|
1451
|
+
logger.info({ clientId }, "Client disconnected");
|
|
1452
|
+
const context = {
|
|
1453
|
+
clientId: connection.id,
|
|
1454
|
+
socket: connection.socket,
|
|
1455
|
+
isAuthenticated: connection.isAuthenticated,
|
|
1456
|
+
principal: connection.principal
|
|
1457
|
+
};
|
|
1458
|
+
for (const interceptor of this.interceptors) {
|
|
1459
|
+
if (interceptor.onDisconnect) {
|
|
1460
|
+
interceptor.onDisconnect(context).catch((err) => {
|
|
1461
|
+
logger.error({ clientId, err }, "Error in onDisconnect interceptor");
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
for (const subId of connection.subscriptions) {
|
|
1466
|
+
this.queryRegistry.unregister(subId);
|
|
1467
|
+
}
|
|
1468
|
+
this.lockManager.handleClientDisconnect(clientId);
|
|
1469
|
+
this.topicManager.unsubscribeAll(clientId);
|
|
1470
|
+
const members = this.cluster.getMembers();
|
|
1471
|
+
for (const memberId of members) {
|
|
1472
|
+
if (!this.cluster.isLocal(memberId)) {
|
|
1473
|
+
this.cluster.send(memberId, "CLUSTER_CLIENT_DISCONNECTED", {
|
|
1474
|
+
originNodeId: this.cluster.config.nodeId,
|
|
1475
|
+
clientId
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
this.clients.delete(clientId);
|
|
1480
|
+
this.metricsService.setConnectedClients(this.clients.size);
|
|
1481
|
+
});
|
|
1482
|
+
ws.send(serialize2({ type: "AUTH_REQUIRED" }));
|
|
1483
|
+
}
|
|
1484
|
+
async handleMessage(client, rawMessage) {
|
|
1485
|
+
const parseResult = MessageSchema.safeParse(rawMessage);
|
|
1486
|
+
if (!parseResult.success) {
|
|
1487
|
+
logger.error({ clientId: client.id, error: parseResult.error }, "Invalid message format from client");
|
|
1488
|
+
client.socket.send(serialize2({
|
|
1489
|
+
type: "ERROR",
|
|
1490
|
+
payload: { code: 400, message: "Invalid message format", details: parseResult.error.errors }
|
|
1491
|
+
}));
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const message = parseResult.data;
|
|
1495
|
+
this.updateClientHlc(client, message);
|
|
1496
|
+
if (!client.isAuthenticated) {
|
|
1497
|
+
if (message.type === "AUTH") {
|
|
1498
|
+
const token = message.token;
|
|
1499
|
+
try {
|
|
1500
|
+
const isRSAKey = this.jwtSecret.includes("-----BEGIN");
|
|
1501
|
+
const verifyOptions = isRSAKey ? { algorithms: ["RS256"] } : { algorithms: ["HS256"] };
|
|
1502
|
+
const decoded = jwt.verify(token, this.jwtSecret, verifyOptions);
|
|
1503
|
+
if (!decoded.roles) {
|
|
1504
|
+
decoded.roles = ["USER"];
|
|
1505
|
+
}
|
|
1506
|
+
if (!decoded.userId && decoded.sub) {
|
|
1507
|
+
decoded.userId = decoded.sub;
|
|
1508
|
+
}
|
|
1509
|
+
client.principal = decoded;
|
|
1510
|
+
client.isAuthenticated = true;
|
|
1511
|
+
logger.info({ clientId: client.id, user: client.principal.userId || "anon" }, "Client authenticated");
|
|
1512
|
+
client.socket.send(serialize2({ type: "AUTH_ACK" }));
|
|
1513
|
+
return;
|
|
1514
|
+
} catch (e) {
|
|
1515
|
+
logger.error({ clientId: client.id, err: e }, "Auth failed");
|
|
1516
|
+
client.socket.send(serialize2({ type: "AUTH_FAIL", error: "Invalid token" }));
|
|
1517
|
+
client.socket.close(4001, "Unauthorized");
|
|
1518
|
+
}
|
|
1519
|
+
} else {
|
|
1520
|
+
client.socket.close(4001, "Auth required");
|
|
1521
|
+
}
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
switch (message.type) {
|
|
1525
|
+
case "QUERY_SUB": {
|
|
1526
|
+
const { queryId, mapName, query } = message.payload;
|
|
1527
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "READ")) {
|
|
1528
|
+
logger.warn({ clientId: client.id, mapName }, "Access Denied: QUERY_SUB");
|
|
1529
|
+
client.socket.send(serialize2({
|
|
1530
|
+
type: "ERROR",
|
|
1531
|
+
payload: { code: 403, message: `Access Denied for map ${mapName}` }
|
|
1532
|
+
}));
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
logger.info({ clientId: client.id, mapName, query }, "Client subscribed");
|
|
1536
|
+
this.metricsService.incOp("SUBSCRIBE", mapName);
|
|
1537
|
+
const allMembers = this.cluster.getMembers();
|
|
1538
|
+
const remoteMembers = allMembers.filter((id) => !this.cluster.isLocal(id));
|
|
1539
|
+
const requestId = crypto.randomUUID();
|
|
1540
|
+
const pending = {
|
|
1541
|
+
requestId,
|
|
1542
|
+
client,
|
|
1543
|
+
queryId,
|
|
1544
|
+
mapName,
|
|
1545
|
+
query,
|
|
1546
|
+
results: [],
|
|
1547
|
+
// Will populate with local results first
|
|
1548
|
+
expectedNodes: new Set(remoteMembers),
|
|
1549
|
+
respondedNodes: /* @__PURE__ */ new Set(),
|
|
1550
|
+
timer: setTimeout(() => this.finalizeClusterQuery(requestId, true), 5e3)
|
|
1551
|
+
// 5s timeout
|
|
1552
|
+
};
|
|
1553
|
+
this.pendingClusterQueries.set(requestId, pending);
|
|
1554
|
+
try {
|
|
1555
|
+
const localResults = await this.executeLocalQuery(mapName, query);
|
|
1556
|
+
pending.results.push(...localResults);
|
|
1557
|
+
if (remoteMembers.length > 0) {
|
|
1558
|
+
for (const nodeId of remoteMembers) {
|
|
1559
|
+
this.cluster.send(nodeId, "CLUSTER_QUERY_EXEC", {
|
|
1560
|
+
requestId,
|
|
1561
|
+
mapName,
|
|
1562
|
+
query
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
} else {
|
|
1566
|
+
this.finalizeClusterQuery(requestId);
|
|
1567
|
+
}
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
logger.error({ err, mapName }, "Failed to execute local query");
|
|
1570
|
+
this.finalizeClusterQuery(requestId);
|
|
1571
|
+
}
|
|
1572
|
+
break;
|
|
1573
|
+
}
|
|
1574
|
+
case "QUERY_UNSUB": {
|
|
1575
|
+
const { queryId: unsubId } = message.payload;
|
|
1576
|
+
this.queryRegistry.unregister(unsubId);
|
|
1577
|
+
client.subscriptions.delete(unsubId);
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
case "CLIENT_OP": {
|
|
1581
|
+
const op = message.payload;
|
|
1582
|
+
const isRemove = op.opType === "REMOVE" || op.record && op.record.value === null;
|
|
1583
|
+
const action = isRemove ? "REMOVE" : "PUT";
|
|
1584
|
+
this.metricsService.incOp(isRemove ? "DELETE" : "PUT", op.mapName);
|
|
1585
|
+
if (!this.securityManager.checkPermission(client.principal, op.mapName, action)) {
|
|
1586
|
+
logger.warn({ clientId: client.id, action, mapName: op.mapName }, "Access Denied: Client OP");
|
|
1587
|
+
client.socket.send(serialize2({
|
|
1588
|
+
type: "OP_REJECTED",
|
|
1589
|
+
payload: { opId: op.id, reason: "Access Denied" }
|
|
1590
|
+
}));
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
logger.info({ clientId: client.id, opType: op.opType, key: op.key, mapName: op.mapName }, "Received op");
|
|
1594
|
+
if (this.partitionService.isLocalOwner(op.key)) {
|
|
1595
|
+
this.processLocalOp(op, false, client.id).catch((err) => {
|
|
1596
|
+
logger.error({ clientId: client.id, err }, "Op failed");
|
|
1597
|
+
client.socket.send(serialize2({
|
|
1598
|
+
type: "OP_REJECTED",
|
|
1599
|
+
payload: { opId: op.id, reason: err.message || "Internal Error" }
|
|
1600
|
+
}));
|
|
1601
|
+
});
|
|
1602
|
+
} else {
|
|
1603
|
+
const owner = this.partitionService.getOwner(op.key);
|
|
1604
|
+
logger.info({ key: op.key, owner }, "Forwarding op");
|
|
1605
|
+
this.cluster.sendToNode(owner, op);
|
|
1606
|
+
}
|
|
1607
|
+
break;
|
|
1608
|
+
}
|
|
1609
|
+
case "OP_BATCH": {
|
|
1610
|
+
const ops = message.payload.ops;
|
|
1611
|
+
logger.info({ clientId: client.id, count: ops.length }, "Received batch");
|
|
1612
|
+
let lastProcessedId = null;
|
|
1613
|
+
let rejectedCount = 0;
|
|
1614
|
+
for (const op of ops) {
|
|
1615
|
+
const isRemove = op.opType === "REMOVE" || op.record && op.record.value === null;
|
|
1616
|
+
const action = isRemove ? "REMOVE" : "PUT";
|
|
1617
|
+
if (!this.securityManager.checkPermission(client.principal, op.mapName, action)) {
|
|
1618
|
+
rejectedCount++;
|
|
1619
|
+
logger.warn({ clientId: client.id, action, mapName: op.mapName }, "Access Denied (Batch)");
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
if (this.partitionService.isLocalOwner(op.key)) {
|
|
1623
|
+
try {
|
|
1624
|
+
await this.processLocalOp({
|
|
1625
|
+
mapName: op.mapName,
|
|
1626
|
+
key: op.key,
|
|
1627
|
+
record: op.record,
|
|
1628
|
+
orRecord: op.orRecord,
|
|
1629
|
+
orTag: op.orTag,
|
|
1630
|
+
opType: op.opType
|
|
1631
|
+
}, false, client.id);
|
|
1632
|
+
if (op.id) {
|
|
1633
|
+
lastProcessedId = op.id;
|
|
1634
|
+
}
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
rejectedCount++;
|
|
1637
|
+
logger.warn({ clientId: client.id, mapName: op.mapName, err }, "Op rejected in batch");
|
|
1638
|
+
}
|
|
1639
|
+
} else {
|
|
1640
|
+
const owner = this.partitionService.getOwner(op.key);
|
|
1641
|
+
this.cluster.sendToNode(owner, {
|
|
1642
|
+
type: "CLIENT_OP",
|
|
1643
|
+
payload: {
|
|
1644
|
+
mapName: op.mapName,
|
|
1645
|
+
key: op.key,
|
|
1646
|
+
record: op.record,
|
|
1647
|
+
orRecord: op.orRecord,
|
|
1648
|
+
orTag: op.orTag,
|
|
1649
|
+
opType: op.opType
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
if (op.id) {
|
|
1653
|
+
lastProcessedId = op.id;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
if (lastProcessedId !== null) {
|
|
1658
|
+
client.socket.send(serialize2({
|
|
1659
|
+
type: "OP_ACK",
|
|
1660
|
+
payload: { lastId: lastProcessedId }
|
|
1661
|
+
}));
|
|
1662
|
+
}
|
|
1663
|
+
if (rejectedCount > 0) {
|
|
1664
|
+
client.socket.send(serialize2({
|
|
1665
|
+
type: "ERROR",
|
|
1666
|
+
payload: { code: 403, message: `Partial batch failure: ${rejectedCount} ops denied` }
|
|
1667
|
+
}));
|
|
1668
|
+
}
|
|
1669
|
+
break;
|
|
1670
|
+
}
|
|
1671
|
+
case "SYNC_INIT": {
|
|
1672
|
+
if (!this.securityManager.checkPermission(client.principal, message.mapName, "READ")) {
|
|
1673
|
+
logger.warn({ clientId: client.id, mapName: message.mapName }, "Access Denied: SYNC_INIT");
|
|
1674
|
+
client.socket.send(serialize2({
|
|
1675
|
+
type: "ERROR",
|
|
1676
|
+
payload: { code: 403, message: `Access Denied for map ${message.mapName}` }
|
|
1677
|
+
}));
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
const lastSync = message.lastSyncTimestamp || 0;
|
|
1681
|
+
const now = Date.now();
|
|
1682
|
+
if (lastSync > 0 && now - lastSync > GC_AGE_MS) {
|
|
1683
|
+
logger.warn({ clientId: client.id, lastSync, age: now - lastSync }, "Client too old, sending SYNC_RESET_REQUIRED");
|
|
1684
|
+
client.socket.send(serialize2({
|
|
1685
|
+
type: "SYNC_RESET_REQUIRED",
|
|
1686
|
+
payload: { mapName: message.mapName }
|
|
1687
|
+
}));
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
logger.info({ clientId: client.id, mapName: message.mapName }, "Client requested sync");
|
|
1691
|
+
this.metricsService.incOp("GET", message.mapName);
|
|
1692
|
+
try {
|
|
1693
|
+
const mapForSync = await this.getMapAsync(message.mapName);
|
|
1694
|
+
if (mapForSync instanceof LWWMap2) {
|
|
1695
|
+
const tree = mapForSync.getMerkleTree();
|
|
1696
|
+
const rootHash = tree.getRootHash();
|
|
1697
|
+
client.socket.send(serialize2({
|
|
1698
|
+
type: "SYNC_RESP_ROOT",
|
|
1699
|
+
payload: {
|
|
1700
|
+
mapName: message.mapName,
|
|
1701
|
+
rootHash,
|
|
1702
|
+
timestamp: this.hlc.now()
|
|
1703
|
+
}
|
|
1704
|
+
}));
|
|
1705
|
+
} else {
|
|
1706
|
+
logger.warn({ mapName: message.mapName }, "SYNC_INIT requested for ORMap - Not Implemented");
|
|
1707
|
+
client.socket.send(serialize2({
|
|
1708
|
+
type: "ERROR",
|
|
1709
|
+
payload: { code: 501, message: `Merkle Sync not supported for ORMap ${message.mapName}` }
|
|
1710
|
+
}));
|
|
1711
|
+
}
|
|
1712
|
+
} catch (err) {
|
|
1713
|
+
logger.error({ err, mapName: message.mapName }, "Failed to load map for SYNC_INIT");
|
|
1714
|
+
client.socket.send(serialize2({
|
|
1715
|
+
type: "ERROR",
|
|
1716
|
+
payload: { code: 500, message: `Failed to load map ${message.mapName}` }
|
|
1717
|
+
}));
|
|
1718
|
+
}
|
|
1719
|
+
break;
|
|
1720
|
+
}
|
|
1721
|
+
case "MERKLE_REQ_BUCKET": {
|
|
1722
|
+
if (!this.securityManager.checkPermission(client.principal, message.payload.mapName, "READ")) {
|
|
1723
|
+
client.socket.send(serialize2({
|
|
1724
|
+
type: "ERROR",
|
|
1725
|
+
payload: { code: 403, message: `Access Denied for map ${message.payload.mapName}` }
|
|
1726
|
+
}));
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
const { mapName, path } = message.payload;
|
|
1730
|
+
try {
|
|
1731
|
+
const mapForBucket = await this.getMapAsync(mapName);
|
|
1732
|
+
if (mapForBucket instanceof LWWMap2) {
|
|
1733
|
+
const treeForBucket = mapForBucket.getMerkleTree();
|
|
1734
|
+
const buckets = treeForBucket.getBuckets(path);
|
|
1735
|
+
const node = treeForBucket.getNode(path);
|
|
1736
|
+
if (node && node.entries && node.entries.size > 0) {
|
|
1737
|
+
const diffRecords = [];
|
|
1738
|
+
for (const key of node.entries.keys()) {
|
|
1739
|
+
diffRecords.push({ key, record: mapForBucket.getRecord(key) });
|
|
1740
|
+
}
|
|
1741
|
+
client.socket.send(serialize2({
|
|
1742
|
+
type: "SYNC_RESP_LEAF",
|
|
1743
|
+
payload: { mapName, path, records: diffRecords }
|
|
1744
|
+
}));
|
|
1745
|
+
} else {
|
|
1746
|
+
client.socket.send(serialize2({
|
|
1747
|
+
type: "SYNC_RESP_BUCKETS",
|
|
1748
|
+
payload: { mapName, path, buckets }
|
|
1749
|
+
}));
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
} catch (err) {
|
|
1753
|
+
logger.error({ err, mapName }, "Failed to load map for MERKLE_REQ_BUCKET");
|
|
1754
|
+
}
|
|
1755
|
+
break;
|
|
1756
|
+
}
|
|
1757
|
+
case "LOCK_REQUEST": {
|
|
1758
|
+
const { requestId, name, ttl } = message.payload;
|
|
1759
|
+
if (!this.securityManager.checkPermission(client.principal, name, "PUT")) {
|
|
1760
|
+
client.socket.send(serialize2({
|
|
1761
|
+
// We don't have LOCK_DENIED type in schema yet?
|
|
1762
|
+
// Using LOCK_RELEASED with success=false as a hack or ERROR.
|
|
1763
|
+
// Ideally ERROR.
|
|
1764
|
+
type: "ERROR",
|
|
1765
|
+
payload: { code: 403, message: `Access Denied for lock ${name}` }
|
|
1766
|
+
}));
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
if (this.partitionService.isLocalOwner(name)) {
|
|
1770
|
+
const result = this.lockManager.acquire(name, client.id, requestId, ttl || 1e4);
|
|
1771
|
+
if (result.granted) {
|
|
1772
|
+
client.socket.send(serialize2({
|
|
1773
|
+
type: "LOCK_GRANTED",
|
|
1774
|
+
payload: { requestId, name, fencingToken: result.fencingToken }
|
|
1775
|
+
}));
|
|
1776
|
+
}
|
|
1777
|
+
} else {
|
|
1778
|
+
const owner = this.partitionService.getOwner(name);
|
|
1779
|
+
if (!this.cluster.getMembers().includes(owner)) {
|
|
1780
|
+
client.socket.send(serialize2({
|
|
1781
|
+
type: "ERROR",
|
|
1782
|
+
payload: { code: 503, message: `Lock owner ${owner} is unavailable` }
|
|
1783
|
+
}));
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
this.cluster.send(owner, "CLUSTER_LOCK_REQ", {
|
|
1787
|
+
originNodeId: this.cluster.config.nodeId,
|
|
1788
|
+
clientId: client.id,
|
|
1789
|
+
requestId,
|
|
1790
|
+
name,
|
|
1791
|
+
ttl
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
break;
|
|
1795
|
+
}
|
|
1796
|
+
case "LOCK_RELEASE": {
|
|
1797
|
+
const { requestId, name, fencingToken } = message.payload;
|
|
1798
|
+
if (this.partitionService.isLocalOwner(name)) {
|
|
1799
|
+
const success = this.lockManager.release(name, client.id, fencingToken);
|
|
1800
|
+
client.socket.send(serialize2({
|
|
1801
|
+
type: "LOCK_RELEASED",
|
|
1802
|
+
payload: { requestId, name, success }
|
|
1803
|
+
}));
|
|
1804
|
+
} else {
|
|
1805
|
+
const owner = this.partitionService.getOwner(name);
|
|
1806
|
+
this.cluster.send(owner, "CLUSTER_LOCK_RELEASE", {
|
|
1807
|
+
originNodeId: this.cluster.config.nodeId,
|
|
1808
|
+
clientId: client.id,
|
|
1809
|
+
requestId,
|
|
1810
|
+
name,
|
|
1811
|
+
fencingToken
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
break;
|
|
1815
|
+
}
|
|
1816
|
+
case "TOPIC_SUB": {
|
|
1817
|
+
const { topic } = message.payload;
|
|
1818
|
+
if (!this.securityManager.checkPermission(client.principal, `topic:${topic}`, "READ")) {
|
|
1819
|
+
logger.warn({ clientId: client.id, topic }, "Access Denied: TOPIC_SUB");
|
|
1820
|
+
client.socket.send(serialize2({
|
|
1821
|
+
type: "ERROR",
|
|
1822
|
+
payload: { code: 403, message: `Access Denied for topic ${topic}` }
|
|
1823
|
+
}));
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
try {
|
|
1827
|
+
this.topicManager.subscribe(client.id, topic);
|
|
1828
|
+
} catch (e) {
|
|
1829
|
+
client.socket.send(serialize2({
|
|
1830
|
+
type: "ERROR",
|
|
1831
|
+
payload: { code: 400, message: e.message }
|
|
1832
|
+
}));
|
|
1833
|
+
}
|
|
1834
|
+
break;
|
|
1835
|
+
}
|
|
1836
|
+
case "TOPIC_UNSUB": {
|
|
1837
|
+
const { topic } = message.payload;
|
|
1838
|
+
this.topicManager.unsubscribe(client.id, topic);
|
|
1839
|
+
break;
|
|
1840
|
+
}
|
|
1841
|
+
case "TOPIC_PUB": {
|
|
1842
|
+
const { topic, data } = message.payload;
|
|
1843
|
+
if (!this.securityManager.checkPermission(client.principal, `topic:${topic}`, "PUT")) {
|
|
1844
|
+
logger.warn({ clientId: client.id, topic }, "Access Denied: TOPIC_PUB");
|
|
1845
|
+
client.socket.send(serialize2({
|
|
1846
|
+
type: "ERROR",
|
|
1847
|
+
payload: { code: 403, message: `Access Denied for topic ${topic}` }
|
|
1848
|
+
}));
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
try {
|
|
1852
|
+
this.topicManager.publish(topic, data, client.id);
|
|
1853
|
+
} catch (e) {
|
|
1854
|
+
client.socket.send(serialize2({
|
|
1855
|
+
type: "ERROR",
|
|
1856
|
+
payload: { code: 400, message: e.message }
|
|
1857
|
+
}));
|
|
1858
|
+
}
|
|
1859
|
+
break;
|
|
1860
|
+
}
|
|
1861
|
+
default:
|
|
1862
|
+
logger.warn({ type: message.type }, "Unknown message type");
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
updateClientHlc(client, message) {
|
|
1866
|
+
let ts;
|
|
1867
|
+
if (message.type === "CLIENT_OP") {
|
|
1868
|
+
const op = message.payload;
|
|
1869
|
+
if (op.record && op.record.timestamp) {
|
|
1870
|
+
ts = op.record.timestamp;
|
|
1871
|
+
} else if (op.orRecord && op.orRecord.timestamp) {
|
|
1872
|
+
} else if (op.orTag) {
|
|
1873
|
+
try {
|
|
1874
|
+
ts = HLC.parse(op.orTag);
|
|
1875
|
+
} catch (e) {
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
if (ts) {
|
|
1880
|
+
this.hlc.update(ts);
|
|
1881
|
+
client.lastActiveHlc = ts;
|
|
1882
|
+
} else {
|
|
1883
|
+
client.lastActiveHlc = this.hlc.now();
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
broadcast(message, excludeClientId) {
|
|
1887
|
+
const isServerEvent = message.type === "SERVER_EVENT";
|
|
1888
|
+
if (isServerEvent) {
|
|
1889
|
+
for (const [id, client] of this.clients) {
|
|
1890
|
+
if (id !== excludeClientId && client.socket.readyState === 1 && client.isAuthenticated && client.principal) {
|
|
1891
|
+
const payload = message.payload;
|
|
1892
|
+
const mapName = payload.mapName;
|
|
1893
|
+
const newPayload = { ...payload };
|
|
1894
|
+
if (newPayload.record) {
|
|
1895
|
+
const newVal = this.securityManager.filterObject(newPayload.record.value, client.principal, mapName);
|
|
1896
|
+
newPayload.record = { ...newPayload.record, value: newVal };
|
|
1897
|
+
}
|
|
1898
|
+
if (newPayload.orRecord) {
|
|
1899
|
+
const newVal = this.securityManager.filterObject(newPayload.orRecord.value, client.principal, mapName);
|
|
1900
|
+
newPayload.orRecord = { ...newPayload.orRecord, value: newVal };
|
|
1901
|
+
}
|
|
1902
|
+
client.socket.send(serialize2({ ...message, payload: newPayload }));
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
} else {
|
|
1906
|
+
const msgData = serialize2(message);
|
|
1907
|
+
for (const [id, client] of this.clients) {
|
|
1908
|
+
if (id !== excludeClientId && client.socket.readyState === 1) {
|
|
1909
|
+
client.socket.send(msgData);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
setupClusterListeners() {
|
|
1915
|
+
this.cluster.on("memberJoined", () => {
|
|
1916
|
+
this.metricsService.setClusterMembers(this.cluster.getMembers().length);
|
|
1917
|
+
});
|
|
1918
|
+
this.cluster.on("memberLeft", () => {
|
|
1919
|
+
this.metricsService.setClusterMembers(this.cluster.getMembers().length);
|
|
1920
|
+
});
|
|
1921
|
+
this.cluster.on("message", (msg) => {
|
|
1922
|
+
switch (msg.type) {
|
|
1923
|
+
case "OP_FORWARD":
|
|
1924
|
+
logger.info({ senderId: msg.senderId }, "Received forwarded op");
|
|
1925
|
+
if (this.partitionService.isLocalOwner(msg.payload.key)) {
|
|
1926
|
+
this.processLocalOp(msg.payload, true, msg.senderId).catch((err) => {
|
|
1927
|
+
logger.error({ err, senderId: msg.senderId }, "Forwarded op failed");
|
|
1928
|
+
});
|
|
1929
|
+
} else {
|
|
1930
|
+
logger.warn({ key: msg.payload.key }, "Received OP_FORWARD but not owner. Dropping.");
|
|
1931
|
+
}
|
|
1932
|
+
break;
|
|
1933
|
+
case "CLUSTER_EVENT":
|
|
1934
|
+
this.handleClusterEvent(msg.payload);
|
|
1935
|
+
break;
|
|
1936
|
+
case "CLUSTER_QUERY_EXEC": {
|
|
1937
|
+
const { requestId, mapName, query } = msg.payload;
|
|
1938
|
+
this.executeLocalQuery(mapName, query).then((results) => {
|
|
1939
|
+
this.cluster.send(msg.senderId, "CLUSTER_QUERY_RESP", {
|
|
1940
|
+
requestId,
|
|
1941
|
+
results
|
|
1942
|
+
});
|
|
1943
|
+
}).catch((err) => {
|
|
1944
|
+
logger.error({ err, mapName }, "Failed to execute cluster query");
|
|
1945
|
+
this.cluster.send(msg.senderId, "CLUSTER_QUERY_RESP", {
|
|
1946
|
+
requestId,
|
|
1947
|
+
results: []
|
|
1948
|
+
});
|
|
1949
|
+
});
|
|
1950
|
+
break;
|
|
1951
|
+
}
|
|
1952
|
+
case "CLUSTER_QUERY_RESP": {
|
|
1953
|
+
const { requestId: reqId, results: remoteResults } = msg.payload;
|
|
1954
|
+
const pendingQuery = this.pendingClusterQueries.get(reqId);
|
|
1955
|
+
if (pendingQuery) {
|
|
1956
|
+
pendingQuery.results.push(...remoteResults);
|
|
1957
|
+
pendingQuery.respondedNodes.add(msg.senderId);
|
|
1958
|
+
if (pendingQuery.respondedNodes.size === pendingQuery.expectedNodes.size) {
|
|
1959
|
+
this.finalizeClusterQuery(reqId);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
break;
|
|
1963
|
+
}
|
|
1964
|
+
case "CLUSTER_GC_REPORT": {
|
|
1965
|
+
this.handleGcReport(msg.senderId, msg.payload.minHlc);
|
|
1966
|
+
break;
|
|
1967
|
+
}
|
|
1968
|
+
case "CLUSTER_GC_COMMIT": {
|
|
1969
|
+
this.performGarbageCollection(msg.payload.safeTimestamp);
|
|
1970
|
+
break;
|
|
1971
|
+
}
|
|
1972
|
+
case "CLUSTER_LOCK_REQ": {
|
|
1973
|
+
const { originNodeId, clientId, requestId, name, ttl } = msg.payload;
|
|
1974
|
+
const compositeId = `${originNodeId}:${clientId}`;
|
|
1975
|
+
const result = this.lockManager.acquire(name, compositeId, requestId, ttl || 1e4);
|
|
1976
|
+
if (result.granted) {
|
|
1977
|
+
this.cluster.send(originNodeId, "CLUSTER_LOCK_GRANTED", {
|
|
1978
|
+
clientId,
|
|
1979
|
+
requestId,
|
|
1980
|
+
name,
|
|
1981
|
+
fencingToken: result.fencingToken
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
break;
|
|
1985
|
+
}
|
|
1986
|
+
case "CLUSTER_LOCK_RELEASE": {
|
|
1987
|
+
const { originNodeId, clientId, requestId, name, fencingToken } = msg.payload;
|
|
1988
|
+
const compositeId = `${originNodeId}:${clientId}`;
|
|
1989
|
+
const success = this.lockManager.release(name, compositeId, fencingToken);
|
|
1990
|
+
this.cluster.send(originNodeId, "CLUSTER_LOCK_RELEASED", {
|
|
1991
|
+
clientId,
|
|
1992
|
+
requestId,
|
|
1993
|
+
name,
|
|
1994
|
+
success
|
|
1995
|
+
});
|
|
1996
|
+
break;
|
|
1997
|
+
}
|
|
1998
|
+
case "CLUSTER_LOCK_RELEASED": {
|
|
1999
|
+
const { clientId, requestId, name, success } = msg.payload;
|
|
2000
|
+
const client = this.clients.get(clientId);
|
|
2001
|
+
if (client) {
|
|
2002
|
+
client.socket.send(serialize2({
|
|
2003
|
+
type: "LOCK_RELEASED",
|
|
2004
|
+
payload: { requestId, name, success }
|
|
2005
|
+
}));
|
|
2006
|
+
}
|
|
2007
|
+
break;
|
|
2008
|
+
}
|
|
2009
|
+
case "CLUSTER_LOCK_GRANTED": {
|
|
2010
|
+
const { clientId, requestId, name, fencingToken } = msg.payload;
|
|
2011
|
+
const client = this.clients.get(clientId);
|
|
2012
|
+
if (client) {
|
|
2013
|
+
client.socket.send(serialize2({
|
|
2014
|
+
type: "LOCK_GRANTED",
|
|
2015
|
+
payload: { requestId, name, fencingToken }
|
|
2016
|
+
}));
|
|
2017
|
+
}
|
|
2018
|
+
break;
|
|
2019
|
+
}
|
|
2020
|
+
case "CLUSTER_CLIENT_DISCONNECTED": {
|
|
2021
|
+
const { clientId, originNodeId } = msg.payload;
|
|
2022
|
+
const compositeId = `${originNodeId}:${clientId}`;
|
|
2023
|
+
this.lockManager.handleClientDisconnect(compositeId);
|
|
2024
|
+
break;
|
|
2025
|
+
}
|
|
2026
|
+
case "CLUSTER_TOPIC_PUB": {
|
|
2027
|
+
const { topic, data, originalSenderId } = msg.payload;
|
|
2028
|
+
this.topicManager.publish(topic, data, originalSenderId, true);
|
|
2029
|
+
break;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
async executeLocalQuery(mapName, query) {
|
|
2035
|
+
const map = await this.getMapAsync(mapName);
|
|
2036
|
+
const records = /* @__PURE__ */ new Map();
|
|
2037
|
+
if (map instanceof LWWMap2) {
|
|
2038
|
+
for (const key of map.allKeys()) {
|
|
2039
|
+
const rec = map.getRecord(key);
|
|
2040
|
+
if (rec && rec.value !== null) {
|
|
2041
|
+
records.set(key, rec);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
} else if (map instanceof ORMap2) {
|
|
2045
|
+
const items = map.items;
|
|
2046
|
+
for (const key of items.keys()) {
|
|
2047
|
+
const values = map.get(key);
|
|
2048
|
+
if (values.length > 0) {
|
|
2049
|
+
records.set(key, { value: values });
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
const localQuery = { ...query };
|
|
2054
|
+
delete localQuery.offset;
|
|
2055
|
+
delete localQuery.limit;
|
|
2056
|
+
return executeQuery(records, localQuery);
|
|
2057
|
+
}
|
|
2058
|
+
finalizeClusterQuery(requestId, timeout = false) {
|
|
2059
|
+
const pending = this.pendingClusterQueries.get(requestId);
|
|
2060
|
+
if (!pending) return;
|
|
2061
|
+
if (timeout) {
|
|
2062
|
+
logger.warn({ requestId, responded: pending.respondedNodes.size, expected: pending.expectedNodes.size }, "Query timed out. Returning partial results.");
|
|
2063
|
+
}
|
|
2064
|
+
clearTimeout(pending.timer);
|
|
2065
|
+
this.pendingClusterQueries.delete(requestId);
|
|
2066
|
+
const { client, queryId, mapName, query, results } = pending;
|
|
2067
|
+
const uniqueResults = /* @__PURE__ */ new Map();
|
|
2068
|
+
for (const res of results) {
|
|
2069
|
+
uniqueResults.set(res.key, res);
|
|
2070
|
+
}
|
|
2071
|
+
const finalResults = Array.from(uniqueResults.values());
|
|
2072
|
+
if (query.sort) {
|
|
2073
|
+
finalResults.sort((a, b) => {
|
|
2074
|
+
for (const [field, direction] of Object.entries(query.sort)) {
|
|
2075
|
+
const valA = a.value[field];
|
|
2076
|
+
const valB = b.value[field];
|
|
2077
|
+
if (valA < valB) return direction === "asc" ? -1 : 1;
|
|
2078
|
+
if (valA > valB) return direction === "asc" ? 1 : -1;
|
|
2079
|
+
}
|
|
2080
|
+
return 0;
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
const slicedResults = query.offset || query.limit ? finalResults.slice(query.offset || 0, (query.offset || 0) + (query.limit || finalResults.length)) : finalResults;
|
|
2084
|
+
const resultKeys = new Set(slicedResults.map((r) => r.key));
|
|
2085
|
+
const sub = {
|
|
2086
|
+
id: queryId,
|
|
2087
|
+
clientId: client.id,
|
|
2088
|
+
mapName,
|
|
2089
|
+
query,
|
|
2090
|
+
socket: client.socket,
|
|
2091
|
+
previousResultKeys: resultKeys,
|
|
2092
|
+
interestedFields: "ALL"
|
|
2093
|
+
};
|
|
2094
|
+
this.queryRegistry.register(sub);
|
|
2095
|
+
client.subscriptions.add(queryId);
|
|
2096
|
+
const filteredResults = slicedResults.map((res) => {
|
|
2097
|
+
const filteredValue = this.securityManager.filterObject(res.value, client.principal, mapName);
|
|
2098
|
+
return { ...res, value: filteredValue };
|
|
2099
|
+
});
|
|
2100
|
+
client.socket.send(serialize2({
|
|
2101
|
+
type: "QUERY_RESP",
|
|
2102
|
+
payload: { queryId, results: filteredResults }
|
|
2103
|
+
}));
|
|
2104
|
+
}
|
|
2105
|
+
handleLockGranted({ clientId, requestId, name, fencingToken }) {
|
|
2106
|
+
const client = this.clients.get(clientId);
|
|
2107
|
+
if (client) {
|
|
2108
|
+
client.socket.send(serialize2({
|
|
2109
|
+
type: "LOCK_GRANTED",
|
|
2110
|
+
payload: { requestId, name, fencingToken }
|
|
2111
|
+
}));
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
const parts = clientId.split(":");
|
|
2115
|
+
if (parts.length === 2) {
|
|
2116
|
+
const [nodeId, realClientId] = parts;
|
|
2117
|
+
if (nodeId !== this.cluster.config.nodeId) {
|
|
2118
|
+
this.cluster.send(nodeId, "CLUSTER_LOCK_GRANTED", {
|
|
2119
|
+
clientId: realClientId,
|
|
2120
|
+
requestId,
|
|
2121
|
+
name,
|
|
2122
|
+
fencingToken
|
|
2123
|
+
});
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
logger.warn({ clientId, name }, "Lock granted to unknown client");
|
|
2128
|
+
}
|
|
2129
|
+
async processLocalOp(op, fromCluster, originalSenderId) {
|
|
2130
|
+
let context = {
|
|
2131
|
+
clientId: originalSenderId || "unknown",
|
|
2132
|
+
isAuthenticated: false,
|
|
2133
|
+
// We might need to fetch this if local
|
|
2134
|
+
fromCluster,
|
|
2135
|
+
originalSenderId
|
|
2136
|
+
};
|
|
2137
|
+
if (!fromCluster && originalSenderId) {
|
|
2138
|
+
const client = this.clients.get(originalSenderId);
|
|
2139
|
+
if (client) {
|
|
2140
|
+
context = {
|
|
2141
|
+
clientId: client.id,
|
|
2142
|
+
socket: client.socket,
|
|
2143
|
+
isAuthenticated: client.isAuthenticated,
|
|
2144
|
+
principal: client.principal,
|
|
2145
|
+
fromCluster,
|
|
2146
|
+
originalSenderId
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
let currentOp = op;
|
|
2151
|
+
try {
|
|
2152
|
+
for (const interceptor of this.interceptors) {
|
|
2153
|
+
if (interceptor.onBeforeOp) {
|
|
2154
|
+
if (currentOp) {
|
|
2155
|
+
currentOp = await interceptor.onBeforeOp(currentOp, context);
|
|
2156
|
+
if (!currentOp) {
|
|
2157
|
+
logger.debug({ interceptor: interceptor.name, opId: op.id }, "Interceptor silently dropped op");
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
} catch (err) {
|
|
2164
|
+
logger.warn({ err, opId: op.id }, "Interceptor rejected op");
|
|
2165
|
+
throw err;
|
|
2166
|
+
}
|
|
2167
|
+
if (!currentOp) return;
|
|
2168
|
+
op = currentOp;
|
|
2169
|
+
const typeHint = op.opType === "OR_ADD" || op.opType === "OR_REMOVE" ? "OR" : "LWW";
|
|
2170
|
+
const map = this.getMap(op.mapName, typeHint);
|
|
2171
|
+
if (typeHint === "OR" && map instanceof LWWMap2) {
|
|
2172
|
+
logger.error({ mapName: op.mapName }, "Map type mismatch: LWWMap but received OR op");
|
|
2173
|
+
throw new Error("Map type mismatch: LWWMap but received OR op");
|
|
2174
|
+
}
|
|
2175
|
+
if (typeHint === "LWW" && map instanceof ORMap2) {
|
|
2176
|
+
logger.error({ mapName: op.mapName }, "Map type mismatch: ORMap but received LWW op");
|
|
2177
|
+
throw new Error("Map type mismatch: ORMap but received LWW op");
|
|
2178
|
+
}
|
|
2179
|
+
let oldRecord;
|
|
2180
|
+
let recordToStore;
|
|
2181
|
+
let tombstonesToStore;
|
|
2182
|
+
const eventPayload = {
|
|
2183
|
+
mapName: op.mapName,
|
|
2184
|
+
key: op.key
|
|
2185
|
+
// Common fields
|
|
2186
|
+
};
|
|
2187
|
+
if (map instanceof LWWMap2) {
|
|
2188
|
+
oldRecord = map.getRecord(op.key);
|
|
2189
|
+
map.merge(op.key, op.record);
|
|
2190
|
+
recordToStore = op.record;
|
|
2191
|
+
eventPayload.eventType = "UPDATED";
|
|
2192
|
+
eventPayload.record = op.record;
|
|
2193
|
+
} else if (map instanceof ORMap2) {
|
|
2194
|
+
oldRecord = map.getRecords(op.key);
|
|
2195
|
+
if (op.opType === "OR_ADD") {
|
|
2196
|
+
map.apply(op.key, op.orRecord);
|
|
2197
|
+
eventPayload.eventType = "OR_ADD";
|
|
2198
|
+
eventPayload.orRecord = op.orRecord;
|
|
2199
|
+
recordToStore = {
|
|
2200
|
+
type: "OR",
|
|
2201
|
+
records: map.getRecords(op.key)
|
|
2202
|
+
};
|
|
2203
|
+
} else if (op.opType === "OR_REMOVE") {
|
|
2204
|
+
map.applyTombstone(op.orTag);
|
|
2205
|
+
eventPayload.eventType = "OR_REMOVE";
|
|
2206
|
+
eventPayload.orTag = op.orTag;
|
|
2207
|
+
recordToStore = {
|
|
2208
|
+
type: "OR",
|
|
2209
|
+
records: map.getRecords(op.key)
|
|
2210
|
+
};
|
|
2211
|
+
tombstonesToStore = {
|
|
2212
|
+
type: "OR_TOMBSTONES",
|
|
2213
|
+
tags: map.getTombstones()
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
this.queryRegistry.processChange(op.mapName, map, op.key, op.record || op.orRecord, oldRecord);
|
|
2218
|
+
const mapSize = map instanceof ORMap2 ? map.totalRecords : map.size;
|
|
2219
|
+
this.metricsService.setMapSize(op.mapName, mapSize);
|
|
2220
|
+
if (this.storage) {
|
|
2221
|
+
if (recordToStore) {
|
|
2222
|
+
this.storage.store(op.mapName, op.key, recordToStore).catch((err) => {
|
|
2223
|
+
logger.error({ mapName: op.mapName, key: op.key, err }, "Failed to persist op");
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
if (tombstonesToStore) {
|
|
2227
|
+
this.storage.store(op.mapName, "__tombstones__", tombstonesToStore).catch((err) => {
|
|
2228
|
+
logger.error({ mapName: op.mapName, err }, "Failed to persist tombstones");
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
this.broadcast({
|
|
2233
|
+
type: "SERVER_EVENT",
|
|
2234
|
+
payload: eventPayload,
|
|
2235
|
+
timestamp: this.hlc.now()
|
|
2236
|
+
}, originalSenderId);
|
|
2237
|
+
const members = this.cluster.getMembers();
|
|
2238
|
+
for (const memberId of members) {
|
|
2239
|
+
if (!this.cluster.isLocal(memberId)) {
|
|
2240
|
+
this.cluster.send(memberId, "CLUSTER_EVENT", eventPayload);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
for (const interceptor of this.interceptors) {
|
|
2244
|
+
if (interceptor.onAfterOp) {
|
|
2245
|
+
interceptor.onAfterOp(op, context).catch((err) => {
|
|
2246
|
+
logger.error({ err }, "Error in onAfterOp");
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
handleClusterEvent(payload) {
|
|
2252
|
+
const { mapName, key, eventType } = payload;
|
|
2253
|
+
const map = this.getMap(mapName, eventType === "OR_ADD" || eventType === "OR_REMOVE" ? "OR" : "LWW");
|
|
2254
|
+
const oldRecord = map instanceof LWWMap2 ? map.getRecord(key) : null;
|
|
2255
|
+
if (this.partitionService.isRelated(key)) {
|
|
2256
|
+
if (map instanceof LWWMap2 && payload.record) {
|
|
2257
|
+
map.merge(key, payload.record);
|
|
2258
|
+
} else if (map instanceof ORMap2) {
|
|
2259
|
+
if (eventType === "OR_ADD" && payload.orRecord) {
|
|
2260
|
+
map.apply(key, payload.orRecord);
|
|
2261
|
+
} else if (eventType === "OR_REMOVE" && payload.orTag) {
|
|
2262
|
+
map.applyTombstone(payload.orTag);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
this.queryRegistry.processChange(mapName, map, key, payload.record || payload.orRecord, oldRecord);
|
|
2267
|
+
this.broadcast({
|
|
2268
|
+
type: "SERVER_EVENT",
|
|
2269
|
+
payload,
|
|
2270
|
+
timestamp: this.hlc.now()
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
getMap(name, typeHint = "LWW") {
|
|
2274
|
+
if (!this.maps.has(name)) {
|
|
2275
|
+
let map;
|
|
2276
|
+
if (typeHint === "OR") {
|
|
2277
|
+
map = new ORMap2(this.hlc);
|
|
2278
|
+
} else {
|
|
2279
|
+
map = new LWWMap2(this.hlc);
|
|
2280
|
+
}
|
|
2281
|
+
this.maps.set(name, map);
|
|
2282
|
+
if (this.storage) {
|
|
2283
|
+
logger.info({ mapName: name }, "Loading map from storage...");
|
|
2284
|
+
const loadPromise = this.loadMapFromStorage(name, typeHint);
|
|
2285
|
+
this.mapLoadingPromises.set(name, loadPromise);
|
|
2286
|
+
loadPromise.finally(() => {
|
|
2287
|
+
this.mapLoadingPromises.delete(name);
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
return this.maps.get(name);
|
|
2292
|
+
}
|
|
2293
|
+
/**
|
|
2294
|
+
* Returns map after ensuring it's fully loaded from storage.
|
|
2295
|
+
* Use this for queries to avoid returning empty results during initial load.
|
|
2296
|
+
*/
|
|
2297
|
+
async getMapAsync(name, typeHint = "LWW") {
|
|
2298
|
+
const mapExisted = this.maps.has(name);
|
|
2299
|
+
this.getMap(name, typeHint);
|
|
2300
|
+
const loadingPromise = this.mapLoadingPromises.get(name);
|
|
2301
|
+
const map = this.maps.get(name);
|
|
2302
|
+
const mapSize = map instanceof LWWMap2 ? Array.from(map.entries()).length : map instanceof ORMap2 ? map.size : 0;
|
|
2303
|
+
logger.info({
|
|
2304
|
+
mapName: name,
|
|
2305
|
+
mapExisted,
|
|
2306
|
+
hasLoadingPromise: !!loadingPromise,
|
|
2307
|
+
currentMapSize: mapSize
|
|
2308
|
+
}, "[getMapAsync] State check");
|
|
2309
|
+
if (loadingPromise) {
|
|
2310
|
+
logger.info({ mapName: name }, "[getMapAsync] Waiting for loadMapFromStorage...");
|
|
2311
|
+
await loadingPromise;
|
|
2312
|
+
const newMapSize = map instanceof LWWMap2 ? Array.from(map.entries()).length : map instanceof ORMap2 ? map.size : 0;
|
|
2313
|
+
logger.info({ mapName: name, mapSizeAfterLoad: newMapSize }, "[getMapAsync] Load completed");
|
|
2314
|
+
}
|
|
2315
|
+
return this.maps.get(name);
|
|
2316
|
+
}
|
|
2317
|
+
async loadMapFromStorage(name, typeHint) {
|
|
2318
|
+
try {
|
|
2319
|
+
const keys = await this.storage.loadAllKeys(name);
|
|
2320
|
+
if (keys.length === 0) return;
|
|
2321
|
+
const hasTombstones = keys.includes("__tombstones__");
|
|
2322
|
+
const relatedKeys = keys.filter((k) => this.partitionService.isRelated(k));
|
|
2323
|
+
if (relatedKeys.length === 0) return;
|
|
2324
|
+
const records = await this.storage.loadAll(name, relatedKeys);
|
|
2325
|
+
let count = 0;
|
|
2326
|
+
let isOR = hasTombstones;
|
|
2327
|
+
if (!isOR) {
|
|
2328
|
+
for (const [k, v] of records) {
|
|
2329
|
+
if (k !== "__tombstones__" && v.type === "OR") {
|
|
2330
|
+
isOR = true;
|
|
2331
|
+
break;
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
const currentMap = this.maps.get(name);
|
|
2336
|
+
if (!currentMap) return;
|
|
2337
|
+
let targetMap = currentMap;
|
|
2338
|
+
if (isOR && currentMap instanceof LWWMap2) {
|
|
2339
|
+
logger.info({ mapName: name }, "Map auto-detected as ORMap. Switching type.");
|
|
2340
|
+
targetMap = new ORMap2(this.hlc);
|
|
2341
|
+
this.maps.set(name, targetMap);
|
|
2342
|
+
} else if (!isOR && currentMap instanceof ORMap2 && typeHint !== "OR") {
|
|
2343
|
+
logger.info({ mapName: name }, "Map auto-detected as LWWMap. Switching type.");
|
|
2344
|
+
targetMap = new LWWMap2(this.hlc);
|
|
2345
|
+
this.maps.set(name, targetMap);
|
|
2346
|
+
}
|
|
2347
|
+
if (targetMap instanceof ORMap2) {
|
|
2348
|
+
for (const [key, record] of records) {
|
|
2349
|
+
if (key === "__tombstones__") {
|
|
2350
|
+
const t = record;
|
|
2351
|
+
if (t && t.tags) t.tags.forEach((tag) => targetMap.applyTombstone(tag));
|
|
2352
|
+
} else {
|
|
2353
|
+
const orVal = record;
|
|
2354
|
+
if (orVal && orVal.records) {
|
|
2355
|
+
orVal.records.forEach((r) => targetMap.apply(key, r));
|
|
2356
|
+
count++;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
} else if (targetMap instanceof LWWMap2) {
|
|
2361
|
+
for (const [key, record] of records) {
|
|
2362
|
+
if (!record.type) {
|
|
2363
|
+
targetMap.merge(key, record);
|
|
2364
|
+
count++;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
if (count > 0) {
|
|
2369
|
+
logger.info({ mapName: name, count }, "Loaded records for map");
|
|
2370
|
+
this.queryRegistry.refreshSubscriptions(name, targetMap);
|
|
2371
|
+
const mapSize = targetMap instanceof ORMap2 ? targetMap.totalRecords : targetMap.size;
|
|
2372
|
+
this.metricsService.setMapSize(name, mapSize);
|
|
2373
|
+
}
|
|
2374
|
+
} catch (err) {
|
|
2375
|
+
logger.error({ mapName: name, err }, "Failed to load map");
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
startGarbageCollection() {
|
|
2379
|
+
this.gcInterval = setInterval(() => {
|
|
2380
|
+
this.reportLocalHlc();
|
|
2381
|
+
}, GC_INTERVAL_MS);
|
|
2382
|
+
}
|
|
2383
|
+
reportLocalHlc() {
|
|
2384
|
+
let minHlc = this.hlc.now();
|
|
2385
|
+
for (const client of this.clients.values()) {
|
|
2386
|
+
if (HLC.compare(client.lastActiveHlc, minHlc) < 0) {
|
|
2387
|
+
minHlc = client.lastActiveHlc;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
const members = this.cluster.getMembers().sort();
|
|
2391
|
+
const leaderId = members[0];
|
|
2392
|
+
const myId = this.cluster.config.nodeId;
|
|
2393
|
+
if (leaderId === myId) {
|
|
2394
|
+
this.handleGcReport(myId, minHlc);
|
|
2395
|
+
} else {
|
|
2396
|
+
this.cluster.send(leaderId, "CLUSTER_GC_REPORT", { minHlc });
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
handleGcReport(nodeId, minHlc) {
|
|
2400
|
+
this.gcReports.set(nodeId, minHlc);
|
|
2401
|
+
const members = this.cluster.getMembers();
|
|
2402
|
+
const allReported = members.every((m) => this.gcReports.has(m));
|
|
2403
|
+
if (allReported) {
|
|
2404
|
+
let globalSafe = this.hlc.now();
|
|
2405
|
+
let initialized = false;
|
|
2406
|
+
for (const ts of this.gcReports.values()) {
|
|
2407
|
+
if (!initialized || HLC.compare(ts, globalSafe) < 0) {
|
|
2408
|
+
globalSafe = ts;
|
|
2409
|
+
initialized = true;
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
const olderThanMillis = globalSafe.millis - GC_AGE_MS;
|
|
2413
|
+
const safeTimestamp = {
|
|
2414
|
+
millis: olderThanMillis,
|
|
2415
|
+
counter: 0,
|
|
2416
|
+
nodeId: globalSafe.nodeId
|
|
2417
|
+
// Doesn't matter much for comparison if millis match, but best effort
|
|
2418
|
+
};
|
|
2419
|
+
logger.info({
|
|
2420
|
+
globalMinHlc: globalSafe.millis,
|
|
2421
|
+
safeGcTimestamp: olderThanMillis,
|
|
2422
|
+
reportsCount: this.gcReports.size
|
|
2423
|
+
}, "GC Consensus Reached. Broadcasting Commit.");
|
|
2424
|
+
const commitMsg = {
|
|
2425
|
+
type: "CLUSTER_GC_COMMIT",
|
|
2426
|
+
// Handled by cluster listener
|
|
2427
|
+
payload: { safeTimestamp }
|
|
2428
|
+
};
|
|
2429
|
+
for (const member of members) {
|
|
2430
|
+
if (!this.cluster.isLocal(member)) {
|
|
2431
|
+
this.cluster.send(member, "CLUSTER_GC_COMMIT", { safeTimestamp });
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
this.performGarbageCollection(safeTimestamp);
|
|
2435
|
+
this.gcReports.clear();
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
performGarbageCollection(olderThan) {
|
|
2439
|
+
logger.info({ olderThanMillis: olderThan.millis }, "Performing Garbage Collection");
|
|
2440
|
+
const now = Date.now();
|
|
2441
|
+
for (const [name, map] of this.maps) {
|
|
2442
|
+
if (map instanceof LWWMap2) {
|
|
2443
|
+
for (const key of map.allKeys()) {
|
|
2444
|
+
const record = map.getRecord(key);
|
|
2445
|
+
if (record && record.value !== null && record.ttlMs) {
|
|
2446
|
+
const expirationTime = record.timestamp.millis + record.ttlMs;
|
|
2447
|
+
if (expirationTime < now) {
|
|
2448
|
+
logger.info({ mapName: name, key }, "Record expired (TTL). Converting to tombstone.");
|
|
2449
|
+
const tombstoneTimestamp = {
|
|
2450
|
+
millis: expirationTime,
|
|
2451
|
+
counter: 0,
|
|
2452
|
+
// Reset counter for expiration time
|
|
2453
|
+
nodeId: this.hlc.getNodeId
|
|
2454
|
+
// Use our ID
|
|
2455
|
+
};
|
|
2456
|
+
const tombstone = { value: null, timestamp: tombstoneTimestamp };
|
|
2457
|
+
const changed = map.merge(key, tombstone);
|
|
2458
|
+
if (changed) {
|
|
2459
|
+
if (this.storage) {
|
|
2460
|
+
this.storage.store(name, key, tombstone).catch(
|
|
2461
|
+
(err) => logger.error({ mapName: name, key, err }, "Failed to persist expired tombstone")
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
const eventPayload = {
|
|
2465
|
+
mapName: name,
|
|
2466
|
+
key,
|
|
2467
|
+
eventType: "UPDATED",
|
|
2468
|
+
record: tombstone
|
|
2469
|
+
};
|
|
2470
|
+
this.broadcast({
|
|
2471
|
+
type: "SERVER_EVENT",
|
|
2472
|
+
payload: eventPayload,
|
|
2473
|
+
timestamp: this.hlc.now()
|
|
2474
|
+
});
|
|
2475
|
+
const members = this.cluster.getMembers();
|
|
2476
|
+
for (const memberId of members) {
|
|
2477
|
+
if (!this.cluster.isLocal(memberId)) {
|
|
2478
|
+
this.cluster.send(memberId, "CLUSTER_EVENT", eventPayload);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
const removedKeys = map.prune(olderThan);
|
|
2486
|
+
if (removedKeys.length > 0) {
|
|
2487
|
+
logger.info({ mapName: name, count: removedKeys.length }, "Pruned records from LWW map");
|
|
2488
|
+
if (this.storage) {
|
|
2489
|
+
this.storage.deleteAll(name, removedKeys).catch((err) => {
|
|
2490
|
+
logger.error({ mapName: name, err }, "Failed to delete pruned keys from storage");
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
} else if (map instanceof ORMap2) {
|
|
2495
|
+
const items = map.items;
|
|
2496
|
+
const tombstonesSet = map.tombstones;
|
|
2497
|
+
const tagsToExpire = [];
|
|
2498
|
+
for (const [key, keyMap] of items) {
|
|
2499
|
+
for (const [tag, record] of keyMap) {
|
|
2500
|
+
if (!tombstonesSet.has(tag)) {
|
|
2501
|
+
if (record.ttlMs) {
|
|
2502
|
+
const expirationTime = record.timestamp.millis + record.ttlMs;
|
|
2503
|
+
if (expirationTime < now) {
|
|
2504
|
+
tagsToExpire.push({ key, tag });
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
for (const { key, tag } of tagsToExpire) {
|
|
2511
|
+
logger.info({ mapName: name, key, tag }, "ORMap Record expired (TTL). Removing.");
|
|
2512
|
+
map.applyTombstone(tag);
|
|
2513
|
+
if (this.storage) {
|
|
2514
|
+
const records = map.getRecords(key);
|
|
2515
|
+
if (records.length > 0) {
|
|
2516
|
+
this.storage.store(name, key, { type: "OR", records });
|
|
2517
|
+
} else {
|
|
2518
|
+
this.storage.delete(name, key);
|
|
2519
|
+
}
|
|
2520
|
+
const currentTombstones = map.getTombstones();
|
|
2521
|
+
this.storage.store(name, "__tombstones__", {
|
|
2522
|
+
type: "OR_TOMBSTONES",
|
|
2523
|
+
tags: currentTombstones
|
|
2524
|
+
});
|
|
2525
|
+
}
|
|
2526
|
+
const eventPayload = {
|
|
2527
|
+
mapName: name,
|
|
2528
|
+
key,
|
|
2529
|
+
eventType: "OR_REMOVE",
|
|
2530
|
+
orTag: tag
|
|
2531
|
+
};
|
|
2532
|
+
this.broadcast({
|
|
2533
|
+
type: "SERVER_EVENT",
|
|
2534
|
+
payload: eventPayload,
|
|
2535
|
+
timestamp: this.hlc.now()
|
|
2536
|
+
});
|
|
2537
|
+
const members = this.cluster.getMembers();
|
|
2538
|
+
for (const memberId of members) {
|
|
2539
|
+
if (!this.cluster.isLocal(memberId)) {
|
|
2540
|
+
this.cluster.send(memberId, "CLUSTER_EVENT", eventPayload);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
const removedTags = map.prune(olderThan);
|
|
2545
|
+
if (removedTags.length > 0) {
|
|
2546
|
+
logger.info({ mapName: name, count: removedTags.length }, "Pruned tombstones from OR map");
|
|
2547
|
+
if (this.storage) {
|
|
2548
|
+
const currentTombstones = map.getTombstones();
|
|
2549
|
+
this.storage.store(name, "__tombstones__", {
|
|
2550
|
+
type: "OR_TOMBSTONES",
|
|
2551
|
+
tags: currentTombstones
|
|
2552
|
+
}).catch((err) => {
|
|
2553
|
+
logger.error({ mapName: name, err }, "Failed to update tombstones");
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
this.broadcast({
|
|
2560
|
+
type: "GC_PRUNE",
|
|
2561
|
+
payload: {
|
|
2562
|
+
olderThan
|
|
2563
|
+
}
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
buildTLSOptions(config) {
|
|
2567
|
+
const options = {
|
|
2568
|
+
cert: readFileSync2(config.certPath),
|
|
2569
|
+
key: readFileSync2(config.keyPath),
|
|
2570
|
+
minVersion: config.minVersion || "TLSv1.2"
|
|
2571
|
+
};
|
|
2572
|
+
if (config.caCertPath) {
|
|
2573
|
+
options.ca = readFileSync2(config.caCertPath);
|
|
2574
|
+
}
|
|
2575
|
+
if (config.ciphers) {
|
|
2576
|
+
options.ciphers = config.ciphers;
|
|
2577
|
+
}
|
|
2578
|
+
if (config.passphrase) {
|
|
2579
|
+
options.passphrase = config.passphrase;
|
|
2580
|
+
}
|
|
2581
|
+
return options;
|
|
2582
|
+
}
|
|
2583
|
+
};
|
|
2584
|
+
|
|
2585
|
+
// src/storage/PostgresAdapter.ts
|
|
2586
|
+
import { Pool } from "pg";
|
|
2587
|
+
var DEFAULT_TABLE_NAME = "topgun_maps";
|
|
2588
|
+
var TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
2589
|
+
function validateTableName(name) {
|
|
2590
|
+
if (!TABLE_NAME_REGEX.test(name)) {
|
|
2591
|
+
throw new Error(
|
|
2592
|
+
`Invalid table name "${name}". Table name must start with a letter or underscore and contain only alphanumeric characters and underscores.`
|
|
2593
|
+
);
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
var PostgresAdapter = class {
|
|
2597
|
+
constructor(configOrPool, options) {
|
|
2598
|
+
if (configOrPool instanceof Pool || configOrPool.connect) {
|
|
2599
|
+
this.pool = configOrPool;
|
|
2600
|
+
} else {
|
|
2601
|
+
this.pool = new Pool(configOrPool);
|
|
2602
|
+
}
|
|
2603
|
+
const tableName = options?.tableName ?? DEFAULT_TABLE_NAME;
|
|
2604
|
+
validateTableName(tableName);
|
|
2605
|
+
this.tableName = tableName;
|
|
2606
|
+
}
|
|
2607
|
+
async initialize() {
|
|
2608
|
+
const client = await this.pool.connect();
|
|
2609
|
+
try {
|
|
2610
|
+
await client.query(`
|
|
2611
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
2612
|
+
map_name TEXT NOT NULL,
|
|
2613
|
+
key TEXT NOT NULL,
|
|
2614
|
+
value JSONB,
|
|
2615
|
+
ts_millis BIGINT NOT NULL,
|
|
2616
|
+
ts_counter INTEGER NOT NULL,
|
|
2617
|
+
ts_node_id TEXT NOT NULL,
|
|
2618
|
+
is_deleted BOOLEAN DEFAULT FALSE,
|
|
2619
|
+
PRIMARY KEY (map_name, key)
|
|
2620
|
+
);
|
|
2621
|
+
`);
|
|
2622
|
+
} finally {
|
|
2623
|
+
client.release();
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
async close() {
|
|
2627
|
+
await this.pool.end();
|
|
2628
|
+
}
|
|
2629
|
+
async load(mapName, key) {
|
|
2630
|
+
const res = await this.pool.query(
|
|
2631
|
+
`SELECT value, ts_millis, ts_counter, ts_node_id, is_deleted
|
|
2632
|
+
FROM ${this.tableName}
|
|
2633
|
+
WHERE map_name = $1 AND key = $2`,
|
|
2634
|
+
[mapName, key]
|
|
2635
|
+
);
|
|
2636
|
+
if (res.rows.length === 0) return void 0;
|
|
2637
|
+
const row = res.rows[0];
|
|
2638
|
+
return this.mapRowToRecord(row);
|
|
2639
|
+
}
|
|
2640
|
+
async loadAll(mapName, keys) {
|
|
2641
|
+
const result = /* @__PURE__ */ new Map();
|
|
2642
|
+
if (keys.length === 0) return result;
|
|
2643
|
+
const res = await this.pool.query(
|
|
2644
|
+
`SELECT key, value, ts_millis, ts_counter, ts_node_id, is_deleted
|
|
2645
|
+
FROM ${this.tableName}
|
|
2646
|
+
WHERE map_name = $1 AND key = ANY($2)`,
|
|
2647
|
+
[mapName, keys]
|
|
2648
|
+
);
|
|
2649
|
+
for (const row of res.rows) {
|
|
2650
|
+
result.set(row.key, this.mapRowToRecord(row));
|
|
2651
|
+
}
|
|
2652
|
+
return result;
|
|
2653
|
+
}
|
|
2654
|
+
async loadAllKeys(mapName) {
|
|
2655
|
+
const res = await this.pool.query(
|
|
2656
|
+
`SELECT key FROM ${this.tableName} WHERE map_name = $1`,
|
|
2657
|
+
[mapName]
|
|
2658
|
+
);
|
|
2659
|
+
return res.rows.map((row) => row.key);
|
|
2660
|
+
}
|
|
2661
|
+
async store(mapName, key, record) {
|
|
2662
|
+
let value;
|
|
2663
|
+
let tsMillis;
|
|
2664
|
+
let tsCounter;
|
|
2665
|
+
let tsNodeId;
|
|
2666
|
+
let isDeleted;
|
|
2667
|
+
if (this.isORMapValue(record)) {
|
|
2668
|
+
value = record;
|
|
2669
|
+
tsMillis = 0;
|
|
2670
|
+
tsCounter = 0;
|
|
2671
|
+
tsNodeId = "__ORMAP__";
|
|
2672
|
+
isDeleted = false;
|
|
2673
|
+
} else {
|
|
2674
|
+
const lww = record;
|
|
2675
|
+
value = lww.value;
|
|
2676
|
+
tsMillis = lww.timestamp.millis;
|
|
2677
|
+
tsCounter = lww.timestamp.counter;
|
|
2678
|
+
tsNodeId = lww.timestamp.nodeId;
|
|
2679
|
+
isDeleted = lww.value === null;
|
|
2680
|
+
}
|
|
2681
|
+
await this.pool.query(
|
|
2682
|
+
`INSERT INTO ${this.tableName} (map_name, key, value, ts_millis, ts_counter, ts_node_id, is_deleted)
|
|
2683
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
2684
|
+
ON CONFLICT (map_name, key) DO UPDATE SET
|
|
2685
|
+
value = EXCLUDED.value,
|
|
2686
|
+
ts_millis = EXCLUDED.ts_millis,
|
|
2687
|
+
ts_counter = EXCLUDED.ts_counter,
|
|
2688
|
+
ts_node_id = EXCLUDED.ts_node_id,
|
|
2689
|
+
is_deleted = EXCLUDED.is_deleted`,
|
|
2690
|
+
[
|
|
2691
|
+
mapName,
|
|
2692
|
+
key,
|
|
2693
|
+
JSON.stringify(value),
|
|
2694
|
+
tsMillis,
|
|
2695
|
+
tsCounter,
|
|
2696
|
+
tsNodeId,
|
|
2697
|
+
isDeleted
|
|
2698
|
+
]
|
|
2699
|
+
);
|
|
2700
|
+
}
|
|
2701
|
+
async storeAll(mapName, records) {
|
|
2702
|
+
const client = await this.pool.connect();
|
|
2703
|
+
try {
|
|
2704
|
+
await client.query("BEGIN");
|
|
2705
|
+
for (const [key, record] of records) {
|
|
2706
|
+
await this.store(mapName, key, record);
|
|
2707
|
+
}
|
|
2708
|
+
await client.query("COMMIT");
|
|
2709
|
+
} catch (e) {
|
|
2710
|
+
await client.query("ROLLBACK");
|
|
2711
|
+
throw e;
|
|
2712
|
+
} finally {
|
|
2713
|
+
client.release();
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
async delete(mapName, key) {
|
|
2717
|
+
await this.pool.query(`DELETE FROM ${this.tableName} WHERE map_name = $1 AND key = $2`, [mapName, key]);
|
|
2718
|
+
}
|
|
2719
|
+
async deleteAll(mapName, keys) {
|
|
2720
|
+
if (keys.length === 0) return;
|
|
2721
|
+
await this.pool.query(
|
|
2722
|
+
`DELETE FROM ${this.tableName} WHERE map_name = $1 AND key = ANY($2)`,
|
|
2723
|
+
[mapName, keys]
|
|
2724
|
+
);
|
|
2725
|
+
}
|
|
2726
|
+
mapRowToRecord(row) {
|
|
2727
|
+
if (row.ts_node_id === "__ORMAP__") {
|
|
2728
|
+
return row.value;
|
|
2729
|
+
}
|
|
2730
|
+
return {
|
|
2731
|
+
value: row.is_deleted ? null : row.value,
|
|
2732
|
+
timestamp: {
|
|
2733
|
+
millis: Number(row.ts_millis),
|
|
2734
|
+
counter: row.ts_counter,
|
|
2735
|
+
nodeId: row.ts_node_id
|
|
2736
|
+
}
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
isORMapValue(record) {
|
|
2740
|
+
return record && typeof record === "object" && (record.type === "OR" || record.type === "OR_TOMBSTONES");
|
|
2741
|
+
}
|
|
2742
|
+
};
|
|
2743
|
+
|
|
2744
|
+
// src/storage/MemoryServerAdapter.ts
|
|
2745
|
+
var MemoryServerAdapter = class {
|
|
2746
|
+
constructor() {
|
|
2747
|
+
// Map<mapName, Map<key, value>>
|
|
2748
|
+
this.storage = /* @__PURE__ */ new Map();
|
|
2749
|
+
}
|
|
2750
|
+
async initialize() {
|
|
2751
|
+
console.log("[MemoryServerAdapter] Initialized in-memory storage");
|
|
2752
|
+
}
|
|
2753
|
+
async close() {
|
|
2754
|
+
this.storage.clear();
|
|
2755
|
+
console.log("[MemoryServerAdapter] Storage cleared and closed");
|
|
2756
|
+
}
|
|
2757
|
+
getMap(mapName) {
|
|
2758
|
+
let map = this.storage.get(mapName);
|
|
2759
|
+
if (!map) {
|
|
2760
|
+
map = /* @__PURE__ */ new Map();
|
|
2761
|
+
this.storage.set(mapName, map);
|
|
2762
|
+
}
|
|
2763
|
+
return map;
|
|
2764
|
+
}
|
|
2765
|
+
async load(mapName, key) {
|
|
2766
|
+
return this.getMap(mapName).get(key);
|
|
2767
|
+
}
|
|
2768
|
+
async loadAll(mapName, keys) {
|
|
2769
|
+
const map = this.getMap(mapName);
|
|
2770
|
+
const result = /* @__PURE__ */ new Map();
|
|
2771
|
+
for (const key of keys) {
|
|
2772
|
+
const value = map.get(key);
|
|
2773
|
+
if (value !== void 0) {
|
|
2774
|
+
result.set(key, value);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
return result;
|
|
2778
|
+
}
|
|
2779
|
+
async loadAllKeys(mapName) {
|
|
2780
|
+
return Array.from(this.getMap(mapName).keys());
|
|
2781
|
+
}
|
|
2782
|
+
async store(mapName, key, record) {
|
|
2783
|
+
this.getMap(mapName).set(key, record);
|
|
2784
|
+
}
|
|
2785
|
+
async storeAll(mapName, records) {
|
|
2786
|
+
const map = this.getMap(mapName);
|
|
2787
|
+
for (const [key, value] of records) {
|
|
2788
|
+
map.set(key, value);
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
async delete(mapName, key) {
|
|
2792
|
+
this.getMap(mapName).delete(key);
|
|
2793
|
+
}
|
|
2794
|
+
async deleteAll(mapName, keys) {
|
|
2795
|
+
const map = this.getMap(mapName);
|
|
2796
|
+
for (const key of keys) {
|
|
2797
|
+
map.delete(key);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
};
|
|
2801
|
+
|
|
2802
|
+
// src/interceptor/TimestampInterceptor.ts
|
|
2803
|
+
var TimestampInterceptor = class {
|
|
2804
|
+
constructor() {
|
|
2805
|
+
this.name = "TimestampInterceptor";
|
|
2806
|
+
}
|
|
2807
|
+
async onBeforeOp(op, context) {
|
|
2808
|
+
if (op.opType === "PUT" && op.record && op.record.value) {
|
|
2809
|
+
if (typeof op.record.value === "object" && op.record.value !== null && !Array.isArray(op.record.value)) {
|
|
2810
|
+
const newValue = {
|
|
2811
|
+
...op.record.value,
|
|
2812
|
+
_serverTimestamp: Date.now()
|
|
2813
|
+
};
|
|
2814
|
+
logger.debug({ key: op.key, mapName: op.mapName, interceptor: this.name }, "Added timestamp");
|
|
2815
|
+
return {
|
|
2816
|
+
...op,
|
|
2817
|
+
record: {
|
|
2818
|
+
...op.record,
|
|
2819
|
+
value: newValue
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
return op;
|
|
2825
|
+
}
|
|
2826
|
+
};
|
|
2827
|
+
|
|
2828
|
+
// src/interceptor/RateLimitInterceptor.ts
|
|
2829
|
+
var RateLimitInterceptor = class {
|
|
2830
|
+
constructor(config = { windowMs: 1e3, maxOps: 50 }) {
|
|
2831
|
+
this.name = "RateLimitInterceptor";
|
|
2832
|
+
this.limits = /* @__PURE__ */ new Map();
|
|
2833
|
+
this.config = config;
|
|
2834
|
+
}
|
|
2835
|
+
async onBeforeOp(op, context) {
|
|
2836
|
+
const clientId = context.clientId;
|
|
2837
|
+
const now = Date.now();
|
|
2838
|
+
let limit = this.limits.get(clientId);
|
|
2839
|
+
if (!limit || now > limit.resetTime) {
|
|
2840
|
+
limit = {
|
|
2841
|
+
count: 0,
|
|
2842
|
+
resetTime: now + this.config.windowMs
|
|
2843
|
+
};
|
|
2844
|
+
this.limits.set(clientId, limit);
|
|
2845
|
+
}
|
|
2846
|
+
limit.count++;
|
|
2847
|
+
if (limit.count > this.config.maxOps) {
|
|
2848
|
+
logger.warn({ clientId, opId: op.id, count: limit.count }, "Rate limit exceeded");
|
|
2849
|
+
throw new Error("Rate limit exceeded");
|
|
2850
|
+
}
|
|
2851
|
+
return op;
|
|
2852
|
+
}
|
|
2853
|
+
// Cleanup old entries periodically?
|
|
2854
|
+
// For now we rely on resetTime check, but map grows.
|
|
2855
|
+
// Simple cleanup on reset logic:
|
|
2856
|
+
// In a real system, we'd use Redis or a proper cache with TTL.
|
|
2857
|
+
// Here we can just prune occasionally or relying on connection disconnect?
|
|
2858
|
+
// Optimization: Cleanup on disconnect
|
|
2859
|
+
async onDisconnect(context) {
|
|
2860
|
+
this.limits.delete(context.clientId);
|
|
2861
|
+
}
|
|
2862
|
+
};
|
|
2863
|
+
export {
|
|
2864
|
+
MemoryServerAdapter,
|
|
2865
|
+
PostgresAdapter,
|
|
2866
|
+
RateLimitInterceptor,
|
|
2867
|
+
SecurityManager,
|
|
2868
|
+
ServerCoordinator,
|
|
2869
|
+
TimestampInterceptor,
|
|
2870
|
+
logger
|
|
2871
|
+
};
|
|
2872
|
+
//# sourceMappingURL=index.mjs.map
|