Skip to content

refactor(uow): ambient unit of work, no transaction parameter in ports - #77

Merged
axelhamil merged 11 commits into
devfrom
refactor/phase-i-4-uow
Oct 29, 2026
Merged

axelhamil merged 11 commits into
devfrom
refactor/phase-i-4-uow

Conversation

@axelhamil

Copy link
Copy Markdown
Owner
  • One ambient scope (AsyncLocalStorage) carries the open transaction and the collected events; nested uow.run joins it, a failed Result or a nested throw rolls the whole run back
  • Repositories read resolveExecutor(); every tx parameter is gone from ports, repositories and services (outbox files excepted, rewritten by I.5)
  • BetterAuth transactions run as their own outermost unit through one adapter; the separate auth AsyncLocalStorage is deleted
  • Workers and sweeps route their transactions through uow.run; no claim holds its lock across a network call
  • IUnitOfWork narrowed to run(work), ITransaction and shared/transaction.ts removed; ambientTransaction() and savepoint() exported by @packages/drizzle
  • mockUnitOfWork replaces every hand mock of run; layering tests forbid tx parameters and db.transaction outside packages/drizzle and scripts
  • Real-DB checks extended (BetterAuth tx inside an app run, SCIM issue then disconnect, nested throw dooms the outer run); all 11 green, 960 api tests

…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.
@axelhamil
axelhamil merged commit 2b27c35 into dev Oct 29, 2026
1 check passed
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