@spfn/core 0.3.0-beta.5 → 0.3.0-beta.6

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/db/index.js CHANGED
@@ -3,7 +3,7 @@ import { env } from '@spfn/core/config';
3
3
  import { logger } from '@spfn/core/logger';
4
4
  import net from 'net';
5
5
  import postgres from 'postgres';
6
- import { QueryError, ConnectionError, DeadlockError, TransactionError, ConstraintViolationError, DuplicateEntryError, DatabaseError } from '@spfn/core/errors';
6
+ import { QueryError, ConnectionError, DeadlockError, TransactionError, ConstraintViolationError, DuplicateEntryError, SerializableError } from '@spfn/core/errors';
7
7
  import { parseNumber, parseBoolean } from '@spfn/core/env';
8
8
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
9
9
  import { join, dirname, basename } from 'path';
@@ -1743,6 +1743,32 @@ function pendingMigrationsSummary(targets) {
1743
1743
  return `${pending} pending migration(s) in ${names}`;
1744
1744
  }
1745
1745
  var txLogger = logger.child("@spfn/core:transaction");
1746
+ var concurrentNestingReported = false;
1747
+ function reportConcurrentNesting() {
1748
+ if (concurrentNestingReported) {
1749
+ return;
1750
+ }
1751
+ concurrentNestingReported = true;
1752
+ txLogger.warn("Concurrent nested transactions are serialized", {
1753
+ reason: "sibling SAVEPOINTs share the outer transaction's connection, where one sibling's ROLLBACK TO would discard the other's writes",
1754
+ hint: "await nested calls one at a time, or pass requiresNew: true to give a branch its own connection. A nested call whose callback awaits a sibling started after it deadlocks."
1755
+ });
1756
+ }
1757
+ function createNestedFrameGate() {
1758
+ let tail = Promise.resolve();
1759
+ let pending = 0;
1760
+ return {
1761
+ run(frame) {
1762
+ if (pending > 0) {
1763
+ reportConcurrentNesting();
1764
+ }
1765
+ pending++;
1766
+ const result = tail.then(frame);
1767
+ tail = result.then(() => void pending--, () => void pending--);
1768
+ return result;
1769
+ }
1770
+ };
1771
+ }
1746
1772
  var asyncContext = new AsyncLocalStorage();
