@spfn/core 0.2.0-beta.44 → 0.2.0-beta.45

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.
@@ -273,6 +273,36 @@ declare function initDatabase(options?: DatabaseOptions): Promise<{
273
273
  * ```
274
274
  */
275
275
  declare function closeDatabase(): Promise<void>;
276
+ /**
277
+ * Force an immediate database pool rebuild
278
+ *
279
+ * Destroys the current postgres.js pool(s) and rebuilds them with the same
280
+ * configuration passed to the original `initDatabase()` call (or whatever
281
+ * was detected from environment variables). Uses the same atomic-swap
282
+ * strategy as the periodic health check: new connections are created and
283
+ * tested BEFORE the old ones are torn down, so `getDatabase()` callers never
284
+ * observe a missing instance.
285
+ *
286
+ * Use this when application code detects that the pool is stuck and does not
287
+ * want to wait for the next periodic health check tick. Concurrent calls are
288
+ * coalesced — if a reconnect is already in progress, this resolves to `false`
289
+ * without starting a second one.
290
+ *
291
+ * @param reason - Short label describing why the rebuild was requested (for logs)
292
+ * @returns `true` if a reconnection ran, `false` if one was already in-flight.
293
+ * Resolves after the rebuild completes (success or max retries exhausted).
294
+ *
295
+ * @example
296
+ * ```typescript
297
+ * import { forceReconnectDatabase } from '@spfn/core/db';
298
+ *
299
+ * app.post('/admin/db/reconnect', async (c) => {
300
+ * const ran = await forceReconnectDatabase('admin_request');
301
+ * return c.json({ reconnected: ran });
302
+ * });
303
+ * ```
304
+ */
305
+ declare function forceReconnectDatabase(reason?: string): Promise<boolean>;
276
306
  /**
277
307
  * Get database connection info (for debugging)
278
308
  *
@@ -308,6 +338,60 @@ declare function getDatabaseInfo(): {
308
338
  isReplica: boolean;
309
339
  };
310
340
 
341
+ /**
342
+ * Reconnect Trigger — Query-error driven pool rebuild
343
+ *
344
+ * Complements the periodic health check with a fast-path: when application
345
+ * queries start failing with connection-level errors, we do not wait up to
346
+ * DB_HEALTH_CHECK_INTERVAL (default 60s) to notice. A sliding-window counter
347
+ * trips a force-reconnect as soon as the failure rate crosses a threshold.
348
+ *
349
+ * Why this exists:
350
+ * - postgres.js transparently drops dead sockets and opens new ones on the
351
+ * next query. A single `SELECT 1` on the periodic interval can therefore
352
+ * false-pass while user-facing queries keep hitting the remaining dead
353
+ * sockets in the pool.
354
+ * - This module observes real query errors and, when it sees a burst of
355
+ * connection-level failures, calls triggerForceReconnect() which performs
356
+ * the same atomic-swap rebuild as the health check.
357
+ *
358
+ * Configuration (env vars, hardcoded defaults):
359
+ * - DB_RECONNECT_ERROR_THRESHOLD (default 3): errors needed in window
360
+ * - DB_RECONNECT_ERROR_WINDOW_MS (default 10000): sliding window size
361
+ */
362
+ /**
363
+ * Determine whether an error looks like a pool/connection failure
364
+ *
365
+ * Returns true when any layer in the error chain exposes a connection-level
366
+ * code (postgres.js driver code, Node network errno, PG SQLSTATE class 08 etc.)
367
+ * or is an instance of our own ConnectionError wrapper.
368
+ *
369
+ * Returns false for query errors (syntax, constraint violations, etc.) — those
370
+ * should NOT trigger a pool rebuild.
371
+ */
372
+ declare function isConnectionLevelError(error: unknown): boolean;
373
+ /**
374
+ * Reset the internal error counter
375
+ *
376
+ * Exposed for tests that need a clean slate between cases. Does not clear
377
+ * the WeakSet (which is GC-backed and self-cleans with error lifetimes).
378
+ */
379
+ declare function resetConnectionErrorCounter(): void;
380
+ /**
381
+ * Report a database error to the reconnect trigger
382
+ *
383
+ * Call this from any site that catches a query error before rethrowing.
384
+ * It is a no-op for non-connection-level errors. When the threshold is
385
+ * crossed it calls triggerForceReconnect() in the background — callers
386
+ * should NOT await it.
387
+ *
388
+ * Safe to call from any context: catches its own errors so it cannot
389
+ * disrupt the calling catch block. Deduplicates across error-chain
390
+ * re-wrapping so one failure counts exactly once regardless of how many
391
+ * catch layers it passes through.
392
+ */
393
+ declare function reportDatabaseError(error: unknown): void;
394
+
311
395
  /**
312
396
  * Create database connection with exponential backoff retry strategy
313
397
  *
@@ -1674,4 +1758,4 @@ declare abstract class BaseRepository<TSchema extends Record<string, unknown> =
1674
1758
  protected _count<T extends PgTable>(table: T, where?: Record<string, any> | SQL | undefined): Promise<number>;
1675
1759
  }
1676
1760
 
1677
- export { type AfterCommitCallback, BaseRepository, type DatabaseClients, type DrizzleConfigOptions, type PoolConfig, RepositoryError, type RetryConfig, type RunInTransactionOptions, type TransactionContext, type TransactionDB, Transactional, type TransactionalOptions, auditFields, checkConnection, closeDatabase, count, create, createDatabaseConnection, createDatabaseFromEnv, createMany, createSchema, deleteMany, deleteOne, detectDialect, enumText, findMany, findOne, foreignKey, fromPostgresError, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, id, initDatabase, onAfterCommit, optionalForeignKey, packageNameToSchema, publishingFields, runInTransaction, runWithTransaction, setDatabase, softDelete, timestamps, typedJsonb, updateMany, updateOne, upsert, utcTimestamp, uuid, verificationTimestamp };
1761
+ export { type AfterCommitCallback, BaseRepository, type DatabaseClients, type DrizzleConfigOptions, type PoolConfig, RepositoryError, type RetryConfig, type RunInTransactionOptions, type TransactionContext, type TransactionDB, Transactional, type TransactionalOptions, auditFields, checkConnection, closeDatabase, count, create, createDatabaseConnection, createDatabaseFromEnv, createMany, createSchema, deleteMany, deleteOne, detectDialect, enumText, findMany, findOne, forceReconnectDatabase, foreignKey, fromPostgresError, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, id, initDatabase, isConnectionLevelError, onAfterCommit, optionalForeignKey, packageNameToSchema, publishingFields, reportDatabaseError, resetConnectionErrorCounter, runInTransaction, runWithTransaction, setDatabase, softDelete, timestamps, typedJsonb, updateMany, updateOne, upsert, utcTimestamp, uuid, verificationTimestamp };
package/dist/db/index.js CHANGED
@@ -520,9 +520,20 @@ var setHealthCheckInterval = (interval) => {
520
520
  var setMonitoringConfig = (config) => {
521
521
  globalThis.__SPFN_DB_MONITORING__ = config;
522
522
  };
523
+ var getInitOptions = () => globalThis.__SPFN_DB_INIT_OPTIONS__;
524
+ var setInitOptions = (options) => {
525
+ globalThis.__SPFN_DB_INIT_OPTIONS__ = options;
526
+ };
527
+ var getIsClosing = () => globalThis.__SPFN_DB_CLOSING__ === true;
528
+ var setIsClosing = (closing) => {
529
+ globalThis.__SPFN_DB_CLOSING__ = closing;
530
+ };
523
531
  var dbLogger3 = logger.child("@spfn/core:database");
524
532
  var CLIENT_CLOSE_TIMEOUT = 5;
525
533
  var isReconnecting = false;
534
+ function isReconnectingNow() {
535
+ return isReconnecting;
536
+ }
526
537
  async function testDatabaseConnection(db) {
527
538
  await db.execute("SELECT 1");
528
539
  }
@@ -541,6 +552,10 @@ async function closeClient(client) {
541
552
  }
542
553
  }
543
554
  async function reconnectAndRestore(options) {
555
+ if (getIsClosing()) {
556
+ dbLogger3.debug("reconnectAndRestore aborted: database is closing");
557
+ return false;
558
+ }
544
559
  const result = await createDatabaseFromEnv(options);
545
560
  if (!result.write) {
546
561
  return false;
@@ -549,6 +564,16 @@ async function reconnectAndRestore(options) {
549
564
  if (result.read && result.read !== result.write) {
550
565
  await testDatabaseConnection(result.read);
551
566
  }
567
+ if (getIsClosing()) {
568
+ dbLogger3.warn("reconnectAndRestore: close started mid-rebuild, discarding new pool");
569
+ if (result.writeClient) {
570
+ await closeClient(result.writeClient);
571
+ }
572
+ if (result.readClient && result.readClient !== result.writeClient) {
573
+ await closeClient(result.readClient);
574
+ }
575
+ return false;
576
+ }
552
577
  const oldWriteClient = getWriteClient();
553
578
  const oldReadClient = getReadClient();
554
579
  setWriteInstance(result.write);
@@ -586,15 +611,34 @@ function startHealthCheck(config, options, getDatabase2) {
586
611
  const message = error instanceof Error ? error.message : "Unknown error";
587
612
  dbLogger3.error("Database health check failed", { error: message });
588
613
  if (config.reconnect) {
589
- await attemptReconnection(config, options);
614
+ await attemptReconnection(config, options, "health_check_failed");
590
615
  }
591
616
  }
592
617
  }, config.interval);
593
618
  setHealthCheckInterval(interval);
594
619
  }
595
- async function attemptReconnection(config, options) {
620
+ async function triggerForceReconnect(reason) {
621
+ if (!getWriteInstance()) {
622
+ dbLogger3.warn("Force reconnect skipped: database not initialized", { reason });
623
+ return false;
624
+ }
625
+ if (getIsClosing()) {
626
+ dbLogger3.debug("Force reconnect skipped: database is closing", { reason });
627
+ return false;
628
+ }
629
+ const options = getInitOptions();
630
+ const config = buildHealthCheckConfig(options?.healthCheck);
631
+ dbLogger3.warn("Force reconnect triggered", { reason });
632
+ return await attemptReconnection(config, options, reason);
633
+ }
634
+ async function attemptReconnection(config, options, reason) {
635
+ if (isReconnecting) {
636
+ dbLogger3.debug("Reconnection coalesced: attempt already in progress", { reason });
637
+ return false;
638
+ }
596
639
  isReconnecting = true;
597
640
  dbLogger3.warn("Attempting database reconnection", {
641
+ reason,
598
642
  maxRetries: config.maxRetries,
599
643
  retryInterval: `${config.retryInterval}ms`
600
644
  });
@@ -608,7 +652,7 @@ async function attemptReconnection(config, options) {
608
652
  const success = await reconnectAndRestore(options);
609
653
  if (success) {
610
654
  dbLogger3.info("Database reconnection successful", { attempt });
611
- return;
655
+ return true;
612
656
  } else {
613
657
  dbLogger3.error(`Reconnection attempt ${attempt} failed: No write database instance created`);
614
658
  }
@@ -627,6 +671,7 @@ async function attemptReconnection(config, options) {
627
671
  } finally {
628
672
  isReconnecting = false;
629
673
  }
674
+ return true;
630
675
  }
631
676
  function stopHealthCheck() {
632
677
  const healthCheck = getHealthCheckInterval();
@@ -647,7 +692,6 @@ var STACK_TRACE_PATTERNS = {
647
692
  withoutParens: /at (.+):(\d+):(\d+)/
648
693
  };
649
694
  var initPromise = null;
650
- var isClosing = false;
651
695
  async function cleanupDatabaseConnections(writeClient, readClient) {
652
696
  const cleanupPromises = [];
653
697
  if (writeClient) {
@@ -748,7 +792,7 @@ function setDatabase(write, read) {
748
792
  setReadInstance(read ?? write);
749
793
  }
750
794
  async function initDatabase(options) {
751
- if (isClosing) {
795
+ if (getIsClosing()) {
752
796
  throw new Error("Cannot initialize database while closing");
753
797
  }
754
798
  const writeInst = getWriteInstance();
@@ -770,7 +814,7 @@ async function initDatabase(options) {
770
814
  const message = error instanceof Error ? error.message : "Unknown error";
771
815
  throw new Error(`Database connection test failed: ${message}`);
772
816
  }
773
- if (isClosing) {
817
+ if (getIsClosing()) {
774
818
  dbLogger4.warn("Database closed during initialization, cleaning up...");
775
819
  await cleanupDatabaseConnections(result.writeClient, result.readClient);
776
820
  throw new Error("Database closed during initialization");
@@ -779,6 +823,7 @@ async function initDatabase(options) {
779
823
  setReadInstance(result.read);
780
824
  setWriteClient(result.writeClient);
781
825
  setReadClient(result.readClient);
826
+ setInitOptions(options);
782
827
  const hasReplica = result.read && result.read !== result.write;
783
828
  dbLogger4.info(
784
829
  hasReplica ? "Database connected (Primary + Replica)" : "Database connected"
@@ -803,11 +848,11 @@ async function initDatabase(options) {
803
848
  return await initPromise;
804
849
  }
805
850
  async function closeDatabase() {
806
- if (isClosing) {
851
+ if (getIsClosing()) {
807
852
  dbLogger4.debug("Database close already in progress");
808
853
  return;
809
854
  }
810
- isClosing = true;
855
+ setIsClosing(true);
811
856
  if (initPromise) {
812
857
  dbLogger4.debug("Waiting for database initialization to complete before closing...");
813
858
  try {
@@ -820,7 +865,7 @@ async function closeDatabase() {
820
865
  const readInst = getReadInstance();
821
866
  if (!writeInst && !readInst) {
822
867
  dbLogger4.debug("No database connections to close");
823
- isClosing = false;
868
+ setIsClosing(false);
824
869
  return;
825
870
  }
826
871
  try {
@@ -842,9 +887,13 @@ async function closeDatabase() {
842
887
  setWriteClient(void 0);
843
888
  setReadClient(void 0);
844
889
  setMonitoringConfig(void 0);
845
- isClosing = false;
890
+ setInitOptions(void 0);
891
+ setIsClosing(false);
846
892
  }
847
893
  }
894
+ async function forceReconnectDatabase(reason = "manual") {
895
+ return await triggerForceReconnect(reason);
896
+ }
848
897
  function getDatabaseInfo() {
849
898
  const writeInst = getWriteInstance();
850
899
  const readInst = getReadInstance();
@@ -854,6 +903,122 @@ function getDatabaseInfo() {
854
903
  isReplica: !!(readInst && readInst !== writeInst)
855
904
  };
856
905
  }
906
+ var dbLogger5 = logger.child("@spfn/core:database");
907
+ var POSTGRES_JS_CONNECTION_CODES = /* @__PURE__ */ new Set([
908
+ "CONNECTION_ENDED",
909
+ "CONNECTION_CLOSED",
910
+ "CONNECTION_DESTROYED",
911
+ "CONNECT_TIMEOUT",
912
+ "CONNECTION_CONNECT_TIMEOUT"
913
+ ]);
914
+ var NODE_NET_ERROR_CODES = /* @__PURE__ */ new Set([
915
+ "ECONNRESET",
916
+ "ECONNREFUSED",
917
+ "EPIPE",
918
+ "ETIMEDOUT",
919
+ "EHOSTUNREACH",
920
+ "ENETUNREACH",
921
+ "ENOTFOUND"
922
+ ]);
923
+ function isConnectionSqlState(code) {
924
+ if (code.startsWith("08")) return true;
925
+ if (code === "53300") return true;
926
+ if (code === "57P01" || code === "57P02" || code === "57P03") return true;
927
+ return false;
928
+ }
929
+ function* unwrap(error) {
930
+ const seen = /* @__PURE__ */ new Set();
931
+ const stack = [error];
932
+ while (stack.length > 0) {
933
+ const current = stack.pop();
934
+ if (!current || typeof current !== "object" || seen.has(current)) {
935
+ continue;
936
+ }
937
+ seen.add(current);
938
+ const obj = current;
939
+ yield obj;
940
+ for (const key of ["cause", "original", "error", "err", "inner"]) {
941
+ const nested = obj[key];
942
+ if (nested && typeof nested === "object" && !seen.has(nested)) {
943
+ stack.push(nested);
944
+ }
945
+ }
946
+ }
947
+ }
948
+ function isConnectionLevelError(error) {
949
+ if (!error) return false;
950
+ if (error instanceof ConnectionError) return true;
951
+ for (const candidate of unwrap(error)) {
952
+ if (candidate instanceof ConnectionError) return true;
953
+ const code = candidate.code;
954
+ if (typeof code === "string") {
955
+ if (POSTGRES_JS_CONNECTION_CODES.has(code)) return true;
956
+ if (NODE_NET_ERROR_CODES.has(code)) return true;
957
+ if (isConnectionSqlState(code)) return true;
958
+ }
959
+ }
960
+ return false;
961
+ }
962
+ var DEFAULT_THRESHOLD = 3;
963
+ var DEFAULT_WINDOW_MS = 1e4;
964
+ var MIN_WINDOW_MS = 1e3;
965
+ function readPositiveIntEnv(key, defaultValue, min) {
966
+ const raw = process.env[key];
967
+ if (raw === void 0) return defaultValue;
968
+ try {
969
+ return parseNumber(raw, { min, integer: true });
970
+ } catch {
971
+ return defaultValue;
972
+ }
973
+ }
974
+ var ERROR_THRESHOLD = readPositiveIntEnv("DB_RECONNECT_ERROR_THRESHOLD", DEFAULT_THRESHOLD, 1);
975
+ var ERROR_WINDOW_MS = readPositiveIntEnv("DB_RECONNECT_ERROR_WINDOW_MS", DEFAULT_WINDOW_MS, MIN_WINDOW_MS);
976
+ var errorTimestamps = [];
977
+ var reportedErrors = /* @__PURE__ */ new WeakSet();
978
+ function resetConnectionErrorCounter() {
979
+ errorTimestamps.length = 0;
980
+ }
981
+ function checkAndMarkReported(error) {
982
+ let alreadySeen = false;
983
+ const toMark = [];
984
+ for (const candidate of unwrap(error)) {
985
+ if (reportedErrors.has(candidate)) {
986
+ alreadySeen = true;
987
+ }
988
+ toMark.push(candidate);
989
+ }
990
+ if (alreadySeen) return true;
991
+ for (const obj of toMark) {
992
+ reportedErrors.add(obj);
993
+ }
994
+ return false;
995
+ }
996
+ function reportDatabaseError(error) {
997
+ try {
998
+ if (!isConnectionLevelError(error)) return;
999
+ if (isReconnectingNow()) return;
1000
+ if (checkAndMarkReported(error)) return;
1001
+ const now = Date.now();
1002
+ errorTimestamps.push(now);
1003
+ const cutoff = now - ERROR_WINDOW_MS;
1004
+ while (errorTimestamps.length > 0 && errorTimestamps[0] < cutoff) {
1005
+ errorTimestamps.shift();
1006
+ }
1007
+ if (errorTimestamps.length < ERROR_THRESHOLD) return;
1008
+ errorTimestamps.length = 0;
1009
+ dbLogger5.error("Connection-error threshold crossed, forcing pool rebuild", {
1010
+ threshold: ERROR_THRESHOLD,
1011
+ windowMs: ERROR_WINDOW_MS
1012
+ });
1013
+ triggerForceReconnect("query_error_threshold").catch((err) => {
1014
+ const message = err instanceof Error ? err.message : String(err);
1015
+ dbLogger5.error("Forced reconnect after error threshold failed", { error: message });
1016
+ });
1017
+ } catch (err) {
1018
+ const message = err instanceof Error ? err.message : String(err);
1019
+ dbLogger5.debug("reportDatabaseError itself threw, ignoring", { error: message });
1020
+ }
1021
+ }
857
1022
  var BARREL_FILE_PATTERNS = [
858
1023
  "/index",
859
1024
  "/index.ts",
@@ -1416,6 +1581,7 @@ function Transactional(options = {}) {
1416
1581
  }
1417
1582
  );
1418
1583
  } catch (error) {
1584
+ reportDatabaseError(error);
1419
1585
  if (error instanceof DatabaseError) {
1420
1586
  throw error;
1421
1587
  }
@@ -1663,6 +1829,7 @@ var BaseRepository = class {
1663
1829
  try {
1664
1830
  return await queryFn();
1665
1831
  } catch (error) {
1832
+ reportDatabaseError(error);
1666
1833
  const err = error instanceof Error ? error : new Error(String(error));
1667
1834
  const repositoryName = this.constructor.name;
1668
1835
  throw new RepositoryError(
@@ -1915,6 +2082,6 @@ var BaseRepository = class {
1915
2082
  }
1916
2083
  };
1917
2084
 
1918
- export { BaseRepository, RepositoryError, Transactional, auditFields, checkConnection, closeDatabase, count, create, createDatabaseConnection, createDatabaseFromEnv, createMany, createSchema, deleteMany, deleteOne, detectDialect, enumText, findMany, findOne, foreignKey, fromPostgresError, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, id, initDatabase, onAfterCommit, optionalForeignKey, packageNameToSchema, publishingFields, runInTransaction, runWithTransaction, setDatabase, softDelete, timestamps, typedJsonb, updateMany, updateOne, upsert, utcTimestamp, uuid, verificationTimestamp };
2085
+ export { BaseRepository, RepositoryError, Transactional, auditFields, checkConnection, closeDatabase, count, create, createDatabaseConnection, createDatabaseFromEnv, createMany, createSchema, deleteMany, deleteOne, detectDialect, enumText, findMany, findOne, forceReconnectDatabase, foreignKey, fromPostgresError, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, id, initDatabase, isConnectionLevelError, onAfterCommit, optionalForeignKey, packageNameToSchema, publishingFields, reportDatabaseError, resetConnectionErrorCounter, runInTransaction, runWithTransaction, setDatabase, softDelete, timestamps, typedJsonb, updateMany, updateOne, upsert, utcTimestamp, uuid, verificationTimestamp };
1919
2086
  //# sourceMappingURL=index.js.map
1920
2087
  //# sourceMappingURL=index.js.map