Fact
A fact says that something happened. Every other call names one recipient — an address in
fronds:, a facade. A fact names a subject instead: whoever accepts
it receives it, and the one who announces it names nobody.
Announce, and accept
A fact is an entity, usually cut 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 — announces
constructor(private published: Emit<PostPublished>) {}
async publish(id: Post['id']) {
const post = await this.findById(id);
await this.published({ id: post!.id, title: post!.title }); // `at` is stamped for you
}
// fronds/search/handlers/IndexHandler.ts — another frond, accepts
async reindex(fact: Fact<PostPublished>): Promise<void> { … }
There is no topic, no subscribe call, no list of listeners. Accepting a Fact<T> is the
subscription: the scan reads the signature, and that is all. A fourth listener does not reopen
PostHandler. Nothing marks PostPublished as a fact either; it is one because someone announces
or accepts it.
What a listener promises
A listener is an ordinary operation: it keeps its validation and the links that stand around its calls. But it has no caller — no REST route, no GraphQL field, and a page cannot call it. What comes from outside is a command, which announces. Another process delivering the fact still reaches it, and so does the terminal, for an operator replaying one by hand.
Being called by an announcement changes three things:
| called directly | called by an announcement | |
|---|---|---|
| its return | someone reads it | nobody, unless the announcer waits (below) |
| an exception | reaches the caller | reaches a log |
| how many times | once per request | possibly again — write it so a replay is harmless |
A resolver, not a queue
An announcement finds who accepts the fact and calls each of them. There is no queue behind it:
- Handing over is not finishing.
await this.published(…)returns once every listener has been handed the fact, not once they are done. A listener that fails is logged; the announcer goes on. - A fact cannot cause itself. A ring (
A → B → A) is refused, naming it; a diamond (A → B → D,A → C → D) is fine. - Nothing is durable. If the listener's process is down, the fact is lost. A real channel — a
log, a cursor per listener, an acknowledgement — goes under the announcement. What Fougere gives
it is
app.deliver(), which waits for every local listener and rejects if one refused, so a carrier can acknowledge, retry or set the message aside. emit-multirepo has a broker of under a hundred lines that holds the queue, and the frond holds none of it.
Waiting for answers — Emit<T, A>
A second type makes an announcement wait, and hands back what each listener 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 listener answering Promise<void> has no opinion; any other return is one. The rule —
everyone, a majority, the first refusal — is the announcer's, in its own code. A listener that does
not answer refuses the whole announcement: given only the others, the announcer could not tell them
from a full answer, and silence would read as consent. A carrier brings no answer back, so an app
sending facts through one refuses Emit<T, A> at boot.
ask-quorum runs it across three processes.
To change a fact before anyone reads it — a hash, a lookup the announcer should not hold — see Finish a fact.
Across processes
Only fronds: changes. A listener whose code is read here is called through a stand-in when its
frond is placed elsewhere. A listener whose code is not read here — fougere serve blog reads
blog alone — is found on its frond's card, where each operation states the fact it accepts. Either
way the fact arrives as a call, so Emit<T, A> still gets its answers.
A process with no address in fronds: cannot be called, so a listener hosted by a Nuxt app is not
reached from a frond served on its own.
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 { appOptionsOf } 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 appOptionsOf(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 appOptionsOf(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); });
emit-fleet goes further: a hub and N
devices over one socket, facts in both directions, and one device addressed in the data — fronds:
names a kind of frond, never one instance of it.
For a listener in another repository, see A frond from another repository.
Changing a fact
A fact is checked like any input: a key outside the contract is Unknown field. Tolerating it would
let a reader silently ignore a field it was meant to handle. So adding a field breaks every reader
still holding the older copy, and since handing over is not finishing, the refusal reaches the
reader's log, not the sender:
ERR [blog] postPublished → index.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 part of the change:
1. the sender declares the new field
2. every reader: `fougere sync`, then deploy ← first
3. the sender deploys ← last
Removing a field breaks readers the same way. When you cannot deploy the readers first — another
team, a fleet — announce a second fact instead of changing the first. It costs one class and one
Emit<T>; both run side by side, and readers move when they move:
constructor(
private published: Emit<PostPublished>, // the old readers, untouched
private publishedV2: Emit<PostPublishedFull>, // the new shape
) {}
Delete the first Emit when its last reader is gone. A listener is found by the type it accepts,
so a misspelled fact does not compile and the payload is typed.