Writes that stand or fall as one
Storage<Post> is the port whose every gesture is one
statement, and one statement is atomic in every engine. Together is the port whose unit
is a block: what the callback did happens entirely, or not at all.
export default class TransferHandler {
constructor(private together: Together<[Account, Ledger]>) {}
async move(from: string, to: string, amount: number) {
return this.together.run(async ([accounts, ledger]) => {
const debited = await accounts.findById(from);
await accounts.update(from, { balance: debited.balance - amount });
await ledger.create({ from, to, amount });
});
}
}
Resolved by type, like RepositoryOf<E>, Facade<H> and Emit<F> — the type names the
subject, the container holds the realization. Nothing to register, and nothing ambient: no
decorator makes an ordinary storage.create() transactional behind your back, so the block is
visible where you read it.
Inside, you are in the ordinary vocabulary. accounts and ledger are the same guarded
entity ports that repositories forward, and every judge still runs: a value the entity
refuses is refused here too.
Two realizations, and the boot says which
The declaration names its members. Where those members live is a
sources: decision, and it decides what the frame can promise:
Account+LedgerTogether — transaction, source 'db'
Account+LedgerTogether — compensated: account in 'db', ledger in 'accounting' — no isolation
One engine — the members are rebuilt over a real transaction. The engine gives the unwind and the isolation, and it costs nothing.
Several engines — a transaction reaches one engine, so there is none. The frame keeps the before-image of every write and replays the inverses in reverse order. The port has a fixed set of gestures, which is what makes an inverse derivable rather than declared:
| write | its inverse |
|---|---|
create(x) | delete(x.id) — the row is handed back, so it costs nothing |
update(id, patch) | update(id, before) — one read first |
delete(id) | create(row) — one read first |
upsertAll(page) | restore what was there, delete what was not — one read for the page |
This is why it is not a saga: a saga asks its author for an undo per step, because its
steps are arbitrary code. These are not.
Emit<T> resolves outside the block,
so an announcement leaves the moment it is made — including from a frame that then unwinds.
Subscribers will have acted on something that did not happen. Announce afterrun()
returns, where the block has already stood.Covering what a collaborator writes
A member list of entities covers what your block writes. A second list names providers — a service, a repository, a mirror — rebuilt inside the frame so that what they write is covered too:
constructor(private together: Together<[RateCard, Ledger], [RateMirror]>) {}
await this.together.run(async ([rates, ledger], [mirror]) => {
await mirror.refresh(); // its pages are under the same unwind
await ledger.create({ … });
});
RateMirror writes through Storage<RateCard>, so it receives the framed storage through
its ordinary constructor. Not one line of Mirror knows a frame exists.
Two lists rather than one, because in a signature an entity and a class are both written as a name and their types do not separate them. They are two different facts anyway: what the unwind covers, and what is rebuilt to make that true.
What a frame refuses
At boot, so you never discover it at the first call:
a member whose frond is declared in remotes: | it registers no storage here — nothing to record, nothing to undo |
| a name that resolves to nothing | a typo, whose only other symptom is a frame quietly one member short |
| a provider writing an entity absent from the first list | its writes would escape the unwind |
Two more at the call:
A frame opened inside another. On one engine the second transaction waits for the first, so the call hangs — measured, five seconds and no answer; split across engines the same code returns. Two frames that must both hold are one frame, and its member list is the union.
A fact announced from inside a frame. Announcing is dispatch:
every subscriber has been handed it and the carrier has already put it on the wire, while
the writes can still be taken back. Announce after run() returns, which is when it is
true. Deferring to the commit was the other option and it was refused — it would make
Emit<T> behave differently depending on where it is called, and the window it opens
between commit and announcement has no remedy short of an outbox.
And in a compensated frame only: upsert on an entity that declares a
unique constraint besides its key. The inverse of an
upsert is derivable exactly when the conflict is the key; MySQL's ON DUPLICATE KEY UPDATE
fires on any unique constraint, so the question is asked of your declaration and never of
the engine.
Where the gradient stops
Declaring Together<[Account, Ledger]> says these two may not be separated by a change of
topology. That is a real cost, and it is the point: it is the one place where moving a
boundary is refused out loud instead of quietly weakening a guarantee.
When two things genuinely live apart and must both end up written, that is not a frame — it is a fact: commit one half, announce, and let the subscriber commit the other. You then own the compensation, and the framework does not pretend otherwise.
Next: Repositories.