From abd076948d1c7838b8ee19dc0b67d99815d4c768 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 4 May 2026 10:56:46 -0700 Subject: [PATCH] Don't 500 on listSourceBindings after source removal When the React openApiSourceBindingsAtom revalidates after a source delete (sourceWriteKeys invalidate it before the page unmounts), the store threw StorageError because validateBindingTarget required the source row to exist. That surfaced to the browser as a 500 / Sentry alert even though the delete itself succeeded. Split validateBindingTarget into a scope-stack-only validator used by reads (listSourceBindings, resolveSourceBinding) and the original used by writes (setSourceBinding, removeSourceBinding). A removed source now reads back as [] / null; writes still reject as before. --- .../plugins/openapi/src/sdk/plugin.test.ts | 33 +++++++++++++++++++ packages/plugins/openapi/src/sdk/store.ts | 21 ++++++++---- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 826bda7ec..6b5e7ca2c 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -540,6 +540,39 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); + it.effect("listSourceBindings returns [] for a removed source", () => + // Regression: the React bindings atom revalidates after a removeSpec + // (sourceWriteKeys invalidate it) before unmount. The store used to + // throw StorageError("source does not exist"), which surfaced to the + // browser as a 500. A removed source has no bindings — return []. + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: clientLayer }), + memorySecretsPlugin(), + ] as const, + }), + ); + + yield* executor.openapi.addSpec({ + spec: specJson, + scope: TEST_SCOPE, + namespace: "removable", + baseUrl: "", + }); + yield* executor.openapi.removeSpec("removable", TEST_SCOPE); + + const bindings = yield* executor.openapi.listSourceBindings( + "removable", + TEST_SCOPE, + ); + expect(bindings).toEqual([]); + }), + ); + // ------------------------------------------------------------------------- // Multi-scope shadowing — regression suite covering the bug class where // store reads/writes that don't pin scope_id collapse onto whichever row diff --git a/packages/plugins/openapi/src/sdk/store.ts b/packages/plugins/openapi/src/sdk/store.ts index 1dc00e497..4231eb4d6 100644 --- a/packages/plugins/openapi/src/sdk/store.ts +++ b/packages/plugins/openapi/src/sdk/store.ts @@ -419,8 +419,7 @@ export const makeDefaultOpenapiStore = ({ : new Date(row.updated_at as string), }); - const validateBindingTarget = (params: { - readonly sourceId: string; + const validateBindingScopes = (params: { readonly sourceScope: string; readonly targetScope: string; }) => @@ -445,6 +444,18 @@ export const makeDefaultOpenapiStore = ({ }), ); } + }); + + const validateBindingTarget = (params: { + readonly sourceId: string; + readonly sourceScope: string; + readonly targetScope: string; + }) => + Effect.gen(function* () { + yield* validateBindingScopes({ + sourceScope: params.sourceScope, + targetScope: params.targetScope, + }); const source = yield* adapter.findOne({ model: "openapi_source", where: [ @@ -701,8 +712,7 @@ export const makeDefaultOpenapiStore = ({ listSourceBindings: (sourceId, sourceScope) => Effect.gen(function* () { - yield* validateBindingTarget({ - sourceId, + yield* validateBindingScopes({ sourceScope, targetScope: sourceScope, }); @@ -729,8 +739,7 @@ export const makeDefaultOpenapiStore = ({ resolveSourceBinding: (sourceId, sourceScope, slot) => Effect.gen(function* () { - yield* validateBindingTarget({ - sourceId, + yield* validateBindingScopes({ sourceScope, targetScope: sourceScope, });