Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"executor/no-cross-package-relative-imports": "error",
"executor/no-direct-cloud-executor-schema-import": "error",
"executor/require-reactivity-keys": "error",
"executor/require-effect-fn-name": "error",
"executor/no-effect-escape-hatch": "error",
"executor/no-effect-internal-tags": "error",
"executor/no-error-constructor": "error",
Expand Down
31 changes: 31 additions & 0 deletions .skills/effect-source-of-truth/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
name: effect-source-of-truth
description: Ground-truth reference for writing Effect in this repo. Use whenever writing, reviewing, or migrating Effect or Schema code — services, layers, schemas, tagged errors, runtimes, HTTP. This repo runs Effect 4 / effect-smol (beta), where most idioms differ from Effect 2/3, so verify against the vendored source instead of memory.
---

# Effect: source of truth

This repo runs **Effect 4 / effect-smol** (`4.0.0-beta.*`), not Effect 3. Most APIs differ from older Effect examples, blog posts, and model memory. Guessing from memory is the most common way to introduce subtly-wrong Effect code here.

## Do not answer from memory

1. The current Effect source is vendored at **`.reference/effect-smol`**. Search it (rg/Read) for exact APIs, signatures, examples, and tests before writing or reviewing Effect code.
2. Read nearby in-repo code for local house style before introducing a new pattern.
3. Prefer answers backed by a specific source file or a nearby in-repo example over recollection.

Concrete v4 differences already hit in this repo:

- `class Service extends Context.Service<Service, Interface>()("@scope/Name") {}` — not `Context.Tag` / `Effect.Service`. A service tag is **directly yieldable** (`yield* Service`); there is no `.asEffect()`.
- Submodule imports: `effect/unstable/http`, `effect/unstable/httpapi`, `effect/unstable/observability`.
- `effect`'s structural cause: inspect `cause.reasons` (no `Cause.failures` / `Cause.defects`).
- `References.CurrentLogAnnotations` is a `Record` (iterate with `Object.entries`), not a `Map`.

## Conventions are enforced — read the rule's `Skill:` pointer

The custom oxlint plugin (`scripts/oxlint-plugin-executor/`) enforces the house style as CI errors (`--deny-warnings`). When a lint error ends with `Skill: <name>`, read that skill for the _why_ and the fix. Highlights:

- Trace public service methods with `Effect.fn("Domain.method")`; `Effect.fnUntraced` for internal helpers; `Effect.gen` for inline composition.
- Model errors with `Schema.TaggedErrorClass` and raise them as `yield* new MyError({...})` — never `Effect.fail(new MyError())`, never `Data.TaggedError` in public/wire types.
- No `Schema.Class`, no `switch`, no `try`/`catch`/`throw`, no raw `fetch`, no `JSON.parse`, no `Effect.die`/`orDie` for expected failures in domain code. Prefer Effect platform services (`FileSystem`, `HttpClient`).

Before introducing any Effect API or pattern not already present in the repo, confirm it exists in `.reference/effect-smol` and fits these rules.
2 changes: 2 additions & 0 deletions apps/cli/src/integrations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Effect, Layer, ManagedRuntime } from "effect";
import { Observability } from "@executor-js/sdk/observability";
import { FetchHttpClient } from "effect/unstable/http";
import { BunFileSystem } from "@effect/platform-bun";

