lakesync 0.1.8 → 0.2.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/dist/adapter-types-DwsQGQS4.d.ts +94 -0
- package/dist/adapter.d.ts +23 -49
- package/dist/adapter.js +9 -4
- package/dist/analyst.js +1 -1
- package/dist/{base-poller-Bj9kX9dv.d.ts → base-poller-Y7ORYgUv.d.ts} +2 -0
- package/dist/catalogue.js +2 -2
- package/dist/{chunk-LDFFCG2K.js → chunk-4SG66H5K.js} +44 -31
- package/dist/chunk-4SG66H5K.js.map +1 -0
- package/dist/{chunk-LPWXOYNS.js → chunk-C4KD6YKP.js} +59 -43
- package/dist/chunk-C4KD6YKP.js.map +1 -0
- package/dist/{chunk-JI4C4R5H.js → chunk-FIIHPQMQ.js} +196 -118
- package/dist/chunk-FIIHPQMQ.js.map +1 -0
- package/dist/{chunk-TMLG32QV.js → chunk-U2NV4DUX.js} +2 -2
- package/dist/{chunk-QNITY4F6.js → chunk-XVP5DJJ7.js} +16 -13
- package/dist/{chunk-QNITY4F6.js.map → chunk-XVP5DJJ7.js.map} +1 -1
- package/dist/{chunk-KVSWLIJR.js → chunk-YHYBLU6W.js} +2 -2
- package/dist/{chunk-PYRS74YP.js → chunk-ZNY4DSFU.js} +16 -13
- package/dist/{chunk-PYRS74YP.js.map → chunk-ZNY4DSFU.js.map} +1 -1
- package/dist/{chunk-SSICS5KI.js → chunk-ZU7RC7CT.js} +2 -2
- package/dist/client.d.ts +28 -10
- package/dist/client.js +150 -29
- package/dist/client.js.map +1 -1
- package/dist/compactor.d.ts +1 -1
- package/dist/compactor.js +3 -3
- package/dist/connector-jira.d.ts +13 -3
- package/dist/connector-jira.js +6 -2
- package/dist/connector-salesforce.d.ts +13 -3
- package/dist/connector-salesforce.js +6 -2
- package/dist/{coordinator-NXy6tA0h.d.ts → coordinator-eGmZMnJ_.d.ts} +99 -16
- package/dist/create-poller-Cc2MGfhh.d.ts +55 -0
- package/dist/factory-DFfR-030.d.ts +33 -0
- package/dist/gateway-server.d.ts +398 -95
- package/dist/gateway-server.js +743 -56
- package/dist/gateway-server.js.map +1 -1
- package/dist/gateway.d.ts +14 -8
- package/dist/gateway.js +6 -5
- package/dist/index.d.ts +45 -73
- package/dist/index.js +5 -3
- package/dist/parquet.js +2 -2
- package/dist/proto.js +2 -2
- package/dist/react.d.ts +3 -3
- package/dist/{registry-BcspAtZI.d.ts → registry-Dd8JuW8T.d.ts} +1 -1
- package/dist/{request-handler-pUvL7ozF.d.ts → request-handler-B1I5xDOx.d.ts} +71 -27
- package/dist/{src-ROW4XLO7.js → src-WU7IBVC4.js} +6 -4
- package/dist/{types-BrcD1oJg.d.ts → types-D2C9jTbL.d.ts} +33 -23
- package/package.json +1 -1
- package/dist/auth-CAVutXzx.d.ts +0 -30
- package/dist/chunk-JI4C4R5H.js.map +0 -1
- package/dist/chunk-LDFFCG2K.js.map +0 -1
- package/dist/chunk-LPWXOYNS.js.map +0 -1
- package/dist/db-types-CfLMUBfW.d.ts +0 -29
- package/dist/src-B6NLV3FP.js +0 -27
- package/dist/src-ROW4XLO7.js.map +0 -1
- package/dist/src-ZRHKG42A.js +0 -25
- package/dist/src-ZRHKG42A.js.map +0 -1
- package/dist/types-DSC_EiwR.d.ts +0 -45
- /package/dist/{chunk-TMLG32QV.js.map → chunk-U2NV4DUX.js.map} +0 -0
- /package/dist/{chunk-KVSWLIJR.js.map → chunk-YHYBLU6W.js.map} +0 -0
- /package/dist/{chunk-SSICS5KI.js.map → chunk-ZU7RC7CT.js.map} +0 -0
- /package/dist/{src-B6NLV3FP.js.map → src-WU7IBVC4.js.map} +0 -0
package/dist/gateway-server.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
jiraPollerFactory
|
|
3
|
+
} from "./chunk-ZNY4DSFU.js";
|
|
4
|
+
import {
|
|
5
|
+
salesforcePollerFactory
|
|
6
|
+
} from "./chunk-XVP5DJJ7.js";
|
|
1
7
|
import {
|
|
2
8
|
DEFAULT_MAX_BUFFER_AGE_MS,
|
|
3
9
|
DEFAULT_MAX_BUFFER_BYTES,
|
|
@@ -15,12 +21,13 @@ import {
|
|
|
15
21
|
handleSaveSchema,
|
|
16
22
|
handleSaveSyncRules,
|
|
17
23
|
handleUnregisterConnector
|
|
18
|
-
} from "./chunk-
|
|
24
|
+
} from "./chunk-FIIHPQMQ.js";
|
|
19
25
|
import {
|
|
20
26
|
createDatabaseAdapter,
|
|
21
|
-
createQueryFn
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
createQueryFn,
|
|
28
|
+
defaultAdapterFactoryRegistry
|
|
29
|
+
} from "./chunk-C4KD6YKP.js";
|
|
30
|
+
import "./chunk-U2NV4DUX.js";
|
|
24
31
|
import {
|
|
25
32
|
TAG_SYNC_PULL,
|
|
26
33
|
TAG_SYNC_PUSH,
|
|
@@ -28,18 +35,23 @@ import {
|
|
|
28
35
|
decodeSyncPush,
|
|
29
36
|
encodeBroadcastFrame,
|
|
30
37
|
encodeSyncResponse
|
|
31
|
-
} from "./chunk-
|
|
32
|
-
import "./chunk-
|
|
38
|
+
} from "./chunk-YHYBLU6W.js";
|
|
39
|
+
import "./chunk-ZU7RC7CT.js";
|
|
33
40
|
import {
|
|
34
41
|
AuthError,
|
|
42
|
+
Err,
|
|
35
43
|
HLC,
|
|
44
|
+
Ok,
|
|
36
45
|
bigintReplacer,
|
|
37
46
|
bigintReviver,
|
|
47
|
+
createPoller,
|
|
48
|
+
createPollerRegistry,
|
|
38
49
|
extractDelta,
|
|
39
50
|
filterDeltas,
|
|
40
51
|
isActionHandler,
|
|
52
|
+
isDatabaseAdapter,
|
|
41
53
|
verifyToken
|
|
42
|
-
} from "./chunk-
|
|
54
|
+
} from "./chunk-4SG66H5K.js";
|
|
43
55
|
import {
|
|
44
56
|
__require
|
|
45
57
|
} from "./chunk-DGUM43GV.js";
|
|
@@ -158,6 +170,8 @@ var SourcePoller = class {
|
|
|
158
170
|
cursorStates = /* @__PURE__ */ new Map();
|
|
159
171
|
/** Diff snapshot per table (keyed by table name). */
|
|
160
172
|
diffStates = /* @__PURE__ */ new Map();
|
|
173
|
+
/** Optional callback invoked after each poll with the current cursor state. */
|
|
174
|
+
onCursorUpdate;
|
|
161
175
|
constructor(config, gateway) {
|
|
162
176
|
this.config = config;
|
|
163
177
|
this.gateway = gateway;
|
|
@@ -195,6 +209,22 @@ var SourcePoller = class {
|
|
|
195
209
|
this.schedulePoll();
|
|
196
210
|
}, this.config.intervalMs ?? DEFAULT_INTERVAL_MS);
|
|
197
211
|
}
|
|
212
|
+
/** Export cursor state as a JSON-serialisable object for external persistence. */
|
|
213
|
+
getCursorState() {
|
|
214
|
+
const cursors = {};
|
|
215
|
+
for (const [table, state] of this.cursorStates) {
|
|
216
|
+
cursors[table] = state.lastCursor;
|
|
217
|
+
}
|
|
218
|
+
return { cursorStates: cursors };
|
|
219
|
+
}
|
|
220
|
+
/** Restore cursor state from a previously exported snapshot. */
|
|
221
|
+
setCursorState(state) {
|
|
222
|
+
const cursors = state.cursorStates;
|
|
223
|
+
if (!cursors) return;
|
|
224
|
+
for (const [table, cursor] of Object.entries(cursors)) {
|
|
225
|
+
this.cursorStates.set(table, { lastCursor: cursor });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
198
228
|
/** Execute a single poll cycle across all configured tables. */
|
|
199
229
|
async poll() {
|
|
200
230
|
const allDeltas = [];
|
|
@@ -204,13 +234,21 @@ var SourcePoller = class {
|
|
|
204
234
|
allDeltas.push(d);
|
|
205
235
|
}
|
|
206
236
|
}
|
|
207
|
-
if (allDeltas.length === 0)
|
|
237
|
+
if (allDeltas.length === 0) {
|
|
238
|
+
if (this.onCursorUpdate) {
|
|
239
|
+
this.onCursorUpdate(this.getCursorState());
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
208
243
|
const push = {
|
|
209
244
|
clientId: this.clientId,
|
|
210
245
|
deltas: allDeltas,
|
|
211
246
|
lastSeenHlc: 0n
|
|
212
247
|
};
|
|
213
248
|
this.gateway.handlePush(push);
|
|
249
|
+
if (this.onCursorUpdate) {
|
|
250
|
+
this.onCursorUpdate(this.getCursorState());
|
|
251
|
+
}
|
|
214
252
|
}
|
|
215
253
|
// -----------------------------------------------------------------------
|
|
216
254
|
// Cursor strategy
|
|
@@ -321,17 +359,24 @@ var SourcePoller = class {
|
|
|
321
359
|
|
|
322
360
|
// ../gateway-server/src/connector-manager.ts
|
|
323
361
|
var ConnectorManager = class {
|
|
324
|
-
constructor(configStore, gateway) {
|
|
362
|
+
constructor(configStore, gateway, options) {
|
|
325
363
|
this.configStore = configStore;
|
|
326
364
|
this.gateway = gateway;
|
|
365
|
+
this.pollerRegistry = options?.pollerRegistry ?? createPollerRegistry();
|
|
366
|
+
this.adapterRegistry = options?.adapterRegistry ?? defaultAdapterFactoryRegistry();
|
|
367
|
+
this.persistence = options?.persistence ?? null;
|
|
327
368
|
}
|
|
328
369
|
adapters = /* @__PURE__ */ new Map();
|
|
329
370
|
pollers = /* @__PURE__ */ new Map();
|
|
371
|
+
pollerRegistry;
|
|
372
|
+
adapterRegistry;
|
|
373
|
+
persistence;
|
|
330
374
|
/**
|
|
331
375
|
* Register a connector from raw JSON body.
|
|
332
376
|
*
|
|
333
|
-
* Validates via shared handler,
|
|
334
|
-
*
|
|
377
|
+
* Validates via shared handler, then dispatches to the appropriate
|
|
378
|
+
* registry: poller registry for API-based connectors, adapter registry
|
|
379
|
+
* for database-based connectors.
|
|
335
380
|
*/
|
|
336
381
|
async register(raw) {
|
|
337
382
|
const result = await handleRegisterConnector(raw, this.configStore);
|
|
@@ -344,30 +389,19 @@ var ConnectorManager = class {
|
|
|
344
389
|
if (!config) {
|
|
345
390
|
return result;
|
|
346
391
|
}
|
|
347
|
-
|
|
392
|
+
const pollerFactory = this.pollerRegistry.get(config.type);
|
|
393
|
+
if (pollerFactory) {
|
|
348
394
|
try {
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
if (config.type === "salesforce") {
|
|
362
|
-
try {
|
|
363
|
-
const { SalesforceSourcePoller } = await import("./src-B6NLV3FP.js");
|
|
364
|
-
const ingestConfig = config.ingest ? { intervalMs: config.ingest.intervalMs } : void 0;
|
|
365
|
-
const poller = new SalesforceSourcePoller(
|
|
366
|
-
config.salesforce,
|
|
367
|
-
ingestConfig,
|
|
368
|
-
config.name,
|
|
369
|
-
this.gateway
|
|
370
|
-
);
|
|
395
|
+
const poller = createPoller(config, this.gateway, this.pollerRegistry);
|
|
396
|
+
if (this.persistence) {
|
|
397
|
+
const saved = this.persistence.loadCursor(config.name);
|
|
398
|
+
if (saved) {
|
|
399
|
+
poller.setCursorState(JSON.parse(saved));
|
|
400
|
+
}
|
|
401
|
+
poller.onCursorUpdate = (state) => {
|
|
402
|
+
this.persistence.saveCursor(config.name, JSON.stringify(state));
|
|
403
|
+
};
|
|
404
|
+
}
|
|
371
405
|
poller.start();
|
|
372
406
|
this.pollers.set(config.name, poller);
|
|
373
407
|
return result;
|
|
@@ -376,11 +410,11 @@ var ConnectorManager = class {
|
|
|
376
410
|
const message = err instanceof Error ? err.message : String(err);
|
|
377
411
|
return {
|
|
378
412
|
status: 500,
|
|
379
|
-
body: { error: `Failed to
|
|
413
|
+
body: { error: `Failed to create poller for "${config.type}": ${message}` }
|
|
380
414
|
};
|
|
381
415
|
}
|
|
382
416
|
}
|
|
383
|
-
const adapterResult = createDatabaseAdapter(config);
|
|
417
|
+
const adapterResult = createDatabaseAdapter(config, this.adapterRegistry);
|
|
384
418
|
if (!adapterResult.ok) {
|
|
385
419
|
await this.rollbackRegistration(connectors, registeredName);
|
|
386
420
|
return { status: 500, body: { error: adapterResult.error.message } };
|
|
@@ -406,6 +440,15 @@ var ConnectorManager = class {
|
|
|
406
440
|
intervalMs: config.ingest.intervalMs
|
|
407
441
|
};
|
|
408
442
|
const poller = new SourcePoller(pollerConfig, this.gateway);
|
|
443
|
+
if (this.persistence) {
|
|
444
|
+
const saved = this.persistence.loadCursor(config.name);
|
|
445
|
+
if (saved) {
|
|
446
|
+
poller.setCursorState(JSON.parse(saved));
|
|
447
|
+
}
|
|
448
|
+
poller.onCursorUpdate = (state) => {
|
|
449
|
+
this.persistence.saveCursor(config.name, JSON.stringify(state));
|
|
450
|
+
};
|
|
451
|
+
}
|
|
409
452
|
poller.start();
|
|
410
453
|
this.pollers.set(config.name, poller);
|
|
411
454
|
}
|
|
@@ -498,9 +541,264 @@ function handlePreflight(method, res, corsH) {
|
|
|
498
541
|
return true;
|
|
499
542
|
}
|
|
500
543
|
|
|
544
|
+
// ../gateway-server/src/logger.ts
|
|
545
|
+
var LEVEL_VALUE = {
|
|
546
|
+
debug: 0,
|
|
547
|
+
info: 1,
|
|
548
|
+
warn: 2,
|
|
549
|
+
error: 3
|
|
550
|
+
};
|
|
551
|
+
var Logger = class _Logger {
|
|
552
|
+
minLevelValue;
|
|
553
|
+
bindings;
|
|
554
|
+
/** Output function — defaults to stdout, overridable for testing. */
|
|
555
|
+
writeFn;
|
|
556
|
+
constructor(minLevel = "info", bindings = {}, writeFn) {
|
|
557
|
+
this.minLevelValue = LEVEL_VALUE[minLevel];
|
|
558
|
+
this.bindings = bindings;
|
|
559
|
+
this.writeFn = writeFn ?? ((line) => process.stdout.write(`${line}
|
|
560
|
+
`));
|
|
561
|
+
}
|
|
562
|
+
/** Log at debug level. */
|
|
563
|
+
debug(msg, data) {
|
|
564
|
+
this.log("debug", msg, data);
|
|
565
|
+
}
|
|
566
|
+
/** Log at info level. */
|
|
567
|
+
info(msg, data) {
|
|
568
|
+
this.log("info", msg, data);
|
|
569
|
+
}
|
|
570
|
+
/** Log at warn level. */
|
|
571
|
+
warn(msg, data) {
|
|
572
|
+
this.log("warn", msg, data);
|
|
573
|
+
}
|
|
574
|
+
/** Log at error level. */
|
|
575
|
+
error(msg, data) {
|
|
576
|
+
this.log("error", msg, data);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Create a child logger with additional bound context.
|
|
580
|
+
*
|
|
581
|
+
* The child inherits the parent's level and write function, plus
|
|
582
|
+
* merges any parent bindings with the new ones.
|
|
583
|
+
*/
|
|
584
|
+
child(bindings) {
|
|
585
|
+
return new _Logger(this.minLevelName(), { ...this.bindings, ...bindings }, this.writeFn);
|
|
586
|
+
}
|
|
587
|
+
// -----------------------------------------------------------------------
|
|
588
|
+
// Internal
|
|
589
|
+
// -----------------------------------------------------------------------
|
|
590
|
+
log(level, msg, data) {
|
|
591
|
+
if (LEVEL_VALUE[level] < this.minLevelValue) return;
|
|
592
|
+
const entry = {
|
|
593
|
+
level,
|
|
594
|
+
msg,
|
|
595
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
596
|
+
...this.bindings,
|
|
597
|
+
...data
|
|
598
|
+
};
|
|
599
|
+
this.writeFn(JSON.stringify(entry));
|
|
600
|
+
}
|
|
601
|
+
minLevelName() {
|
|
602
|
+
for (const [name, val] of Object.entries(LEVEL_VALUE)) {
|
|
603
|
+
if (val === this.minLevelValue) return name;
|
|
604
|
+
}
|
|
605
|
+
return "info";
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// ../gateway-server/src/metrics.ts
|
|
610
|
+
var Counter = class {
|
|
611
|
+
constructor(name, help) {
|
|
612
|
+
this.name = name;
|
|
613
|
+
this.help = help;
|
|
614
|
+
}
|
|
615
|
+
values = /* @__PURE__ */ new Map();
|
|
616
|
+
/** Increment the counter by `n` (default 1). */
|
|
617
|
+
inc(labels = {}, n = 1) {
|
|
618
|
+
const key = labelKey(labels);
|
|
619
|
+
this.values.set(key, (this.values.get(key) ?? 0) + n);
|
|
620
|
+
}
|
|
621
|
+
/** Return the current value for the given labels. */
|
|
622
|
+
get(labels = {}) {
|
|
623
|
+
return this.values.get(labelKey(labels)) ?? 0;
|
|
624
|
+
}
|
|
625
|
+
/** Reset all values. */
|
|
626
|
+
reset() {
|
|
627
|
+
this.values.clear();
|
|
628
|
+
}
|
|
629
|
+
/** Serialise to Prometheus text exposition format. */
|
|
630
|
+
expose() {
|
|
631
|
+
const lines = [];
|
|
632
|
+
lines.push(`# HELP ${this.name} ${this.help}`);
|
|
633
|
+
lines.push(`# TYPE ${this.name} counter`);
|
|
634
|
+
for (const [key, val] of this.values) {
|
|
635
|
+
lines.push(`${this.name}${key} ${val}`);
|
|
636
|
+
}
|
|
637
|
+
return lines.join("\n");
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
var Gauge = class {
|
|
641
|
+
constructor(name, help) {
|
|
642
|
+
this.name = name;
|
|
643
|
+
this.help = help;
|
|
644
|
+
}
|
|
645
|
+
values = /* @__PURE__ */ new Map();
|
|
646
|
+
/** Set to an absolute value. */
|
|
647
|
+
set(labels = {}, value = 0) {
|
|
648
|
+
this.values.set(labelKey(labels), value);
|
|
649
|
+
}
|
|
650
|
+
/** Increment by `n` (default 1). */
|
|
651
|
+
inc(labels = {}, n = 1) {
|
|
652
|
+
const key = labelKey(labels);
|
|
653
|
+
this.values.set(key, (this.values.get(key) ?? 0) + n);
|
|
654
|
+
}
|
|
655
|
+
/** Decrement by `n` (default 1). */
|
|
656
|
+
dec(labels = {}, n = 1) {
|
|
657
|
+
const key = labelKey(labels);
|
|
658
|
+
this.values.set(key, (this.values.get(key) ?? 0) - n);
|
|
659
|
+
}
|
|
660
|
+
/** Return the current value for the given labels. */
|
|
661
|
+
get(labels = {}) {
|
|
662
|
+
return this.values.get(labelKey(labels)) ?? 0;
|
|
663
|
+
}
|
|
664
|
+
/** Reset all values. */
|
|
665
|
+
reset() {
|
|
666
|
+
this.values.clear();
|
|
667
|
+
}
|
|
668
|
+
/** Serialise to Prometheus text exposition format. */
|
|
669
|
+
expose() {
|
|
670
|
+
const lines = [];
|
|
671
|
+
lines.push(`# HELP ${this.name} ${this.help}`);
|
|
672
|
+
lines.push(`# TYPE ${this.name} gauge`);
|
|
673
|
+
for (const [key, val] of this.values) {
|
|
674
|
+
lines.push(`${this.name}${key} ${val}`);
|
|
675
|
+
}
|
|
676
|
+
return lines.join("\n");
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
var Histogram = class {
|
|
680
|
+
constructor(name, help, buckets) {
|
|
681
|
+
this.name = name;
|
|
682
|
+
this.help = help;
|
|
683
|
+
this.buckets = buckets;
|
|
684
|
+
this.buckets = [...buckets].sort((a, b) => a - b);
|
|
685
|
+
}
|
|
686
|
+
data = /* @__PURE__ */ new Map();
|
|
687
|
+
/** Record an observation. */
|
|
688
|
+
observe(labels = {}, value = 0) {
|
|
689
|
+
const key = labelKey(labels);
|
|
690
|
+
let bucket = this.data.get(key);
|
|
691
|
+
if (!bucket) {
|
|
692
|
+
bucket = {
|
|
693
|
+
bucketCounts: new Array(this.buckets.length + 1).fill(0),
|
|
694
|
+
sum: 0,
|
|
695
|
+
count: 0
|
|
696
|
+
};
|
|
697
|
+
this.data.set(key, bucket);
|
|
698
|
+
}
|
|
699
|
+
bucket.sum += value;
|
|
700
|
+
bucket.count += 1;
|
|
701
|
+
for (let i = 0; i < this.buckets.length; i++) {
|
|
702
|
+
if (value <= this.buckets[i]) {
|
|
703
|
+
bucket.bucketCounts[i]++;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
bucket.bucketCounts[this.buckets.length]++;
|
|
707
|
+
}
|
|
708
|
+
/** Return the count of observations for the given labels. */
|
|
709
|
+
getCount(labels = {}) {
|
|
710
|
+
return this.data.get(labelKey(labels))?.count ?? 0;
|
|
711
|
+
}
|
|
712
|
+
/** Return the sum of observations for the given labels. */
|
|
713
|
+
getSum(labels = {}) {
|
|
714
|
+
return this.data.get(labelKey(labels))?.sum ?? 0;
|
|
715
|
+
}
|
|
716
|
+
/** Reset all values. */
|
|
717
|
+
reset() {
|
|
718
|
+
this.data.clear();
|
|
719
|
+
}
|
|
720
|
+
/** Serialise to Prometheus text exposition format. */
|
|
721
|
+
expose() {
|
|
722
|
+
const lines = [];
|
|
723
|
+
lines.push(`# HELP ${this.name} ${this.help}`);
|
|
724
|
+
lines.push(`# TYPE ${this.name} histogram`);
|
|
725
|
+
for (const [key, bucket] of this.data) {
|
|
726
|
+
const labelStr = key === "" ? "" : key;
|
|
727
|
+
const separator = labelStr === "" ? "{" : `${labelStr.slice(0, -1)},`;
|
|
728
|
+
const closeBrace = "}";
|
|
729
|
+
for (let i = 0; i < this.buckets.length; i++) {
|
|
730
|
+
const le = this.buckets[i];
|
|
731
|
+
lines.push(
|
|
732
|
+
`${this.name}_bucket${separator}le="${le}"${closeBrace} ${bucket.bucketCounts[i]}`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
lines.push(
|
|
736
|
+
`${this.name}_bucket${separator}le="+Inf"${closeBrace} ${bucket.bucketCounts[this.buckets.length]}`
|
|
737
|
+
);
|
|
738
|
+
lines.push(`${this.name}_sum${labelStr} ${bucket.sum}`);
|
|
739
|
+
lines.push(`${this.name}_count${labelStr} ${bucket.count}`);
|
|
740
|
+
}
|
|
741
|
+
return lines.join("\n");
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
var MetricsRegistry = class {
|
|
745
|
+
pushTotal = new Counter("lakesync_push_total", "Total push requests");
|
|
746
|
+
pullTotal = new Counter("lakesync_pull_total", "Total pull requests");
|
|
747
|
+
flushTotal = new Counter("lakesync_flush_total", "Total flush operations");
|
|
748
|
+
flushDuration = new Histogram(
|
|
749
|
+
"lakesync_flush_duration_ms",
|
|
750
|
+
"Flush duration in milliseconds",
|
|
751
|
+
[10, 50, 100, 500, 1e3, 5e3]
|
|
752
|
+
);
|
|
753
|
+
pushLatency = new Histogram(
|
|
754
|
+
"lakesync_push_latency_ms",
|
|
755
|
+
"Push request latency in milliseconds",
|
|
756
|
+
[1, 5, 10, 50, 100, 500]
|
|
757
|
+
);
|
|
758
|
+
bufferBytes = new Gauge("lakesync_buffer_bytes", "Current buffer size in bytes");
|
|
759
|
+
bufferDeltas = new Gauge("lakesync_buffer_deltas", "Current number of buffered deltas");
|
|
760
|
+
wsConnections = new Gauge("lakesync_ws_connections", "Active WebSocket connections");
|
|
761
|
+
activeRequests = new Gauge("lakesync_active_requests", "In-flight HTTP requests");
|
|
762
|
+
/** Return the full Prometheus text exposition payload. */
|
|
763
|
+
expose() {
|
|
764
|
+
const sections = [
|
|
765
|
+
this.pushTotal.expose(),
|
|
766
|
+
this.pullTotal.expose(),
|
|
767
|
+
this.flushTotal.expose(),
|
|
768
|
+
this.flushDuration.expose(),
|
|
769
|
+
this.pushLatency.expose(),
|
|
770
|
+
this.bufferBytes.expose(),
|
|
771
|
+
this.bufferDeltas.expose(),
|
|
772
|
+
this.wsConnections.expose(),
|
|
773
|
+
this.activeRequests.expose()
|
|
774
|
+
];
|
|
775
|
+
return `${sections.join("\n\n")}
|
|
776
|
+
`;
|
|
777
|
+
}
|
|
778
|
+
/** Reset all metrics. */
|
|
779
|
+
reset() {
|
|
780
|
+
this.pushTotal.reset();
|
|
781
|
+
this.pullTotal.reset();
|
|
782
|
+
this.flushTotal.reset();
|
|
783
|
+
this.flushDuration.reset();
|
|
784
|
+
this.pushLatency.reset();
|
|
785
|
+
this.bufferBytes.reset();
|
|
786
|
+
this.bufferDeltas.reset();
|
|
787
|
+
this.wsConnections.reset();
|
|
788
|
+
this.activeRequests.reset();
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
function labelKey(labels) {
|
|
792
|
+
const entries = Object.entries(labels);
|
|
793
|
+
if (entries.length === 0) return "";
|
|
794
|
+
const parts = entries.map(([k, v]) => `${k}="${v}"`).join(",");
|
|
795
|
+
return `{${parts}}`;
|
|
796
|
+
}
|
|
797
|
+
|
|
501
798
|
// ../gateway-server/src/persistence.ts
|
|
502
799
|
var MemoryPersistence = class {
|
|
503
800
|
buffer = [];
|
|
801
|
+
cursors = /* @__PURE__ */ new Map();
|
|
504
802
|
appendBatch(deltas) {
|
|
505
803
|
this.buffer.push(...deltas);
|
|
506
804
|
}
|
|
@@ -512,6 +810,13 @@ var MemoryPersistence = class {
|
|
|
512
810
|
}
|
|
513
811
|
close() {
|
|
514
812
|
this.buffer = [];
|
|
813
|
+
this.cursors.clear();
|
|
814
|
+
}
|
|
815
|
+
saveCursor(connectorName, cursor) {
|
|
816
|
+
this.cursors.set(connectorName, cursor);
|
|
817
|
+
}
|
|
818
|
+
loadCursor(connectorName) {
|
|
819
|
+
return this.cursors.get(connectorName) ?? null;
|
|
515
820
|
}
|
|
516
821
|
};
|
|
517
822
|
var SqlitePersistence = class {
|
|
@@ -523,6 +828,9 @@ var SqlitePersistence = class {
|
|
|
523
828
|
this.db.exec(
|
|
524
829
|
"CREATE TABLE IF NOT EXISTS unflushed_deltas (id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL)"
|
|
525
830
|
);
|
|
831
|
+
this.db.exec(
|
|
832
|
+
"CREATE TABLE IF NOT EXISTS connector_cursors (name TEXT PRIMARY KEY, cursor TEXT NOT NULL)"
|
|
833
|
+
);
|
|
526
834
|
}
|
|
527
835
|
appendBatch(deltas) {
|
|
528
836
|
const stmt = this.db.prepare("INSERT INTO unflushed_deltas (data) VALUES (?)");
|
|
@@ -540,11 +848,89 @@ var SqlitePersistence = class {
|
|
|
540
848
|
clear() {
|
|
541
849
|
this.db.exec("DELETE FROM unflushed_deltas");
|
|
542
850
|
}
|
|
851
|
+
saveCursor(connectorName, cursor) {
|
|
852
|
+
this.db.prepare(
|
|
853
|
+
"INSERT INTO connector_cursors (name, cursor) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET cursor = excluded.cursor"
|
|
854
|
+
).run(connectorName, cursor);
|
|
855
|
+
}
|
|
856
|
+
loadCursor(connectorName) {
|
|
857
|
+
const row = this.db.prepare("SELECT cursor FROM connector_cursors WHERE name = ?").get(connectorName);
|
|
858
|
+
return row?.cursor ?? null;
|
|
859
|
+
}
|
|
543
860
|
close() {
|
|
544
861
|
this.db.close();
|
|
545
862
|
}
|
|
546
863
|
};
|
|
547
864
|
|
|
865
|
+
// ../gateway-server/src/rate-limiter.ts
|
|
866
|
+
var DEFAULT_MAX_REQUESTS = 100;
|
|
867
|
+
var DEFAULT_WINDOW_MS = 6e4;
|
|
868
|
+
var CLEANUP_INTERVAL_MS = 6e4;
|
|
869
|
+
var RateLimiter = class {
|
|
870
|
+
maxRequests;
|
|
871
|
+
windowMs;
|
|
872
|
+
clients = /* @__PURE__ */ new Map();
|
|
873
|
+
cleanupTimer = null;
|
|
874
|
+
constructor(config = {}) {
|
|
875
|
+
this.maxRequests = config.maxRequests ?? DEFAULT_MAX_REQUESTS;
|
|
876
|
+
this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
|
|
877
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
878
|
+
if (this.cleanupTimer.unref) {
|
|
879
|
+
this.cleanupTimer.unref();
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Attempt to consume one request token for the given client.
|
|
884
|
+
*
|
|
885
|
+
* @returns `true` if the request is allowed, `false` if rate-limited.
|
|
886
|
+
*/
|
|
887
|
+
tryConsume(clientId) {
|
|
888
|
+
const now = Date.now();
|
|
889
|
+
const entry = this.clients.get(clientId);
|
|
890
|
+
if (!entry || now - entry.windowStart >= this.windowMs) {
|
|
891
|
+
this.clients.set(clientId, { count: 1, windowStart: now });
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
if (entry.count >= this.maxRequests) {
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
entry.count++;
|
|
898
|
+
return true;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Calculate the number of seconds until the current window resets
|
|
902
|
+
* for a given client. Used for the Retry-After header.
|
|
903
|
+
*/
|
|
904
|
+
retryAfterSeconds(clientId) {
|
|
905
|
+
const entry = this.clients.get(clientId);
|
|
906
|
+
if (!entry) return 0;
|
|
907
|
+
const elapsed = Date.now() - entry.windowStart;
|
|
908
|
+
const remaining = Math.max(0, this.windowMs - elapsed);
|
|
909
|
+
return Math.ceil(remaining / 1e3);
|
|
910
|
+
}
|
|
911
|
+
/** Remove all tracked clients and reset state. */
|
|
912
|
+
reset() {
|
|
913
|
+
this.clients.clear();
|
|
914
|
+
}
|
|
915
|
+
/** Stop the periodic cleanup timer. */
|
|
916
|
+
dispose() {
|
|
917
|
+
if (this.cleanupTimer) {
|
|
918
|
+
clearInterval(this.cleanupTimer);
|
|
919
|
+
this.cleanupTimer = null;
|
|
920
|
+
}
|
|
921
|
+
this.clients.clear();
|
|
922
|
+
}
|
|
923
|
+
/** Remove stale entries whose window has expired. */
|
|
924
|
+
cleanup() {
|
|
925
|
+
const now = Date.now();
|
|
926
|
+
for (const [clientId, entry] of this.clients) {
|
|
927
|
+
if (now - entry.windowStart >= this.windowMs) {
|
|
928
|
+
this.clients.delete(clientId);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
|
|
548
934
|
// ../gateway-server/src/router.ts
|
|
549
935
|
var ROUTES = [
|
|
550
936
|
["POST", /^\/sync\/([^/]+)\/push$/, "push"],
|
|
@@ -579,19 +965,40 @@ import { createServer } from "http";
|
|
|
579
965
|
|
|
580
966
|
// ../gateway-server/src/shared-buffer.ts
|
|
581
967
|
var SharedBuffer = class {
|
|
582
|
-
constructor(sharedAdapter) {
|
|
968
|
+
constructor(sharedAdapter, config) {
|
|
583
969
|
this.sharedAdapter = sharedAdapter;
|
|
970
|
+
this.consistencyMode = config?.consistencyMode ?? "eventual";
|
|
584
971
|
}
|
|
972
|
+
consistencyMode;
|
|
585
973
|
/**
|
|
586
974
|
* Write-through push: write to shared adapter for cross-instance visibility.
|
|
587
975
|
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
976
|
+
* In "eventual" mode (default), failures are logged but do not fail the push.
|
|
977
|
+
* In "strong" mode, failures are returned as errors.
|
|
590
978
|
*/
|
|
591
979
|
async writeThroughPush(deltas) {
|
|
592
980
|
try {
|
|
593
|
-
await this.sharedAdapter.insertDeltas(deltas);
|
|
594
|
-
|
|
981
|
+
const result = await this.sharedAdapter.insertDeltas(deltas);
|
|
982
|
+
if (!result.ok) {
|
|
983
|
+
if (this.consistencyMode === "strong") {
|
|
984
|
+
return Err({ code: "SHARED_WRITE_FAILED", message: result.error.message });
|
|
985
|
+
}
|
|
986
|
+
console.warn(
|
|
987
|
+
`[lakesync] Shared buffer write failed (eventual mode): ${result.error.message}`
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
return Ok(void 0);
|
|
991
|
+
} catch (error) {
|
|
992
|
+
if (this.consistencyMode === "strong") {
|
|
993
|
+
return Err({
|
|
994
|
+
code: "SHARED_WRITE_FAILED",
|
|
995
|
+
message: error instanceof Error ? error.message : String(error)
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
console.warn(
|
|
999
|
+
`[lakesync] Shared buffer write error (eventual mode): ${error instanceof Error ? error.message : String(error)}`
|
|
1000
|
+
);
|
|
1001
|
+
return Ok(void 0);
|
|
595
1002
|
}
|
|
596
1003
|
}
|
|
597
1004
|
/**
|
|
@@ -626,15 +1033,31 @@ var SharedBuffer = class {
|
|
|
626
1033
|
// ../gateway-server/src/ws-manager.ts
|
|
627
1034
|
import { WebSocketServer } from "ws";
|
|
628
1035
|
var WebSocketManager = class {
|
|
629
|
-
constructor(gateway, configStore, gatewayId, jwtSecret) {
|
|
1036
|
+
constructor(gateway, configStore, gatewayId, jwtSecret, limits) {
|
|
630
1037
|
this.gateway = gateway;
|
|
631
1038
|
this.configStore = configStore;
|
|
632
1039
|
this.gatewayId = gatewayId;
|
|
633
1040
|
this.jwtSecret = jwtSecret;
|
|
634
1041
|
this.wss = new WebSocketServer({ noServer: true });
|
|
1042
|
+
this.maxConnections = limits?.maxConnections ?? 1e3;
|
|
1043
|
+
this.maxMessagesPerSecond = limits?.maxMessagesPerSecond ?? 50;
|
|
1044
|
+
this.rateResetTimer = setInterval(() => {
|
|
1045
|
+
this.messageRates.clear();
|
|
1046
|
+
}, 1e3);
|
|
1047
|
+
if (this.rateResetTimer.unref) {
|
|
1048
|
+
this.rateResetTimer.unref();
|
|
1049
|
+
}
|
|
635
1050
|
}
|
|
636
1051
|
wss;
|
|
637
1052
|
clients = /* @__PURE__ */ new Map();
|
|
1053
|
+
maxConnections;
|
|
1054
|
+
maxMessagesPerSecond;
|
|
1055
|
+
messageRates = /* @__PURE__ */ new Map();
|
|
1056
|
+
rateResetTimer = null;
|
|
1057
|
+
/** The current number of connected clients. */
|
|
1058
|
+
get connectionCount() {
|
|
1059
|
+
return this.clients.size;
|
|
1060
|
+
}
|
|
638
1061
|
/** Attach upgrade listener to an HTTP server. */
|
|
639
1062
|
attach(httpServer) {
|
|
640
1063
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
@@ -650,6 +1073,11 @@ var WebSocketManager = class {
|
|
|
650
1073
|
}
|
|
651
1074
|
/** Close all connections and shut down the WebSocket server. */
|
|
652
1075
|
close() {
|
|
1076
|
+
if (this.rateResetTimer) {
|
|
1077
|
+
clearInterval(this.rateResetTimer);
|
|
1078
|
+
this.rateResetTimer = null;
|
|
1079
|
+
}
|
|
1080
|
+
this.messageRates.clear();
|
|
653
1081
|
for (const ws of this.clients.keys()) {
|
|
654
1082
|
try {
|
|
655
1083
|
ws.close(1001, "Server shutting down");
|
|
@@ -663,6 +1091,11 @@ var WebSocketManager = class {
|
|
|
663
1091
|
// Upgrade handling
|
|
664
1092
|
// -----------------------------------------------------------------------
|
|
665
1093
|
async handleUpgrade(req, socket, head) {
|
|
1094
|
+
if (this.clients.size >= this.maxConnections) {
|
|
1095
|
+
socket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n");
|
|
1096
|
+
socket.destroy();
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
666
1099
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
667
1100
|
let token = extractBearerToken(req);
|
|
668
1101
|
if (!token) {
|
|
@@ -694,17 +1127,43 @@ var WebSocketManager = class {
|
|
|
694
1127
|
const claims = auth?.customClaims ?? {};
|
|
695
1128
|
this.clients.set(ws, { clientId, claims });
|
|
696
1129
|
ws.on("message", (data) => {
|
|
1130
|
+
if (!this.checkMessageRate(ws)) {
|
|
1131
|
+
ws.close(1008, "Rate limit exceeded");
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
697
1134
|
void this.handleMessage(ws, data, clientId, claims);
|
|
698
1135
|
});
|
|
699
1136
|
ws.on("close", () => {
|
|
700
1137
|
this.clients.delete(ws);
|
|
1138
|
+
this.messageRates.delete(ws);
|
|
701
1139
|
});
|
|
702
1140
|
ws.on("error", () => {
|
|
703
1141
|
this.clients.delete(ws);
|
|
1142
|
+
this.messageRates.delete(ws);
|
|
704
1143
|
});
|
|
705
1144
|
});
|
|
706
1145
|
}
|
|
707
1146
|
// -----------------------------------------------------------------------
|
|
1147
|
+
// Message rate limiting
|
|
1148
|
+
// -----------------------------------------------------------------------
|
|
1149
|
+
/**
|
|
1150
|
+
* Check and increment the message rate for a client.
|
|
1151
|
+
* @returns `true` if the message is allowed, `false` if rate-limited.
|
|
1152
|
+
*/
|
|
1153
|
+
checkMessageRate(ws) {
|
|
1154
|
+
const now = Date.now();
|
|
1155
|
+
const entry = this.messageRates.get(ws);
|
|
1156
|
+
if (!entry || now - entry.windowStart >= 1e3) {
|
|
1157
|
+
this.messageRates.set(ws, { count: 1, windowStart: now });
|
|
1158
|
+
return true;
|
|
1159
|
+
}
|
|
1160
|
+
if (entry.count >= this.maxMessagesPerSecond) {
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
entry.count++;
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
// -----------------------------------------------------------------------
|
|
708
1167
|
// Message handling
|
|
709
1168
|
// -----------------------------------------------------------------------
|
|
710
1169
|
async handleMessage(ws, data, clientId, claims) {
|
|
@@ -801,6 +1260,10 @@ var WebSocketManager = class {
|
|
|
801
1260
|
// ../gateway-server/src/server.ts
|
|
802
1261
|
var DEFAULT_PORT = 3e3;
|
|
803
1262
|
var DEFAULT_FLUSH_INTERVAL_MS = 3e4;
|
|
1263
|
+
var DEFAULT_DRAIN_TIMEOUT_MS = 1e4;
|
|
1264
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
1265
|
+
var DEFAULT_FLUSH_TIMEOUT_MS = 6e4;
|
|
1266
|
+
var DEFAULT_ADAPTER_HEALTH_TIMEOUT_MS = 5e3;
|
|
804
1267
|
function readBody(req) {
|
|
805
1268
|
return new Promise((resolve, reject) => {
|
|
806
1269
|
const chunks = [];
|
|
@@ -830,11 +1293,20 @@ var GatewayServer = class {
|
|
|
830
1293
|
persistence;
|
|
831
1294
|
connectors;
|
|
832
1295
|
sharedBuffer;
|
|
1296
|
+
rateLimiter;
|
|
1297
|
+
logger;
|
|
1298
|
+
metrics;
|
|
833
1299
|
httpServer = null;
|
|
834
1300
|
wsManager = null;
|
|
835
1301
|
flushTimer = null;
|
|
836
1302
|
resolvedPort = 0;
|
|
837
1303
|
pollers = [];
|
|
1304
|
+
/** Whether the server is draining (rejecting new requests during shutdown). */
|
|
1305
|
+
draining = false;
|
|
1306
|
+
/** Number of in-flight requests currently being handled. */
|
|
1307
|
+
activeRequests = 0;
|
|
1308
|
+
/** Signal handler cleanup functions. */
|
|
1309
|
+
signalCleanup = null;
|
|
838
1310
|
constructor(config) {
|
|
839
1311
|
this.config = {
|
|
840
1312
|
port: config.port ?? DEFAULT_PORT,
|
|
@@ -851,8 +1323,16 @@ var GatewayServer = class {
|
|
|
851
1323
|
);
|
|
852
1324
|
this.configStore = new MemoryConfigStore();
|
|
853
1325
|
this.persistence = config.persistence === "sqlite" ? new SqlitePersistence(config.sqlitePath ?? "./lakesync-buffer.sqlite") : new MemoryPersistence();
|
|
854
|
-
this.sharedBuffer = config.cluster ? new SharedBuffer(config.cluster.sharedAdapter) : null;
|
|
855
|
-
|
|
1326
|
+
this.sharedBuffer = config.cluster ? new SharedBuffer(config.cluster.sharedAdapter, config.cluster.sharedBufferConfig) : null;
|
|
1327
|
+
const pollerRegistry = config.pollerRegistry ?? createPollerRegistry().with("jira", jiraPollerFactory).with("salesforce", salesforcePollerFactory);
|
|
1328
|
+
this.connectors = new ConnectorManager(this.configStore, this.gateway, {
|
|
1329
|
+
pollerRegistry,
|
|
1330
|
+
adapterRegistry: config.adapterRegistry,
|
|
1331
|
+
persistence: this.persistence
|
|
1332
|
+
});
|
|
1333
|
+
this.rateLimiter = config.rateLimiter ? new RateLimiter(config.rateLimiter) : null;
|
|
1334
|
+
this.logger = new Logger(config.logLevel ?? "info");
|
|
1335
|
+
this.metrics = new MetricsRegistry();
|
|
856
1336
|
}
|
|
857
1337
|
/**
|
|
858
1338
|
* Start the HTTP server and periodic flush timer.
|
|
@@ -864,9 +1344,7 @@ var GatewayServer = class {
|
|
|
864
1344
|
async start() {
|
|
865
1345
|
const persisted = this.persistence.loadAll();
|
|
866
1346
|
if (persisted.length > 0) {
|
|
867
|
-
|
|
868
|
-
this.gateway.buffer.append(delta);
|
|
869
|
-
}
|
|
1347
|
+
this.gateway.rehydrate(persisted);
|
|
870
1348
|
this.persistence.clear();
|
|
871
1349
|
}
|
|
872
1350
|
this.httpServer = createServer((req, res) => {
|
|
@@ -885,7 +1363,8 @@ var GatewayServer = class {
|
|
|
885
1363
|
this.gateway,
|
|
886
1364
|
this.configStore,
|
|
887
1365
|
this.config.gatewayId,
|
|
888
|
-
this.config.jwtSecret
|
|
1366
|
+
this.config.jwtSecret,
|
|
1367
|
+
this.config.wsLimits
|
|
889
1368
|
);
|
|
890
1369
|
this.wsManager.attach(this.httpServer);
|
|
891
1370
|
this.flushTimer = setInterval(() => {
|
|
@@ -894,13 +1373,25 @@ var GatewayServer = class {
|
|
|
894
1373
|
if (this.config.ingestSources) {
|
|
895
1374
|
for (const source of this.config.ingestSources) {
|
|
896
1375
|
const poller = new SourcePoller(source, this.gateway);
|
|
1376
|
+
const saved = this.persistence.loadCursor(source.name);
|
|
1377
|
+
if (saved) {
|
|
1378
|
+
poller.setCursorState(JSON.parse(saved));
|
|
1379
|
+
}
|
|
1380
|
+
poller.onCursorUpdate = (state) => {
|
|
1381
|
+
this.persistence.saveCursor(source.name, JSON.stringify(state));
|
|
1382
|
+
};
|
|
897
1383
|
poller.start();
|
|
898
1384
|
this.pollers.push(poller);
|
|
899
1385
|
}
|
|
900
1386
|
}
|
|
1387
|
+
this.setupSignalHandlers();
|
|
901
1388
|
}
|
|
902
1389
|
/** Stop the server, pollers, connectors, and WebSocket connections. */
|
|
903
1390
|
async stop() {
|
|
1391
|
+
if (this.signalCleanup) {
|
|
1392
|
+
this.signalCleanup();
|
|
1393
|
+
this.signalCleanup = null;
|
|
1394
|
+
}
|
|
904
1395
|
await this.connectors.stopAll();
|
|
905
1396
|
for (const poller of this.pollers) {
|
|
906
1397
|
poller.stop();
|
|
@@ -920,8 +1411,15 @@ var GatewayServer = class {
|
|
|
920
1411
|
});
|
|
921
1412
|
this.httpServer = null;
|
|
922
1413
|
}
|
|
1414
|
+
if (this.rateLimiter) {
|
|
1415
|
+
this.rateLimiter.dispose();
|
|
1416
|
+
}
|
|
923
1417
|
this.persistence.close();
|
|
924
1418
|
}
|
|
1419
|
+
/** Whether the server is currently draining connections. */
|
|
1420
|
+
get isDraining() {
|
|
1421
|
+
return this.draining;
|
|
1422
|
+
}
|
|
925
1423
|
/** The port the server is listening on (available after start). */
|
|
926
1424
|
get port() {
|
|
927
1425
|
return this.resolvedPort || this.config.port;
|
|
@@ -930,6 +1428,10 @@ var GatewayServer = class {
|
|
|
930
1428
|
get gatewayInstance() {
|
|
931
1429
|
return this.gateway;
|
|
932
1430
|
}
|
|
1431
|
+
/** The Prometheus metrics registry. */
|
|
1432
|
+
get metricsRegistry() {
|
|
1433
|
+
return this.metrics;
|
|
1434
|
+
}
|
|
933
1435
|
// -----------------------------------------------------------------------
|
|
934
1436
|
// Request handling — cors -> auth -> route -> handler
|
|
935
1437
|
// -----------------------------------------------------------------------
|
|
@@ -939,17 +1441,56 @@ var GatewayServer = class {
|
|
|
939
1441
|
const url = new URL(rawUrl, `http://${req.headers.host ?? "localhost"}`);
|
|
940
1442
|
const pathname = url.pathname;
|
|
941
1443
|
const origin = req.headers.origin ?? null;
|
|
1444
|
+
const requestId = crypto.randomUUID();
|
|
1445
|
+
const reqLogger = this.logger.child({ requestId, method, path: pathname });
|
|
1446
|
+
this.metrics.activeRequests.inc();
|
|
1447
|
+
res.on("close", () => {
|
|
1448
|
+
this.metrics.activeRequests.dec();
|
|
1449
|
+
});
|
|
942
1450
|
const corsH = corsHeaders(origin, { allowedOrigins: this.config.allowedOrigins });
|
|
943
1451
|
if (handlePreflight(method, res, corsH)) return;
|
|
944
1452
|
if (pathname === "/health" && method === "GET") {
|
|
945
1453
|
sendJson(res, { status: "ok" }, 200, corsH);
|
|
946
1454
|
return;
|
|
947
1455
|
}
|
|
1456
|
+
if (pathname === "/ready" && method === "GET") {
|
|
1457
|
+
await this.handleReady(res, corsH);
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
if (pathname === "/metrics" && method === "GET") {
|
|
1461
|
+
this.updateBufferGauges();
|
|
1462
|
+
const body = this.metrics.expose();
|
|
1463
|
+
res.writeHead(200, {
|
|
1464
|
+
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
|
1465
|
+
...corsH
|
|
1466
|
+
});
|
|
1467
|
+
res.end(body);
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
948
1470
|
if (pathname === "/connectors/types" && method === "GET") {
|
|
949
1471
|
const result = handleListConnectorTypes();
|
|
950
1472
|
sendResult(res, result, corsH);
|
|
951
1473
|
return;
|
|
952
1474
|
}
|
|
1475
|
+
if (this.draining) {
|
|
1476
|
+
sendError(res, "Service is shutting down", 503, corsH);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const timeoutMs = this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
1480
|
+
res.setTimeout(timeoutMs, () => {
|
|
1481
|
+
if (!res.writableEnded) {
|
|
1482
|
+
sendError(res, "Request timeout", 504, corsH);
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
this.activeRequests++;
|
|
1486
|
+
try {
|
|
1487
|
+
await this.dispatchRoute(req, res, method, url, pathname, corsH, reqLogger);
|
|
1488
|
+
} finally {
|
|
1489
|
+
this.activeRequests--;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
/** Dispatch an authenticated request to the correct route handler. */
|
|
1493
|
+
async dispatchRoute(req, res, method, url, pathname, corsH, reqLogger) {
|
|
953
1494
|
const route = matchRoute(pathname, method);
|
|
954
1495
|
if (!route) {
|
|
955
1496
|
sendError(res, "Not found", 404, corsH);
|
|
@@ -970,12 +1511,23 @@ var GatewayServer = class {
|
|
|
970
1511
|
return;
|
|
971
1512
|
}
|
|
972
1513
|
const auth = this.config.jwtSecret ? authResult.claims : void 0;
|
|
1514
|
+
if (this.rateLimiter) {
|
|
1515
|
+
const clientKey = auth?.clientId ?? req.socket.remoteAddress ?? "unknown";
|
|
1516
|
+
if (!this.rateLimiter.tryConsume(clientKey)) {
|
|
1517
|
+
const retryAfter = this.rateLimiter.retryAfterSeconds(clientKey);
|
|
1518
|
+
sendError(res, "Too many requests", 429, {
|
|
1519
|
+
...corsH,
|
|
1520
|
+
"Retry-After": String(retryAfter)
|
|
1521
|
+
});
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
973
1525
|
switch (route.action) {
|
|
974
1526
|
case "push":
|
|
975
|
-
await this.handlePush(req, res, corsH, auth);
|
|
1527
|
+
await this.handlePush(req, res, corsH, auth, reqLogger);
|
|
976
1528
|
break;
|
|
977
1529
|
case "pull":
|
|
978
|
-
await this.handlePull(url, res, corsH, auth);
|
|
1530
|
+
await this.handlePull(url, res, corsH, auth, reqLogger);
|
|
979
1531
|
break;
|
|
980
1532
|
case "action":
|
|
981
1533
|
await this.handleAction(req, res, corsH, auth);
|
|
@@ -984,7 +1536,7 @@ var GatewayServer = class {
|
|
|
984
1536
|
this.handleDescribeActions(res, corsH);
|
|
985
1537
|
break;
|
|
986
1538
|
case "flush":
|
|
987
|
-
await this.handleFlush(res, corsH);
|
|
1539
|
+
await this.handleFlush(res, corsH, reqLogger);
|
|
988
1540
|
break;
|
|
989
1541
|
case "schema":
|
|
990
1542
|
await this.handleSaveSchemaRoute(req, res, corsH);
|
|
@@ -1011,9 +1563,11 @@ var GatewayServer = class {
|
|
|
1011
1563
|
// -----------------------------------------------------------------------
|
|
1012
1564
|
// Route handlers — thin wrappers delegating to shared handlers or modules
|
|
1013
1565
|
// -----------------------------------------------------------------------
|
|
1014
|
-
async handlePush(req, res, corsH, auth) {
|
|
1566
|
+
async handlePush(req, res, corsH, auth, reqLogger) {
|
|
1567
|
+
const start = performance.now();
|
|
1015
1568
|
const contentLength = Number(req.headers["content-length"] ?? "0");
|
|
1016
1569
|
if (contentLength > MAX_PUSH_PAYLOAD_BYTES) {
|
|
1570
|
+
this.metrics.pushTotal.inc({ status: "error" });
|
|
1017
1571
|
sendError(res, "Payload too large (max 1 MiB)", 413, corsH);
|
|
1018
1572
|
return;
|
|
1019
1573
|
}
|
|
@@ -1026,12 +1580,23 @@ var GatewayServer = class {
|
|
|
1026
1580
|
if (result.status === 200 && this.sharedBuffer) {
|
|
1027
1581
|
const pushResult = result.body;
|
|
1028
1582
|
if (pushResult.deltas.length > 0) {
|
|
1029
|
-
await this.sharedBuffer.writeThroughPush(pushResult.deltas);
|
|
1583
|
+
const writeResult = await this.sharedBuffer.writeThroughPush(pushResult.deltas);
|
|
1584
|
+
if (!writeResult.ok) {
|
|
1585
|
+
this.metrics.pushTotal.inc({ status: "error" });
|
|
1586
|
+
sendError(res, writeResult.error.message, 502, corsH);
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1030
1589
|
}
|
|
1031
1590
|
}
|
|
1591
|
+
const status = result.status === 200 ? "ok" : "error";
|
|
1592
|
+
const durationMs = Math.round(performance.now() - start);
|
|
1593
|
+
this.metrics.pushTotal.inc({ status });
|
|
1594
|
+
this.metrics.pushLatency.observe({}, performance.now() - start);
|
|
1595
|
+
this.updateBufferGauges();
|
|
1596
|
+
reqLogger?.info("push completed", { status: result.status, durationMs });
|
|
1032
1597
|
sendResult(res, result, corsH);
|
|
1033
1598
|
}
|
|
1034
|
-
async handlePull(url, res, corsH, auth) {
|
|
1599
|
+
async handlePull(url, res, corsH, auth, reqLogger) {
|
|
1035
1600
|
const syncRules = await this.configStore.getSyncRules(this.config.gatewayId);
|
|
1036
1601
|
const result = await handlePullRequest(
|
|
1037
1602
|
this.gateway,
|
|
@@ -1055,6 +1620,9 @@ var GatewayServer = class {
|
|
|
1055
1620
|
}
|
|
1056
1621
|
}
|
|
1057
1622
|
}
|
|
1623
|
+
const pullStatus = result.status === 200 ? "ok" : "error";
|
|
1624
|
+
this.metrics.pullTotal.inc({ status: pullStatus });
|
|
1625
|
+
reqLogger?.info("pull completed", { status: result.status });
|
|
1058
1626
|
sendJson(res, body, result.status, corsH);
|
|
1059
1627
|
}
|
|
1060
1628
|
async handleAction(req, res, corsH, auth) {
|
|
@@ -1065,10 +1633,17 @@ var GatewayServer = class {
|
|
|
1065
1633
|
handleDescribeActions(res, corsH) {
|
|
1066
1634
|
sendJson(res, this.gateway.describeActions(), 200, corsH);
|
|
1067
1635
|
}
|
|
1068
|
-
async handleFlush(res, corsH) {
|
|
1636
|
+
async handleFlush(res, corsH, reqLogger) {
|
|
1637
|
+
const start = performance.now();
|
|
1069
1638
|
const result = await handleFlushRequest(this.gateway, {
|
|
1070
1639
|
clearPersistence: () => this.persistence.clear()
|
|
1071
1640
|
});
|
|
1641
|
+
const durationMs = Math.round(performance.now() - start);
|
|
1642
|
+
const status = result.status === 200 ? "ok" : "error";
|
|
1643
|
+
this.metrics.flushTotal.inc({ status });
|
|
1644
|
+
this.metrics.flushDuration.observe({}, performance.now() - start);
|
|
1645
|
+
this.updateBufferGauges();
|
|
1646
|
+
reqLogger?.info("flush completed", { status: result.status, durationMs });
|
|
1072
1647
|
sendResult(res, result, corsH);
|
|
1073
1648
|
}
|
|
1074
1649
|
async handleSaveSchemaRoute(req, res, corsH) {
|
|
@@ -1102,6 +1677,91 @@ var GatewayServer = class {
|
|
|
1102
1677
|
sendResult(res, result, corsH);
|
|
1103
1678
|
}
|
|
1104
1679
|
// -----------------------------------------------------------------------
|
|
1680
|
+
// Buffer gauge helpers
|
|
1681
|
+
// -----------------------------------------------------------------------
|
|
1682
|
+
/** Synchronise buffer gauge metrics with the current buffer state. */
|
|
1683
|
+
updateBufferGauges() {
|
|
1684
|
+
const stats = this.gateway.bufferStats;
|
|
1685
|
+
this.metrics.bufferBytes.set({}, stats.byteSize);
|
|
1686
|
+
this.metrics.bufferDeltas.set({}, stats.logSize);
|
|
1687
|
+
}
|
|
1688
|
+
// -----------------------------------------------------------------------
|
|
1689
|
+
// Readiness probe
|
|
1690
|
+
// -----------------------------------------------------------------------
|
|
1691
|
+
/** Handle GET /ready — checks draining status and adapter health. */
|
|
1692
|
+
async handleReady(res, corsH) {
|
|
1693
|
+
if (this.draining) {
|
|
1694
|
+
sendJson(res, { status: "not_ready", reason: "draining" }, 503, corsH);
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
const adapterHealthy = await this.checkAdapterHealth();
|
|
1698
|
+
if (!adapterHealthy) {
|
|
1699
|
+
sendJson(res, { status: "not_ready", reason: "adapter unreachable" }, 503, corsH);
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
sendJson(res, { status: "ready" }, 200, corsH);
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Check whether the configured adapter is reachable.
|
|
1706
|
+
*
|
|
1707
|
+
* For a DatabaseAdapter, attempts a lightweight query with a timeout.
|
|
1708
|
+
* For a LakeAdapter, attempts a headObject call (404 still means reachable).
|
|
1709
|
+
* Returns true when no adapter is configured (stateless mode).
|
|
1710
|
+
*/
|
|
1711
|
+
async checkAdapterHealth() {
|
|
1712
|
+
const adapter = this.config.adapter;
|
|
1713
|
+
if (!adapter) return true;
|
|
1714
|
+
const timeoutMs = DEFAULT_ADAPTER_HEALTH_TIMEOUT_MS;
|
|
1715
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
1716
|
+
setTimeout(() => resolve(false), timeoutMs);
|
|
1717
|
+
});
|
|
1718
|
+
try {
|
|
1719
|
+
if (isDatabaseAdapter(adapter)) {
|
|
1720
|
+
const healthCheck2 = adapter.queryDeltasSince(0n, []).then((result) => result.ok);
|
|
1721
|
+
return await Promise.race([healthCheck2, timeoutPromise]);
|
|
1722
|
+
}
|
|
1723
|
+
const healthCheck = adapter.headObject("__health__").then(() => true).catch(() => true);
|
|
1724
|
+
return await Promise.race([healthCheck, timeoutPromise]);
|
|
1725
|
+
} catch {
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
// -----------------------------------------------------------------------
|
|
1730
|
+
// Graceful shutdown — signal handlers
|
|
1731
|
+
// -----------------------------------------------------------------------
|
|
1732
|
+
/** Register SIGTERM/SIGINT handlers for graceful shutdown. */
|
|
1733
|
+
setupSignalHandlers() {
|
|
1734
|
+
const shutdown = () => {
|
|
1735
|
+
void this.gracefulShutdown();
|
|
1736
|
+
};
|
|
1737
|
+
process.on("SIGTERM", shutdown);
|
|
1738
|
+
process.on("SIGINT", shutdown);
|
|
1739
|
+
this.signalCleanup = () => {
|
|
1740
|
+
process.off("SIGTERM", shutdown);
|
|
1741
|
+
process.off("SIGINT", shutdown);
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
/** Graceful shutdown: stop accepting, drain, flush, exit. */
|
|
1745
|
+
async gracefulShutdown() {
|
|
1746
|
+
if (this.draining) return;
|
|
1747
|
+
this.draining = true;
|
|
1748
|
+
this.logger.info("Graceful shutdown initiated, draining requests...");
|
|
1749
|
+
if (this.httpServer) {
|
|
1750
|
+
this.httpServer.close();
|
|
1751
|
+
}
|
|
1752
|
+
const drainTimeout = this.config.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
|
1753
|
+
const start = Date.now();
|
|
1754
|
+
while (this.activeRequests > 0 && Date.now() - start < drainTimeout) {
|
|
1755
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1756
|
+
}
|
|
1757
|
+
try {
|
|
1758
|
+
await this.gateway.flush();
|
|
1759
|
+
} catch {
|
|
1760
|
+
}
|
|
1761
|
+
await this.stop();
|
|
1762
|
+
process.exit(0);
|
|
1763
|
+
}
|
|
1764
|
+
// -----------------------------------------------------------------------
|
|
1105
1765
|
// Periodic flush
|
|
1106
1766
|
// -----------------------------------------------------------------------
|
|
1107
1767
|
async periodicFlush() {
|
|
@@ -1116,11 +1776,32 @@ var GatewayServer = class {
|
|
|
1116
1776
|
return;
|
|
1117
1777
|
}
|
|
1118
1778
|
}
|
|
1779
|
+
const flushTimeoutMs = this.config.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
|
|
1780
|
+
const start = performance.now();
|
|
1119
1781
|
try {
|
|
1120
|
-
const
|
|
1782
|
+
const flushPromise = this.gateway.flush();
|
|
1783
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
1784
|
+
setTimeout(() => resolve({ ok: false, timedOut: true }), flushTimeoutMs);
|
|
1785
|
+
});
|
|
1786
|
+
const result = await Promise.race([flushPromise, timeoutPromise]);
|
|
1787
|
+
if ("timedOut" in result) {
|
|
1788
|
+
this.logger.warn(`Periodic flush timed out after ${flushTimeoutMs}ms`);
|
|
1789
|
+
this.metrics.flushTotal.inc({ status: "error" });
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1121
1792
|
if (result.ok) {
|
|
1122
1793
|
this.persistence.clear();
|
|
1794
|
+
this.metrics.flushTotal.inc({ status: "ok" });
|
|
1795
|
+
} else {
|
|
1796
|
+
this.metrics.flushTotal.inc({ status: "error" });
|
|
1123
1797
|
}
|
|
1798
|
+
this.metrics.flushDuration.observe({}, performance.now() - start);
|
|
1799
|
+
this.updateBufferGauges();
|
|
1800
|
+
} catch (err) {
|
|
1801
|
+
this.metrics.flushTotal.inc({ status: "error" });
|
|
1802
|
+
this.logger.error("periodic flush failed", {
|
|
1803
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1804
|
+
});
|
|
1124
1805
|
} finally {
|
|
1125
1806
|
if (lock) {
|
|
1126
1807
|
await lock.release(lockKey);
|
|
@@ -1132,8 +1813,14 @@ export {
|
|
|
1132
1813
|
AdapterBasedLock,
|
|
1133
1814
|
AuthError,
|
|
1134
1815
|
ConnectorManager,
|
|
1816
|
+
Counter,
|
|
1135
1817
|
GatewayServer,
|
|
1818
|
+
Gauge,
|
|
1819
|
+
Histogram,
|
|
1820
|
+
Logger,
|
|
1136
1821
|
MemoryPersistence,
|
|
1822
|
+
MetricsRegistry,
|
|
1823
|
+
RateLimiter,
|
|
1137
1824
|
SharedBuffer,
|
|
1138
1825
|
SourcePoller,
|
|
1139
1826
|
SqlitePersistence,
|