docs/CONVENTIONS.md
Code conventions
Rules for Effect code, Solid glue and tests, taken from the executor reference (~/dev/tranzfer2-references/apps/executor, its .agents/skills/wrdn-* and .oxlintrc.jsonc) and adapted to Solid. Lint enforces some; the rest is judgment. Types and composition edges are in STRUCTURE.md.
Failure
- Failures live in the Effect error channel as tagged errors.
Schema.TaggedErrorinpackages/contractsfor wire errors,Data.TaggedErrorinside the API. - Add an error type only when a caller recovers differently: a distinct UI state, retry rule or status. Otherwise reuse one.
- Build the error at the failure site with
new DomainError(...). A helper earns its place by classifying or translating an outside error, not by wrapping a constructor. - A failing input stays a failure. Never turn it into
false,null,[]or"unknown"to quiet a type. - Keep
try/catch,throwand.catchat a true adapter edge (a browser API, Uppy callback or Worker entry). Contain it in one function that translates straight into a typed failure, and say so in a one-line comment. - UI handlers read mutation results as
ExitfromrunPromiseExitand branch onExit.isFailure. They do not wraprunPromiseintry/catch.
Unknown data
- Decode untrusted input once, at the boundary, with
Schema.decodeUnknownEffect. Parse JSON strings withSchema.fromJsonString(MySchema). - Domain code receives the decoded type. It does not probe
unknownwithin,Reflect.get,as unknown as XorJSON.parse(...) as X. - A cast needs a narrow scope and a comment stating the invariant that makes it true.
Types come from values
- Schema first:
export type Delivery = typeof Delivery.Type. No hand-written interface beside a schema that describes the same shape. - Value first: a service object built by a factory exports
ReturnType<typeof make>. Keep an explicit interface only when several implementations share it, likeStorage. - Skip return annotations. Inference is the source of truth.
Named workflows
- Every non-trivial Effect is
Effect.fn("Service.method")(function* method() {...}). The name is the span. Private steps with real logic (complete,settle) get the same treatment; one-line query helpers do not. - Sequence with
Effect.genorEffect.andThen. Check the installed Effect for every API before using it; the pinned prerelease drops and renames names, andeffecttsgo/outdated-apicatches stale ones.
Running effects
runFork,runPromise,runPromiseExitandManagedRuntimeappear only at edges: the web runtime bridge inapps/web/src/api, Uppy and window callbacks, Solid event handlers and actions, tests. Domain code returns Effects.- HTTP inside
apps/apigoes through Effect'sHttpClient. Rawfetchis allowed where the platform demands it: a Workerfetchexport, a service-binding call, and browser-only sign-in handlers.
Structure
- Import another workspace package through its name (
@tranzfer/contracts), never a relative path across a package root. - Extract a helper when two callers share behavior, not when two lines look alike. State shared by several views lives in the feature's state module (
dashboard/deliveries.ts), not in a view. - Export what other modules read. Setters and internals stay module-private.
Solid
- State that tracks an in-flight mutation (pending rows, optimistic status) is
createOptimisticorcreateOptimisticStoreinside anaction. It never sits in a plain signal cleaned up bytry/finally. - Reactivity diagnostics are defects. The repair list is in AGENTS.md.
Tests
- Import from
@effect/vitest, nevervitest. - Keep
expectoutsideif, ternaries and&&. Split the cases, or assert the whole value:expect(result).toEqual({ ok: true }). - Time-based rules use
TestClock. Assert through the service API or the typed client, and read the storage fake only for the storage contract (what was sealed or purged). - A failing assertion means the product or the test is wrong. Fix one of them; do not weaken the assertion. New test files still need approval (TESTING.md).
Lint exceptions
An exception is a file-scoped override with a one-line reason, approved by the user. A blanket disable for a rule is a defect.