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:

writeits 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.

All-or-nothing holds either way — while the process lives. Isolation does not. Under a transaction, nobody outside can see the block half-done. Compensated, they can: between two writes a reader sees the half, and the unwind puts it back afterwards.And the unwind is held in memory, so a compensated frame whose process dies mid-block leaves what it had written. A transaction does not: its engine rolls back without anyone asking.The boot states which realization it built rather than letting you assume the stronger one.
A fact announced inside a frame is not covered.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 nothinga typo, whose only other symptom is a frame quietly one member short
a provider writing an entity absent from the first listits writes would escape the unwind
a member an aggregate owns, outside that aggregateonly 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.

Built with Fougere and Nuxt, hosted on GitHub Pages.