Skip to content

Add pg-transaction module #3516

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 7 commits into from
Closed

Add pg-transaction module #3516

wants to merge 7 commits into from

Conversation

brianc
Copy link
Owner

@brianc brianc commented Jul 18, 2025

Addpg-transaction module. With the introduction of the using keyword to javascript, I've been interested in experimenting with incorporating it into pg code because quite a bit of database driver code is dealing with resource management and clean up. This is still a draft until I can get the tests passing for all (supported) node versions and write docs and so on, but want to put this up early to get some 👀 on it and some thoughts about the API, etc.

One thing to note: savepoints aren't in scope for this. I have honestly never used them, and if you need something more advanced like savepoints its probably best to roll that code yourself more manually.

Particularly interested in more failure modes I could test that I'm missing in the tests here.

edit: note - the old maintainer of the pg-transaction module on npm has graciously turned over the ownership/name to me, and since this is a drastic departure from its old API this would be a semver major bump of the old module. cc @jrf0110 ❤️

Copy link

cloudflare-workers-and-pages bot commented Jul 18, 2025

Deploying node-postgres with  Cloudflare Pages  Cloudflare Pages

Latest commit: b954f53
Status: ✅  Deploy successful!
Preview URL: https://d83c65ed.node-postgres.pages.dev
Branch Preview URL: https://bmc-pg-txn.node-postgres.pages.dev

View logs

@brianc
Copy link
Owner Author

brianc commented Jul 18, 2025

hmmm this is actually a bit frustrating...with the using keyword in the dispose handler you don't get informed whether the block you're exiting (thus causing the disposal to trigger) is exiting due to a thrown error or not. This means doing a completely automatic "commit if no error, otherwise rollback if error" seems impossible. So there are two different APIs I'm playing with. The first is what's currently on this PR:

Option 1:

async function doSomethingTransactionalInDatabase() {
  await using txn = transaction(client, async (client) => {
    await client.query('...')
  })
}

With option 1 since we're calling a callback with the client we can know for sure if the async callback throws and then automatically commit/rollback accordingly.

Option 2:

async function doSomethingTransactionalInDatabase() {
  async using txn = transaction(client)
  await client.query('...')
  await txn.commit()
}

Option 2 would be making the transaction object have a commit method which must be called before the scope is exited, otherwise it will automatically roll back the transaction. I feel like option 2 is more akin to how transactions are done with SQL...unless you explicity call COMMIT at the end of your sequence of queries, your transaction will not commit. In the case of postgres if you also never call ROLLBACK then you'll kinda hang in an uncommitted transaction forever, and w/ disposables we have at least the luxury of knowing the txn scope has been exited so we can auto-rollback (thus terminating the txn).

With option two I could also put a .query method on the transaction object itself to potentially make the API a bit nicer & allow the transaction to be passed around inside an application:

Option 2:

async function doSomethingTransactionalInDatabase() {
  async using txn = transaction(client)
  await txn.query('...')
  await txn.commit()
}

There's also a 3rd thing I played with, but not a fan at all. Including it for sake of completeness I suppose. The semantics here would be once the scope exits the transaction is automatically commited. Problem is, because we don't have access to an error being thrown or not when disposing we will always commit, even when there's an error. You'd have to explictly call txn.rollback():

Option 3:

async function doSomethingTransactionInDatabase() {
  async using txn = transaction(client)
  client.query('....')
  throw new Error("the first query will still be comitted')
  client.query('...this one wont ever get hit')
}

// if you wanted to properly rollback you'd have to do this disgustingness:

async function doSomethingTransactionInDatabase() {
  async using txn = transaction(client)
  try {
   client.query('....')
    throw new Error("the first query will still be comitted')
    client.query('...this one wont ever get hit')
  } catch (e) {
    txn.rollback()
    throw e
  }
}

@brianc
Copy link
Owner Author

brianc commented Jul 18, 2025

hmmm the more i mess w/ this the more it feels like using disposable here doesn't really fit. Particularly, option 1 can be accomplished without disposables at all. Just auto-commit at the end of an async callback if its successful, and auto-rollback otherwise. That also means there's no requirement for changing minimum node version or anything. I think I might go back to the drawing board a bit here.

The more I use option 2 it feels yucky to have to call "commit" but what you're really doing is deferring a call to commit to happen later (when the block exits) which kinda seems to violate the "principal of least surprise" and I also just don't think its bringing much to the table outside a standard async callback.

@brianc brianc closed this Jul 18, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant