* Add secret/connection usages SDK surface + UI placeholder Plugins gain optional `usagesForSecret` / `usagesForConnection` callbacks that the executor fans out across via `executor.secrets.usages(id)` and `executor.connections.usages(id)`. Secret/connection removal is now RESTRICT — refuses with `SecretInUseError` / `ConnectionInUseError` when any plugin reports the id as in use, surfacing the count to the caller. API gains GET `/secrets/:id/usages` and `/connections/:id/usages` endpoints. React atoms and a small "Used by …" footer on each row in the Secrets and Connections tabs round out the surface. No plugin implements `usagesForSecret`/`usagesForConnection` yet, so the footer renders nothing and remove still succeeds for everything. Plugin migration lands in follow-up commits. * Normalize graphql plugin secret/connection refs Move secret/connection references out of the JSON columns on graphql_source (auth, headers, query_params) into: - two new flat columns auth_kind + auth_connection_id (indexed) - two child tables graphql_source_header / graphql_source_query_param, each with kind/text_value/secret_id/secret_prefix and an indexed secret_id This lets usagesForSecret and usagesForConnection answer "where is X referenced?" with a single indexed SELECT instead of scanning JSON, and secrets.remove / connections.remove now correctly RESTRICT when a graphql source still references the id. Migration 0007 backfills existing JSON via json_each + json_extract, then drops the old columns. Three tests cover the backfill against a hand-seeded pre-migration DB; three plugin-level tests cover usage fan-out and remove RESTRICT. * Normalize openapi plugin secret/connection refs Move secret/connection references out of the JSON columns on openapi_source and openapi_source_binding into: - openapi_source_binding gains kind/secret_id/connection_id/text_value columns (replacing the discriminated-union json value) - openapi_source_query_param: child table for query_params, mirrors graphql_source_query_param shape - openapi_source_spec_fetch_header / spec_fetch_query_param: child tables for invocation_config.specFetchCredentials.{headers,queryParams} Drops openapi_source.query_params and openapi_source.invocation_config JSON columns. headers and oauth2 stay JSON because they hold slot names rather than direct refs — the slots resolve through bindings, which ARE normalized. Plugin gains usagesForSecret / usagesForConnection that fan out across all four locations in one indexed SELECT each. secrets.remove now correctly RESTRICTs when an openapi binding still uses the secret. Migration 0008 backfills via json_extract / json_each, then drops the old columns. Three migration tests cover the backfill from a hand-seeded pre-migration DB; two plugin-level tests cover usage fan-out and remove RESTRICT. * Normalize mcp plugin secret/connection refs Move secret/connection references out of the JSON `mcp_source.config` column into: - Flat columns on mcp_source: auth_kind, auth_header_name, auth_secret_id, auth_secret_prefix, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id (each indexed where it carries a ref). Replaces the McpConnectionAuth json blob. - mcp_source_header / mcp_source_query_param: child tables for the remote source's SecretBackedMap entries (same shape as graphql / openapi child tables). The remaining structural fields (transport, endpoint, command, args, etc.) stay as JSON in `config` because they're plugin-private and vary by transport. mcp_binding.binding stays JSON too — McpToolBinding carries no refs and inputSchema/outputSchema are arbitrary user JSON. Plugin gains usagesForSecret / usagesForConnection. Migration 0009 backfills via json_extract / json_each, then strips the extracted fields with json_remove. Three migration tests cover backfill from a hand-seeded pre-migration DB; two plugin-level tests cover usage fan-out across header-auth and oauth2 sources. * Normalize google-discovery plugin secret/connection refs Move secret/connection references out of the JSON google_discovery_source.config column into: - Flat columns on google_discovery_source: auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes (each indexed where it carries a ref). Replaces the GoogleDiscoveryAuth json. - google_discovery_source_credential_header / google_discovery_source_credential_query_param: child tables for the optional GoogleDiscoveryFetchCredentials SecretBackedMap entries. The remaining structural fields (discoveryUrl, service, version, rootUrl, servicePath) stay in `config` as JSON — they're plugin-private and never carry refs. google_discovery_binding stays JSON too: the GoogleDiscoveryMethodBinding has no refs, and method input/output schemas are arbitrary user data. Plugin gains usagesForSecret / usagesForConnection. Migration 0010 backfills via json_extract / json_each, then strips the extracted fields with json_remove. Three migration tests cover backfill from a hand-seeded pre-migration DB; one plugin-level test covers usage fan-out across auth_*_secret_id and auth_connection_id. * Consolidate plugin normalization into single migration Merge graphql/openapi/mcp/google-discovery normalization into one migration per app: 0007_normalize_plugin_secret_refs (local sqlite) and 0008_normalize_plugin_secret_refs (cloud pg, mcp-only since cloud doesn't ship google-discovery). Each test now imports a shared PRE_0007_SQL helper that seeds all four plugins' parent tables at the pre-migration shape — needed because the single consolidated migration touches every plugin in one transaction. * Address review: legacy openapi OAuth + strict usage gate + shadow-aware delete Three issues from review: 1. migrateOpenApi was early-returning when invocation_config column was missing (post-0007). Rows with legacy OAuth in oauth2 column silently lost their migration to a Connection. Fixed: - migrate-connections now reads oauth2 column even without invocation_config, and adapts its UPDATE statement accordingly. - Both 0007 (sqlite) and 0008 (pg) migrations COALESCE invocation_config.oauth2 into the oauth2 column before dropping invocation_config, so legacy data isn't lost mid-migration. 2. secrets.remove / connections.remove gated on a lenient usage helper that swallowed plugin failures into empty arrays — a transient plugin error could silently allow a delete that creates dangling refs. Added secretsUsagesStrict / connectionsUsagesStrict that propagate plugin failures as StorageFailure and wired them into the delete RESTRICT path. Display path keeps the lenient variant. 3. The RESTRICT check was global by id, conflicting with the documented shadowing model: deleting a user-scope override should reveal the outer fallback, but the global check blocked any delete whose id is referenced anywhere. Now scoped: only enforce RESTRICT when the delete would leave no row with that id (i.e., the reference would actually dangle).
Showing the changes to packages/plugins/openapi/src (3 of 41 files). Show all
Unified · Split
| @@ -20,7 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | |
| 21 | 21 | const TEST_SCOPE = "test-scope"; |
| 22 | 22 | import { openApiPlugin } from "./plugin"; |
| 23 | −import { OAuth2Auth } from "./types"; | |
| 23 | +import { OAuth2Auth, OpenApiSourceBindingInput } from "./types"; | |
| 24 | 24 | |
| 25 | 25 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; |
| 26 | 26 | |
| @@ -847,4 +847,108 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 820 unmodified lines | ||
| 847 | 847 | expect(tools.some((t) => t.id.startsWith("deferred."))).toBe(true); |
| 848 | 848 | }), |
| 849 | 849 | ); |
| 850 | + | |
| 851 | + // ------------------------------------------------------------------------- | |
| 852 | + // Usage tracking — once openapi normalizes its bindings, query_params, | |
| 853 | + // and specFetchCredentials, `usagesForSecret` and `usagesForConnection` | |
| 854 | + // should surface every reference and `secrets.remove` should RESTRICT. | |
| 855 | + // ------------------------------------------------------------------------- | |
| 856 | + | |
| 857 | + it.effect("usagesForSecret aggregates bindings and child rows", () => | |
| 858 | + Effect.gen(function* () { | |
| 859 | + const executor = yield* createExecutor( | |
| 860 | + makeTestConfig({ | |
| 861 | + plugins: [memorySecretsPlugin(), openApiPlugin()] as const, | |
| 862 | + }), | |
| 863 | + ); | |
| 864 | + | |
| 865 | + yield* executor.secrets.set( | |
| 866 | + new SetSecretInput({ | |
| 867 | + id: SecretId.make("api-key"), | |
| 868 | + scope: ScopeId.make(TEST_SCOPE), | |
| 869 | + name: "API Key", | |
| 870 | + value: "abc123", | |
| 871 | + provider: "memory", | |
| 872 | + }), | |
| 873 | + ); | |
| 874 | + | |
| 875 | + // Add a source whose query_params reference the secret directly. | |
| 876 | + yield* executor.openapi.addSpec({ | |
| 877 | + spec: specJson, | |
| 878 | + scope: TEST_SCOPE, | |
| 879 | + namespace: "with_secret", | |
| 880 | + baseUrl: "http://example.com", | |
| 881 | + queryParams: { token: { secretId: "api-key" } }, | |
| 882 | + }); | |
| 883 | + | |
| 884 | + // Configure a slot binding pointing at the same secret. | |
| 885 | + yield* executor.openapi.setSourceBinding( | |
| 886 | + new OpenApiSourceBindingInput({ | |
| 887 | + sourceId: "with_secret", | |
| 888 | + sourceScope: ScopeId.make(TEST_SCOPE), | |
| 889 | + scope: ScopeId.make(TEST_SCOPE), | |
| 890 | + slot: "header:authorization", | |
| 891 | + value: { kind: "secret", secretId: SecretId.make("api-key") }, | |
| 892 | + }), | |
| 893 | + ); | |
| 894 | + | |
| 895 | + const usages = yield* executor.secrets.usages(SecretId.make("api-key")); | |
| 896 | + expect(usages.length).toBe(2); | |
| 897 | + const slots = usages.map((u) => u.slot).sort(); | |
| 898 | + expect(slots).toEqual([ | |
| 899 | + "binding:header:authorization", | |
| 900 | + "query_param:token", | |
| 901 | + ]); | |
| 902 | + expect(usages.every((u) => u.pluginId === "openapi")).toBe(true); | |
| 903 | + }), | |
| 904 | + ); | |
| 905 | + | |
| 906 | + it.effect("secrets.remove refuses while an openapi binding still uses it", () => | |
| 907 | + Effect.gen(function* () { | |
| 908 | + const executor = yield* createExecutor( | |
| 909 | + makeTestConfig({ | |
| 910 | + plugins: [memorySecretsPlugin(), openApiPlugin()] as const, | |
| 911 | + }), | |
| 912 | + ); | |
| 913 | + yield* executor.secrets.set( | |
| 914 | + new SetSecretInput({ | |
| 915 | + id: SecretId.make("locked"), | |
| 916 | + scope: ScopeId.make(TEST_SCOPE), | |
| 917 | + name: "Locked", | |
| 918 | + value: "v", | |
| 919 | + provider: "memory", | |
| 920 | + }), | |
| 921 | + ); | |
| 922 | + | |
| 923 | + yield* executor.openapi.addSpec({ | |
| 924 | + spec: specJson, | |
| 925 | + scope: TEST_SCOPE, | |
| 926 | + namespace: "ref", | |
| 927 | + baseUrl: "http://example.com", | |
| 928 | + }); | |
| 929 | + yield* executor.openapi.setSourceBinding( | |
| 930 | + new OpenApiSourceBindingInput({ | |
| 931 | + sourceId: "ref", | |
| 932 | + sourceScope: ScopeId.make(TEST_SCOPE), | |
| 933 | + scope: ScopeId.make(TEST_SCOPE), | |
| 934 | + slot: "header:authorization", | |
| 935 | + value: { kind: "secret", secretId: SecretId.make("locked") }, | |
| 936 | + }), | |
| 937 | + ); | |
| 938 | + | |
| 939 | + const failure = yield* executor.secrets | |
| 940 | + .remove(SecretId.make("locked")) | |
| 941 | + .pipe(Effect.flip); | |
| 942 | + expect((failure as { _tag: string })._tag).toBe("SecretInUseError"); | |
| 943 | + | |
| 944 | + // Detach the binding, then remove succeeds. | |
| 945 | + yield* executor.openapi.removeSourceBinding( | |
| 946 | + "ref", | |
| 947 | + ScopeId.make(TEST_SCOPE), | |
| 948 | + "header:authorization", | |
| 949 | + ScopeId.make(TEST_SCOPE), | |
| 950 | + ); | |
| 951 | + yield* executor.secrets.remove(SecretId.make("locked")); | |
| 952 | + }), | |
| 953 | + ); | |
| 850 | 954 | }); |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | ScopeId, |
| 11 | 11 | SecretId, |
| 12 | 12 | SourceDetectionResult, |
| 13 | + Usage, | |
| 13 | 14 | definePlugin, |
| 14 | 15 | resolveSecretBackedMap, |
| 15 | 16 | type PluginCtx, |
| @@ -1030,6 +1031,82 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1014 unmodified lines | ||
| 1030 | 1031 | |
| 1031 | 1032 | removeSource: ({ ctx, sourceId, scope }) => ctx.storage.removeSource(sourceId, scope), |
| 1032 | 1033 | |
| 1034 | + // Aggregate usages across the four places openapi can hold a direct | |
| 1035 | + // secret ref: | |
| 1036 | + // - openapi_source_binding.secret_id (kind=secret slot bindings) | |
| 1037 | + // - openapi_source_query_param | |
| 1038 | + // - openapi_source_spec_fetch_header / spec_fetch_query_param | |
| 1039 | + // Each is one indexed SELECT in the store; the merge plus a single | |
| 1040 | + // source-name JOIN happens here. | |
| 1041 | + usagesForSecret: ({ ctx, args }) => | |
| 1042 | + Effect.gen(function* () { | |
| 1043 | + const bindings = yield* ctx.storage.findBindingsBySecret(args.secretId); | |
| 1044 | + const childRows = yield* ctx.storage.findChildRowsBySecret( | |
| 1045 | + args.secretId, | |
| 1046 | + ); | |
| 1047 | + | |
| 1048 | + const sourceKeys = new Set<string>(); | |
| 1049 | + for (const b of bindings) { | |
| 1050 | + sourceKeys.add(`${b.sourceScopeId}:${b.sourceId}`); | |
| 1051 | + } | |
| 1052 | + for (const r of childRows) { | |
| 1053 | + sourceKeys.add(`${r.scope_id}:${r.source_id}`); | |
| 1054 | + } | |
| 1055 | + const sources = yield* ctx.storage.lookupSourceNames([...sourceKeys]); | |
| 1056 | + | |
| 1057 | + const out: Usage[] = []; | |
| 1058 | + for (const b of bindings) { | |
| 1059 | + out.push( | |
| 1060 | + new Usage({ | |
| 1061 | + pluginId: "openapi", | |
| 1062 | + scopeId: ScopeId.make(b.scopeId), | |
| 1063 | + ownerKind: "openapi-source-binding", | |
| 1064 | + ownerId: b.sourceId, | |
| 1065 | + ownerName: | |
| 1066 | + sources.get(`${b.sourceScopeId}:${b.sourceId}`) ?? null, | |
| 1067 | + slot: `binding:${b.slot}`, | |
| 1068 | + }), | |
| 1069 | + ); | |
| 1070 | + } | |
| 1071 | + for (const r of childRows) { | |
| 1072 | + out.push( | |
| 1073 | + new Usage({ | |
| 1074 | + pluginId: "openapi", | |
| 1075 | + scopeId: ScopeId.make(r.scope_id), | |
| 1076 | + ownerKind: `openapi-source-${r.kind.replace(/_/g, "-")}`, | |
| 1077 | + ownerId: r.source_id, | |
| 1078 | + ownerName: sources.get(`${r.scope_id}:${r.source_id}`) ?? null, | |
| 1079 | + slot: `${r.kind}:${r.name}`, | |
| 1080 | + }), | |
| 1081 | + ); | |
| 1082 | + } | |
| 1083 | + return out; | |
| 1084 | + }), | |
| 1085 | + | |
| 1086 | + usagesForConnection: ({ ctx, args }) => | |
| 1087 | + Effect.gen(function* () { | |
| 1088 | + const bindings = yield* ctx.storage.findBindingsByConnection( | |
| 1089 | + args.connectionId, | |
| 1090 | + ); | |
| 1091 | + const sourceKeys = new Set<string>(); | |
| 1092 | + for (const b of bindings) { | |
| 1093 | + sourceKeys.add(`${b.sourceScopeId}:${b.sourceId}`); | |
| 1094 | + } | |
| 1095 | + const sources = yield* ctx.storage.lookupSourceNames([...sourceKeys]); | |
| 1096 | + return bindings.map( | |
| 1097 | + (b) => | |
| 1098 | + new Usage({ | |
| 1099 | + pluginId: "openapi", | |
| 1100 | + scopeId: ScopeId.make(b.scopeId), | |
| 1101 | + ownerKind: "openapi-source-binding", | |
| 1102 | + ownerId: b.sourceId, | |
| 1103 | + ownerName: | |
| 1104 | + sources.get(`${b.sourceScopeId}:${b.sourceId}`) ?? null, | |
| 1105 | + slot: `binding:${b.slot}`, | |
| 1106 | + }), | |
| 1107 | + ); | |
| 1108 | + }), | |
| 1109 | + | |
| 1033 | 1110 | // Re-fetch the spec from its origin URL (captured at addSpec time) |
| 1034 | 1111 | // and replay the same parse → extract → upsertSource → register |
| 1035 | 1112 | // path used by addSpec. Sources without a stored URL surface a |
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | import { Effect, Schema } from "effect"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | + ConnectionId, | |
| 4 | 5 | defineSchema, |
| 5 | 6 | ScopeId, |
| 7 | + SecretId, | |
| 6 | 8 | StorageError, |
| 7 | 9 | type StorageDeps, |
| 8 | 10 | type StorageFailure, |
| @@ -27,6 +29,15 @@ import { · 18 unmodified lines | ||
| 27 | 29 | // - openapi_source_binding: credential bindings for shared sources |
| 28 | 30 | // --------------------------------------------------------------------------- |
| 29 | 31 | |
| 32 | +// Each of the secret-backed child tables (`openapi_source_query_param`, | |
| 33 | +// `openapi_source_spec_fetch_header`, | |
| 34 | +// `openapi_source_spec_fetch_query_param`) shares the same column shape: | |
| 35 | +// id/scope_id/source_id/name plus a `kind` enum that discriminates a | |
| 36 | +// literal text value from a secret reference (with optional prefix). | |
| 37 | +// The fields are inlined per-table because `defineSchema`'s type | |
| 38 | +// narrowing relies on the literal types staying on the original | |
| 39 | +// declaration site. | |
| 40 | + | |
| 30 | 41 | export const openapiSchema = defineSchema({ |
| 31 | 42 | openapi_source: { |
| 32 | 43 | fields: { |
| @@ -40,10 +51,14 @@ export const openapiSchema = defineSchema({ · 7 unmodified lines | ||
| 40 | 51 | // is the address re-fetched on `refreshSource`. |
| 41 | 52 | source_url: { type: "string", required: false }, |
| 42 | 53 | base_url: { type: "string", required: false }, |
| 54 | + // `headers` and `oauth2` stay JSON: these carry slot names, not | |
| 55 | + // direct secret/connection ids. The secrets/connections that | |
| 56 | + // actually power them live one level of indirection deeper, in | |
| 57 | + // `openapi_source_binding` rows keyed by slot — and those ARE | |
| 58 | + // normalized below. Headers and oauth2 are plugin-private | |
| 59 | + // structural data, not cross-cutting refs. | |
| 43 | 60 | headers: { type: "json", required: false }, |
| 44 | − query_params: { type: "json", required: false }, | |
| 45 | 61 | oauth2: { type: "json", required: false }, |
| 46 | − invocation_config: { type: "json", required: true }, | |
| 47 | 62 | }, |
| 48 | 63 | }, |
| 49 | 64 | openapi_operation: { |
| @@ -66,11 +81,54 @@ export const openapiSchema = defineSchema({ · 16 unmodified lines | ||
| 66 | 81 | // bindings when a shared source is removed. |
| 67 | 82 | target_scope_id: { type: "string", required: true, index: true }, |
| 68 | 83 | slot: { type: "string", required: true, index: true }, |
| 69 | − value: { type: "json", required: true }, | |
| 84 | + // Discriminated union, flattened. Exactly one of secret_id / | |
| 85 | + // connection_id / text_value is populated based on `kind`. | |
| 86 | + // `secret_id` and `connection_id` are indexed so usages queries | |
| 87 | + // are one-hop SELECTs. | |
| 88 | + kind: { type: ["secret", "connection", "text"], required: true }, | |
| 89 | + secret_id: { type: "string", required: false, index: true }, | |
| 90 | + connection_id: { type: "string", required: false, index: true }, | |
| 91 | + text_value: { type: "string", required: false }, | |
| 70 | 92 | created_at: { type: "date", required: true }, |
| 71 | 93 | updated_at: { type: "date", required: true }, |
| 72 | 94 | }, |
| 73 | 95 | }, |
| 96 | + openapi_source_query_param: { | |
| 97 | + fields: { | |
| 98 | + id: { type: "string", required: true }, | |
| 99 | + scope_id: { type: "string", required: true, index: true }, | |
| 100 | + source_id: { type: "string", required: true, index: true }, | |
| 101 | + name: { type: "string", required: true }, | |
| 102 | + kind: { type: ["text", "secret"], required: true }, | |
| 103 | + text_value: { type: "string", required: false }, | |
| 104 | + secret_id: { type: "string", required: false, index: true }, | |
| 105 | + secret_prefix: { type: "string", required: false }, | |
| 106 | + }, | |
| 107 | + }, | |
| 108 | + openapi_source_spec_fetch_header: { | |
| 109 | + fields: { | |
| 110 | + id: { type: "string", required: true }, | |
| 111 | + scope_id: { type: "string", required: true, index: true }, | |
| 112 | + source_id: { type: "string", required: true, index: true }, | |
| 113 | + name: { type: "string", required: true }, | |
| 114 | + kind: { type: ["text", "secret"], required: true }, | |
| 115 | + text_value: { type: "string", required: false }, | |
| 116 | + secret_id: { type: "string", required: false, index: true }, | |
| 117 | + secret_prefix: { type: "string", required: false }, | |
| 118 | + }, | |
| 119 | + }, | |
| 120 | + openapi_source_spec_fetch_query_param: { | |
| 121 | + fields: { | |
| 122 | + id: { type: "string", required: true }, | |
| 123 | + scope_id: { type: "string", required: true, index: true }, | |
| 124 | + source_id: { type: "string", required: true, index: true }, | |
| 125 | + name: { type: "string", required: true }, | |
| 126 | + kind: { type: ["text", "secret"], required: true }, | |
| 127 | + text_value: { type: "string", required: false }, | |
| 128 | + secret_id: { type: "string", required: false, index: true }, | |
| 129 | + secret_prefix: { type: "string", required: false }, | |
| 130 | + }, | |
| 131 | + }, | |
| 74 | 132 | }); |
| 75 | 133 | |
| 76 | 134 | export type OpenapiSchema = typeof openapiSchema; |
| @@ -166,16 +224,70 @@ const decodeBinding = Schema.decodeUnknownSync(OperationBinding); · 89 unmodified lines | ||
| 166 | 224 | |
| 167 | 225 | const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth); |
| 168 | 226 | const encodeOAuth2SourceConfig = Schema.encodeSync(OAuth2SourceConfig); |
| 169 | −const encodeSourceBindingValue = Schema.encodeSync(OpenApiSourceBindingValue); | |
| 170 | −const decodeSourceBindingValue = Schema.decodeUnknownSync( | |
| 171 | − OpenApiSourceBindingValue, | |
| 172 | −); | |
| 173 | 227 | |
| 174 | −const asJsonObject = (value: unknown): Record<string, unknown> => { | |
| 175 | − if (value == null) return {}; | |
| 176 | − if (typeof value === "string") | |
| 177 | − return JSON.parse(value) as Record<string, unknown>; | |
| 178 | − return value as Record<string, unknown>; | |
| 228 | +interface ChildRow { | |
| 229 | + readonly id: string; | |
| 230 | + readonly scope_id: string; | |
| 231 | + readonly source_id: string; | |
| 232 | + readonly name: string; | |
| 233 | + readonly kind: "text" | "secret"; | |
| 234 | + readonly text_value?: string; | |
| 235 | + readonly secret_id?: string; | |
| 236 | + readonly secret_prefix?: string; | |
| 237 | + // Index signature to satisfy adapter's `RowInput` shape (the typed | |
| 238 | + // adapter exposes its row shape with one). | |
| 239 | + readonly [k: string]: unknown; | |
| 240 | +} | |
| 241 | + | |
| 242 | +// Collapse a SecretBackedValue map into the flat child-table column | |
| 243 | +// shape used by openapi_source_query_param and the two | |
| 244 | +// openapi_source_spec_fetch_* tables. Returns one record per entry. | |
| 245 | +const valueMapToChildRows = ( | |
| 246 | + sourceId: string, | |
| 247 | + scope: string, | |
| 248 | + values: Record<string, HeaderValue> | undefined, | |
| 249 | +): readonly ChildRow[] => { | |
| 250 | + if (!values) return []; | |
| 251 | + return Object.entries(values).map(([name, value]) => { | |
| 252 | + const id = JSON.stringify([sourceId, name]); | |
| 253 | + if (typeof value === "string") { | |
| 254 | + return { | |
| 255 | + id, | |
| 256 | + scope_id: scope, | |
| 257 | + source_id: sourceId, | |
| 258 | + name, | |
| 259 | + kind: "text", | |
| 260 | + text_value: value, | |
| 261 | + }; | |
| 262 | + } | |
| 263 | + return { | |
| 264 | + id, | |
| 265 | + scope_id: scope, | |
| 266 | + source_id: sourceId, | |
| 267 | + name, | |
| 268 | + kind: "secret", | |
| 269 | + secret_id: value.secretId, | |
| 270 | + secret_prefix: value.prefix, | |
| 271 | + }; | |
| 272 | + }); | |
| 273 | +}; | |
| 274 | + | |
| 275 | +const childRowsToValueMap = ( | |
| 276 | + rows: readonly Record<string, unknown>[], | |
| 277 | +): Record<string, HeaderValue> => { | |
| 278 | + const out: Record<string, HeaderValue> = {}; | |
| 279 | + for (const row of rows) { | |
| 280 | + const name = row.name as string; | |
| 281 | + if (row.kind === "secret" && typeof row.secret_id === "string") { | |
| 282 | + const prefix = row.secret_prefix as string | undefined | null; | |
| 283 | + out[name] = prefix | |
| 284 | + ? { secretId: row.secret_id, prefix } | |
| 285 | + : { secretId: row.secret_id }; | |
| 286 | + } else if (row.kind === "text" && typeof row.text_value === "string") { | |
| 287 | + out[name] = row.text_value; | |
| 288 | + } | |
| 289 | + } | |
| 290 | + return out; | |
| 179 | 291 | }; |
| 180 | 292 | |
| 181 | 293 | const toJsonRecord = (value: unknown): Record<string, unknown> => |
| @@ -367,6 +479,44 @@ export interface OpenapiStore { · 185 unmodified lines | ||
| 367 | 479 | slot: string, |
| 368 | 480 | scope: string, |
| 369 | 481 | ) => Effect.Effect<void, StorageFailure>; |
| 482 | + | |
| 483 | + // --------------------------------------------------------------------- | |
| 484 | + // Usage lookups — back `usagesForSecret` / `usagesForConnection`. | |
| 485 | + // Each is one indexed SELECT against the new normalized columns. | |
| 486 | + // --------------------------------------------------------------------- | |
| 487 | + | |
| 488 | + /** Source-binding rows that point at the given secret id. */ | |
| 489 | + readonly findBindingsBySecret: ( | |
| 490 | + secretId: string, | |
| 491 | + ) => Effect.Effect<readonly OpenApiSourceBindingRef[], StorageFailure>; | |
| 492 | + | |
| 493 | + /** Source-binding rows that point at the given connection id. */ | |
| 494 | + readonly findBindingsByConnection: ( | |
| 495 | + connectionId: string, | |
| 496 | + ) => Effect.Effect<readonly OpenApiSourceBindingRef[], StorageFailure>; | |
| 497 | + | |
| 498 | + /** Child rows from query_params / specFetch tables that reference the | |
| 499 | + * given secret id, tagged with the table they came from so the | |
| 500 | + * caller can produce a readable `slot` like | |
| 501 | + * `query_param:foo` or `spec_fetch_header:Authorization`. */ | |
| 502 | + readonly findChildRowsBySecret: (secretId: string) => Effect.Effect< | |
| 503 | + readonly { | |
| 504 | + readonly kind: | |
| 505 | + | "query_param" | |
| 506 | + | "spec_fetch_header" | |
| 507 | + | "spec_fetch_query_param"; | |
| 508 | + readonly source_id: string; | |
| 509 | + readonly scope_id: string; | |
| 510 | + readonly name: string; | |
| 511 | + }[], | |
| 512 | + StorageFailure | |
| 513 | + >; | |
| 514 | + | |
| 515 | + /** Resolve display names for one or more `(scope_id, source_id)` pairs | |
| 516 | + * in a single round trip, keyed by `${scope_id}:${source_id}`. */ | |
| 517 | + readonly lookupSourceNames: ( | |
| 518 | + keys: readonly string[], | |
| 519 | + ) => Effect.Effect<ReadonlyMap<string, string>, StorageFailure>; | |
| 370 | 520 | } |
| 371 | 521 | |
| 372 | 522 | // --------------------------------------------------------------------------- |
| @@ -400,6 +550,25 @@ export const makeDefaultOpenapiStore = ({ · 27 unmodified lines | ||
| 400 | 550 | encodeSyntheticRowIdPart(scopeId), |
| 401 | 551 | ].join("::"); |
| 402 | 552 | |
| 553 | + const rowToSourceBindingValue = ( | |
| 554 | + row: Record<string, unknown>, | |
| 555 | + ): OpenApiSourceBindingValue => { | |
| 556 | + const kind = row.kind as string; | |
| 557 | + if (kind === "secret" && typeof row.secret_id === "string") { | |
| 558 | + return { kind: "secret", secretId: SecretId.make(row.secret_id) }; | |
| 559 | + } | |
| 560 | + if (kind === "connection" && typeof row.connection_id === "string") { | |
| 561 | + return { | |
| 562 | + kind: "connection", | |
| 563 | + connectionId: ConnectionId.make(row.connection_id), | |
| 564 | + }; | |
| 565 | + } | |
| 566 | + // text fallback covers both well-formed text rows and any | |
| 567 | + // partial/null row that survived a malformed write — `text_value` | |
| 568 | + // defaults to "" so the type stays satisfied without a throw. | |
| 569 | + return { kind: "text", text: (row.text_value as string | null) ?? "" }; | |
| 570 | + }; | |
| 571 | + | |
| 403 | 572 | const rowToSourceBinding = ( |
| 404 | 573 | row: Record<string, unknown>, |
| 405 | 574 | ): OpenApiSourceBindingRef => |
| @@ -408,7 +577,7 @@ export const makeDefaultOpenapiStore = ({ · 2 unmodified lines | ||
| 408 | 577 | sourceScopeId: ScopeId.make(row.source_scope_id as string), |
| 409 | 578 | scopeId: ScopeId.make(row.target_scope_id as string), |
| 410 | 579 | slot: row.slot as string, |
| 411 | − value: decodeSourceBindingValue(asJsonObject(row.value)), | |
| 580 | + value: rowToSourceBindingValue(row), | |
| 412 | 581 | createdAt: |
| 413 | 582 | row.created_at instanceof Date |
| 414 | 583 | ? row.created_at |
| @@ -419,6 +588,18 @@ export const makeDefaultOpenapiStore = ({ · 4 unmodified lines | ||
| 419 | 588 | : new Date(row.updated_at as string), |
| 420 | 589 | }); |
| 421 | 590 | |
| 591 | + const sourceBindingValueColumns = ( | |
| 592 | + value: OpenApiSourceBindingValue, | |
| 593 | + ): { kind: string; secret_id?: string; connection_id?: string; text_value?: string } => { | |
| 594 | + if (value.kind === "secret") { | |
| 595 | + return { kind: "secret", secret_id: value.secretId }; | |
| 596 | + } | |
| 597 | + if (value.kind === "connection") { | |
| 598 | + return { kind: "connection", connection_id: value.connectionId }; | |
| 599 | + } | |
| 600 | + return { kind: "text", text_value: value.text }; | |
| 601 | + }; | |
| 602 | + | |
| 422 | 603 | const validateBindingScopes = (params: { |
| 423 | 604 | readonly sourceScope: string; |
| 424 | 605 | readonly targetScope: string; |
| @@ -485,39 +666,88 @@ export const makeDefaultOpenapiStore = ({ · 60 unmodified lines | ||
| 485 | 666 | return source; |
| 486 | 667 | }); |
| 487 | 668 | |
| 488 | − const rowToSource = (row: Record<string, unknown>): StoredSource => { | |
| 489 | − const normalizedHeaders = normalizeStoredHeaders(row.headers); | |
| 490 | − const normalizedOAuth2 = normalizeStoredOAuth2(row.oauth2); | |
| 491 | − const invocationConfig = asJsonObject(row.invocation_config); | |
| 492 | − return { | |
| 493 | − namespace: row.id as string, | |
| 494 | − scope: row.scope_id as string, | |
| 495 | − name: row.name as string, | |
| 496 | − config: { | |
| 497 | − spec: row.spec as string, | |
| 498 | − sourceUrl: (row.source_url as string | null | undefined) ?? undefined, | |
| 499 | − baseUrl: (row.base_url as string | null | undefined) ?? undefined, | |
| 500 | − headers: normalizedHeaders.headers, | |
| 501 | − queryParams: decodeHeaders(row.query_params), | |
| 502 | − specFetchCredentials: invocationConfig.specFetchCredentials as | |
| 503 | − | OpenApiSpecFetchCredentials | |
| 504 | − | undefined, | |
| 505 | − oauth2: normalizedOAuth2.oauth2, | |
| 506 | − }, | |
| 507 | − legacy: | |
| 508 | − Object.keys(normalizedHeaders.legacy).length > 0 || | |
| 509 | − normalizedOAuth2.legacy | |
| 510 | − ? { | |
| 511 | − ...(Object.keys(normalizedHeaders.legacy).length > 0 | |
| 512 | − ? { headers: normalizedHeaders.legacy } | |
| 669 | + const loadChildValueMap = ( | |
| 670 | + model: | |
| 671 | + | "openapi_source_query_param" | |
| 672 | + | "openapi_source_spec_fetch_header" | |
| 673 | + | "openapi_source_spec_fetch_query_param", | |
| 674 | + sourceId: string, | |
| 675 | + scope: string, | |
| 676 | + ) => | |
| 677 | + adapter | |
| 678 | + .findMany({ | |
| 679 | + model, | |
| 680 | + where: [ | |
| 681 | + { field: "source_id", value: sourceId }, | |
| 682 | + { field: "scope_id", value: scope }, | |
| 683 | + ], | |
| 684 | + }) | |
| 685 | + .pipe(Effect.map(childRowsToValueMap)); | |
| 686 | + | |
| 687 | + const rowToSource = ( | |
| 688 | + row: Record<string, unknown>, | |
| 689 | + ): Effect.Effect<StoredSource, StorageFailure> => | |
| 690 | + Effect.gen(function* () { | |
| 691 | + const sourceId = row.id as string; | |
| 692 | + const scope = row.scope_id as string; | |
| 693 | + const normalizedHeaders = normalizeStoredHeaders(row.headers); | |
| 694 | + const normalizedOAuth2 = normalizeStoredOAuth2(row.oauth2); | |
| 695 | + | |
| 696 | + const queryParams = yield* loadChildValueMap( | |
| 697 | + "openapi_source_query_param", | |
| 698 | + sourceId, | |
| 699 | + scope, | |
| 700 | + ); | |
| 701 | + const specFetchHeaders = yield* loadChildValueMap( | |
| 702 | + "openapi_source_spec_fetch_header", | |
| 703 | + sourceId, | |
| 704 | + scope, | |
| 705 | + ); | |
| 706 | + const specFetchQueryParams = yield* loadChildValueMap( | |
| 707 | + "openapi_source_spec_fetch_query_param", | |
| 708 | + sourceId, | |
| 709 | + scope, | |
| 710 | + ); | |
| 711 | + const specFetchCredentials: OpenApiSpecFetchCredentials | undefined = | |
| 712 | + Object.keys(specFetchHeaders).length === 0 && | |
| 713 | + Object.keys(specFetchQueryParams).length === 0 | |
| 714 | + ? undefined | |
| 715 | + : { | |
| 716 | + ...(Object.keys(specFetchHeaders).length > 0 | |
| 717 | + ? { headers: specFetchHeaders } | |
| 513 | 718 | : {}), |
| 514 | − ...(normalizedOAuth2.legacy | |
| 515 | − ? { oauth2: normalizedOAuth2.legacy } | |
| 719 | + ...(Object.keys(specFetchQueryParams).length > 0 | |
| 720 | + ? { queryParams: specFetchQueryParams } | |
| 516 | 721 | : {}), |
| 517 | − } | |
| 518 | − : undefined, | |
| 519 | − }; | |
| 520 | − }; | |
| 722 | + }; | |
| 723 | + | |
| 724 | + return { | |
| 725 | + namespace: sourceId, | |
| 726 | + scope, | |
| 727 | + name: row.name as string, | |
| 728 | + config: { | |
| 729 | + spec: row.spec as string, | |
| 730 | + sourceUrl: (row.source_url as string | null | undefined) ?? undefined, | |
| 731 | + baseUrl: (row.base_url as string | null | undefined) ?? undefined, | |
| 732 | + headers: normalizedHeaders.headers, | |
| 733 | + queryParams, | |
| 734 | + specFetchCredentials, | |
| 735 | + oauth2: normalizedOAuth2.oauth2, | |
| 736 | + }, | |
| 737 | + legacy: | |
| 738 | + Object.keys(normalizedHeaders.legacy).length > 0 || | |
| 739 | + normalizedOAuth2.legacy | |
| 740 | + ? { | |
| 741 | + ...(Object.keys(normalizedHeaders.legacy).length > 0 | |
| 742 | + ? { headers: normalizedHeaders.legacy } | |
| 743 | + : {}), | |
| 744 | + ...(normalizedOAuth2.legacy | |
| 745 | + ? { oauth2: normalizedOAuth2.legacy } | |
| 746 | + : {}), | |
| 747 | + } | |
| 748 | + : undefined, | |
| 749 | + }; | |
| 750 | + }); | |
| 521 | 751 | |
| 522 | 752 | const rowToOperation = (row: Record<string, unknown>): StoredOperation => ({ |
| 523 | 753 | toolId: row.id as string, |
| @@ -527,6 +757,35 @@ export const makeDefaultOpenapiStore = ({ · 3 unmodified lines | ||
| 527 | 757 | ), |
| 528 | 758 | }); |
| 529 | 759 | |
| 760 | + // Replace the rows of one child table for a source: delete then bulk | |
| 761 | + // insert. Single helper so upsertSource and updateSourceMeta both | |
| 762 | + // funnel through the same write path. | |
| 763 | + const replaceChildRows = ( | |
| 764 | + model: | |
| 765 | + | "openapi_source_query_param" | |
| 766 | + | "openapi_source_spec_fetch_header" | |
| 767 | + | "openapi_source_spec_fetch_query_param", | |
| 768 | + sourceId: string, | |
| 769 | + scope: string, | |
| 770 | + values: Record<string, HeaderValue> | undefined, | |
| 771 | + ) => | |
| 772 | + Effect.gen(function* () { | |
| 773 | + yield* adapter.deleteMany({ | |
| 774 | + model, | |
| 775 | + where: [ | |
| 776 | + { field: "source_id", value: sourceId }, | |
| 777 | + { field: "scope_id", value: scope }, | |
| 778 | + ], | |
| 779 | + }); | |
| 780 | + const rows = valueMapToChildRows(sourceId, scope, values); | |
| 781 | + if (rows.length === 0) return; | |
| 782 | + yield* adapter.createMany({ | |
| 783 | + model, | |
| 784 | + data: rows, | |
| 785 | + forceAllowId: true, | |
| 786 | + }); | |
| 787 | + }); | |
| 788 | + | |
| 530 | 789 | const deleteSource = ( |
| 531 | 790 | namespace: string, |
| 532 | 791 | scope: string, |
| @@ -540,6 +799,20 @@ export const makeDefaultOpenapiStore = ({ · 7 unmodified lines | ||
| 540 | 799 | { field: "scope_id", value: scope }, |
| 541 | 800 | ], |
| 542 | 801 | }); |
| 802 | + // Drop every child table's rows for this source/scope. | |
| 803 | + for (const model of [ | |
| 804 | + "openapi_source_query_param", | |
| 805 | + "openapi_source_spec_fetch_header", | |
| 806 | + "openapi_source_spec_fetch_query_param", | |
| 807 | + ] as const) { | |
| 808 | + yield* adapter.deleteMany({ | |
| 809 | + model, | |
| 810 | + where: [ | |
| 811 | + { field: "source_id", value: namespace }, | |
| 812 | + { field: "scope_id", value: scope }, | |
| 813 | + ], | |
| 814 | + }); | |
| 815 | + } | |
| 543 | 816 | yield* adapter.delete({ |
| 544 | 817 | model: "openapi_source", |
| 545 | 818 | where: [ |
| @@ -587,18 +860,30 @@ export const makeDefaultOpenapiStore = ({ · 41 unmodified lines | ||
| 587 | 860 | ], |
| 588 | 861 | ), |
| 589 | 862 | ) as Record<string, unknown>, |
| 590 | − query_params: input.config.queryParams, | |
| 591 | 863 | oauth2: input.config.oauth2 |
| 592 | 864 | ? toJsonRecord(encodeOAuth2SourceConfig(input.config.oauth2)) |
| 593 | 865 | : undefined, |
| 594 | − invocation_config: { | |
| 595 | − ...(input.config.specFetchCredentials | |
| 596 | − ? { specFetchCredentials: input.config.specFetchCredentials } | |
| 597 | − : {}), | |
| 598 | − }, | |
| 599 | 866 | }, |
| 600 | 867 | forceAllowId: true, |
| 601 | 868 | }); |
| 869 | + yield* replaceChildRows( | |
| 870 | + "openapi_source_query_param", | |
| 871 | + input.namespace, | |
| 872 | + input.scope, | |
| 873 | + input.config.queryParams, | |
| 874 | + ); | |
| 875 | + yield* replaceChildRows( | |
| 876 | + "openapi_source_spec_fetch_header", | |
| 877 | + input.namespace, | |
| 878 | + input.scope, | |
| 879 | + input.config.specFetchCredentials?.headers, | |
| 880 | + ); | |
| 881 | + yield* replaceChildRows( | |
| 882 | + "openapi_source_spec_fetch_query_param", | |
| 883 | + input.namespace, | |
| 884 | + input.scope, | |
| 885 | + input.config.specFetchCredentials?.queryParams, | |
| 886 | + ); | |
| 602 | 887 | if (operations.length > 0) { |
| 603 | 888 | yield* adapter.createMany({ |
| 604 | 889 | model: "openapi_operation", |
| @@ -623,7 +908,7 @@ export const makeDefaultOpenapiStore = ({ · 18 unmodified lines | ||
| 623 | 908 | ], |
| 624 | 909 | }); |
| 625 | 910 | if (!existingRow) return; |
| 626 | − const existing = rowToSource(existingRow); | |
| 911 | + const existing = yield* rowToSource(existingRow); | |
| 627 | 912 | |
| 628 | 913 | const nextName = patch.name?.trim() || existing.name; |
| 629 | 914 | const nextBaseUrl = |
| @@ -632,10 +917,6 @@ export const makeDefaultOpenapiStore = ({ · 2 unmodified lines | ||
| 632 | 917 | patch.headers !== undefined |
| 633 | 918 | ? patch.headers |
| 634 | 919 | : (existing.config.headers ?? {}); |
| 635 | − const nextQueryParams = | |
| 636 | − patch.queryParams !== undefined | |
| 637 | − ? patch.queryParams | |
| 638 | − : (existing.config.queryParams ?? {}); | |
| 639 | 920 | const nextOAuth2 = |
| 640 | 921 | patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2; |
| 641 | 922 | |
| @@ -660,30 +941,41 @@ export const makeDefaultOpenapiStore = ({ · 18 unmodified lines | ||
| 660 | 941 | }, |
| 661 | 942 | ]), |
| 662 | 943 | ) as Record<string, unknown>, |
| 663 | − query_params: nextQueryParams, | |
| 664 | 944 | oauth2: nextOAuth2 |
| 665 | 945 | ? toJsonRecord(encodeOAuth2SourceConfig(nextOAuth2)) |
| 666 | 946 | : undefined, |
| 667 | − invocation_config: asJsonObject(existingRow.invocation_config), | |
| 668 | 947 | }, |
| 669 | 948 | }); |
| 949 | + if (patch.queryParams !== undefined) { | |
| 950 | + yield* replaceChildRows( | |
| 951 | + "openapi_source_query_param", | |
| 952 | + namespace, | |
| 953 | + scope, | |
| 954 | + patch.queryParams, | |
| 955 | + ); | |
| 956 | + } | |
| 670 | 957 | }), |
| 671 | 958 | |
| 672 | 959 | getSource: (namespace, scope) => |
| 673 | − adapter | |
| 674 | − .findOne({ | |
| 960 | + Effect.gen(function* () { | |
| 961 | + const row = yield* adapter.findOne({ | |
| 675 | 962 | model: "openapi_source", |
| 676 | 963 | where: [ |
| 677 | 964 | { field: "id", value: namespace }, |
| 678 | 965 | { field: "scope_id", value: scope }, |
| 679 | 966 | ], |
| 680 | − }) | |
| 681 | − .pipe(Effect.map((row) => (row ? rowToSource(row) : null))), | |
| 967 | + }); | |
| 968 | + if (!row) return null; | |
| 969 | + return yield* rowToSource(row); | |
| 970 | + }), | |
| 682 | 971 | |
| 683 | 972 | listSources: () => |
| 684 | − adapter | |
| 685 | − .findMany({ model: "openapi_source" }) | |
| 686 | − .pipe(Effect.map((rows) => rows.map(rowToSource))), | |
| 973 | + Effect.gen(function* () { | |
| 974 | + const rows = yield* adapter.findMany({ model: "openapi_source" }); | |
| 975 | + return yield* Effect.forEach(rows, rowToSource, { | |
| 976 | + concurrency: "unbounded", | |
| 977 | + }); | |
| 978 | + }), | |
| 687 | 979 | |
| 688 | 980 | getOperationByToolId: (toolId, scope) => |
| 689 | 981 | adapter |
| @@ -779,6 +1071,7 @@ export const makeDefaultOpenapiStore = ({ · 89 unmodified lines | ||
| 779 | 1071 | input.scope as string, |
| 780 | 1072 | ); |
| 781 | 1073 | const now = new Date(); |
| 1074 | + const valueColumns = sourceBindingValueColumns(input.value); | |
| 782 | 1075 | yield* adapter.delete({ |
| 783 | 1076 | model: "openapi_source_binding", |
| 784 | 1077 | where: [{ field: "id", value: id }], |
| @@ -791,7 +1084,7 @@ export const makeDefaultOpenapiStore = ({ · 6 unmodified lines | ||
| 791 | 1084 | source_scope_id: input.sourceScope as string, |
| 792 | 1085 | target_scope_id: input.scope as string, |
| 793 | 1086 | slot: input.slot, |
| 794 | − value: toJsonRecord(encodeSourceBindingValue(input.value)), | |
| 1087 | + ...valueColumns, | |
| 795 | 1088 | created_at: now, |
| 796 | 1089 | updated_at: now, |
| 797 | 1090 | }, |
| @@ -825,5 +1118,64 @@ export const makeDefaultOpenapiStore = ({ · 27 unmodified lines | ||
| 825 | 1118 | ], |
| 826 | 1119 | }); |
| 827 | 1120 | }), |
| 1121 | + | |
| 1122 | + findBindingsBySecret: (secretId) => | |
| 1123 | + adapter | |
| 1124 | + .findMany({ | |
| 1125 | + model: "openapi_source_binding", | |
| 1126 | + where: [{ field: "secret_id", value: secretId }], | |
| 1127 | + }) | |
| 1128 | + .pipe(Effect.map((rows) => rows.map(rowToSourceBinding))), | |
| 1129 | + | |
| 1130 | + findBindingsByConnection: (connectionId) => | |
| 1131 | + adapter | |
| 1132 | + .findMany({ | |
| 1133 | + model: "openapi_source_binding", | |
| 1134 | + where: [{ field: "connection_id", value: connectionId }], | |
| 1135 | + }) | |
| 1136 | + .pipe(Effect.map((rows) => rows.map(rowToSourceBinding))), | |
| 1137 | + | |
| 1138 | + findChildRowsBySecret: (secretId) => | |
| 1139 | + Effect.gen(function* () { | |
| 1140 | + const tables = [ | |
| 1141 | + { model: "openapi_source_query_param" as const, kind: "query_param" as const }, | |
| 1142 | + { model: "openapi_source_spec_fetch_header" as const, kind: "spec_fetch_header" as const }, | |
| 1143 | + { model: "openapi_source_spec_fetch_query_param" as const, kind: "spec_fetch_query_param" as const }, | |
| 1144 | + ]; | |
| 1145 | + const perTable = yield* Effect.forEach( | |
| 1146 | + tables, | |
| 1147 | + (t) => | |
| 1148 | + adapter | |
| 1149 | + .findMany({ | |
| 1150 | + model: t.model, | |
| 1151 | + where: [{ field: "secret_id", value: secretId }], | |
| 1152 | + }) | |
| 1153 | + .pipe( | |
| 1154 | + Effect.map((rows) => | |
| 1155 | + rows.map((r) => ({ | |
| 1156 | + kind: t.kind, | |
| 1157 | + source_id: r.source_id as string, | |
| 1158 | + scope_id: r.scope_id as string, | |
| 1159 | + name: r.name as string, | |
| 1160 | + })), | |
| 1161 | + ), | |
| 1162 | + ), | |
| 1163 | + { concurrency: "unbounded" }, | |
| 1164 | + ); | |
| 1165 | + return perTable.flat(); | |
| 1166 | + }), | |
| 1167 | + | |
| 1168 | + lookupSourceNames: (keys) => | |
| 1169 | + Effect.gen(function* () { | |
| 1170 | + if (keys.length === 0) return new Map<string, string>(); | |
| 1171 | + const rows = yield* adapter.findMany({ model: "openapi_source" }); | |
| 1172 | + const requested = new Set(keys); | |
| 1173 | + const out = new Map<string, string>(); | |
| 1174 | + for (const r of rows) { | |
| 1175 | + const key = `${r.scope_id as string}:${r.id as string}`; | |
| 1176 | + if (requested.has(key)) out.set(key, r.name as string); | |
| 1177 | + } | |
| 1178 | + return out; | |
| 1179 | + }), | |
| 828 | 1180 | }; |
| 829 | 1181 | }; |