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 declares a source. The short answer: Fate.source(ViewClass, {id}, handlers) builds the loader for one entity — the kernel SourceDefinition plus Effect handlers, with the loader contract (at least one of byId/byIds, silent reads, E = never) enforced at the type level. This replaced the bridge's fateSource + hand-written SourceDefinition literals (deleted in the v1 cutover, ADR 0042). Handlers delegate to the domain services — fate never queries the database (ADR 0016).
import {Fate} from "@kampus/fate-effect";
import {Sozluk} from "./Sozluk.ts";
import {TermView} from "./views.ts";
export const termSource = Fate.source(TermView, {id: "slug"}, {
byId: function* (slug) {
const sozluk = yield* Sozluk;
const rows = yield* sozluk.getTermSummariesByIds([slug]);
return rows[0] ?? null;
},
byIds: function* (slugs) {
const sozluk = yield* Sozluk;
return yield* sozluk.getTermSummariesByIds(slugs);
},
});- The first argument is the
FateDataViewclass (fate-effect-data-views.md); the constructor readsView.viewoff it. Handler parameter types and the row type are inferred from the class — no annotations needed. {id}names the row's primary-key field — the field fate refs the entity by ("slug"for Term,"id"for most entities).- The result carries
definition(a kernelSourceDefinition,{id, view}— the exact object fate's identity-keyed registry will hold, created once),typeName(the literal entity name), andhandlers(Effect-returning functions). The serving path consumeshandlersnatively: the interpreter's byId walk batches them throughRequestResolveron the request fiber (fate-effect-interpreter.md); the compile step's promise-shapedSourceExecutoradaptation survives only as the differential oracle's baseline (fate-effect-compiler.md).FateServer.layerunionsFateSourceServices<typeof src>into its requirements.
Sources LOAD, operations RESOLVE. The constructor's types encode the whole contract:
- At least one of
byId/byIdsis required — a source that can't load an entity by ref is unrepresentable;connectionalone doesn't typecheck.byIdsis the workhorse (it's what kills N+1 under the v2RequestResolverbatching); implement it for every entity reachable as a relation. byIdsmust be membership-stable. The interpreter's batch window merges concurrent operations' ids into ONE deduplicatedbyIdscall and re-masks each operation's rows by id membership (Walk.tsrunGroup). That masking is byte-faithful to fate only when the rows returned are a function of the id SET — every SQLIN-shaped loader qualifies. A cursor-limited, row-capped, or order-sensitivebyIds(where the rows for{a}aren't thea-rows of{a, b}) silently diverges under a merged window: an operation can receive fewer or differently-ordered rows than it would alone. If a loader can't beIN-shaped, implementbyIdonly and let the per-id arm carry it.- Reads are silent.
byIdreturnsnullfor a missing id;byIdsreturns the rows that exist — fewer than asked is success, not failure. Handlers return raw domain rows; fate masks them to the requested selection afterward. Eis pinnednever. A handler whose effect has a typed failure is a compile error. Infrastructure failures are defects — and the die happens one layer DOWN, inside the domain service (feature-services.md boundary rule), so a source handler calls the service bare; there is nothing toorDiehere and noDrizzleimport belongs in a sources file. Defects reject the operation without becoming domain values.Ris inferred from the handler bodies (a domain service,Auth-style per-request services, …) and is visible on the source's type — a forgotten layer is a compile error at the composition site, not a runtime miss.
Sources carry no connection handler and no orderBy contract: every connection — root and nested — is delivered by a custom resolver in queries.ts/lists.ts calling the service keyset method directly (ADR 0019). The keyset ORDER BY lives in the service; the view's FateDataView.list(View, {orderBy}) mirrors it. See fate-connections.md.
| View | Service | Notes |
|---|---|---|
Term, Definition |
Sozluk |
definitionCount via the service row |
Post, Comment, Tag |
Pano |
Tag is a pure kind→label map (no DB) |
User, Profile |
Pasaport |
User.byIds is the hottest path (authors everywhere) |
Contribution |
— | synthetic; capability-less entry, rows exist only in queries.profile's reshape |
Vote/karma stay inside Sozluk/Pano (which delegate to Vote) — there is no fate view for votes; scores surface as fields on the entity that owns them.
The server's source-completeness validation (fate-effect-server.md) requires every view-reachable entity to be registered, but some entities are synthetic — their rows exist only as a resolver's reshape, with no by-id fetch path at all (Contribution: flattened from definitions/posts/comments by queries.profile, delivered inline through Profile.contributions). Fate.source deliberately refuses a loader-less source; Fate.syntheticSource is the one sanctioned escape hatch (apps/web/worker/features/pasaport/sources.ts):
export const contributionSource = Fate.syntheticSource(ContributionView);The constructor registers the entity with an empty handlers bag — zero capabilities, kept inside the package: any actual capability call fails loudly (the walk's capability-less arm is fate's internal error arm; the oracle baseline adapts {} to an empty executor fate rejects the same way), exactly like the bridge era's capability-less executor. The loud failure is pinned by the package's parity corpus (Interpreter.walk.test.ts / Interpreter.features.test.ts). Reserve this for genuinely synthetic entities; if a fetch path exists, implement byIds.
A root-only synthetic entity (no view nesting reaches it) needs no source at all — give its query the wire type-name string instead of the view class (stats/queries.ts: landingStats is {type: "LandingStats"}), which keeps the entity out of the reachability walk; the Root map still carries the kernel view for codegen.
Each provided handler body is passed to Effect.fn("<Entity>.<capability>") — Term.byId, Term.byIds, Term.connection — so the span name is derived from the view class and cannot drift. Consequences for authors:
- Write handler bodies as plain generator functions (or Effect-returning functions — the two halves of
Effect.fn's own body contract). Don't pre-wrap a handler in your ownEffect.fn: you'd get a second, nested span. - This is the source-side exception to the "handlers are
Effect.fn("<wire name>")" rule for operations: operations have a wire name the author owns; a source capability's name is fully determined by entity + capability, so the constructor owns it.
- Don't pass
View.view(or a raw kernel view) as the first argument — the constructor wants the class ({view, typeName}); the literaltypeNameis what names spans and feeds init-time source-completeness checks. - Don't fail a loader with a typed error to signal "not found" — absence is
null/fewer rows. Typed errors belong to operations (fate-effect-wire-errors.md). - Don't hand-write
SourceDefinitionliterals next toFate.source—src.definitionIS the definition; creating a second{id, view}object breaks fate's identity-keyed registry assumptions. - Don't reach for
connectionto make a source loadable — it paginates an already-loadable entity (keyset semantics, ADR 0019); refs resolve throughbyId/byIds.
packages/fate-effect/src/Source.unit.test.ts is the standing guard: exported source consts keep the declaration-nameability gate (TS2883) honest under the package's composite tsconfig, @ts-expect-error pins the at-least-one contract, and the span test pins the <Entity>.<capability> names.