Two writes as one
Two writes that must both land, or neither: a debit and its ledger line, an order and its stock.
Each write through a repository is one statement, and one statement is atomic in every engine.
Together makes a block atomic: 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]) => {
await accounts.update(from, (account) => ({ balance: account.balance - amount }));
await ledger.create({ from, to, amount });
});
}
}
It is asked for by type, like Repository<E> or Facade<H>: nothing to register. Nothing makes
an ordinary write transactional behind your back either — 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 validator still runs: a value the entity
refuses is refused here too.
A value computed from what is there
update(id, (row) => …) takes the row as it stands and writes what the function answers. No other
write reaches that row between the read and the write, so two transfers from one account both
count, and a check inside the function sees the balance it is about to change:
await accounts.update(from, (account) => {
if (account.balance < amount) throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Insufficient funds' });
return { balance: account.balance - amount };
});
Reading with findById and writing a value computed from it does not hold: two calls read the same
balance, and the second write erases the first. Atomic is not isolated — the frame undoes a half
block, and says nothing of who else wrote meanwhile.
A block that changes several rows of one entity hands them over together, and gets them back by key:
await stocks.updateByKeys(productIds, (stock) => ({ units: stock.units - wanted.get(stock.sku)! }));
Every row is held by one read, taken in key order: two blocks asking for the same rows queue rather than each holding one, so they cannot stop each other, and a key with no row refuses the whole set before anything is written.
SQL reads the row FOR UPDATE on Postgres and MySQL, and writes it onto itself first on SQLite and
SQL Server, which every engine locks; the lock lasts until the transaction ends. A Map or a file
takes the writes on one key one after the other. Two blocks that take the same rows in opposite
orders may stop each other: the engine gives up on one, which answers CONFLICT — taking rows in a
fixed order, by key, avoids it.
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:
Together<[Account, Ledger]> — transaction, source 'main'
Together<[Account, Ledger]> — compensated: account in 'main', 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 only, so there is none. The frame keeps what each row was before every write, and undoes the writes 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, change) | 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 |
So you write no undo step: the writes are a fixed set of gestures, and each one's inverse is known.
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 fronds: | 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 |
| a member an aggregate owns, outside that aggregate | only its owner writes it — a frame elsewhere would be a second way in |
Two more at the call:
A frame opened inside another. On one engine the second transaction waits for the first, so the call hangs; 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 — see the warning above: announce
after run() returns.
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.