1747
1773
  function getTransactionContext() {
1748
1774
  return asyncContext.getStore() ?? null;
@@ -1755,7 +1781,7 @@ function runWithTransaction(tx, txId, callback) {
1755
1781
  const existingContext = getTransactionContext();
1756
1782
  const newLevel = existingContext ? existingContext.level + 1 : 1;
1757
1783
  if (existingContext) {
1758
- txLogger.info("Nested transaction started (SAVEPOINT)", {
1784
+ txLogger.debug("Nested transaction started (SAVEPOINT)", {
1759
1785
  outerTxId: existingContext.txId,
1760
1786
  innerTxId: txId,
1761
1787
  level: newLevel
@@ -1763,9 +1789,19 @@ function runWithTransaction(tx, txId, callback) {
1763
1789
  } else {
1764
1790
  txLogger.debug("Root transaction context set", { txId, level: newLevel });
1765
1791
  }
1766
- const afterCommitCallbacks = existingContext ? existingContext.afterCommitCallbacks : [];
1792
+ const beforeCommitCallbacks = existingContext?.beforeCommitCallbacks ?? [];
1793
+ const afterCommitCallbacks = existingContext?.afterCommitCallbacks ?? [];
1794
+ const afterRollbackCallbacks = existingContext?.afterRollbackCallbacks ?? [];
1767
1795
  return asyncContext.run(
1768
- { tx, txId, level: newLevel, afterCommitCallbacks },
1796
+ {
1797
+ tx,
1798
+ txId,
1799
+ level: newLevel,
1800
+ beforeCommitCallbacks,
1801
+ afterCommitCallbacks,
1802
+ afterRollbackCallbacks,
1803
+ nestedFrames: createNestedFrameGate()
1804
+ },
1769
1805
  callback
1770
1806
  );
1771
1807
  }
@@ -1781,14 +1817,67 @@ function onAfterCommit(callback) {
1781
1817
  }
1782
1818
  context.afterCommitCallbacks.push(callback);
1783
1819
  }
1820
+ function onBeforeCommit(callback) {
1821
+ const context = getTransactionContext();
1822
+ if (!context) {
1823
+ txLogger.warn(
1824
+ "beforeCommit callback ran immediately (no transaction): a throw cannot abort anything"
1825
+ );
1826
+ Promise.resolve().then(callback).catch((err) => {
1827
+ txLogger.error("beforeCommit callback failed (no transaction)", {
1828
+ error: err instanceof Error ? err.message : String(err)
1829
+ });
1830
+ });
1831
+ return;
1832
+ }
1833
+ context.beforeCommitCallbacks.push(callback);
1834
+ }
1835
+ function onAfterRollback(callback) {
1836
+ const context = getTransactionContext();
1837
+ if (!context) {
1838
+ txLogger.warn("afterRollback callback ignored (no transaction)");
1839
+ return;
1840
+ }
1841
+ context.afterRollbackCallbacks.push(callback);
1842
+ }
1784
1843
  var MAX_TIMEOUT_MS = 2147483647;
1785
1844
  var txLogger2 = logger.child("@spfn/core:transaction");
1845
+ function logSafely(emit) {
1846
+ try {
1847
+ emit();
1848
+ } catch {
1849
+ }
1850
+ }
1851
+ async function runBeforeCommitCallbacks(callbacks) {
1852
+ for (const cb of [...callbacks]) {
1853
+ await cb();
1854
+ }
1855
+ }
1856
+ async function runAfterRollbackCallbacks(callbacks, txId, context, enableLogging) {
1857
+ if (enableLogging) {
1858
+ logSafely(() => txLogger2.debug("Executing afterRollback callbacks", {
1859
+ txId,
1860
+ context,
1861
+ count: callbacks.length
1862
+ }));
1863
+ }
1864
+ for (const cb of callbacks) {
1865
+ await Promise.resolve().then(cb).catch((err) => logSafely(() => {
1866
+ txLogger2.error("afterRollback callback failed", {
1867
+ txId,
1868
+ context,
1869
+ error: err instanceof Error ? err.message : String(err)
1870
+ });
1871
+ }));
1872
+ }
1873
+ }
1786
1874
  async function runInTransaction(callback, options = {}) {
1787
1875
  const defaultTimeout = env.TRANSACTION_TIMEOUT;
1788
1876
  const {
1789
1877
  slowThreshold = 1e3,
1790
1878
  enableLogging = true,
1791
- context = "transaction"
1879
+ context = "transaction",
1880
+ requiresNew = false
1792
1881
  } = options;
1793
1882
  const timeout = options.timeout ?? defaultTimeout;
1794
1883
  const idleTimeout = options.idleTimeout ?? env.TRANSACTION_IDLE_TIMEOUT;
@@ -1866,24 +1955,37 @@ async function runInTransaction(callback, options = {}) {
1866
1955
  }
1867
1956
  throw error;
1868
1957
  }
1869
- const existingContext = getTransactionContext();
1870
- const isNested = existingContext !== null;
1871
- if (isNested && timeout > 0 && enableLogging) {
1958
+ const savepointOwner = requiresNew ? null : getTransactionContext();
1959
+ const isNested = savepointOwner !== null;
1960
+ if (isNested && options.timeout !== void 0 && options.timeout > 0 && enableLogging) {
1872
1961
  txLogger2.warn("Timeout ignored in nested transaction", {
1873
1962
  txId,
1874
1963
  context,
1875
- outerTxId: existingContext.txId,
1964
+ outerTxId: savepointOwner.txId,
1876
1965
  requestedTimeout: `${timeout}ms`,
1877
- reason: "SET LOCAL statement_timeout affects the entire outer transaction"
1966
+ reason: "the SAVEPOINT runs under the outer transaction's statement_timeout; SET LOCAL here would re-scope the whole outer transaction"
1878
1967
  });
1879
1968
  }
1880
1969
  if (enableLogging) {
1881
1970
  txLogger2.debug("Transaction started", { txId, context });
1882
1971
  }
1883
1972
  const startTime = Date.now();
1973
+ let beforeCommitCallbacks = [];
1884
1974
  let afterCommitCallbacks = [];
1975
+ let afterRollbackCallbacks = [];
1976
+ const openTransaction = (body) => {
1977
+ if (savepointOwner) {
1978
+ return savepointOwner.nestedFrames.run(
1979
+ () => savepointOwner.tx.transaction(body)
1980
+ );
1981
+ }
1982
+ if (requiresNew) {
1983
+ return asyncContext.exit(() => writeDb.transaction(body));
1984
+ }
1985
+ return writeDb.transaction(body);
1986
+ };
1885
1987
  try {
1886
- const result = await writeDb.transaction(async (tx) => {
1988
+ const result = await openTransaction(async (tx) => {
1887
1989
  const transaction = tx;
1888
1990
  if (timeout > 0 && !isNested) {
1889
1991
  await transaction.execute(sql.raw(`SET LOCAL statement_timeout = ${timeout}`));
@@ -1892,81 +1994,117 @@ async function runInTransaction(callback, options = {}) {
1892
1994
  await transaction.execute(sql.raw(`SET LOCAL idle_in_transaction_session_timeout = ${idleTimeout}`));
1893
1995
  }
1894
1996
  return await runWithTransaction(transaction, txId, async () => {
1895
- const innerResult = await callback(transaction);
1896
1997
  if (!isNested) {
1897
1998
  const ctx = getTransactionContext();
1898
1999
  if (ctx) {
1899
- afterCommitCallbacks = [...ctx.afterCommitCallbacks];
2000
+ beforeCommitCallbacks = ctx.beforeCommitCallbacks;
2001
+ afterCommitCallbacks = ctx.afterCommitCallbacks;
2002
+ afterRollbackCallbacks = ctx.afterRollbackCallbacks;
2003
+ }
2004
+ }
2005
+ const innerResult = await callback(transaction);
2006
+ if (!isNested && beforeCommitCallbacks.length > 0) {
2007
+ if (enableLogging) {
2008
+ logSafely(() => txLogger2.debug("Executing beforeCommit callbacks", {
2009
+ txId,
2010
+ context,
2011
+ count: beforeCommitCallbacks.length
2012
+ }));
1900
2013
  }
2014
+ await runBeforeCommitCallbacks(beforeCommitCallbacks);
1901
2015
  }
1902
2016
  return innerResult;
1903
2017
  });
1904
2018
  });
1905
2019
  const duration = Date.now() - startTime;
1906
2020
  if (enableLogging) {
1907
- if (duration >= slowThreshold) {
1908
- txLogger2.warn("Slow transaction committed", {
1909
- txId,
1910
- context,
1911
- duration: `${duration}ms`,
1912
- threshold: `${slowThreshold}ms`,
1913
- hint: "A transaction holds a pooled connection (and row locks) for its whole duration. If this is slow because of non-DB work (external API, etc.) inside the transaction, move that work out \u2014 it starves the connection pool."
1914
- });
1915
- } else {
1916
- txLogger2.debug("Transaction committed", {
1917
- txId,
1918
- context,
1919
- duration: `${duration}ms`
1920
- });
1921
- }
2021
+ logSafely(() => {
2022
+ if (duration >= slowThreshold) {
2023
+ txLogger2.warn("Slow transaction committed", {
2024
+ txId,
2025
+ context,
2026
+ duration: `${duration}ms`,
2027
+ threshold: `${slowThreshold}ms`,
2028
+ hint: "A transaction holds a pooled connection (and row locks) for its whole duration. If this is slow because of non-DB work (external API, etc.) inside the transaction, move that work out \u2014 it starves the connection pool."
2029
+ });
2030
+ } else {
2031
+ txLogger2.debug("Transaction committed", {
2032
+ txId,
2033
+ context,
2034
+ duration: `${duration}ms`
2035
+ });
2036
+ }
2037
+ });
1922
2038
  }
1923
2039
  if (!isNested && afterCommitCallbacks.length > 0) {
1924
2040
  if (enableLogging) {
1925
- txLogger2.debug("Executing afterCommit callbacks", {
2041
+ logSafely(() => txLogger2.debug("Executing afterCommit callbacks", {
1926
2042
  txId,
1927
2043
  context,
1928
2044
  count: afterCommitCallbacks.length
1929
- });
2045
+ }));
1930
2046
  }
1931
2047
  for (const cb of afterCommitCallbacks) {
1932
- Promise.resolve().then(cb).catch((err) => {
2048
+ Promise.resolve().then(cb).catch((err) => logSafely(() => {
1933
2049
  txLogger2.error("afterCommit callback failed", {
1934
2050
  txId,
1935
2051
  context,
1936
2052
  error: err instanceof Error ? err.message : String(err)
1937
2053
  });
1938
- });
2054
+ }));
1939
2055
  }
1940
2056
  }
1941
2057
  return result;
1942
2058
  } catch (error) {
1943
2059
  const duration = Date.now() - startTime;
1944
2060
  if (enableLogging) {
1945
- if (duration >= slowThreshold) {
1946
- txLogger2.warn("Slow transaction rolled back", {
1947
- txId,
1948
- context,
1949
- duration: `${duration}ms`,
1950
- threshold: `${slowThreshold}ms`,
1951
- error: error instanceof Error ? error.message : String(error),
1952
- errorType: error instanceof Error ? error.name : "Unknown",
1953
- hint: "If the error is an idle-in-transaction timeout, the transaction held a pooled connection while awaiting non-DB work (external API, etc.). Move that work out of the transaction."
1954
- });
1955
- } else {
1956
- txLogger2.error("Transaction rolled back", {
1957
- txId,
1958
- context,
1959
- duration: `${duration}ms`,
1960
- error: error instanceof Error ? error.message : String(error),
1961
- errorType: error instanceof Error ? error.name : "Unknown"
1962
- });
1963
- }
2061
+ logSafely(() => {
2062
+ if (duration >= slowThreshold) {
2063
+ txLogger2.warn("Slow transaction rolled back", {
2064
+ txId,
2065
+ context,
2066
+ duration: `${duration}ms`,
2067
+ threshold: `${slowThreshold}ms`,
2068
+ error: error instanceof Error ? error.message : String(error),
2069
+ errorType: error instanceof Error ? error.name : "Unknown",
2070
+ hint: "If the error is an idle-in-transaction timeout, the transaction held a pooled connection while awaiting non-DB work (external API, etc.). Move that work out of the transaction."
2071
+ });
2072
+ } else {
2073
+ txLogger2.error("Transaction rolled back", {
2074
+ txId,
2075
+ context,
2076
+ duration: `${duration}ms`,
2077
+ error: error instanceof Error ? error.message : String(error),
2078
+ errorType: error instanceof Error ? error.name : "Unknown"
2079
+ });
2080
+ }
2081
+ });
2082
+ }
2083
+ if (!isNested && afterRollbackCallbacks.length > 0) {
2084
+ await runAfterRollbackCallbacks(afterRollbackCallbacks, txId, context, enableLogging).catch(() => void 0);
1964
2085
  }
1965
2086
  throw error;
1966
2087
  }
1967
2088
  }
1968
2089
 
1969
2090
  // src/db/transaction/middleware.ts
2091
+ var SQLSTATE_PATTERN = /^[0-9A-Z]{5}$/;
2092
+ function isDriverOriginError(error) {
2093
+ if (!error || typeof error !== "object") {
2094
+ return false;
2095
+ }
2096
+ const candidate = error;
2097
+ if (typeof candidate.code !== "string") {
2098
+ return false;
2099
+ }
2100
+ if (POSTGRES_JS_CONNECTION_CODES.has(candidate.code)) {
2101
+ return true;
2102
+ }
2103
+ if (!SQLSTATE_PATTERN.test(candidate.code)) {
2104
+ return false;
2105
+ }
2106
+ return typeof candidate.severity === "string" || typeof candidate.severity_local === "string";
2107
+ }
1970
2108
  function Transactional(options = {}) {
1971
2109
  return createMiddleware(async (c, next) => {
1972
2110
  const route = `${c.req.method} ${c.req.path}`;
@@ -1986,13 +2124,10 @@ function Transactional(options = {}) {
1986
2124
  );
1987
2125
  } catch (error) {
1988
2126
  reportDatabaseError(error);
1989
- if (error instanceof DatabaseError) {
1990
- throw error;
1991
- }
1992
- if (error instanceof TransactionError) {
2127
+ if (error instanceof SerializableError) {
1993
2128
  throw error;
1994
2129
  }
1995
- if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
2130
+ if (isDriverOriginError(error)) {
1996
2131
  throw fromPostgresError(error);
1997
2132
  }
1998
2133
  throw error;
@@ -2531,6 +2666,6 @@ var BaseRepository = class {
2531
2666
  }
2532
2667
  };
2533
2668
 
2534
- export { BaseRepository, PROJECT_MIGRATIONS_TABLE, PROJECT_TARGET_NAME, RUN_MIGRATIONS_HINT, RepositoryError, Transactional, auditFields, checkConnection, closeDatabase, collectMigrationStatus, count, countPendingMigrations, create, createDatabaseConnection, createDatabaseFromEnv, createMany, createSchema, deleteMany, deleteOne, detectDialect, discoverFunctionMigrations, enumText, filterPendingEntries, findMany, findOne, forceReconnectDatabase, foreignKey, formatPendingMigrations, fromPostgresError, functionMigrationsTable, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, getTransactionContext, hasMigrationTargets, id, initDatabase, isConnectionLevelError, migrationTargets, onAfterCommit, optionalForeignKey, packageNameToSchema, pendingMigrationTargets, pendingMigrationsSummary, projectMigrationsDir, publishingFields, readMigrationEntries, reportDatabaseError, resetConnectionErrorCounter, runInTransaction, runWithTransaction, setDatabase, setDatabaseProvider, softDelete, timestamps, typedJsonb, updateMany, updateOne, upsert, utcTimestamp, uuid, verificationTimestamp };
2669
+ export { BaseRepository, PROJECT_MIGRATIONS_TABLE, PROJECT_TARGET_NAME, RUN_MIGRATIONS_HINT, RepositoryError, Transactional, auditFields, checkConnection, closeDatabase, collectMigrationStatus, count, countPendingMigrations, create, createDatabaseConnection, createDatabaseFromEnv, createMany, createSchema, deleteMany, deleteOne, detectDialect, discoverFunctionMigrations, enumText, filterPendingEntries, findMany, findOne, forceReconnectDatabase, foreignKey, formatPendingMigrations, fromPostgresError, functionMigrationsTable, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, getTransactionContext, hasMigrationTargets, id, initDatabase, isConnectionLevelError, migrationTargets, onAfterCommit, onAfterRollback, onBeforeCommit, optionalForeignKey, packageNameToSchema, pendingMigrationTargets, pendingMigrationsSummary, projectMigrationsDir, publishingFields, readMigrationEntries, reportDatabaseError, resetConnectionErrorCounter, runInTransaction, runWithTransaction, setDatabase, setDatabaseProvider, softDelete, timestamps, typedJsonb, updateMany, updateOne, upsert, utcTimestamp, uuid, verificationTimestamp };
2535
2670
  //# sourceMappingURL=index.js.map
2536
2671
  //# sourceMappingURL=index.js.map