Expand Down Expand Up @@ -26,6 +27,7 @@ export const fetchIntegrations = (): void => {
integrationsRegistryLayer({ userAgent: USER_AGENT, recurring: false }).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(BunFileSystem.layer),
Layer.provideMerge(Observability.layer),
),
);
runtime.runFork(
Expand Down
3 changes: 2 additions & 1 deletion apps/local/src/executor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Context, Data, Effect, Layer, ManagedRuntime, Schema } from "effect";
import { Observability } from "@executor-js/sdk/observability";
import { type Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
Expand Down Expand Up @@ -749,7 +750,7 @@ const createLocalExecutorLayer = () => {

export const createExecutorHandle = async () => {
const layer = createLocalExecutorLayer();
const runtime = ManagedRuntime.make(layer);
const runtime = ManagedRuntime.make(layer.pipe(Layer.provideMerge(Observability.layer)));
const bundle = await runtime.runPromise(LocalExecutorTag);

return {
Expand Down
2 changes: 2 additions & 0 deletions apps/local/src/integrations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Layer, ManagedRuntime } from "effect";
import { Observability } from "@executor-js/sdk/observability";
import { FetchHttpClient } from "effect/unstable/http";
import { NodeFileSystem } from "@effect/platform-node";

Expand All @@ -18,6 +19,7 @@ const integrationsRuntime = ManagedRuntime.make(
integrationsRegistryLayer({ userAgent: USER_AGENT }).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provideMerge(Observability.layer),
),
);

Expand Down
5 changes: 4 additions & 1 deletion apps/local/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Context, Effect, Layer, ManagedRuntime } from "effect";
import { Observability } from "@executor-js/sdk/observability";

import { createExecutionEngine } from "@executor-js/execution";
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
Expand Down Expand Up @@ -82,7 +83,9 @@ const ServerHandlersLive = Layer.effect(ServerHandlersService)(
),
);

const serverHandlersRuntime = ManagedRuntime.make(ServerHandlersLive);
const serverHandlersRuntime = ManagedRuntime.make(
ServerHandlersLive.pipe(Layer.provideMerge(Observability.layer)),
);

export const getServerHandlers = (): Promise<ServerHandlers> =>
serverHandlersRuntime.runPromise(ServerHandlersService);
Expand Down
81 changes: 53 additions & 28 deletions packages/core/api/src/account/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { HttpApiBuilder } from "effect/unstable/httpapi";
import { HttpServerRequest } from "effect/unstable/http";
import { Effect } from "effect";

import { AccountHttpApi } from "./api";
import {
AccountHttpApi,
type CreateApiKeyBody,
type InviteMemberBody,
type UpdateMemberRoleBody,
type UpdateOrgNameBody,
} from "./api";
import { AccountProvider, type AccountHeaders } from "./service";

// ---------------------------------------------------------------------------
Expand All @@ -11,6 +17,10 @@ import { AccountProvider, type AccountHeaders } from "./service";
// both cloud and self-host serve identical routes — only the service impl
// differs. The neutral errors thrown by the service map directly to their HTTP
// statuses (401/403/500) via the contract annotations.
//
// Each handler is an `Effect.fn("account.<endpoint>")` so it opens a named
// trace span / fiber. The `ctx` parameter is annotated explicitly because the
// endpoint-input type does not flow through `Effect.fn` by inference.
// ---------------------------------------------------------------------------

