Repository navigation
refactor(uow): ambient unit of work, no transaction parameter in ports - #77
Merged
Merged
Conversation
…ent scope UnitOfWorkContext becomes the single AsyncLocalStorage of the kit: a scope holds the provider transaction, the events collected in it and a closed flag set when the run settles, so a promise that outlives its run reads no transaction instead of a connection already handed back to the pool. EventCollector now sits on that scope, and collectFrom pulls the events of one or several saved aggregates through the new IEventSource interface.
The outermost TransactionService.run opens db.transaction and publishes it in UnitOfWorkContext; a run started inside it joins that transaction (no savepoint, no second connection) instead of throwing. A failed Result returned at any depth marks the unit rollback-only, keeps the first failure and makes the outermost run resolve to it; a throw rolls back and propagates. Events of a successful nested run wait for the outermost flush. resolveExecutor returns the ambient transaction or db, and a promise that outlives its run gets db because the scope is closed. check:uow-rollback proves it on Postgres: same pid and txid for nested and outer, nested failure and swallowed nested failure roll back the outer write, nested throw propagates, a late promise reads the pool, a timer inside a run keeps the run's backend, outer write after a nested one commits both.
A run built without a flush handler used to commit and drop the events it had collected. The flush now throws EventsWithoutOutbox, naming the event types, so the writes roll back with them. The api container always passes the outbox enqueue, so only a mis-wired run fails; check:uow-rollback proves the write is undone on Postgres.
mockUnitOfWork, behind @packages/test/bun/unit-of-work, gives use-case tests a typed IUnitOfWork double that follows TransactionService: a nested run joins the open one through UnitOfWorkContext, a failed Result at any depth rolls the unit back and the outer run returns that failure, events are written only on commit, and commit "fails" rejects. ddd-kit is a peer dependency because ddd-kit already depends on this package for its vitest config, a regular dependency would make the turbo graph cyclic.
The audit, email queue, profile and api-token ports drop their optional tx parameter: their repositories read resolveExecutor(), so a call made inside uow.run lands in the open transaction and a call made outside autocommits on the pool. emitEvent drops its tx argument the same way, which removes it from every caller; the raw transactions still owned by the workers, the notification flush and the BetterAuth bridge make their tx ambient around the emit until their own migration. The SCIM savepoint stays the one explicit savepoint, opened on the ambient executor. reserveQuota and countScopedRows read the ambient transaction too, and reserveQuota refuses outside a run since its advisory lock would end with the statement. Unit tests move from passthroughUow and hand mocks of run to mockUnitOfWork, and assert the outbox received the run's transaction instead of a forwarded handle.
…ifications, policies The subscription, consent, notification and policy acceptance stores drop their optional tx parameter and read resolveExecutor(), including the methods that used to query db directly, so every call made inside a uow.run joins its transaction. The consent and policy services and the notification routes open uow.run(async () => ...) and stop threading a handle. passthroughUow is gone: the consent and policy suites run on mockUnitOfWork like the others.
…otas, rgpd, webhooks The rgpd, quota usage and webhook stores read resolveExecutor() instead of taking a tx, including the account wipe and the service side webhook writes (create, update, delete, replay, targeted delivery, secret rotation); the methods that queried db directly read it too. RgpdService and WebhooksService open uow.run(async () => ...) and stop threading a handle. The webhook delivery worker keeps its required tx on the claim, status and failure-counter methods until its transactions move to the unit of work. Repository tests inject their fake executor through the ambient scope instead of a tx argument.
The third AsyncLocalStorage (shared/auth/auth-transaction.ts) is gone. The drizzle adapter now receives db wrapped so every transaction it opens is an outermost run of the TransactionService: the hooks it runs inside read that transaction through resolveExecutor(), and anything collected is flushed before BetterAuth commits. The hooks that opened their own transaction (Personal org bootstrap, change-email request, empty org collapse, and the direct db.transaction of user.update.after) call uow.run, which joins BetterAuth's run when there is one. The emit adapter calls emitEvent directly, auth-queries drop their tx parameter and read resolveExecutor(), and AdminActionService and PATCH /api/v1/me stop threading a tx into setOrgSsoEnforced and updateUserName. check:auth-transaction gains the step "an event emitted in a databaseHook is rolled back with the hook write"; replacing the wrapped client by plain db makes the first SSO sign-in fail again.
…f work The email and webhook delivery workers, the notification digest flush and the retention sweep helpers stop opening db.transaction and threading a handle: each envelope becomes uow.run, the repositories they call (email queue claim, markSent, markFailed; webhook delivery updateStatus, findPendingBatch, createAttempt; webhook endpoint bumpFailure, resetFailure, markDisabled) read resolveExecutor(), and the UnitOfWorkContext.run(tx, ...) wrappers around emitEvent are gone, the emit reads the run's transaction. Both workers receive the unit of work by constructor injection. No claim holds its FOR UPDATE SKIP LOCKED lock across a network call, so none is redesigned. file | decision | reason email-delivery-worker claim | uow.run | claim commits a lease before the Resend call email-delivery-worker markSent | uow.run | one repository call after the send email-delivery-worker settle | uow.run | markFailed and its exhausted event commit together webhook-delivery-worker claim | uow.run | lease committed before the fetch, SET LOCAL kept webhook-delivery-worker dead letters | uow.run | status, attempt and terminal events grouped webhook-delivery-worker post-fetch | uow.run | written after the response, lock not held flush-notification-emails | uow.run | lock held only across the email queue insert sweep-purge | uow.run | SET LOCAL guards and delete need one transaction sweep-count | uow.run | SET LOCAL statement_timeout needs a transaction
…tories IUnitOfWork is now a single run(work) whose work receives nothing: the open transaction lives only in the ambient scope and stores read it through resolveExecutor(). startTransaction, the transaction generic on IUnitOfWork, BaseRepository and ScopedRepository, and the api's ITransaction alias are gone. The BetterAuth adapter proxy hands the plugin the ambient transaction of the run it opens. Two layering tests guard the result: no file under modules or shared declares a tx parameter, and no api source opens a transaction outside packages/drizzle and scripts. Their allowlists name the outbox port, repository, subscribers and dispatcher (rewritten by the I.5 lease outbox) and the one intentional SCIM savepoint, each with its reason.
BetterAuth's adapter transactions joined an open app run, so inside the SCIM connection lock the plugin's pool reads missed its own uncommitted writes and its changes no longer committed on their own. The adapter now goes through TransactionService.runOutermost, which always opens a fresh transaction and masks the ambient one, and rejects when the run resolves to a failed Result instead of reporting success over a rollback. A nested run that throws now dooms the outer run even when the outer work catches the error. The open transaction is read through one accessor, ambientTransaction(), savepoint() replaces the hand-built savepoint scope, and the outbox enqueue resolves the ambient executor itself, so emitEvent no longer threads the transaction.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
uow.runjoins it, a failedResultor a nested throw rolls the whole run backresolveExecutor(); everytxparameter is gone from ports, repositories and services (outbox files excepted, rewritten by I.5)uow.run; no claim holds its lock across a network callIUnitOfWorknarrowed torun(work),ITransactionandshared/transaction.tsremoved;ambientTransaction()andsavepoint()exported by@packages/drizzlemockUnitOfWorkreplaces every hand mock ofrun; layering tests forbidtxparameters anddb.transactionoutsidepackages/drizzleand scripts