Facts
Every other call names one recipient: fronds: one address per Frond,
Facade<T> one facade. 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 facade, its validator and its middlewares, because an emission and a direct call are the same call. What it does not have is a caller: no door open to one names it — REST serves no route to it, GraphQL no field, and the envelope a page calls refuses it. What enters from outside is a command, which announces. A peer delivering the fact from another process still reaches it, and so does the terminal, whose caller is the operator replaying a fact by hand.
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 facade 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 a broker of under a hundred lines holds the queue and the Frond holds none of it.
Waiting for the answers — Emit<T, A>
The second type makes an announcement wait. Emit<T> hands the fact over and returns nothing;
Emit<T, A> waits for every subscriber and gives back what each one answered.
// fronds/booking/handlers/BookingHandler.ts — asks, and decides with the answers
constructor(private mayBook: Emit<CanBook, Verdict>) {}
async reserve(room: string) {
const verdicts = await this.mayBook({ room });
return { room, booked: verdicts.length > 0 && verdicts.every((one) => one.ok) };
}
// fronds/rooms/handlers/RoomsHandler.ts — its return type IS its answer
async check(question: Fact<CanBook>): Promise<Verdict> { … }
A subscriber answering Promise<void> has no opinion; any other return is one. The law —
unanimity here, a majority or the first refusal elsewhere — is the announcer's, written in its
own code. A subscriber that fails to answer refuses the announcement rather than shrinking it:
handed the survivors, the announcer could not tell them from a complete answer, and silence
would read as consent. And a fact that a carrier would take is refused at boot: a carrier
reaches whoever subscribed elsewhere and brings nothing back.
See ask-quorum — three processes, and what a missing answer costs.
Finishing a fact — Pipe<T>
An operation that takes Pipe<T> answers the fact every subscriber then reads. It is for what
must be TRANSFORMED — a hash pick cannot produce, a lookup needing a dependency the announcer
should not hold — never for dropping a field: a fact is already a projection, so what should not
travel is simply not declared.
// fronds/privacy/handlers/HashHandler.ts
async set(fact: Pipe<PostPublished>): Promise<PostPublished> {
return { ...fact, author: this.salt.hash(fact.author) };
}
Several links run in the order the fact's OWNER declares, each reading what the previous one answered:
// fronds/blog/frond.config.ts — blog owns `postPublished`
export default { pipes: { postPublished: ['TenantHandler', 'HashHandler'] } };
Two links with no order refuse the boot, naming both; a link the order does not list refuses
too, and so does a frond ordering a fact it does not own. A link that answers nothing is
refused. A link is a hard dependency where a subscriber is not: a subscriber that throws is
logged and the announcement goes on, a link that throws stops it. And since a link is called,
it has an address — local, or named in fronds: — and never sits behind a carrier.
See pipe-split.
Across processes
fronds: is the only line that changes. When the listener's Frond is on this disk, it is
scanned, so the emitter knows its signature; declaring it remote only says it is not hosted
here, and its facade resolves to a stand-in that sends the call.
When it is not read here — fougere serve blog reads blog alone — the listener says it
itself. Each op on a Frond's card states the fact it listens to (listens), and the emitter
reads the cards of the Fronds fronds: places elsewhere. Either way the fact reaches the
listener as a call, so an Emit<T, A> gets its answers back.
A process with no address in fronds: cannot be called, so it cannot be reached either: a
listener hosted by a Nuxt app is not reached from a Frond served on its own.
See emit-split — the same
PostHandler, published twice, and the pid in the output says which process indexed.
demos/emit-split10 files/**
* The same emission, twice: in this process, then across a wire.
*
* `PostHandler` is not read twice and not compiled twice. `remotes:` is the whole
* difference between the two runs below — one line of deployment, zero lines of domain.
*
* terminal 1 pnpm dev:search
* terminal 2 pnpm dev
*/
import { createApp, createLocalRunner } from '@fougere/core';
import { statementOf } from '@fougere/defaults';
import { frondAliases, setModuleLoader } from '@fougere/core/node';
import type { App, InvocationContext } from '@fougere/core';
import { createContainer } from '@fougere/container';
import { createHttpTransport } from '@fougere/transport-http';
const SEARCH = `http://127.0.0.1:${process.env.PORT ?? 4210}`;
const inv = (params: Record<string, string>): InvocationContext =>
({ params, query: {}, input: undefined, state: {} });
/** Dispatch is not delivery — the emitter returns before its listeners are done. */
const settle = () => new Promise((r) => setTimeout(r, 300));
async function publish(app: App, id: string, title: string) {
return createLocalRunner(app)({ address: 'post', op: 'publish' }, inv({ id, title }));
}
function title(n: string, text: string) {
console.log(`\n\x1b[1m${n}\x1b[0m ${text}`);
}
async function main() {
const { createJiti } = await import('jiti');
const jiti = createJiti(import.meta.url, {
interopDefault: true,
// `@fronds/<name>` is how a frond names its neighbour; the loader has to know it.
alias: await frondAliases(import.meta.dirname),
});
setModuleLoader((path) => jiti.import(path) as Promise<Record<string, unknown>>);
title('1.', 'Both fronds in THIS process — nothing is declared, nothing is registered');
const together = await createApp({ ...(await statementOf(import.meta.dirname)), createContainer });
console.log(await publish(together, 'p1', 'Ferns are not plants that give up'));
await settle();
title('2.', `search declared remote — remotes: { search: '${SEARCH}' }`);
console.log(' the SAME PostHandler, unchanged. Watch the other terminal.\n');
const split = await createApp({
...(await statementOf(import.meta.dirname)),
createContainer,
// The whole topology statement. `search/` still sits on disk — it is scanned, so the
// emitter knows its signature; it is simply not hosted here.
remotes: { search: SEARCH },
remoteTransport: createHttpTransport,
});
console.log(await publish(split, 'p2', 'A frond is a leaf that repeats itself'));
await settle();
console.log('\n\x1b[2m Same emitter, two topologies. The fact found its listener both times.\x1b[0m\n');
await together[Symbol.asyncDispose]?.();
await split[Symbol.asyncDispose]?.();
}
main().catch((err) => { console.error(err); process.exit(1); });
Beyond two processes, emit-fleet
is a hub and N devices over one socket: facts travel in both directions, a device that
starts before the hub reconnects on its own, and addressing one device is done in the
data — because fronds: names a kind of Frond, never an instance of one.
Across repositories
Two things stop at a repository boundary, and only one of them is fronds:.
Finding the listeners. When the other team's Frond has an address in your fronds:, its
card says what it listens to, and the fact reaches it as a call — nothing more to write. A
call keeps nothing, though: a listener that is down misses the fact. When that matters, or
when the far side has no address you can call, 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, validator 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
validated against the emitter's own declaration.
A carrier has no address, and that is the line between the three words. Emit announces a
subject and Fact receives one, so neither names anybody — a carrier can take them across.
A Pipe<T> — the op that FINISHES a fact — is CALLED: it has an address, local or named in
fronds:, and
it answers. So a link crosses a process and never a carrier, which is the same line Emit<T, A>
draws.
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 validator 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.
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: Storage — the gestures every storage answers.