const requestHeaders = Effect.map(
Expand All @@ -20,68 +30,83 @@ const requestHeaders = Effect.map(

export const AccountHandlers = HttpApiBuilder.group(AccountHttpApi, "account", (handlers) =>
handlers
.handle("me", () =>
Effect.gen(function* () {
.handle(
"me",
Effect.fn("account.me")(function* () {
const headers = yield* requestHeaders;
return yield* (yield* AccountProvider).me(headers);
}),
)
.handle("listApiKeys", () =>
Effect.gen(function* () {
.handle(
"listApiKeys",
Effect.fn("account.listApiKeys")(function* () {
const headers = yield* requestHeaders;
return yield* (yield* AccountProvider).listApiKeys(headers);
}),
)
.handle("createApiKey", ({ payload }) =>
Effect.gen(function* () {
.handle(
"createApiKey",
Effect.fn("account.createApiKey")(function* (ctx: { payload: typeof CreateApiKeyBody.Type }) {
const headers = yield* requestHeaders;
return yield* (yield* AccountProvider).createApiKey(headers, payload.name);
return yield* (yield* AccountProvider).createApiKey(headers, ctx.payload.name);
}),
)
.handle("revokeApiKey", ({ params }) =>
Effect.gen(function* () {
.handle(
"revokeApiKey",
Effect.fn("account.revokeApiKey")(function* (ctx: { params: { apiKeyId: string } }) {
const headers = yield* requestHeaders;
return yield* (yield* AccountProvider).revokeApiKey(headers, params.apiKeyId);
return yield* (yield* AccountProvider).revokeApiKey(headers, ctx.params.apiKeyId);
}),
)
.handle("listMembers", () =>
Effect.gen(function* () {
.handle(
"listMembers",
Effect.fn("account.listMembers")(function* () {
const headers = yield* requestHeaders;
return yield* (yield* AccountProvider).listMembers(headers);
}),
)
.handle("listRoles", () =>
Effect.gen(function* () {
.handle(
"listRoles",
Effect.fn("account.listRoles")(function* () {
const headers = yield* requestHeaders;
return yield* (yield* AccountProvider).listRoles(headers);
}),
)
.handle("inviteMember", ({ payload }) =>
Effect.gen(function* () {
.handle(
"inviteMember",
Effect.fn("account.inviteMember")(function* (ctx: { payload: typeof InviteMemberBody.Type }) {
const headers = yield* requestHeaders;
return yield* (yield* AccountProvider).inviteMember(headers, payload);
return yield* (yield* AccountProvider).inviteMember(headers, ctx.payload);
}),
)
.handle("removeMember", ({ params }) =>
Effect.gen(function* () {
.handle(
"removeMember",
Effect.fn("account.removeMember")(function* (ctx: { params: { membershipId: string } }) {
const headers = yield* requestHeaders;
return yield* (yield* AccountProvider).removeMember(headers, params.membershipId);
return yield* (yield* AccountProvider).removeMember(headers, ctx.params.membershipId);
}),
)
.handle("updateMemberRole", ({ params, payload }) =>
Effect.gen(function* () {
.handle(
"updateMemberRole",
Effect.fn("account.updateMemberRole")(function* (ctx: {
params: { membershipId: string };
payload: typeof UpdateMemberRoleBody.Type;
}) {
const headers = yield* requestHeaders;
return yield* (yield* AccountProvider).updateMemberRole(
headers,
params.membershipId,
payload.roleSlug,
ctx.params.membershipId,
ctx.payload.roleSlug,
);
}),
)
.handle("updateOrgName", ({ payload }) =>
Effect.gen(function* () {
.handle(
"updateOrgName",
Effect.fn("account.updateOrgName")(function* (ctx: {
payload: typeof UpdateOrgNameBody.Type;
}) {
const headers = yield* requestHeaders;
return yield* (yield* AccountProvider).updateOrgName(headers, payload.name);
return yield* (yield* AccountProvider).updateOrgName(headers, ctx.payload.name);
}),
),
);
3 changes: 2 additions & 1 deletion packages/core/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"./http-source": "./src/http-source.ts",
"./promise": "./src/promise.ts",
"./client": "./src/client.ts",
"./testing": "./src/testing.ts"
"./testing": "./src/testing.ts",
"./observability": "./src/observability.ts"
},
"publishConfig": {
"access": "public",
Expand Down
86 changes: 86 additions & 0 deletions packages/core/sdk/src/observability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Cause, Logger, References } from "effect";

// ---------------------------------------------------------------------------
// Structured logging for executor's client runtimes (cli / local / desktop).
//
// These apps do NOT phone home: this installs a structured, service-annotated
// logger only. There is no OTLP exporter and no external endpoint — nothing
// leaves the machine. `Effect.fn("Domain.method")` span names still produce
// named fibers and log/span context here, but spans stay in-process.
//
// Cloud is the only surface that exports telemetry, and it keeps its own
// (Axiom) setup — this module is intentionally not wired there.
// ---------------------------------------------------------------------------

type Fields = Record<string, unknown>;

const LEVEL: Record<string, string> = {
Trace: "debug",
Debug: "debug",
Warn: "warn",
Error: "error",
Fatal: "error",
};

const level = (label: string): string => LEVEL[label] ?? "info";

const render = (value: unknown): string => {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
// Non-throwing stringify: handle bigint + circular refs inline so domain
// code never needs a try/catch boundary.
const seen = new WeakSet<object>();
return (
JSON.stringify(value, (_key, candidate) => {
if (typeof candidate === "bigint") return candidate.toString();
if (typeof candidate === "object" && candidate !== null) {
if (seen.has(candidate)) return "[Circular]";
seen.add(candidate);
}
return candidate;
}) ?? String(value)
);
};

const message = (input: unknown): string => {
if (Array.isArray(input)) return input.map(render).join(" ");
return render(input);
};

// A single yieldable logger: structured key=value annotations, log-span
// durations, and a pretty-printed cause on failures. Writes to stderr so CLI
// stdout stays reserved for machine-readable output.
export const logger = Logger.make((options) => {
const fields: Fields = {};
for (const [key, value] of Object.entries(
options.fiber.getRef(References.CurrentLogAnnotations),
)) {
if (value !== undefined && value !== null) fields[key] = value;
}
const now = options.date.getTime();
for (const [key, start] of options.fiber.getRef(References.CurrentLogSpans)) {
fields[`${key}.ms`] = now - start;
}

// `service` is promoted into the line prefix rather than rendered as a field.
const service = typeof fields.service === "string" ? fields.service : undefined;
delete fields.service;

const prefix = service ? `${level(options.logLevel)} ${service}:` : `${level(options.logLevel)}:`;
const annotations = Object.entries(fields)
.map(([key, value]) => `${key}=${render(value)}`)
.join(" ");
const cause = options.cause.reasons.length > 0 ? `\n${Cause.pretty(options.cause)}` : "";

const line = [prefix, message(options.message), annotations]
.filter((part) => part.length > 0)
.join(" ");
process.stderr.write(`${line}${cause}\n`);
});

// Replaces the default logger in the runtimes it is merged into.
export const layer = Logger.layer([logger], { mergeWithExisting: false });

export * as Observability from "./observability";
32 changes: 32 additions & 0 deletions packages/core/sdk/src/oxlint-plugin-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,36 @@ describe("executor oxlint plugin", () => {
expect(result.status).toBe(0);
expect(result.stdout).toContain("Found 0 warnings and 0 errors.");
});

it("rejects Effect.fn without a string span name", async () => {
const result = await runOxlintOn(
"unnamed-effect-fn.ts",
`
import { Effect } from "effect";

export const run = Effect.fn(function* () {
return yield* Effect.succeed(1);
});
`,
);

expect(result.status).toBe(1);
expect(result.stdout).toContain("executor(require-effect-fn-name)");
});

it("allows Effect.fn with a string span name", async () => {
const result = await runOxlintOn(
"named-effect-fn.ts",
`
import { Effect } from "effect";

export const run = Effect.fn("Demo.run")(function* () {
return yield* Effect.succeed(1);
});
`,
);

expect(result.status).toBe(0);
expect(result.stdout).toContain("Found 0 warnings and 0 errors.");
});
});
2 changes: 2 additions & 0 deletions scripts/oxlint-plugin-executor.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import preferEffectPredicate from "./oxlint-plugin-executor/rules/prefer-effect-
import preferSchemaInferredTypes from "./oxlint-plugin-executor/rules/prefer-schema-inferred-types.js";
import preferYieldTaggedError from "./oxlint-plugin-executor/rules/prefer-yield-tagged-error.js";
import preferValueInferredExtensionTypes from "./oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js";
import requireEffectFnName from "./oxlint-plugin-executor/rules/require-effect-fn-name.js";
import requireReactivityKeys from "./oxlint-plugin-executor/rules/require-reactivity-keys.js";

export default {
Expand All @@ -44,6 +45,7 @@ export default {
"no-cross-package-relative-imports": noCrossPackageRelativeImports,
"no-direct-cloud-executor-schema-import": noDirectCloudExecutorSchemaImport,
"require-reactivity-keys": requireReactivityKeys,
"require-effect-fn-name": requireEffectFnName,
"no-effect-escape-hatch": noEffectEscapeHatch,
"no-effect-internal-tags": noEffectInternalTags,
"no-error-constructor": noErrorConstructor,
Expand Down
Loading
Loading