Facts
Every other call names one recipient: remotes one address per Frond,
Facade<T> one door. Emit<T> names a
subject instead, and the number of readers is not the emitter's business.
Announcing a fact
A fact is an entity, usually derived from the one it is about:
// fronds/blog/entities/PostPublished.ts
export default class PostPublished extends Post.pick('id', 'title').extend({ at: created() }) {}
// fronds/blog/handlers/PostHandler.ts — the emitter
constructor(private published: Emit<PostPublished>) {}
async publish(id: string, title: string) {
await this.published({ id, title, at: new Date() });
}
// fronds/search/handlers/IndexHandler.ts — another Frond entirely
async reindex(fact: Fact<PostPublished>): Promise<void> { … }
There is no topic, no subscribe call, no listener list. Accepting a Fact<T> IS the
subscription — the scan reads the signature, and that is the whole mechanism. A fourth
listener does not reopen PostHandler.
Nothing marks PostPublished as a fact either: it becomes one because somebody writes
Emit<PostPublished> or Fact<PostPublished> about it. Either end alone is enough, so
removing the emitter does not quietly unsubscribe anybody.
What Fact<T> promises
Not merely "I subscribe". Push is the strict mode and pull is a special case of it: an op written for push is correct when called directly, the reverse is false. So the wrapper commits the operation to three things no type can check:
| pull | push | |
|---|---|---|
| its return | somebody reads it | nobody |
| an exception | reaches the client | reaches a log |
| how many times | once per request | at least once — replayable |
A subscriber is an ordinary operation. It keeps its door, its judge and its middlewares,
because an emission and a direct call are the same call — which is also why it appears
in your public surface. Put it under handlers/<audience>/ to keep it out of a client one.
It is a resolver, not a channel
A bus moves messages: a queue, an envelope, a delivery semantic. This has none of that. It
answers who, then hands over to the door that already exists — resolve returning N
things instead of one. Consequences, stated rather than discovered:
- Dispatch is not delivery.
await this.published(...)returns once every subscriber has been handed the fact, never once any of them is done. A subscriber's failure reaches a log; a publication is never hostage to its own indexer. - A fact cannot cause itself. A ring (
A → B → A) is refused and the message names it; a diamond (A → B → D,A → C → D) stays legal. - Nothing is durable, and nothing here will be. Kill the listener's process and the
fact is lost. At-least-once means putting a real channel under the dispatch — a log, a
cursor per subscriber, an ack — and none of that belongs to a resolver.
What Fougere owes such a channel is the ability to answer did it land?, and that isapp.deliver(): it waits for every local listener and rejects if one refused, so a carrier can ack, retry or dead-letter. It is the exact opposite of announcing, on purpose — "dispatch is not delivery" protects the emitter, and a carrier is not the emitter. See emit-multirepo, where the eighty-line broker holds the queue and the Frond holds none of it.
Across processes
remotes: is the only line that changes. The listener's Frond stays on disk either way: it
is scanned, so the emitter knows its signature; declaring it remote only says it is not
hosted here, and its door resolves to a stand-in that sends the call.
See emit-split — the same
PostHandler, published twice, and the pid in the output says which process indexed.
Across repositories
Two things stop at a repository boundary, and only one of them is remotes:.
Finding the listeners. The dispatch reads their code, and another team's Frond is not on
this disk. So a carrier goes under the emission — onEmit hands the fact to a name, and the
far side subscribes to that same name from its own code. Neither reads the other; they meet
on a string derived on both sides.
const app = await createApp({
root: import.meta.dirname,
createContainer,
onEmit: (fact, payload) => publishToYourBroker(fact, payload), // out
});
// in — the same local dispatch, judge and middlewares included
await app.deliver(fact, payload);
// and what to subscribe to, read off your own signatures
app.listensTo(); // ['postPublished']
Knowing the shape. The card publishes it: a frond's facts list carries every
Emit<T> its handlers inject, so fougere sync writes the class instead of a subscriber
copying the fields by hand. Re-export it into one of your fronds and an arriving fact is
judged against the emitter's own declaration.
See emit-multirepo — two projects, one fact, and the first publication lost on purpose to show what a carrier buys.
Changing a fact
A fact meets the same judge as any other input: a key outside the contract is
Unknown field, and that is deliberate. Tolerating it would mean a reader silently
ignoring a field it was meant to handle — the failure you find six months later.
So adding a field to a fact breaks every reader still carrying the older copy. The refusal reaches the reader's log and never the sender, because dispatch is not delivery — so that line is the whole of the evidence, and it says what to do:
ERR [blog] postPublished → indexHandler.reindex refused the shape — author: Unknown field.
If 'postPublished' gained a field, this copy is older than the sender's:
re-run `fougere sync`.
The order is therefore part of the change:
1. sender declares the new field
2. every reader: `fougere sync`, then deploy ← first
3. sender deploys ← last
Removing a field is breaking in the same way, and no amount of tolerance would have helped: the data a reader needs is simply not there.
When you do not control step 2 — another team, a fleet, anything you cannot deploy in
order — announce a second fact instead of changing the first. It costs one class and
one Emit<T>, both facts run in parallel, and readers move when they move:
constructor(
private published: Emit<PostPublished>, // the old readers, untouched
private publishedV2: Emit<PostPublishedFull>, // the new shape
) {}
Nothing registers a fact, so a second one adds nothing to unregister later. Delete the
first Emit when its last reader is gone.
An EventBus used to sit here and was removed rather than published. It resolved by
string — the one place that did not resolve by type, so a misspelled event name raised
nothing — carried an unknown payload, and awaited every listener while handing their
failures back to the emitter.
Next: Presenters — computed fields added to an entity's output.