Skip to content

Commit 1f71a71

Browse files
authored
ban Schema.Class, migrate all usage to Schema.Struct (#768)
Effect 4.x makes `Schema.Class` nominally typed at runtime — the encode path does an `instanceof` check and rejects any plain object, regardless of field shape. TypeScript's structural typing assigns plain objects to Class-typed parameters happily, so the compiler can't catch the mismatch. The result has bitten executor twice this week alone (Sentry NODE-CLOUDFLARE-WORKERS-3N: Google user, Vercel user, and a fresh SharePoint report today) — wire-decoded `oauth2` payloads reaching `encodeSync` and crashing the txn with "Expected OpenApiOAuth2SourceConfig, got {...}", surfacing to users as an opaque `InternalError({ traceId })` after OAuth completes. The Effect community has documented the same footgun for two years — imagio and Patrick Roza in Feb 2024 (https://www.answeroverflow.com/m/1206705364288938064), and gcanti himself in Nov 2025 confirming "TypeScript is structural" with the explicit recommendation: "if you don't necessarily need the Schema.Class features, then Schema.Struct is always a better option" (https://www.answeroverflow.com/m/1268175268019830906). None of the 75-odd Class declarations in this repo had methods, getters, setters, or any behavior beyond the schema — they were pure data shapes wearing a class wrapper for no benefit. This commit: - Migrates every `Schema.Class<X>("X")(fields) {}` declaration to `Schema.Struct(fields)` + `type X = typeof X.Type` via `scripts/migrate-schema-class.ts`. The codemod is idempotent and supports `--dry-run`. - Migrates `Schema.TaggedClass` (2 sites) to `Schema.TaggedStruct`. - Rewrites ~460 `new X(...)` call sites to `X.make(...)`, the equivalent on Struct that applies constructor defaults and validates fields. - `Schema.TaggedErrorClass` and `Schema.ErrorClass` are exempt — they're how Effect models typed-error channels and `instanceof` for them is by design. - Consolidates the eight existing Struct/Class duality pairs (`OAuth2SourceConfig`/`Schema`, `OpenApiSourceBindingInput`/`Schema`, etc) — the Struct half was created to bridge the API boundary; with Class gone there's only one schema per shape. - Replaces `instanceof FormElicitation` and `_tag === "..."` checks with hoisted `Schema.is(...)` predicates, satisfying the existing `no-manual-tag-check` and `no-inline-schema-compile` rules. - Adds `executor/no-schema-class` oxlint rule banning `Schema.Class` and `Schema.TaggedClass` anywhere — `TaggedErrorClass`/`ErrorClass` pass through. Supersedes `no-schema-class-http-payload`, which only caught direct payload usage and missed nested-in-Struct cases (the exact shape the OAuth2 bug took). - Deletes the now-redundant `check-http-payload-schemas.ts` script and its test — the new lint rule covers everything they did, more broadly. Verified: full `bun run lint`, `bun run typecheck`, `bun run test`, and `bun run format:check` clean.
1 parent e49e66f commit 1f71a71

97 files changed

Lines changed: 1222 additions & 1555 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.oxlintrc.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"executor/no-raw-fetch": "error",
2424
"executor/no-redundant-primitive-cast": "error",
2525
"executor/no-redundant-error-factory": "error",
26-
"executor/no-schema-class-http-payload": "error",
26+
"executor/no-schema-class": "error",
2727
"executor/no-switch-statement": "error",
2828
"executor/no-ts-nocheck": "error",
2929
"executor/no-try-catch-or-throw": "error",

apps/cloud/src/mcp-miniflare.e2e.node.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ import { makeTestBearer } from "./test-bearer";
4747
// cloud engine consumes is derived from the same types the handlers use.
4848
// ---------------------------------------------------------------------------
4949

50-
class ApprovedResponse extends Schema.Class<ApprovedResponse>("ApprovedResponse")({
50+
const ApprovedResponse = Schema.Struct({
5151
approved: Schema.Boolean,
52-
}) {}
52+
});
53+
type ApprovedResponse = typeof ApprovedResponse.Type;
5354

5455
const ApproveGroup = HttpApiGroup.make("approve").add(
5556
HttpApiEndpoint.post("approveThing", "/approve", {
@@ -60,7 +61,7 @@ const ApproveGroup = HttpApiGroup.make("approve").add(
6061
const UpstreamApi = HttpApi.make("approveApi").add(ApproveGroup);
6162

6263
const ApproveHandlers = HttpApiBuilder.group(UpstreamApi, "approve", (h) =>
63-
h.handle("approveThing", () => Effect.succeed(new ApprovedResponse({ approved: true }))),
64+
h.handle("approveThing", () => Effect.succeed(ApprovedResponse.make({ approved: true }))),
6465
);
6566

6667
const UpstreamApiLive = HttpApiBuilder.layer(UpstreamApi).pipe(Layer.provide(ApproveHandlers));

apps/cloud/src/mcp-session.e2e.node.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const elicitingTestPlugin = definePlugin(() => ({
6868
}) =>
6969
Effect.gen(function* () {
7070
const response = yield* elicit(
71-
new FormElicitation({
71+
FormElicitation.make({
7272
message: "Approve?",
7373
requestedSchema: {
7474
type: "object",
@@ -106,7 +106,7 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO
106106
const schema = collectSchemas(plugins);
107107
const adapter = makePostgresAdapter({ db, schema });
108108
const blobs = makePostgresBlobStore({ db });
109-
const scope = new Scope({
109+
const scope = Scope.make({
110110
id: ScopeId.make(scopeId),
111111
name: scopeName,
112112
createdAt: new Date(),

apps/cloud/src/services/__test-harness__/api-harness.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string
7373
const schema = collectSchemas(plugins);
7474
const adapter = makePostgresAdapter({ db, schema });
7575
const blobs = makePostgresBlobStore({ db });
76-
const orgScope = new Scope({
76+
const orgScope = Scope.make({
7777
id: ScopeId.make(orgId),
7878
name: orgName,
7979
createdAt: new Date(),
8080
});
81-
const userOrgScope = new Scope({
81+
const userOrgScope = Scope.make({
8282
id: ScopeId.make(userOrgScopeId(userId, orgId)),
8383
name: `Personal · ${orgName}`,
8484
createdAt: new Date(),

apps/cloud/src/services/executor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,12 @@ export const createScopedExecutor = (
7171
const adapter = makePostgresAdapter({ db, schema });
7272
const blobs = makePostgresBlobStore({ db });
7373

74-
const orgScope = new Scope({
74+
const orgScope = Scope.make({
7575
id: ScopeId.make(organizationId),
7676
name: organizationName,
7777
createdAt: new Date(),
7878
});
79-
const userOrgScope = new Scope({
79+
const userOrgScope = Scope.make({
8080
id: ScopeId.make(`user-org:${userId}:${organizationId}`),
8181
name: `Personal · ${organizationName}`,
8282
createdAt: new Date(),

apps/local/src/server/config-sync.boot.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,12 @@ describe("syncFromConfig — MCP auth replay", () => {
152152
const executor = yield* makeExecutor();
153153
const connectionId = ConnectionId.make("mcp-oauth2-linear");
154154
yield* executor.connections.create(
155-
new CreateConnectionInput({
155+
CreateConnectionInput.make({
156156
id: connectionId,
157157
scope: TEST_SCOPE,
158158
provider: OAUTH2_PROVIDER_KEY,
159159
identityLabel: "user@example.com",
160-
accessToken: new TokenMaterial({
160+
accessToken: TokenMaterial.make({
161161
secretId: SecretId.make(`${connectionId}.access_token`),
162162
name: "MCP Access Token",
163163
value: "access-token-value",

apps/local/src/server/executor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ const createLocalExecutorLayer = () => {
307307
const adapter = makeSqliteAdapter({ db, schema });
308308
const blobs = makeSqliteBlobStore({ db });
309309

310-
const scope = new Scope({
310+
const scope = Scope.make({
311311
id: ScopeId.make(scopeId),
312312
name: cwd,
313313
createdAt: new Date(),

apps/local/src/server/mcp-oauth.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => {
246246
const adapter = makeSqliteAdapter({ db, schema });
247247
const blobs = makeSqliteBlobStore({ db });
248248

249-
const scope = new Scope({
249+
const scope = Scope.make({
250250
id: ScopeId.make(scopeId),
251251
name: "test",
252252
createdAt: new Date(),

apps/local/src/server/migrate-connections.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ const insertOpenApiCredentialBinding = (
252252

253253
const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentials"]);
254254

255-
class LegacyOpenApiOAuth2 extends Schema.Class<LegacyOpenApiOAuth2>("LegacyOpenApiOAuth2")({
255+
const LegacyOpenApiOAuth2 = Schema.Struct({
256256
kind: Schema.Literal("oauth2"),
257257
securitySchemeName: Schema.String,
258258
flow: OAuth2Flow,
@@ -265,7 +265,8 @@ class LegacyOpenApiOAuth2 extends Schema.Class<LegacyOpenApiOAuth2>("LegacyOpenA
265265
expiresAt: Schema.NullOr(Schema.Number),
266266
scope: Schema.NullOr(Schema.String),
267267
scopes: Schema.Array(Schema.String),
268-
}) {}
268+
});
269+
type LegacyOpenApiOAuth2 = typeof LegacyOpenApiOAuth2.Type;
269270

270271
const decodeOpenApiCurrent = Schema.decodeUnknownOption(OAuth2SourceConfig);
271272
const decodeOpenApiLegacy = decodeUnknownOptionAs<LegacyOpenApiOAuth2>(LegacyOpenApiOAuth2);

examples/all-plugins/src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { workosVaultPlugin } from "@executor-js/plugin-workos-vault";
4747
// SourceRegistry, SecretStore, and PolicyEngine service instances.
4848
// ---------------------------------------------------------------------------
4949

50-
const scope = new Scope({
50+
const scope = Scope.make({
5151
id: ScopeId.make("example-scope"),
5252
name: "/tmp/example-workspace",
5353
createdAt: new Date(),
@@ -203,7 +203,7 @@ const program = Effect.gen(function* () {
203203
console.log("Registered providers:", providers);
204204

205205
yield* executor.secrets.set(
206-
new SetSecretInput({
206+
SetSecretInput.make({
207207
id: SecretId.make("example-api-token"),
208208
scope: "example-scope" as SetSecretInput["scope"],
209209
name: "Example API Token",

0 commit comments

Comments
 (0)