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.
Files changed (60) hide show
  1. package/dist/adapter-types-DwsQGQS4.d.ts +94 -0
  2. package/dist/adapter.d.ts +23 -49
  3. package/dist/adapter.js +9 -4
  4. package/dist/analyst.js +1 -1
  5. package/dist/{base-poller-Bj9kX9dv.d.ts → base-poller-Y7ORYgUv.d.ts} +2 -0
  6. package/dist/catalogue.js +2 -2
  7. package/dist/{chunk-LDFFCG2K.js → chunk-4SG66H5K.js} +44 -31
  8. package/dist/chunk-4SG66H5K.js.map +1 -0
  9. package/dist/{chunk-LPWXOYNS.js → chunk-C4KD6YKP.js} +59 -43
  10. package/dist/chunk-C4KD6YKP.js.map +1 -0
  11. package/dist/{chunk-JI4C4R5H.js → chunk-FIIHPQMQ.js} +196 -118
  12. package/dist/chunk-FIIHPQMQ.js.map +1 -0
  13. package/dist/{chunk-TMLG32QV.js → chunk-U2NV4DUX.js} +2 -2
  14. package/dist/{chunk-QNITY4F6.js → chunk-XVP5DJJ7.js} +16 -13
  15. package/dist/{chunk-QNITY4F6.js.map → chunk-XVP5DJJ7.js.map} +1 -1
  16. package/dist/{chunk-KVSWLIJR.js → chunk-YHYBLU6W.js} +2 -2
  17. package/dist/{chunk-PYRS74YP.js → chunk-ZNY4DSFU.js} +16 -13
  18. package/dist/{chunk-PYRS74YP.js.map → chunk-ZNY4DSFU.js.map} +1 -1
  19. package/dist/{chunk-SSICS5KI.js → chunk-ZU7RC7CT.js} +2 -2
  20. package/dist/client.d.ts +28 -10
  21. package/dist/client.js +150 -29
  22. package/dist/client.js.map +1 -1
  23. package/dist/compactor.d.ts +1 -1
  24. package/dist/compactor.js +3 -3
  25. package/dist/connector-jira.d.ts +13 -3
  26. package/dist/connector-jira.js +6 -2
  27. package/dist/connector-salesforce.d.ts +13 -3
  28. package/dist/connector-salesforce.js +6 -2
  29. package/dist/{coordinator-NXy6tA0h.d.ts → coordinator-eGmZMnJ_.d.ts} +99 -16
  30. package/dist/create-poller-Cc2MGfhh.d.ts +55 -0
  31. package/dist/factory-DFfR-030.d.ts +33 -0
  32. package/dist/gateway-server.d.ts +398 -95
  33. package/dist/gateway-server.js +743 -56
  34. package/dist/gateway-server.js.map +1 -1
  35. package/dist/gateway.d.ts +14 -8
  36. package/dist/gateway.js +6 -5
  37. package/dist/index.d.ts +45 -73
  38. package/dist/index.js +5 -3
  39. package/dist/parquet.js +2 -2
  40. package/dist/proto.js +2 -2
  41. package/dist/react.d.ts +3 -3
  42. package/dist/{registry-BcspAtZI.d.ts → registry-Dd8JuW8T.d.ts} +1 -1
  43. package/dist/{request-handler-pUvL7ozF.d.ts → request-handler-B1I5xDOx.d.ts} +71 -27
  44. package/dist/{src-ROW4XLO7.js → src-WU7IBVC4.js} +6 -4
  45. package/dist/{types-BrcD1oJg.d.ts → types-D2C9jTbL.d.ts} +33 -23
  46. package/package.json +1 -1
  47. package/dist/auth-CAVutXzx.d.ts +0 -30
  48. package/dist/chunk-JI4C4R5H.js.map +0 -1
  49. package/dist/chunk-LDFFCG2K.js.map +0 -1
  50. package/dist/chunk-LPWXOYNS.js.map +0 -1
  51. package/dist/db-types-CfLMUBfW.d.ts +0 -29
  52. package/dist/src-B6NLV3FP.js +0 -27
  53. package/dist/src-ROW4XLO7.js.map +0 -1
  54. package/dist/src-ZRHKG42A.js +0 -25
  55. package/dist/src-ZRHKG42A.js.map +0 -1
  56. package/dist/types-DSC_EiwR.d.ts +0 -45
  57. /package/dist/{chunk-TMLG32QV.js.map → chunk-U2NV4DUX.js.map} +0 -0
  58. /package/dist/{chunk-KVSWLIJR.js.map → chunk-YHYBLU6W.js.map} +0 -0
  59. /package/dist/{chunk-SSICS5KI.js.map → chunk-ZU7RC7CT.js.map} +0 -0
  60. /package/dist/{src-B6NLV3FP.js.map → src-WU7IBVC4.js.map} +0 -0
@@ -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-JI4C4R5H.js";
24
+ } from "./chunk-FIIHPQMQ.js";
19
25
  import {
20
26
  createDatabaseAdapter,
21
- createQueryFn
22
- } from "./chunk-LPWXOYNS.js";
23
- import "./chunk-TMLG32QV.js";
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-KVSWLIJR.js";
32
- import "./chunk-SSICS5KI.js";
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-LDFFCG2K.js";
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) return;
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, creates adapter/poller as appropriate,
334
- * registers with the gateway.
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
- if (config.type === "jira") {
392
+ const pollerFactory = this.pollerRegistry.get(config.type);
393
+ if (pollerFactory) {
348
394
  try {
349
- const { JiraSourcePoller } = await import("./src-ZRHKG42A.js");
350
- const ingestConfig = config.ingest ? { intervalMs: config.ingest.intervalMs } : void 0;
351
- const poller = new JiraSourcePoller(config.jira, ingestConfig, config.name, this.gateway);
352
- poller.start();
353
- this.pollers.set(config.name, poller);
354
- return result;
355
- } catch (err) {
356
- await this.rollbackRegistration(connectors, registeredName);
357
- const message = err instanceof Error ? err.message : String(err);
358
- return { status: 500, body: { error: `Failed to load Jira connector: ${message}` } };
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 load Salesforce connector: ${message}` }
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
- * Gateway buffer handles fast reads; shared adapter handles
589
- * cross-instance visibility and durability.
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
- } catch {
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
- this.connectors = new ConnectorManager(this.configStore, this.gateway);
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
- for (const delta of persisted) {
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 result = await this.gateway.flush();
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,