diff --git a/.agents/skills/effect/SKILL.md b/.agents/skills/effect/SKILL.md new file mode 100644 index 0000000000..1027916b35 --- /dev/null +++ b/.agents/skills/effect/SKILL.md @@ -0,0 +1,98 @@ +--- +name: effect +description: | + Opinionated guide for building production TypeScript applications with Effect v4. Use when implementing Effect workflows, services, layers, schemas, configuration, schedules, caches, streams, HTTP clients, or tests. +license: MIT +compatibility: Requires Effect v4. Examples are reviewed against the version documented in this repository. +--- + +# Effect + +Use current Effect v4 APIs and the production defaults in this skill. Established project conventions still take precedence unless the task is explicitly changing them. + +## Source Rule + +Check these before guessing: + +- the nearest `AGENTS.md` and any project-local Effect practices doc +- the project-pinned `effect` package source and version +- current upstream Effect source when the installed package does not answer the question + +## Branch Chooser + +Read only the branch references that match the task. + +- Data models, schemas, brands, variants, optional keys, or decoders: read `references/SCHEMA.md`. +- Services, module surfaces, layers, runtime wiring, errors, `Effect.fn`, or test services: read `references/SERVICES_LAYERS.md`. +- Runtime config, env variables, `ConfigProvider`, or `layerConfig`: read `references/CONFIG.md`. +- Retry, repeat, polling, backoff, jitter, rate-limit-aware policies, or pass loops: read `references/SCHEDULING.md`. +- Memoization, per-key TTL caches, deduplicating concurrent lookups, or request batching: read `references/CACHING.md`. +- Streams, event sources, async iterables, queues/pubsubs, pagination, backpressure, or stream consumers: read `references/STREAMS.md`. +- Outgoing HTTP calls, Effect HttpClient, status handling, or HTTP rate limiting: read `references/HTTP_CLIENTS.md`. +- Effect tests, time, sleeps, concurrency synchronization, or fakes: read `references/TESTING.md`. + +If a task spans several branches, read all matching files before editing. + +## Core Defaults + +- Compose workflows with `Effect.gen(function* () { ... })`. +- Define public service methods and non-trivial internal service methods with `Effect.fn("Domain.operation")`. +- Use `Effect.fnUntraced` only for internal helpers where stack-frame/span metadata is intentionally unnecessary. +- Prefer `Context.Service` for application services when the codebase has not standardized on another current service-tag style. +- Build real service implementations with `Layer.effect(Service, Effect.gen(...))` and return `Service.of({ ... })`. +- Model records with `Schema.Struct(...)` plus a same-name `interface`. +- Model typed Effect errors with `Schema.TaggedError`. +- Read runtime config through `Config`, not direct `process.env` access in application logic. +- Use `Schedule` for retry, repeat, polling, pacing, and backoff policies. +- Use `Stream` for effectful sources that emit many values over time and need pull, backpressure, interruption, or transformation. +- Prefer Effect HTTP client modules for outgoing HTTP in Effect applications when their typed errors, layers, and client transforms are useful. +- Prefer Effect-aware tests, explicit layers, and deterministic synchronization over sleeps. +- Prefer decoders and `schema.makeEffect(...)` at untrusted boundaries; reserve throwing `schema.make(...)` for trusted construction, and never use casts to skip validation. + +## Quick Selection Guide + +- Ordinary object record: `Schema.Struct(...)` plus same-name `interface`. +- Scalar ID/value object: constrained branded schema. +- Internal workflow decision or state: `Data.TaggedEnum<...>` plus `Data.taggedEnum<...>()` constructors and exhaustive `$match`. +- Reusable boundary-crossing tagged variant: `Schema.TaggedStruct(...)` plus same-name `interface`. +- Boundary-crossing tagged union: `Schema.TaggedUnion(...)` with `.cases`, `.guards`, and `.match`. +- External/custom discriminator such as `type`: `Schema.Struct({ type: Schema.tag("variant"), ... })` plus `Schema.toTaggedUnion("type")` when union helpers are needed. +- Expected typed failure: `Schema.TaggedError`. +- Unknown boundary payload: `Schema.decodeUnknownEffect(...)`. +- Service boundary: `Context.Service()(...)` plus `Layer.effect(...)` plus `Service.of(...)`. +- Public or non-trivial internal service method: `Effect.fn("Domain.operation")`. +- Runtime configuration: `Config` recipes read in layers; override with `ConfigProvider` in tests. +- Event source: `Stream` consumed with `Stream.runForEach(...)` and forked with `Effect.forkScoped` in the owning layer. +- Queue-backed event source: `Queue` for the producer boundary, `Stream.fromQueue(...)` for consumers. +- Broadcast event source: `PubSub` / `Stream.fromPubSub(...)` or `SubscriptionRef` for latest-value state. +- Polling worker: `runPass().pipe(Effect.repeat(Schedule.spaced(...)))`, with typed pass failures handled before repeat. +- Retry transient operation: `Effect.retry(...)` / `Effect.retryOrElse(...)` with a bounded `Schedule`. +- Keyed lookup cache with TTL and concurrent-lookup dedupe: prefer `Cache.make(...)` / exit-aware `Cache.makeWith(...)` when their lifecycle and eviction model fit. +- Memoize a single effect result: `Effect.cached(...)` / `Effect.cachedWithTTL(...)`. +- Batch N keys into one backend call (only when a real batch endpoint exists): `Effect.request(...)` + `RequestResolver`. +- HTTP request in an Effect application: prefer Effect `HttpClient` plus request/response schema decoding. +- HTTP transient retry: `HttpClient.retryTransient(...)`. +- Time-sensitive test: `TestClock`, not real sleeping. +- Concurrent/background test synchronization: `Deferred`, `Queue`, `Latch`, `Ref`, or explicit test hooks. + +## Boundary Rules + +- Keep HTTP handlers thin: decode input, read context, call services, map typed errors to transport responses. +- Keep business rules in services or domain functions, not transport handlers. +- Wrap HTTP clients, SDKs, CLIs, and external integrations in named effects at adapter boundaries. +- Decode persisted rows with Schema or SQL-specific helpers when values are not trivially trusted. +- Keep provider/network calls outside authoritative database transactions. +- Catch or retry only when the current boundary has a truthful response. +- Retry only when the operation has proven idempotency. +- Let exhausted failures remain visible unless the boundary has a real fallback. + +## Do Nots + +- Do not use `as any`, non-null assertions, or unchecked casts to silence Effect typing problems. +- Do not introduce `Schema.Class` or `Schema.TaggedClass` as default app data-modeling patterns. +- Do not hand-roll `_tag` error classes when `Schema.TaggedError` fits. +- Do not use cause-level recovery when typed-error recovery is enough. +- Do not use `Layer.mergeAll(...)` or `provideMerge(...)` as blind make-it-compile tools. +- Do not hide required application authority, credentials, persistence, transports, or external services behind `Context.Reference` defaults. +- Do not add arbitrary `Effect.sleep(...)` to tests when a deterministic synchronization primitive is available. +- Do not hand-roll Map/TTL/prune caches or in-flight dedupe when `effect/Cache` fits. diff --git a/.agents/skills/effect/references/CACHING.md b/.agents/skills/effect/references/CACHING.md new file mode 100644 index 0000000000..2454e0aa0d --- /dev/null +++ b/.agents/skills/effect/references/CACHING.md @@ -0,0 +1,75 @@ +# Caching, Memoization, And Request Dedupe + +Use this when memoizing expensive lookups, caching per-key results with TTL, deduplicating concurrent identical calls, or considering request batching. + +Prefer `effect/Cache` over a `Map` + timestamp + prune-loop cache when its keyed memoization, TTL, capacity, lifecycle, and eviction semantics fit. + +## Core Rules + +- `Cache.make({ capacity, lookup, timeToLive })` caches per-key lookups with one fixed TTL for all entries. +- `Cache.makeWith(lookup, { capacity, timeToLive(exit, key) })` computes TTL per entry from the lookup's `Exit` — the tool for "cache successes, not failures". +- Concurrent `Cache.get` calls for the same missing key share one pending lookup — dedupe is built in; do not add your own in-flight tracking. +- `capacity` is required and bounds the cache; stop writing manual prune/evict loops. +- Return a zero TTL (`0` or `"0 millis"`) from `timeToLive` to avoid caching transient failures or degraded fallbacks without failing the caller. A short negative-cache TTL can be appropriate for stable failures such as not-found results. +- `Cache.invalidate(cache, key)` / `Cache.refresh(cache, key)` handle explicit staleness; `Cache.has` checks without triggering a lookup. +- Cache construction is effectful. Build the cache once in the owning layer/scope and share the handle; a cache built per call caches nothing. +- For a single value (no key), use `Effect.cached(effect)` or `Effect.cachedWithTTL(effect, ttl)` instead of a one-key Cache. +- For cached resources that need cleanup (connections, clients), use `ScopedCache`. + +## Exit-Aware TTL (cache successes, skip degraded results) + +```ts +import { Cache, Duration, Effect, Exit } from "effect"; + +const makeResolver = Effect.gen(function* () { + const cache = yield* Cache.makeWith( + (channelRef: string) => resolveUncached(channelRef), // never-failing, returns { where, cacheable } + { + capacity: 300, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.cacheable ? "10 minutes" : Duration.zero, + }, + ); + return (channelRef: string) => + Cache.get(cache, channelRef).pipe(Effect.map((resolved) => resolved.where)); +}); +``` + +This replaces a hand-rolled `Map` plus prune logic, and upgrades it: repeated rows pointing at the same key during one burst share a single provider call. + +## Expensive Client Acquisition Belongs In The Layer, Not The Lookup + +A cache cannot fix a lookup that pays a scoped acquisition per call, such as SDK client construction or authentication. Acquire clients once via the owning layer (`Layer.build` inside a `Layer.unwrap(Effect.gen(...))` composition, or a service dependency) so the cached lookup is a plain call: + +```ts +// Bad: every cache miss acquires a fresh client +const lookup = (id: string) => getRecord(id).pipe(Effect.provide(apiClientLayer(options))); + +// Good: client built once for the layer's lifetime; misses are one API call +// Layer.build requires Scope.Scope; acquire this inside the owning layer's scope. +const context = yield * Layer.build(apiClientLayer(options)); +const lookup = (id: string) => Context.get(context, ApiClient).getRecord(id); +``` + +## Request Batching (`Effect.request` + `RequestResolver`) + +Batching exists for backends with a real batch endpoint: the resolver receives an array of pending requests and can collapse them into one wire call. + +- Use it when the API can answer N keys in one call (SQL `IN (...)`, DataLoader-style endpoints, batch GET). +- Do not reach for it when the backend only has per-item endpoints (most REST provider APIs): a batched resolver still loops one call per entry, so it buys nothing over `Effect.forEach(items, f, { concurrency })` plus `Cache` for dedupe/memoization. +- `RequestResolver.batchN(resolver, n)` bounds batch size; `RequestResolver.makeGrouped` groups requests that must resolve through different targets. + +Selection guide: + +- Same key requested repeatedly over time → `Cache`. +- Same key requested concurrently in one burst → `Cache` (shared pending lookup). +- Many distinct keys, backend has a batch endpoint → `Effect.request` + `RequestResolver`. +- Many distinct keys, per-item endpoint only → `Effect.forEach(..., { concurrency: n })`, optionally through a `Cache`. + +## Do Nots + +- Do not hand-roll Map/TTL/prune caches, in-flight dedupe maps, or LRU logic when `Cache` fits. +- Choose failure TTLs by semantics. Skip transient failures and degraded fallbacks by default; bounded negative caching can protect an upstream from repeated stable failures. +- Do not build a cache inside the request handler or per call — hoist it to the owning layer. +- Do not adopt `RequestResolver` batching for per-item REST endpoints just because "batching" sounds faster. +- Do not put scoped client acquisition inside the cache lookup; acquire once in the layer. diff --git a/.agents/skills/effect/references/CONFIG.md b/.agents/skills/effect/references/CONFIG.md new file mode 100644 index 0000000000..50c03fca7e --- /dev/null +++ b/.agents/skills/effect/references/CONFIG.md @@ -0,0 +1,60 @@ +# Config + +Use this when reading runtime configuration, env vars, `.env` files, provider-specific settings, or writing `layerConfig(...)` helpers. + +Read runtime configuration through Effect `Config` recipes and provider layers, not direct `process.env` access inside application logic. + +```ts +export const dataDirectoryConfig = Config.schema(AbsolutePath, "APP_DATA_DIR"); + +export const layerFromEnvironment = Layer.effect( + Configuration.Service, + Effect.gen(function* () { + const apiKey = yield* Config.redacted("API_KEY"); + const optionalModel = yield* Config.option(Config.string("MODEL")); + const enabled = yield* Config.boolean("FEATURE_ENABLED").pipe(Config.withDefault(false)); + + return Configuration.Service.of({ apiKey, optionalModel, enabled }); + }), +); +``` + +## Config Recipes + +- `Config` is yieldable and reads the current `ConfigProvider` reference. +- The default provider is `ConfigProvider.fromEnv()`. +- Use `Config.redacted(...)` for credentials. +- Use `Config.schema(...)` or `Config.mapOrFail(...)` for refined values. +- Use `Config.option(...)` for semantic absence. +- Use `Config.withDefault(...)` for missing-data defaults only; malformed values still fail. +- Use `Config.orElse(...)` only when intentionally catching any config parse failure. +- Use `Config.unwrap(...)` / `Config.Wrap` for `layerConfig(...)` helpers. + +## Providers + +- Use `ConfigProvider.layer(provider)` to replace the active provider for an app or suite. +- Use `ConfigProvider.layerAdd(provider)` for fallbacks; pass `{ asPrimary: true }` when the added provider must override the current provider. +- Use `ConfigProvider.fromUnknown(...)` for deterministic test config. +- Use `ConfigProvider.fromEnv(...)` for environment variables. +- Use `ConfigProvider.constantCase` when camelCase schema keys should read `SCREAMING_SNAKE_CASE` env vars. +- Use `ConfigProvider.nested(...)` to scope a provider under a prefix. +- Treat `.env`, directory, and environment providers as startup/boundary sources, not business-workflow reads. + +## Layer Config Helpers + +Library-style layers often expose both concrete `layer(options)` and config-backed `layerConfig(options: Config.Wrap)`. + +```ts +export const layerConfig = (config: Config.Wrap) => + Layer.effect( + Client.Service, + Config.unwrap(config).pipe( + Effect.flatMap(makeClient), + Effect.map((client) => Client.Service.of(client)), + ), + ); +``` + +Use this pattern when a service naturally supports runtime config while still allowing tests to pass concrete values. + +Use `Layer.succeed(AppConfiguration.Service, testConfig)` when the app already wraps environment config in an application service and the test does not need to exercise Config decoding itself. diff --git a/.agents/skills/effect/references/HTTP_CLIENTS.md b/.agents/skills/effect/references/HTTP_CLIENTS.md new file mode 100644 index 0000000000..34b6b8033c --- /dev/null +++ b/.agents/skills/effect/references/HTTP_CLIENTS.md @@ -0,0 +1,101 @@ +# HTTP Clients + +Use this when writing outgoing HTTP calls, Effect HttpClient adapters, status classification, HTTP retries, or rate limiting. + +Use Effect HTTP client modules for outgoing HTTP in app/provider code: + +- `effect/unstable/http/HttpClient` +- `effect/unstable/http/HttpClientRequest` +- `effect/unstable/http/HttpClientResponse` +- `effect/unstable/http/HttpClientError` + +Prefer Effect HttpClient in Effect application and provider code when its typed errors, layers, and transforms are useful. Raw `fetch` remains reasonable for browser or edge constraints, small adapters, platform transports, and libraries that intentionally avoid unstable Effect HTTP APIs. + +## Boundary Shape + +HTTP adapter methods should be named effects that own the full boundary: + +- construct request +- attach auth and headers +- execute request +- classify status +- decode response body +- map transport/status/decode failures to typed domain errors +- apply retry/rate-limit policy where idempotent + +Keep raw provider/network effects outside business services and database transactions. + +## Effect HttpClient + +Useful APIs: + +- `HttpClient.get(...)`, `post(...)`, `put(...)`, `patch(...)`, `del(...)`, `execute(...)` for service accessors. +- `HttpClient.mapRequest(...)` / `mapRequestEffect(...)` for configured client transforms. +- `HttpClientRequest.prependUrl(...)` for base URLs. +- `HttpClientRequest.bearerToken(...)` for bearer auth. +- `HttpClientRequest.acceptJson` for JSON accept headers. +- `HttpClientRequest.bodyJson(...)` for effectful JSON body encoding. +- `HttpClientRequest.schemaBodyJson(...)` for schema-backed JSON body encoding. +- `HttpClient.filterStatusOk` / `HttpClientResponse.filterStatusOk` before decoding when non-2xx responses are failures. +- `HttpClientResponse.schemaBodyJson(...)` for body-only decoding, `schemaJson(...)` for status/headers/body decoding, and `schemaNoBody(...)` for status/headers decoding. +- `HttpClient.retryTransient(...)` for common transient HTTP failures. +- `HttpClient.withRateLimiter(...)` for proactive pacing and learning from rate-limit headers. It requires a `RateLimiter` plus initial window, limit, and key options; it adds `RateLimiterError` to the error channel and retries `429` responses by default. + +## Retry And Rate Limits + +Use `HttpClient.retryTransient(...)` for common transient HTTP failures: + +- transport errors +- timeouts +- `408` +- `429` +- `500` +- `502` +- `503` +- `504` + +Use `HttpClient.withRateLimiter(...)` when the client should proactively pace requests and learn from rate-limit / `Retry-After` headers. + +Use operation-level `Effect.retry(...)` when retry depends on domain-specific typed errors, provider payloads, or idempotency rules. Read `SCHEDULING.md` for custom schedules and `retryAfterMs` typed-provider patterns. + +## Raw Fetch Exception + +Use raw `fetch` deliberately when implementing a platform transport, adapting an API that cannot use Effect HttpClient, or targeting a runtime/library boundary where the unstable Effect HTTP modules are not an appropriate dependency. + +If a temporary raw `fetch` boundary is unavoidable, keep it inside an adapter service and still use Effect boundary discipline. + +```ts +const request = Effect.fn("Provider.request")(function* (input: RequestInput) { + const response = yield* Effect.tryPromise({ + try: (signal) => fetch(input.url, { signal, headers: input.headers }), + catch: (cause) => new ProviderError({ operation: "Provider.request", cause }), + }); + + if (!response.ok) { + return yield* Effect.fail( + new ProviderRejected({ + operation: "Provider.request", + status: response.status, + }), + ); + } + + const json = yield* Effect.tryPromise({ + try: () => response.json(), + catch: (cause) => new ProviderError({ operation: "Provider.decodeJson", cause }), + }); + + return yield* Schema.decodeUnknownEffect(ResponseSchema)(json).pipe( + Effect.mapError((cause) => new ProviderError({ operation: "Provider.decodeResponse", cause })), + ); +}); +``` + +Guidance: + +- Prefer replacing this with Effect HttpClient before adding more behavior. +- Wire `AbortSignal` from `Effect.tryPromise` into `fetch` when raw fetch is unavoidable. +- Classify HTTP status before decoding successful payloads. +- Decode unknown response bodies with Schema at the boundary. +- Preserve provider evidence needed for diagnosis, but redact secrets and private payloads. +- Apply retry only for idempotent operations. diff --git a/.agents/skills/effect/references/SCHEDULING.md b/.agents/skills/effect/references/SCHEDULING.md new file mode 100644 index 0000000000..c5a08f48a7 --- /dev/null +++ b/.agents/skills/effect/references/SCHEDULING.md @@ -0,0 +1,133 @@ +# Scheduling And Retry + +Use this when writing retries, repeats, polling workers, backoff, jitter, rate-limit-aware policies, timeouts, or pass loops. + +Use `Schedule` for retry, polling, pacing, and repeated background work instead of hand-rolled `while (true)` loops with sleeps. + +## Core Rules + +- `Effect.retry(...)` retries typed failures; defects and interruptions are not retried. +- `Effect.repeat(...)` repeats successful effects; failures stop repetition unless the pass handles them first. +- The source effect runs once before the schedule is stepped. +- `Schedule.recurs(3)` means three retries/repetitions after the initial run. +- `Schedule.spaced(...)` waits after work completes. +- `Schedule.fixed(...)` aligns executions to a cadence. +- Use `Schedule.exponential(...)` or `Schedule.fibonacci(...)` for backoff. +- Add `Schedule.jittered` to avoid synchronized retry storms. +- Use `Schedule.recurs(...)` for a counter schedule or `Schedule.upTo({ times })` to bound a delay schedule. +- Use `Schedule.tap(({ input }) => ...)` to log retry inputs. +- `Schedule.tap(...)` receives the full schedule metadata, including `input`, `output`, and `duration`. +- Use `Schedule.setInputType()` before input-dependent combinators when the input type would otherwise be `unknown`. +- Use `Effect.retryOrElse(...)` when exhausted retries need a fallback/reporting effect. +- Retry only at the narrowest boundary with proven idempotency. +- Exhausted failures should remain visible unless the boundary has a truthful fallback. + +## Polling Workers + +Prefer typed pass failures over cause recovery. + +```ts +const pass = runPass().pipe( + Effect.tapError((error) => Effect.logError("Worker.pass_failed", error)), + Effect.ignore, +); + +const run = pass.pipe(Effect.repeat(Schedule.spaced("1 second"))); +``` + +This shape says expected operational pass failures are logged and the worker continues. Defects still defect and can reach supervision. + +Use cause-level recovery only at supervision boundaries where the policy is truly "report non-interrupt failure and continue". + +```ts +const logNonInterruptCauseAndContinue = (message: string) => + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => Effect.logError(message, cause), + ); +``` + +Do not catch causes just to make failures disappear. If only expected typed failures should be recoverable, use `Effect.catchIf(...)`, `Effect.catchFilter(...)`, `Effect.catchTag(...)`, or `Effect.retry(...)` on those typed errors instead. + +## Per-Item Failure Isolation + +For batch workers, catch expected item-level typed failures around each item so one bad item does not stall the batch. + +```ts +yield * + Effect.forEach( + items, + (item) => + processItem(item).pipe( + Effect.tapError((error) => + Effect.logError("Worker.item_failed", error).pipe( + Effect.annotateLogs({ itemId: item.id }), + ), + ), + Effect.ignore, + ), + { discard: true, concurrency: 5 }, + ); +``` + +Only do this when retrying the item later is truthful or skipping the item is the product policy. + +## Reusable Retry Policy + +```ts +const projectionRetrySchedule: Schedule.Schedule = Schedule.exponential( + "100 millis", +).pipe(Schedule.jittered, Schedule.upTo({ times: 5 })); + +const reconcileWithRetry = (target: Target) => + reconcile(target).pipe( + Effect.retryOrElse( + projectionRetrySchedule.pipe( + Schedule.tap(({ input: error }) => + Effect.logWarning("Agent.Projection.reconcile.retrying").pipe( + Effect.annotateLogs({ operation: error.operation }), + ), + ), + ), + (error) => Effect.logError("Agent.Projection.reconcile.stopped", error), + ), + ); +``` + +Use this when the operation is idempotent and retry state is useful for logs or metrics. + +## Rate-Limit-Aware Typed Retry + +For provider errors that carry `retryAfterMs`, let the schedule use the larger of the backoff delay and the provider delay. + +```ts +type RateLimited = { + readonly retryAfterMs?: number | undefined; +}; + +const providerRetrySchedule: Schedule.Schedule = Schedule.exponential( + "200 millis", +).pipe( + Schedule.jittered, + Schedule.upTo({ times: 5 }), + Schedule.setInputType(), + Schedule.passthrough, + Schedule.modifyDelay(({ input, duration }) => + Effect.succeed( + input.retryAfterMs === undefined + ? duration + : Duration.max(duration, Duration.millis(input.retryAfterMs)), + ), + ), +); +``` + +Use this for operation-level retries over typed provider errors. For Effect HttpClient-level 429 handling and proactive pacing, read `HTTP_CLIENTS.md`. + +## Timeouts And Delays + +- Use `Effect.timeout(...)` when the operation has a real deadline. +- Use `Effect.delay(...)` when one operation should start later. +- Use `Effect.sleep(...)` inside production workflows only when sleeping itself is the domain behavior. +- Avoid manual sleep loops; use `Effect.repeat(...)` with `Schedule` for recurring work. +- In tests, use `TestClock` rather than real time. Read `TESTING.md`. diff --git a/.agents/skills/effect/references/SCHEMA.md b/.agents/skills/effect/references/SCHEMA.md new file mode 100644 index 0000000000..150e8ef103 --- /dev/null +++ b/.agents/skills/effect/references/SCHEMA.md @@ -0,0 +1,130 @@ +# Schema And Data Modeling + +Use this when touching data models, DTOs, row schemas, wire contracts, brands, variants, optional fields, or decoders. + +## Records + +Default to `Schema.Struct(...)` plus a same-name `interface`. + +```ts +export const User = Schema.Struct({ + id: UserId, + name: Schema.NonEmptyString, + email: Schema.optionalKey(Schema.String), +}); + +export interface User extends Schema.Schema.Type {} +``` + +Guidance: + +- Add `.annotate({ identifier: "User" })` only when tooling consumes it: HTTP API, RPC, OpenAPI/JSON Schema, docs, diagnostics, or codegen. +- Use `schema.make(...)` when construction is trusted. +- Use `schema.makeEffect(...)` when construction failure should stay in the Effect error channel. +- Decode unknown input at boundaries with `Schema.decodeUnknownEffect(...)` by default. +- Use `Schema.decodeUnknownSync(...)` only in scripts, tests, or startup paths where throwing is acceptable. +- Use `Schema.decodeUnknownOption(...)` only when mismatch details are intentionally discarded. +- Use `Schema.decodeUnknownResult(...)` for pure code that wants explicit success/failure without Effect. + +## Field And Contract Reuse + +Reuse fields directly when contracts are semantically related. + +```ts +export const CreateUserInput = Schema.Struct({ + name: User.fields.name, + email: User.fields.email, +}); + +export const StoredUser = User.pipe( + Schema.fieldsAssign({ + createdAt: Schema.DateTimeUtcFromString, + }), +); +``` + +Guidance: + +- Use `.fields`, `Schema.fieldsAssign(...)`, and `.mapFields(...)` when contracts are genuinely related. +- Use `Schema.encodeKeys(...)` when decoded TypeScript names differ from encoded wire/storage keys and naming is the only difference. +- Keep explicit mapping when behavior, joins, validation, or domain translation is involved. +- Use `Schema.extendTo(...)` sparingly for decoded-only derived fields. +- Use field reuse to build small related contracts, not one oversized inheritance-by-schema object. + +## Optionality And Defaults + +- Use `Schema.optionalKey(...)` for absent JSON/storage keys. +- Use `Schema.optional(...)` only when explicit `undefined` is part of the contract. +- Use `Schema.NullOr`, `Schema.UndefinedOr`, or `Schema.NullishOr` only when nullish values are truly part of the encoded contract. +- Keep normalized defaulted values as required fields and apply defaults in constructors/decoding. +- Do not make domain values optional merely for construction convenience. + +## Nominal Values + +- Use constrained branded schemas for scalar IDs and value objects. +- Use normal schema constraints before `Schema.brand(...)` for most code. +- Reach for `Schema.fromBrand(...)` only when the project already models brands with `Brand` constructors or wants the check packaged with the brand constructor. + +## Variants + +```ts +type Step = Data.TaggedEnum<{ + Continue: { readonly cursor: number }; + Finished: { readonly count: number }; +}>; + +export const Step = Data.taggedEnum(); + +const next = Step.Continue({ cursor: 10 }); +const label = Step.$match(next, { + Continue: ({ cursor }) => `continue at ${cursor}`, + Finished: ({ count }) => `finished ${count}`, +}); +``` + +```ts +export const Event = Schema.TaggedUnion({ + Started: { runId: RunId }, + Finished: { runId: RunId, result: Schema.Json }, +}); + +export type Event = typeof Event.Type; + +const event = Event.cases.Started.make({ runId }); +const label = Event.match(event, { + Started: ({ runId }) => `started ${runId}`, + Finished: ({ runId }) => `finished ${runId}`, +}); +``` + +Guidance: + +- Use `Data.TaggedEnum` for internal control-flow algebras; it provides constructors, `$is`, and exhaustive `$match`. Do not add a Schema solely to obtain these utilities. +- Use `Schema.TaggedStruct` for the ordinary Effect-owned `_tag` variant. +- Use `Schema.TaggedUnion` when the union needs decoding, encoding, persistence, wire validation, JSON Schema derivation, or schema composition. +- Prefer a principled split over forcing one representation everywhere: Data internally, Schema at boundaries. +- Use `Schema.tag(...)` when an external contract has a custom discriminator field such as `type` or `kind`; combine those structs with `Schema.toTaggedUnion("type")` when union helpers are needed. +- If the encoded contract omits the discriminant, use `Schema.tagDefaultOmit(...)` deliberately. +- Avoid `Schema.Class` and `Schema.TaggedClass` for new data models. + +## Errors + +`Schema.TaggedError` is the explicit class exception for typed Effect errors. + +```ts +export class PersistenceError extends Schema.TaggedError()( + "UserRepo.PersistenceError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) {} +``` + +Guidance: + +- Map infrastructure failures into domain-specific tagged errors at service boundaries. +- Include operation labels when they help diagnose adapter, persistence, provider, or transport failures. +- Use schema unions for public API or transport error surfaces. +- Use `Schema.Defect()` for defect-like payloads. +- Preserve interruption when catching broad causes at ingress, worker, or stream boundaries. diff --git a/.agents/skills/effect/references/SERVICES_LAYERS.md b/.agents/skills/effect/references/SERVICES_LAYERS.md new file mode 100644 index 0000000000..50b4c88bf8 --- /dev/null +++ b/.agents/skills/effect/references/SERVICES_LAYERS.md @@ -0,0 +1,155 @@ +# Services, Layers, And Modules + +Use this when defining service tags, module surfaces, layer implementations, runtime wiring, typed errors, or `Effect.fn` operation boundaries. + +## Module Surface + +One opinionated application-module style uses file-local role names and one canonical ES module namespace projection. Follow the existing codebase's module style when it has one; this convention is not required by Effect. + +```ts +export interface Interface { + readonly get: (id: UserId) => Effect.Effect; +} + +export class Service extends Context.Service()("@app/UserRepo") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const get = Effect.fn("UserRepo.get")(function* (id: UserId) { + // ... + }); + + return Service.of({ get }); + }), +); + +export class NotFound extends Schema.TaggedError()("UserRepo.NotFound", { id: UserId }) {} + +export * as UserRepo from "./user-repo.js"; +``` + +Consumers use the module namespace. + +```ts +import { UserRepo } from "./user-repo.js"; + +const program = Effect.gen(function* () { + const repo = yield* UserRepo.Service; + return yield* repo.get(id); +}); +``` + +The self-export is deliberate. It lets the file remain the module while giving every consumer the same domain-first name, without a TypeScript `namespace`, wrapper object, or repeated consumer-side aliases. + +```ts +// Sibling module: import the owning leaf directly. +import { UserRepo } from "./user-repo.js"; + +// Folder or package barrel: relay the identity established by the leaf. +export { UserRepo } from "./user-repo.js"; +``` + +Guidance: + +- Do not name the tag class `UserRepo` inside `user-repo.ts`; the module namespace is the domain name. +- In this module style, single-file modules self-export their canonical namespace at the bottom: `export * as UserRepo from "./user-repo.js"`. +- Sibling modules import that namespace from the owning leaf; they do not import through their own aggregate barrel. +- Folder and package barrels relay established leaf identities with `export { UserRepo } from "./user-repo.js"`. +- The resulting `UserRepo.UserRepo === UserRepo` self-reference is unusual. Use this pattern only where the runtime and toolchain support it; otherwise use named exports or a separate barrel. +- Export only intentional surface; keep local schemas, row codecs, helpers, and implementation details unexported. +- Do not introduce TypeScript `namespace` declarations for organization. +- Use a named service class such as `class UserRepo extends Context.Service...` when an external library or existing codebase does not use module namespace style. + +## Layer Constructors + +Choose the layer constructor that matches the thing produced. + +```ts +Layer.succeed(Service, impl); // already-built service +Layer.sync(Service, () => impl); // lazy synchronous service +Layer.effect(Service, makeEffect); // effectful service acquisition +``` + +Guidance: + +- Default real implementations to `Layer.effect(Service, Effect.gen(...))`. +- Use `Layer.effectContext(...)` when one acquisition intentionally supplies multiple services, especially first-class test stubs or one client backing several service tags. +- Use `Layer.unwrap(...)` when config or runtime discovery chooses/builds the layer. +- Use `Layer.fresh(...)` or `Effect.provide(layer, { local: true })` only when a test or operation needs isolated acquisition. +- Use `Context.Reference` rarely, only for ambient/defaultable runtime references where a safe default is real. + +## Long-Lived Work + +A layer that starts a stream, listener, worker, subscription, or forever loop must fork that work into the layer scope. Layer acquisition must complete. + +```ts +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const events = yield* Events.Service; + + yield* events.stream.pipe(Stream.runForEach(handleEvent), Effect.forkScoped); + }), +); +``` + +Guidance: + +- Use `Effect.forkScoped`, `FiberSet`, or `FiberMap` for scoped background work. +- Do not run forever work inline during layer acquisition. +- Do not expose public `start` methods unless the domain explicitly needs manual lifecycle control. + +## Runtime Wiring + +- Use `Layer.provide(...)` to hide an implementation dependency. +- Use `Layer.provideMerge(...)` only when the dependency should remain exposed for downstream consumers. +- Use `Layer.mergeAll(...)` for independent exposed layers. +- Prefer flat, topologically sorted runtime layer values with named subgraphs. +- Avoid using `provideMerge` as a blind make-it-compile tool. +- Avoid hiding important authority or lifecycle dependencies behind broad invisible provisioning. + +## Effect.fn + +Use extra `Effect.fn(...)` arguments for wrappers that apply to the whole function call. Each transform receives `(effect, ...originalArgs)`. + +```ts +const readAttachment = Effect.fn("Attachment.read")( + function* (ref: AttachmentRef) { + return yield* api.read(ref); + }, + (effect, ref) => effect.pipe(attachmentError("Attachment.read", { attachmentId: ref.id })), +); +``` + +Good whole-function transforms: + +- error classification +- localized recovery +- logging annotations +- spans +- retry +- timeout +- ensuring cleanup +- small local provisioning +- result mapping + +Guidance: + +- Keep the generator body focused on the core workflow. +- Use transforms when the wrapper needs original arguments. +- Do not build long clever pipelines; one or two transforms is usually enough. +- Do not use this for local branch-level handling inside the workflow. + +## Operation Error Helpers + +For boundary errors with operation labels, prefer a shared curried `mapError` helper over hand-writing wrappers in every module. + +```ts +const persistenceError = operationError(PersistenceError.make); + +const row = yield * query.pipe(persistenceError("UserRepository.findById")); +``` + +Name the local helper after the error it produces, such as `persistenceError`, `projectionError`, or `processingError`. Use `Effect.fn(...)` and spans for observability in addition to payload labels, not instead of them. diff --git a/.agents/skills/effect/references/STREAMS.md b/.agents/skills/effect/references/STREAMS.md new file mode 100644 index 0000000000..45b26fdf1f --- /dev/null +++ b/.agents/skills/effect/references/STREAMS.md @@ -0,0 +1,134 @@ +# Streams + +Use this when working with `Stream`, event sources, async iterables, queue/pubsub-backed streams, pagination, backpressure, throttling, debouncing, or long-lived stream consumers. + +## Mental Model + +`Stream` is an effectful source that can emit many `A` values over time, fail with `E`, and require services `R`. Streams are pull-based and backpressured; consumption controls demand. + +Use streams for sources that are naturally many-valued and time-ordered: + +- gateway events +- provider callbacks adapted through queues +- subscription/event logs +- paginated APIs +- file/stdin/platform streams +- scheduled ticks when values matter +- pipelines with filtering, mapping, buffering, throttling, or bounded concurrent processing + +Do not use streams just to loop forever. For one repeated effect with no emitted values, use `Effect.repeat(...)` with `Schedule`; read `SCHEDULING.md`. + +## Source Chooser + +- In-memory values: `Stream.make(...)` or `Stream.fromIterable(...)`. +- Test fixtures: `Stream.fromIterable(...)`, often with `Stream.concat(Stream.never)` for an open subscription. +- Queue-backed callback boundary: `Queue` plus `Stream.fromQueue(...)`. +- Broadcast events: `PubSub` plus `Stream.fromPubSub(...)`. +- Latest-value state plus updates: `SubscriptionRef`. +- Schedule-generated ticks/values: `Stream.fromSchedule(...)`. +- Paginated pull APIs: `Stream.paginate(...)`; its effectful step returns `Effect, Option], E, R>`. The stream emits `A` elements, not page arrays. `Option.none()` ends pagination after emitting that step's elements; empty arrays are allowed. +- Async iterable/platform source: `Stream.fromAsyncIterable(...)` when no native Effect source exists. +- Effect that produces a stream after reading services/config: `Stream.unwrap(...)`. + +## Transformation Chooser + +- Pure transformation: `Stream.map(...)`. +- Effectful transformation: `Stream.mapEffect(...)`. +- Bounded concurrent effectful transformation: `Stream.mapEffect(fn, { concurrency })`. +- Drop ordering when order is irrelevant and latency matters: `Stream.mapEffect(fn, { concurrency, unordered: true })`. +- One input to zero/many outputs: `Stream.flatMap(...)`. +- Multiple inner streams concurrently: `Stream.flatMap(fn, { concurrency })`. +- Keep only matching values: `Stream.filter(...)` / `Stream.filterEffect(...)`. +- Stateful transformation: `Stream.mapAccum(...)` / `Stream.mapAccumEffect(...)`. + +## Consumption Chooser + +- Side-effecting consumer: `Stream.runForEach(...)`. +- Ignore elements but run the stream: `Stream.runDrain`. +- Tests/small finite streams: `Stream.runCollect`. +- First N values in tests: `Stream.take(n)` plus `Stream.runCollect`. +- Fold into a value: `Stream.runFold(...)`. +- Long-lived consumer in a layer: `stream.pipe(Stream.runForEach(...), Effect.forkScoped)`. + +Avoid `Stream.runCollect` on unbounded or production event streams. + +## Long-Lived Consumers + +Own long-lived stream consumers in layers and fork them into the layer scope. + +```ts +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const gateway = yield* Gateway.Service; + + yield* gateway.events.pipe( + Stream.filter(isMessageEvent), + Stream.runForEach(handleEvent), + Effect.forkScoped, + ); + }), +); +``` + +Guidance: + +- Let the layer own the stream lifetime. +- Use `Effect.forkScoped` for the ordinary case. +- If methods need to fork work into the layer lifetime, capture `Scope.Scope` during layer acquisition and use `Effect.forkIn(scope)` internally. Do not expose the scope as public service API. +- Preserve stream failures unless the owning boundary has a truthful recovery policy. + +## Queues, PubSub, And SubscriptionRef + +- Use `Queue` when each event/item should be consumed by one consumer or worker. +- Use `PubSub` when every subscriber should see every event. +- Use `SubscriptionRef` when consumers need the current value and a stream of changes. +- Expose a `Stream` from service interfaces when callers should consume events, not push into the queue. +- Keep producer queues/private refs inside the implementation or test service. + +Good service shape: + +```ts +export interface Interface { + readonly events: Stream.Stream; + readonly status: Stream.Stream; +} +``` + +Implementation can use private `Queue` / `SubscriptionRef`; consumers see streams. + +## Backpressure And Buffers + +Prefer natural stream backpressure first. + +Use `Stream.buffer(...)` only when producer and consumer should decouple. + +- `strategy: "suspend"`: apply backpressure when full. +- `strategy: "dropping"`: drop new values when full. +- `strategy: "sliding"`: keep the latest values by dropping old ones. +- `capacity: "unbounded"`: rare; use only when growth is bounded elsewhere. + +Use `Stream.debounce(...)` for quiet-period behavior and `Stream.throttle(...)` / `Stream.throttleEffect(...)` for rate-shaped streams. + +## Error Handling + +- Prefer typed stream errors over defects. +- Use `Stream.mapError(...)` to translate errors at boundaries. +- Use `Stream.catchIf(...)`, `Stream.catchTag(...)`, or `Stream.catchFilter(...)` for typed recovery. +- Use `Stream.catchCause(...)` only at explicit supervision boundaries. +- Do not hide stream defects by default; let them reach the owning layer/runtime unless the stream is explicitly best-effort. + +## Keyed Concurrency + +For streams of work keyed by session/channel/id, prefer a named helper over ad hoc maps of fibers. + +If the codebase already has a keyed-run helper (for example a `runForEachKeyed` that runs different keys concurrently while serializing each key and coalescing pending values into one latest-value rerun), use it. Otherwise build one named helper with `FiberMap` rather than scattering fiber bookkeeping through consumers. + +Use this for projection/reconciliation streams where each key needs ordered processing but different keys can run in parallel. + +## Tests + +- Use `Stream.fromIterable(...)` for finite fixtures. +- Use `Stream.empty` for no events. +- Use `Stream.fromQueue(...)` with a test-owned `Queue` when the test needs to drive events interactively. +- Use `Stream.take(n)` plus `Stream.runCollect` for finite assertions. +- Avoid real sleeps; coordinate with `Deferred`, `Queue`, `Latch`, and `TestClock`. diff --git a/.agents/skills/effect/references/TESTING.md b/.agents/skills/effect/references/TESTING.md new file mode 100644 index 0000000000..c65c7a37af --- /dev/null +++ b/.agents/skills/effect/references/TESTING.md @@ -0,0 +1,111 @@ +# Testing + +Use this when writing Effect tests, tests involving time, retry, schedules, concurrency, workers, services, fakes, or config. + +## Defaults + +- Use `it.effect` by default. +- Use `it.live` only when real time or live runtime services are the behavior under test. +- Use test layers and `ConfigProvider` rather than global mutation. +- Use `TestClock.setTime` / `TestClock.adjust` for sleeps, schedules, retries, leases, and timeouts. +- Fork sleeping effects before advancing `TestClock`. +- Avoid arbitrary `Effect.sleep(...)` in tests; it usually makes tests slow and flaky. +- Assert typed failures, rollback, interruption, finalization, retry bounds, idempotency, concurrency laws, and malformed persistence where relevant. + +```ts +it.effect("finds a user", () => + Effect.gen(function* () { + const users = yield* UserRepo.Service; + const result = yield* users.find(UserId.make("u1")); + expect(Option.isSome(result)).toBe(true); + }).pipe(Effect.provide(UserRepo.testLayer)), +); +``` + +## Synchronization Instead Of Sleeps + +- Use `Deferred` for one-shot readiness/completion signals. +- Use `Queue` for handing test-controlled work or observed events across fibers. +- Use `Latch` for reusable open/close coordination gates. +- Use `Ref` for shared test observation state. +- Use explicit test hooks when the production boundary can expose a deterministic synchronization point. + +```ts +it.effect("publishes exactly once", () => + Effect.gen(function* () { + const published = yield* Queue.unbounded(); + const ready = yield* Deferred.make(); + + const runWorker = makeWorker({ + onReady: () => Deferred.succeed(ready, undefined), + onPublish: (message) => Queue.offer(published, message), + }); + + yield* runWorker.pipe(Effect.forkScoped); + + yield* Deferred.await(ready); + const message = yield* Queue.take(published); + + expect(message).toEqual(expectedMessage); + }), +); +``` + +## First-Class App Test Stubs + +Use `TestInterface extends Interface`, `TestService`, and `testLayer` for reusable/stateful fakes. + +```ts +export interface Interface { + readonly send: (message: Message) => Effect.Effect; +} + +export class Service extends Context.Service()("@app/Notifier") {} + +export interface TestInterface extends Interface { + readonly sentMessages: () => Effect.Effect>; + readonly failNextSend: (error: SendError) => Effect.Effect; +} + +export class TestService extends Context.Service()( + "@app/Notifier/Test", +) {} + +export const testLayer = Layer.effectContext( + Effect.gen(function* () { + const sent = yield* Ref.make>([]); + const nextFailure = yield* Ref.make>(Option.none()); + + const service = TestService.of({ + send: Effect.fn("Notifier.Test.send")(function* (message) { + const failure = yield* Ref.getAndSet(nextFailure, Option.none()); + if (Option.isSome(failure)) return yield* Effect.fail(failure.value); + yield* Ref.update(sent, (messages) => [...messages, message]); + }), + sentMessages: Effect.fn("Notifier.Test.sentMessages")(function* () { + return yield* Ref.get(sent); + }), + failNextSend: Effect.fn("Notifier.Test.failNextSend")(function* (error) { + yield* Ref.set(nextFailure, Option.some(error)); + }), + }); + + return Context.empty().pipe(Context.add(Service, service), Context.add(TestService, service)); + }), +); +``` + +Guidance: + +- The same object should back both the real `Service` tag and `TestService` tag. +- Production code depends only on the real service tag. +- Tests use `TestService` for control and inspection. +- Use function-valued service members, including zero-argument operations, so `Effect.fn` fits naturally. +- Use `Layer.succeed` for complete dead-simple static test implementations. +- Use `Layer.mock` only for tiny local partial mocks where omitted members should fail loudly if used. + +## Config In Tests + +Use `ConfigProvider.layer(ConfigProvider.fromUnknown(...))` when the test should exercise Config decoding. + +Use `Layer.succeed(AppConfiguration.Service, config)` when the app wraps decoded config in its own service and the test does not need to exercise env decoding. diff --git a/AGENTS.md b/AGENTS.md index 1b06594b68..e4e8dad459 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,10 +15,7 @@ Bun monorepo with workspaces under `apps/` and `packages/`. `pnpm` is the packag Use an existing TypeScript/Bun workspace, especially `packages/api`, as the package-structure reference. Published `apps/cli` and `packages/config` are not private; `apps/docs` and `packages/cli-*` have their own shapes. Generic lint, format, and unused-code tooling is -root-owned. Effect lint covers a growing allow list of areas, defined by the `!` entries in -`.oxlintrc.effect.json` (the source of truth) and enforced through the root scripts; it -currently spans `packages/stack`, the experimental and smaller `apps/cli/src/commands` -families and most of the shared compute runtime, and expands area by area. +root-owned. ### Config Naming Vocabulary @@ -32,56 +29,14 @@ Use a family-neutral name when a symbol deliberately spans both families. See th ## Effect -Effect V4 source is in `.repos/effect/`; use it instead of `node_modules`, with core APIs in -`.repos/effect/packages/effect/`, test helpers in `.repos/effect/packages/vitest/`, and migration -notes in `.repos/effect/MIGRATION.md`. These are read-only source checkouts that may be ahead of -installed dependencies; when APIs differ, use the matching release tag inside the reference -repository. Run `pnpm repos:install` if it is absent. - -- Write new TypeScript runtime code in Effect. Internal helpers return Effects; Promise facades - belong only at public edges. Wrap a foreign Promise once at its leaf with `Effect.tryPromise`, - pass cancellation when supported, and map failures into typed domain errors. -- Effects are reusable: allocate mutable state per execution (`Effect.suspend`, `Effect.gen`, or - scoped acquisition), and keep `Effect.sync` total by using `Effect.try` for throwing thunks. -- Keep service requirements visible through the type until composition. Provide services with - layers or `Effect.provide`; do not hide missing services with casts, nested runtimes, globals, - or synchronous adapters. -- Use `Scope`/`acquireRelease` for resources. Limit `uninterruptibleMask` to the - acquisition-to-registration handoff and keep blocking acquisition interruptible with `restore`. - Prefer `Effect.forkChild` or scope-owned fibers; detached work needs a documented lifetime and - completion path. Use native `Deferred`, `Latch`, `Semaphore`, `Queue`, `PubSub`, `Schedule`, - and race or concurrent combinators instead of waiter arrays, polling sleeps, or shared - cancellation flags. -- Shared initialization and teardown are single-flight operations: callers join one cached - Effect, fiber, or `Deferred>`; interrupting one waiter must not cancel shared teardown. -- `Effect.callback` owns its full foreign lifecycle: register listeners before starting, resume at - most once, and on cancellation remove owned listeners and close or destroy the exact resource. -- Expected failures use typed `Data.TaggedError` and `Effect.fail`; never throw them inside Effect - programs. Defects are impossible invariants. Recover with the narrowest `catch` operator; use - `catchCause` only when recovery intentionally handles defects or interruption, preserve every - other cause, and do not use operational `orDie`/`Layer.orDie`. -- Preserve `Data.TaggedError` string identities in `apps/cli/src` and `packages/config/src` when - renaming classes; class names may change, but tags must remain stable. See the CLI - [telemetry identity rule](apps/cli/AGENTS.md#telemetry). -- Use public helpers (`Exit.isSuccess`, `Option.isSome`, `Cause.isTimeoutError`, and similar) and - exhaustive `Match`/predicate helpers for domain variants. Raw `._tag` is for schema/type - definitions, serialization, or genuinely dynamic boundaries only. -- Compose schemas with `decodeUnknownEffect`, `decodeEffect`, and `encodeEffect`, mapping - `SchemaError` into domain errors. Sync codecs are acceptable only at an explicitly synchronous, - service-free edge that intentionally throws. - -### Effect linting - -- Fix the underlying design when Effect lint reports a violation. Refactor to native - Effect constructs; do not silence findings with `oxlint-disable`, casts, file - exclusions, or weaker lint configuration. -- A suppression is acceptable only for a demonstrated false positive or an unavoidable - foreign-library boundary. Before retaining one, inspect the corresponding Effect API - and identify the specific missing capability or behavior that prevents replacement; - existing Promise-based code, native API usage, or refactoring effort alone do not - justify an exception. Limit it to the specific rule and smallest scope, and explain - why a compliant implementation is not possible. -- Passing lint by bypassing its rules does not complete an Effect migration. +Always use the [Effect skill](.agents/skills/effect/SKILL.md) when writing or changing code. Read +the references relevant to the task before editing. Write +all TypeScript runtime code in Effect. Promise-returning APIs are allowed only as package exports +for consumers that do not use Effect. +The skill is authoritative for Effect coding practices when repository instructions conflict. + +Effect linting uses oxlint via `.oxlintrc.effect.json`; run `pnpm lint:effect:check` or +`pnpm lint:effect:fix` from the repository root. ## Commands, validation, and workflows @@ -173,17 +128,16 @@ docs when interfaces, ownership, or lifecycle changes. Name tests `*.unit.test.ts`, `*.integration.test.ts`, or `*.e2e.test.ts`; colocate them with source. Use `tests/` for shared helpers. For CLI commands, unit-test complex pure logic, integration-test -handlers and feature matrices with realistic Effect layers, and reserve E2E for one to three -golden-path subprocess workflows. Handler integration is the default for command behavior. Use -`@effect/vitest`'s `it.live` with stateful mock factories returning `{ layer, state }`; -assert resulting state and user-visible behavior, not `vi.fn()` call details. See +handlers and feature matrices with realistic dependencies, and reserve E2E for one to three +golden-path subprocess workflows. Handler integration is the default for command behavior. Assert +resulting state and user-visible behavior, not mock call details. See [`login.integration.test.ts`](apps/cli/src/commands/login/login.integration.test.ts) and [`login.e2e.test.ts`](apps/cli/src/commands/login/login.e2e.test.ts); E2E uses [`tests/helpers/cli.ts`](apps/cli/tests/helpers/cli.ts) and `runSupabase()`. Keep tests flake-resistant: -- Subscribe before triggering a transition; use observable readiness/completion, never sleeps or polling delays for propagation, startup, cancellation, cleanup, or port release. Timeouts are guards; use TestClock or fake timers for timing semantics. +- Subscribe before triggering a transition; use observable readiness/completion, never sleeps or polling delays for propagation, startup, cancellation, cleanup, or port release. Timeouts are guards; use controlled clocks or fake timers for timing semantics. - Assume file-level parallelism: use unique IDs, roots, process markers, and derived resources; never disable parallelism globally. - Never release and reuse an ephemeral port or assume a released endpoint is a dead backend; own a refusal listener or inject the failure. - Require subprocess readiness and stdout/stderr diagnostics; clean up only exact owned resources. Reproduce and stress flake fixes, then repeat the green case. diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 65f20f1b5a..58cac7c8ef 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -25,9 +25,8 @@ src/commands// SIDE_EFFECTS.md # required compatibility contract ``` -Register every command in `src/cli/root.ts`. Keep `.format.ts` and `.encoders.ts` pure. Use -`Effect.fn` for exported command handlers and `Effect.fnUntraced` for small internal helpers; -tracing is local observability and span names follow `.`. Read `src/shared/` and the +Register every command in `src/cli/root.ts`. Keep `.format.ts` and `.encoders.ts` pure. Tracing is +local observability and span names follow `.`. Read `src/shared/` and the command-level infrastructure under `src/config/`, `src/auth/`, `src/telemetry/`, `src/output/`, and `src/command-internal/` before adding an equivalent helper. @@ -112,8 +111,8 @@ contracts remain unchanged. Update tests, generated schemas, and side-effect doc ## Telemetry -> The string passed to `Data.TaggedError("...")` is the PostHog `error_fingerprint` identity -> (`tag:`); preserve it when renaming classes. +> An error’s tag is the PostHog `error_fingerprint` identity (`tag:`); preserve it when +> renaming classes. > [`src/shared/telemetry/error-tag-stability.unit.test.ts`](src/shared/telemetry/error-tag-stability.unit.test.ts) > compares every CLI and `@supabase/config` tag with the committed snapshot.