Derived from the in-repo source (
packages/fate-effect,apps/web) +@nkzw/fate@1.3.1where the lib is implicated — re-verify on pin bump.
How @kampus/fate-effect composes a fate server. The short answer: fate has exactly one composite — the server — so it is the one Effect service. FateServer is the package-owned tag (the HttpRouter idiom; no user-defined class), FateServer.config(...) captures the records, and FateServer.layer(config) is the only composition construct — domain requirements are discharged with ordinary Layer.provide. There is no menu, fragment, group, or per-feature tag. Entries are authored per fate-effect-operations.md and fate-effect-sources.md.
import {FateServer} from "@kampus/fate-effect";
import {Layer} from "effect";
import {panoLists, panoMutations, panoQueries, panoSources} from "../pano/fate.ts";
import {sozlukLists, sozlukMutations, sozlukQueries, sozlukSources} from "../sozluk/fate.ts";
export const fateConfig = FateServer.config({
queries: {...sozlukQueries, ...panoQueries},
lists: {...sozlukLists, ...panoLists},
mutations: {...sozlukMutations, ...panoMutations},
sources: [...sozlukSources, ...panoSources],
live: liveBusConfig,
});
export const FateServerLive = FateServer.layer(fateConfig).pipe(
Layer.provide([SozlukLive, PanoLive]),
);configmirrorscreateFateServer's options shape.queries/lists/mutationsare fate's records (dotted wire names → entries);sourcesis an array ofFate.sourceentries (the package's one deviation: fate's ownsourcesoption is the derived{getSource, registry}resolver, which only the oracle-baseline compile step builds — fate-effect-compiler.md — keying the registry by the definition objects' identity; the serving interpreter reads the entry array directly).livepasses through to fate unchanged.configis pure data capture — full entry types are preserved on the value (InferFateAPI/codegen fidelity rides on them); all validation happens at layer construction.FateServer.layer(config)returnsLayer<FateServer, never, R>where R is the union of every handler's and source's requirements (Schema decoding services included) minus the per-request pair. A forgotten domain layer is a compile error where the layer is consumed (e.g.ManagedRuntime.make), because the undischarged layer is not aLayer<FateServer>.
The server's per-request contract. Handlers yield* them like any other service:
Effect.fn("definition.add")(function* ({input}) {
const user = yield* CurrentUser.required; // fails Unauthorized → UNAUTHORIZED on the wire
const live = yield* LivePublisher;
const definition = yield* sozluk.addDefinition({...input, userId: user.id});
yield* live.topic("Term.definitions", {slug: input.termSlug}).appendNode("Definition", definition.id, {node: definition});
return definition;
})but no worker-level layer ever provides them: the interpreter (fate-effect-interpreter.md) provides the pair onto each operation as VALUES off the one FateRequestContext the route builds (currentUser from the session, livePublisher from the request's execution context — the oracle-baseline compile step does the same on its plane), and FateServerRequirements excludes both from the layer's R. This is what made the bridge's FateContext smuggling unnecessary (the bridge is deleted — ADR 0042). LivePublisher's publish methods are typed Effect<void> — waitUntil scheduling and error-swallowing live inside its layer, once, so "a publish cannot fail the mutation" is a type, not a per-call-site convention.
An app can register extra per-request services beyond the pair — e.g. a CurrentActor derived from CurrentUser — without coupling fate-effect to the app's vocabulary (no authz import; the package names none of them). The seam has two ends:
- Declare the extra per-request tags at the composition root:
FateServer.layer(config, [CurrentActor]). The registration is a type-level witness only — the layer captures build-time services exactly as the single-arg overload does and never reads the keys at runtime. Its job is to widenFateServerRequirements<C, PR>so the registered tags drop out of the layer's R alongside the pair. A handler depending on a registered per-request service is therefore not aLayer.providerequirement; it's filled per request. - Fulfill it per request: put the tags' VALUES in
FateRequestContext.requestServices(an opaqueContext.Context<never>, like the captured build-timeservices).provideRequestPairprovides this bag innermost of the build-time services (Provision.ts), so a per-request value wins there too. Absent ⇒Context.empty().
The two ends are deliberately decoupled, mirroring the pair: registering a tag at the layer excludes it from build-time R, but nothing forces the request context to actually supply it — a declared-but-unprovided per-request service fails loudly at run (Service not found), never silently, exactly like a missing currentUser. Conversely, a handler that needs a service the app neither layers nor registers stays in R and is a compile error at the composition site — the leak is caught at build time. (RegisteredRequestServices<Keys> extracts each key's R-channel identifier off its Context.Key Identifier phantom.)
The package owns only the tag + contract; the live implementation is the worker's — it needs the LiveDO topic fan-out and the request's execution context, which the package can't know. livePublisherFor(options) builds the per-request service VALUE (a value, not a Layer) over two capabilities:
livePublisherFor({
publish: (topicKey, message) => liveTopics.publish(topicKey, message, limits), // worker-init LiveTopics
waitUntil: (promise) => executionCtx.waitUntil(promise), // the request's execution context
});- Wire shape by construction: every publish resolves topics + frames directly inside
livePublisherFor(live-publisher.ts) — the single frame-building code path (frame shapes +topicsForPublishlive inprotocol.ts), pinned byte-identical to the retired bridge bus bylive-publisher.unit.test.ts's literal + frozen-baseline fixtures. The staticliveBusConfigfate holds (event-bus.ts) builds no frames at all: it is a throwing stub that exists only for fate's build-time"subscribe" in livecheck. (PublishMessage.match.procedureis a plainstring: the envelope is wire data; the publish side is string-typed — the package cannot know phoenix's procedures — and the typo gate is the schema-closed subscribe side plus the live integration suite.) - Scheduling: the topic call is handed to
waitUntilas a detached promise — nothing on the request path awaits the fan-out. TheEffect.runPromiseat that sink is a deliberate boundary:waitUntilis a Promise sink outside the request fiber, and on CF it is the only way to extend work past the response (no shutdown hook, no surviving daemon fibers — ADR 0029/0041), so a forked fiber would be killed with the request. - Swallowing, both halves: a rejecting topic call is caught on the detached promise and logged; a synchronous throw is caught by
Effect.try+Effect.ignore({log: "Warn"})— ADR 0039's swallow law applied once inside the implementation, which is what lets call sites carry no error handling at all.
FateServer.layer dies with a FateServerConfigError (a defect — composition mistakes are programmer errors; E stays never) listing every problem at once:
- Duplicate wire names across the category records —
duplicate wire name "term" — declared by queries["term"] and lists["term"]. Within ONE record, spread collapses duplicate keys before any code can see them (fate's own shape) — the check covers collisions across the spread records, which the manifest would otherwise merge silently. - Duplicate sources per entity — fate resolves a view to one definition by type name, so a second source is a silent override waiting to happen.
- View-reachable entities without a source — every entity reachable through a view object (operation success views + nested relation views, recursively) must have a source:
view-reachable entity "Definition" has no source (reached from queries["term"]). String-typed operations (type: "Health") have no view by design and require nothing. - Typeless mutations —
mutation "definition.add" carries no wire type: fate's manifest carries every mutation's wire type.Fate.mutationmakes this unrepresentable in typed code (MutationDefinitionrequirestype:); the runtime check guards the erased shape's widerstring | undefined. It lives incollectConfigIssuesso the same mistake fails layer construction AND both compile surfaces (toCodegenServerat build time; the oracle baseline'stoFetchHandleron first call) with the same wording — pinned in all three suites.
Every config entry is a constructor value: the record types are Record<string, AnyFateQuery> / AnyFateList / AnyFateMutation, and sources is FateSourcesList = ReadonlyArray<AnyFateSourceEntry>. The raw bridge-shaped arms (RawFateOperation / RawFateSourceEntry) that carried migration coexistence were removed with the v2 cutover (ADR 0042's removal slate, landed with ADR 0043) — they no longer exist in the package, and no raw record exists in phoenix.
The one structural escape hatch that remains is Fate.syntheticSource(ViewClass) — a capability-less entry for a synthetic view-reachable entity that has no by-id fetch path (Contribution). It satisfies the source-completeness validation while any actual capability call fails loudly inside the package; see the escape-hatch section of fate-effect-sources.md.
- Don't
@ts-expect-erroran undischarged-layer pin. The effect LSP plugin reports the mismatch as TS377034 (missingLayerContext), which escapes the directive under the Effect-patchedtsc— same family as the TS377003 finding in fate-effect-operations.md. Pin withexpectTypeOf(...).not.toExtend<Layer.Layer<FateServer>>()bounds instead. - Don't export a config whose inferred type embeds a raw kernel
dataView()value (e.g. inside an inline hand-built source entry) — fate's non-exported symbol key trips TS2883 under compositetsc.Fate.*entries (includingFate.syntheticSource) are already portable; if you must hand-build an entry, annotate itAnyFateSourceEntryat the declaration site. - Don't provide
CurrentUser/LivePublisherfrom worker-level layers — they're per-request. If they show up at aLayer.providesite, the request boundary is in the wrong place. - Never re-tag
CurrentUserorLivePublisher. The tag identifiers (fate-effect/CurrentUser,fate-effect/LivePublisher) are load-bearing:FateServerRequirementsexcludes the pair from R by tag identity, so a second tag with the same shape silently re-adds the requirement. Extend the service interface in place instead. - Don't pre-merge feature records through a helper — the config's spreads ARE the merge, exactly fate's shape; the layer's init check is the safety net for cross-record collisions.