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 directlycalled by an announcement
its returnsomeone reads itnobody, unless the announcer waits (below)
an exceptionreaches the callerreaches a log
how many timesonce per requestpossibly 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
main.ts
/**
 * 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.

Put it to work

Built with Fougere and Nuxt, hosted on GitHub Pages.