diff --git a/apps/local/drizzle/0007_normalize_graphql.sql b/apps/local/drizzle/0007_normalize_graphql.sql new file mode 100644 index 000000000..03b08f169 --- /dev/null +++ b/apps/local/drizzle/0007_normalize_graphql.sql @@ -0,0 +1,106 @@ +-- Normalize graphql plugin: move secret/connection refs out of JSON +-- columns into proper relational shape so usagesForSecret / +-- usagesForConnection are one indexed SELECT instead of a JSON scan. +-- +-- Old shape: +-- graphql_source.headers json Record +-- graphql_source.query_params json Record +-- graphql_source.auth json {kind:"none"} | {kind:"oauth2", connectionId} +-- +-- New shape: +-- graphql_source.auth_kind enum("none","oauth2") NOT NULL +-- graphql_source.auth_connection_id text indexed nullable +-- graphql_source_header(scope_id, id, source_id, name, kind, text_value, secret_id, secret_prefix) +-- graphql_source_query_param(scope_id, id, source_id, name, kind, text_value, secret_id, secret_prefix) + +CREATE TABLE `graphql_source_header` ( + `id` text NOT NULL, + `scope_id` text NOT NULL, + `source_id` text NOT NULL, + `name` text NOT NULL, + `kind` text NOT NULL, + `text_value` text, + `secret_id` text, + `secret_prefix` text, + PRIMARY KEY(`scope_id`, `id`) +); +--> statement-breakpoint +CREATE INDEX `graphql_source_header_scope_id_idx` ON `graphql_source_header` (`scope_id`);--> statement-breakpoint +CREATE INDEX `graphql_source_header_source_id_idx` ON `graphql_source_header` (`source_id`);--> statement-breakpoint +CREATE INDEX `graphql_source_header_secret_id_idx` ON `graphql_source_header` (`secret_id`);--> statement-breakpoint + +CREATE TABLE `graphql_source_query_param` ( + `id` text NOT NULL, + `scope_id` text NOT NULL, + `source_id` text NOT NULL, + `name` text NOT NULL, + `kind` text NOT NULL, + `text_value` text, + `secret_id` text, + `secret_prefix` text, + PRIMARY KEY(`scope_id`, `id`) +); +--> statement-breakpoint +CREATE INDEX `graphql_source_query_param_scope_id_idx` ON `graphql_source_query_param` (`scope_id`);--> statement-breakpoint +CREATE INDEX `graphql_source_query_param_source_id_idx` ON `graphql_source_query_param` (`source_id`);--> statement-breakpoint +CREATE INDEX `graphql_source_query_param_secret_id_idx` ON `graphql_source_query_param` (`secret_id`);--> statement-breakpoint + +-- New auth columns. `auth_kind` defaults to "none" so existing rows that +-- predate this migration are valid even if the json was null. +ALTER TABLE `graphql_source` ADD `auth_kind` text DEFAULT 'none' NOT NULL;--> statement-breakpoint +ALTER TABLE `graphql_source` ADD `auth_connection_id` text;--> statement-breakpoint +CREATE INDEX `graphql_source_auth_connection_id_idx` ON `graphql_source` (`auth_connection_id`);--> statement-breakpoint + +-- Backfill auth from the JSON column. json_extract returns NULL for +-- missing paths, so a row with auth=NULL or kind="none" leaves +-- auth_connection_id NULL and auth_kind defaulted to "none". +UPDATE `graphql_source` +SET + `auth_kind` = COALESCE(json_extract(`auth`, '$.kind'), 'none'), + `auth_connection_id` = json_extract(`auth`, '$.connectionId') +WHERE `auth` IS NOT NULL;--> statement-breakpoint + +-- Backfill headers. For each (source, header_name) pair: if the value +-- is a json object with .secretId, write a kind=secret row; otherwise +-- write a kind=text row with the literal string. json_each iterates +-- the keys of the headers object. +INSERT OR IGNORE INTO `graphql_source_header` + (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`) +SELECT + s.`scope_id`, + s.`id` || ':' || h.`key`, + s.`id`, + h.`key`, + CASE + WHEN h.`type` = 'object' AND json_extract(h.`value`, '$.secretId') IS NOT NULL THEN 'secret' + ELSE 'text' + END, + CASE WHEN h.`type` = 'object' THEN NULL ELSE h.`value` END, + CASE WHEN h.`type` = 'object' THEN json_extract(h.`value`, '$.secretId') ELSE NULL END, + CASE WHEN h.`type` = 'object' THEN json_extract(h.`value`, '$.prefix') ELSE NULL END +FROM `graphql_source` s, json_each(s.`headers`) h +WHERE s.`headers` IS NOT NULL;--> statement-breakpoint + +-- Same for query_params. +INSERT OR IGNORE INTO `graphql_source_query_param` + (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`) +SELECT + s.`scope_id`, + s.`id` || ':' || q.`key`, + s.`id`, + q.`key`, + CASE + WHEN q.`type` = 'object' AND json_extract(q.`value`, '$.secretId') IS NOT NULL THEN 'secret' + ELSE 'text' + END, + CASE WHEN q.`type` = 'object' THEN NULL ELSE q.`value` END, + CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.secretId') ELSE NULL END, + CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.prefix') ELSE NULL END +FROM `graphql_source` s, json_each(s.`query_params`) q +WHERE s.`query_params` IS NOT NULL;--> statement-breakpoint + +-- Drop the old JSON columns. SQLite ≥ 3.35 supports ALTER TABLE DROP +-- COLUMN directly; bun's bundled SQLite is well past that. +ALTER TABLE `graphql_source` DROP COLUMN `headers`;--> statement-breakpoint +ALTER TABLE `graphql_source` DROP COLUMN `query_params`;--> statement-breakpoint +ALTER TABLE `graphql_source` DROP COLUMN `auth`; diff --git a/apps/local/drizzle/meta/0007_snapshot.json b/apps/local/drizzle/meta/0007_snapshot.json new file mode 100644 index 000000000..a9f2d28eb --- /dev/null +++ b/apps/local/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1593 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "11111111-2222-3333-4444-555555555555", + "prevId": "7a331df8-ce69-4d97-b6ce-6bf3aff98b56", + "tables": { + "blob": { + "name": "blob", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "blob_namespace_key_pk": { + "columns": [ + "namespace", + "key" + ], + "name": "blob_namespace_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connection": { + "name": "connection", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_secret_id": { + "name": "access_token_secret_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_secret_id": { + "name": "refresh_token_secret_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_state": { + "name": "provider_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_scope_id_idx": { + "name": "connection_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "connection_provider_idx": { + "name": "connection_provider_idx", + "columns": [ + "provider" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connection_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "connection_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "definition": { + "name": "definition", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "definition_scope_id_idx": { + "name": "definition_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "definition_source_id_idx": { + "name": "definition_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + }, + "definition_plugin_id_idx": { + "name": "definition_plugin_id_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "definition_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "definition_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_discovery_binding": { + "name": "google_discovery_binding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "google_discovery_binding_scope_id_idx": { + "name": "google_discovery_binding_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "google_discovery_binding_source_id_idx": { + "name": "google_discovery_binding_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "google_discovery_binding_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "google_discovery_binding_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_discovery_source": { + "name": "google_discovery_source", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "google_discovery_source_scope_id_idx": { + "name": "google_discovery_source_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "google_discovery_source_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "google_discovery_source_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "graphql_operation": { + "name": "graphql_operation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "graphql_operation_scope_id_idx": { + "name": "graphql_operation_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "graphql_operation_source_id_idx": { + "name": "graphql_operation_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_operation_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "graphql_operation_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "graphql_source": { + "name": "graphql_source", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "auth_connection_id": { + "name": "auth_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "graphql_source_scope_id_idx": { + "name": "graphql_source_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "graphql_source_auth_connection_id_idx": { + "name": "graphql_source_auth_connection_id_idx", + "columns": [ + "auth_connection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_source_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "graphql_source_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_binding": { + "name": "mcp_binding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_binding_scope_id_idx": { + "name": "mcp_binding_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "mcp_binding_source_id_idx": { + "name": "mcp_binding_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_binding_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "mcp_binding_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_source": { + "name": "mcp_source", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_source_scope_id_idx": { + "name": "mcp_source_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_source_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "mcp_source_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth2_session": { + "name": "oauth2_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_scope": { + "name": "token_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth2_session_scope_id_idx": { + "name": "oauth2_session_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "oauth2_session_plugin_id_idx": { + "name": "oauth2_session_plugin_id_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "oauth2_session_connection_id_idx": { + "name": "oauth2_session_connection_id_idx", + "columns": [ + "connection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "oauth2_session_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "oauth2_session_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "openapi_operation": { + "name": "openapi_operation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "openapi_operation_scope_id_idx": { + "name": "openapi_operation_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "openapi_operation_source_id_idx": { + "name": "openapi_operation_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_operation_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "openapi_operation_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "openapi_source": { + "name": "openapi_source", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "query_params": { + "name": "query_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth2": { + "name": "oauth2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invocation_config": { + "name": "invocation_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "openapi_source_scope_id_idx": { + "name": "openapi_source_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_source_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "openapi_source_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "openapi_source_binding": { + "name": "openapi_source_binding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_scope_id": { + "name": "source_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_scope_id": { + "name": "target_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot": { + "name": "slot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "openapi_source_binding_source_id_idx": { + "name": "openapi_source_binding_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + }, + "openapi_source_binding_source_scope_id_idx": { + "name": "openapi_source_binding_source_scope_id_idx", + "columns": [ + "source_scope_id" + ], + "isUnique": false + }, + "openapi_source_binding_target_scope_id_idx": { + "name": "openapi_source_binding_target_scope_id_idx", + "columns": [ + "target_scope_id" + ], + "isUnique": false + }, + "openapi_source_binding_slot_idx": { + "name": "openapi_source_binding_slot_idx", + "columns": [ + "slot" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "secret": { + "name": "secret", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owned_by_connection_id": { + "name": "owned_by_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "secret_scope_id_idx": { + "name": "secret_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "secret_provider_idx": { + "name": "secret_provider_idx", + "columns": [ + "provider" + ], + "isUnique": false + }, + "secret_owned_by_connection_id_idx": { + "name": "secret_owned_by_connection_id_idx", + "columns": [ + "owned_by_connection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "secret_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "secret_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "source": { + "name": "source", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "can_remove": { + "name": "can_remove", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "can_edit": { + "name": "can_edit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "source_scope_id_idx": { + "name": "source_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "source_plugin_id_idx": { + "name": "source_plugin_id_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "source_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "source_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool": { + "name": "tool", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_schema": { + "name": "input_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_schema": { + "name": "output_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_scope_id_idx": { + "name": "tool_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "tool_source_id_idx": { + "name": "tool_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + }, + "tool_plugin_id_idx": { + "name": "tool_plugin_id_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tool_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "tool_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_policy": { + "name": "tool_policy", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_policy_scope_id_position_idx": { + "name": "tool_policy_scope_id_position_idx", + "columns": [ + "scope_id", + "position" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tool_policy_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "tool_policy_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "graphql_source_header": { + "name": "graphql_source_header", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_id": { + "name": "secret_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_prefix": { + "name": "secret_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "graphql_source_header_scope_id_idx": { + "name": "graphql_source_header_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "graphql_source_header_source_id_idx": { + "name": "graphql_source_header_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + }, + "graphql_source_header_secret_id_idx": { + "name": "graphql_source_header_secret_id_idx", + "columns": [ + "secret_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_source_header_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "graphql_source_header_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "graphql_source_query_param": { + "name": "graphql_source_query_param", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_id": { + "name": "secret_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_prefix": { + "name": "secret_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "graphql_source_query_param_scope_id_idx": { + "name": "graphql_source_query_param_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "graphql_source_query_param_source_id_idx": { + "name": "graphql_source_query_param_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + }, + "graphql_source_query_param_secret_id_idx": { + "name": "graphql_source_query_param_secret_id_idx", + "columns": [ + "secret_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_source_query_param_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "graphql_source_query_param_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index e09c5dc8f..804d7dd62 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1777850000001, "tag": "0006_neat_terror", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1778100000000, + "tag": "0007_normalize_graphql", + "breakpoints": true } ] } diff --git a/apps/local/src/server/executor-schema.ts b/apps/local/src/server/executor-schema.ts index 7edf427c7..f24dc621e 100644 --- a/apps/local/src/server/executor-schema.ts +++ b/apps/local/src/server/executor-schema.ts @@ -209,12 +209,44 @@ export const graphql_source = sqliteTable("graphql_source", { scope_id: text('scope_id').notNull(), name: text('name').notNull(), endpoint: text('endpoint').notNull(), - headers: text('headers', { mode: "json" }), - query_params: text('query_params', { mode: "json" }), - auth: text('auth', { mode: "json" }) + auth_kind: text({ enum: ['none', 'oauth2'] }).default("none").notNull(), + auth_connection_id: text('auth_connection_id') }, (table) => [ primaryKey({ columns: [table.scope_id, table.id] }), index("graphql_source_scope_id_idx").on(table.scope_id), + index("graphql_source_auth_connection_id_idx").on(table.auth_connection_id), +]); + +export const graphql_source_header = sqliteTable("graphql_source_header", { + id: text('id').notNull(), + scope_id: text('scope_id').notNull(), + source_id: text('source_id').notNull(), + name: text('name').notNull(), + kind: text({ enum: ['text', 'secret'] }).notNull(), + text_value: text('text_value'), + secret_id: text('secret_id'), + secret_prefix: text('secret_prefix') +}, (table) => [ + primaryKey({ columns: [table.scope_id, table.id] }), + index("graphql_source_header_scope_id_idx").on(table.scope_id), + index("graphql_source_header_source_id_idx").on(table.source_id), + index("graphql_source_header_secret_id_idx").on(table.secret_id), +]); + +export const graphql_source_query_param = sqliteTable("graphql_source_query_param", { + id: text('id').notNull(), + scope_id: text('scope_id').notNull(), + source_id: text('source_id').notNull(), + name: text('name').notNull(), + kind: text({ enum: ['text', 'secret'] }).notNull(), + text_value: text('text_value'), + secret_id: text('secret_id'), + secret_prefix: text('secret_prefix') +}, (table) => [ + primaryKey({ columns: [table.scope_id, table.id] }), + index("graphql_source_query_param_scope_id_idx").on(table.scope_id), + index("graphql_source_query_param_source_id_idx").on(table.source_id), + index("graphql_source_query_param_secret_id_idx").on(table.secret_id), ]); export const graphql_operation = sqliteTable("graphql_operation", { @@ -228,11 +260,3 @@ export const graphql_operation = sqliteTable("graphql_operation", { index("graphql_operation_source_id_idx").on(table.source_id), ]); -export const blob = sqliteTable("blob", { - namespace: text('namespace').notNull(), - key: text('key').notNull(), - value: text('value').notNull() -}, (table) => [ - primaryKey({ columns: [table.namespace, table.key] }), -]); - diff --git a/apps/local/src/server/migrate-graphql-bindings.test.ts b/apps/local/src/server/migrate-graphql-bindings.test.ts new file mode 100644 index 000000000..a72ec22d9 --- /dev/null +++ b/apps/local/src/server/migrate-graphql-bindings.test.ts @@ -0,0 +1,225 @@ +// End-to-end test for `0007_normalize_graphql.sql`: seed a DB at the +// pre-migration (0006) shape with json-blob headers/query_params/auth, +// run the migration, assert that the JSON unpacks into the new +// normalized columns / child tables and that the JSON columns are gone. + +import { describe, expect, it } from "@effect/vitest"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; + +const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); + +// Minimal pre-migration shape — only the graphql tables we care about, +// plus the drizzle bookkeeping `__drizzle_migrations` table that the +// runner uses to skip already-applied migrations. Stamping all +// migrations 0000..0006 as applied lets us run only 0007 against this +// hand-crafted DB. +const PRE_0007_SQL = ` + CREATE TABLE __drizzle_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hash TEXT NOT NULL, + created_at NUMERIC + ); + + CREATE TABLE graphql_source ( + id TEXT NOT NULL, + scope_id TEXT NOT NULL, + name TEXT NOT NULL, + endpoint TEXT NOT NULL, + headers TEXT, + query_params TEXT, + auth TEXT, + PRIMARY KEY (scope_id, id) + ); + + CREATE TABLE graphql_operation ( + id TEXT NOT NULL, + scope_id TEXT NOT NULL, + source_id TEXT NOT NULL, + binding TEXT NOT NULL, + PRIMARY KEY (scope_id, id) + ); +`; + +const stampPriorMigrationsApplied = (db: Database) => { + // drizzle's migration runner reads this table and skips any hashes + // already present. Insert one fixed-shape row per prior migration so + // only 0007 actually runs. + const stmt = db.prepare( + "INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)", + ); + for (let i = 0; i <= 6; i += 1) { + stmt.run(`stub-${i.toString().padStart(4, "0")}`, Date.now()); + } +}; + +describe("0007_normalize_graphql backfill", () => { + it("flattens auth json into auth_kind/auth_connection_id columns", () => { + const dir = mkdtempSync(join(tmpdir(), "graphql-mig-")); + const dbPath = join(dir, "test.sqlite"); + try { + const db = new Database(dbPath); + db.exec(PRE_0007_SQL); + stampPriorMigrationsApplied(db); + + db.prepare( + "INSERT INTO graphql_source (scope_id, id, name, endpoint, auth) VALUES (?, ?, ?, ?, ?)", + ).run( + "default-scope", + "github", + "GitHub", + "https://api.github.com/graphql", + JSON.stringify({ kind: "oauth2", connectionId: "conn-1" }), + ); + + db.close(); + + const drizzleDb = drizzle(new Database(dbPath)); + migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + + const after = new Database(dbPath, { readonly: true }); + const row = after + .prepare( + "SELECT auth_kind, auth_connection_id FROM graphql_source WHERE id = ?", + ) + .get("github") as { auth_kind: string; auth_connection_id: string }; + expect(row.auth_kind).toBe("oauth2"); + expect(row.auth_connection_id).toBe("conn-1"); + // Old json column is gone. + const cols = after + .prepare("PRAGMA table_info('graphql_source')") + .all() as ReadonlyArray<{ name: string }>; + expect(cols.some((c) => c.name === "auth")).toBe(false); + expect(cols.some((c) => c.name === "headers")).toBe(false); + expect(cols.some((c) => c.name === "query_params")).toBe(false); + after.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("explodes header/query_param json into child rows", () => { + const dir = mkdtempSync(join(tmpdir(), "graphql-mig-")); + const dbPath = join(dir, "test.sqlite"); + try { + const db = new Database(dbPath); + db.exec(PRE_0007_SQL); + stampPriorMigrationsApplied(db); + + const headers = { + // Literal text header. + "X-Static": "literal-value", + // Secret-backed header without prefix. + Authorization: { secretId: "sec-token" }, + // Secret-backed with prefix. + "X-Bearer": { secretId: "sec-bearer", prefix: "Bearer " }, + }; + const queryParams = { + api_key: { secretId: "sec-key" }, + }; + + db.prepare( + "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, query_params, auth) VALUES (?, ?, ?, ?, ?, ?, ?)", + ).run( + "default-scope", + "example", + "Example", + "https://example.com/graphql", + JSON.stringify(headers), + JSON.stringify(queryParams), + JSON.stringify({ kind: "none" }), + ); + + db.close(); + + const drizzleDb = drizzle(new Database(dbPath)); + migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + + const after = new Database(dbPath, { readonly: true }); + const headerRows = after + .prepare( + "SELECT name, kind, text_value, secret_id, secret_prefix FROM graphql_source_header WHERE source_id = ? ORDER BY name", + ) + .all("example") as ReadonlyArray<{ + name: string; + kind: string; + text_value: string | null; + secret_id: string | null; + secret_prefix: string | null; + }>; + expect(headerRows).toHaveLength(3); + + const byName = new Map(headerRows.map((r) => [r.name, r])); + expect(byName.get("X-Static")).toMatchObject({ + kind: "text", + text_value: "literal-value", + secret_id: null, + }); + expect(byName.get("Authorization")).toMatchObject({ + kind: "secret", + text_value: null, + secret_id: "sec-token", + secret_prefix: null, + }); + expect(byName.get("X-Bearer")).toMatchObject({ + kind: "secret", + secret_id: "sec-bearer", + secret_prefix: "Bearer ", + }); + + const paramRow = after + .prepare( + "SELECT kind, secret_id FROM graphql_source_query_param WHERE source_id = ?", + ) + .get("example") as { kind: string; secret_id: string }; + expect(paramRow).toMatchObject({ kind: "secret", secret_id: "sec-key" }); + + after.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("handles graphql_source rows with null json (empty config)", () => { + const dir = mkdtempSync(join(tmpdir(), "graphql-mig-")); + const dbPath = join(dir, "test.sqlite"); + try { + const db = new Database(dbPath); + db.exec(PRE_0007_SQL); + stampPriorMigrationsApplied(db); + + db.prepare( + "INSERT INTO graphql_source (scope_id, id, name, endpoint) VALUES (?, ?, ?, ?)", + ).run("default-scope", "bare", "Bare", "https://bare.example/graphql"); + db.close(); + + const drizzleDb = drizzle(new Database(dbPath)); + migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + + const after = new Database(dbPath, { readonly: true }); + const row = after + .prepare( + "SELECT auth_kind, auth_connection_id FROM graphql_source WHERE id = ?", + ) + .get("bare") as { auth_kind: string; auth_connection_id: string | null }; + expect(row.auth_kind).toBe("none"); + expect(row.auth_connection_id).toBeNull(); + + const headerCount = ( + after + .prepare( + "SELECT count(*) as n FROM graphql_source_header WHERE source_id = ?", + ) + .get("bare") as { n: number } + ).n; + expect(headerCount).toBe(0); + after.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 7e95e16ee..a42f1e2dc 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -507,4 +507,127 @@ describe("graphqlPlugin", () => { expect(orgView?.endpoint).toBe("http://org.example.com/graphql"); }), ); + + // ------------------------------------------------------------------------- + // Usage tracking — `usagesForSecret` and `usagesForConnection` should + // surface every reference to a secret/connection across the plugin's + // normalized child tables, and `secrets.remove` / `connections.remove` + // should refuse while a reference exists. + // ------------------------------------------------------------------------- + + it.effect("usagesForSecret returns one Usage per header/query_param ref", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const, + }), + ); + + yield* executor.secrets.set({ + id: SecretId.make("api-key"), + scope: ScopeId.make(TEST_SCOPE), + name: "API Key", + value: "abc123", + provider: "memory", + }); + + yield* executor.graphql.addSource({ + endpoint: "http://localhost:4000/graphql", + scope: TEST_SCOPE, + introspectionJson, + namespace: "with_secret", + headers: { Authorization: { secretId: "api-key", prefix: "Bearer " } }, + queryParams: { token: { secretId: "api-key" } }, + }); + + const usages = yield* executor.secrets.usages(SecretId.make("api-key")); + // Two refs: one header, one query param. + expect(usages.length).toBe(2); + const slots = usages.map((u) => u.slot).sort(); + expect(slots).toEqual(["header:Authorization", "query_param:token"]); + expect(usages.every((u) => u.pluginId === "graphql")).toBe(true); + expect(usages.every((u) => u.ownerId === "with_secret")).toBe(true); + }), + ); + + it.effect("secrets.remove refuses while a graphql source still uses it", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const, + }), + ); + + yield* executor.secrets.set({ + id: SecretId.make("locked"), + scope: ScopeId.make(TEST_SCOPE), + name: "Locked", + value: "v", + provider: "memory", + }); + + yield* executor.graphql.addSource({ + endpoint: "http://localhost:4000/graphql", + scope: TEST_SCOPE, + introspectionJson, + namespace: "ref", + headers: { "X-Token": { secretId: "locked" } }, + }); + + const result = yield* executor.secrets.remove(SecretId.make("locked")).pipe( + Effect.flip, + ); + expect((result as { _tag: string })._tag).toBe("SecretInUseError"); + + // After detaching the source, remove succeeds. + yield* executor.graphql.removeSource("ref", TEST_SCOPE); + yield* executor.secrets.remove(SecretId.make("locked")); + }), + ); + + it.effect("usagesForConnection returns one Usage per source", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const, + }), + ); + + const connectionId = ConnectionId.make("graphql-conn"); + yield* executor.connections.create( + new CreateConnectionInput({ + id: connectionId, + scope: ScopeId.make(TEST_SCOPE), + provider: "oauth2", + identityLabel: "Conn", + accessToken: new TokenMaterial({ + secretId: SecretId.make(`${connectionId}.access_token`), + name: "Access Token", + value: "tok", + }), + refreshToken: null, + expiresAt: null, + oauthScope: null, + providerState: null, + }), + ); + + yield* executor.graphql.addSource({ + endpoint: "http://localhost:4000/graphql", + scope: TEST_SCOPE, + introspectionJson, + namespace: "oauth_ref", + auth: { kind: "oauth2", connectionId }, + }); + + const usages = yield* executor.connections.usages(connectionId); + expect(usages.length).toBe(1); + expect(usages[0]).toMatchObject({ + pluginId: "graphql", + ownerKind: "graphql-source-auth", + ownerId: "oauth_ref", + slot: "auth.oauth2", + }); + }), + ); }); diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 450d66a68..a785b731e 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -7,7 +7,9 @@ import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers"; import { definePlugin, + ScopeId, SourceDetectionResult, + Usage, type StorageFailure, type ToolAnnotations, type ToolRow, @@ -632,6 +634,82 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { removeSource: ({ ctx, sourceId, scope }) => ctx.storage.removeSource(sourceId, scope), + // Look up every place this secret appears across the plugin's two + // child tables (`graphql_source_header`, `graphql_source_query_param`). + // The store runs behind the scoped adapter so reads automatically + // walk the executor's scope stack — no scope arg needed. + usagesForSecret: ({ ctx, args }) => + Effect.gen(function* () { + // Adapter access via the underlying typed view on the store deps. + // We thread it through `ctx.storage` rather than re-grabbing it + // because the store already owns the typed adapter handle; expose + // a single helper rather than re-implementing the where/joins. + const headerRows = yield* ctx.storage.findHeaderRowsBySecret( + args.secretId, + ); + const paramRows = yield* ctx.storage.findQueryParamRowsBySecret( + args.secretId, + ); + + // Resolve owner names by joining to graphql_source. We batch the + // distinct (source_id, scope_id) pairs to one findMany rather + // than N+1 lookups. + const sourceKeys = new Set(); + for (const r of [...headerRows, ...paramRows]) { + sourceKeys.add(`${r.scope_id}:${r.source_id}`); + } + const sources = yield* ctx.storage.lookupSourceNames([...sourceKeys]); + + const out: Usage[] = []; + for (const r of headerRows) { + out.push( + new Usage({ + pluginId: "graphql", + scopeId: ScopeId.make(r.scope_id), + ownerKind: "graphql-source-header", + ownerId: r.source_id, + ownerName: + sources.get(`${r.scope_id}:${r.source_id}`) ?? null, + slot: `header:${r.name}`, + }), + ); + } + for (const r of paramRows) { + out.push( + new Usage({ + pluginId: "graphql", + scopeId: ScopeId.make(r.scope_id), + ownerKind: "graphql-source-query-param", + ownerId: r.source_id, + ownerName: + sources.get(`${r.scope_id}:${r.source_id}`) ?? null, + slot: `query_param:${r.name}`, + }), + ); + } + return out; + }), + + usagesForConnection: ({ ctx, args }) => + Effect.gen(function* () { + // OAuth refs only appear in graphql_source.auth_connection_id — + // one indexed lookup. No child tables to scan. + const sources = yield* ctx.storage.findSourcesByConnection( + args.connectionId, + ); + return sources.map( + (s) => + new Usage({ + pluginId: "graphql", + scopeId: ScopeId.make(s.scope), + ownerKind: "graphql-source-auth", + ownerId: s.namespace, + ownerName: s.name, + slot: "auth.oauth2", + }), + ); + }), + detect: ({ url }) => Effect.gen(function* () { const trimmed = url.trim(); diff --git a/packages/plugins/graphql/src/sdk/store.ts b/packages/plugins/graphql/src/sdk/store.ts index f5eab1997..c542e547a 100644 --- a/packages/plugins/graphql/src/sdk/store.ts +++ b/packages/plugins/graphql/src/sdk/store.ts @@ -14,9 +14,19 @@ import { } from "./types"; // --------------------------------------------------------------------------- -// Schema — two tables: -// - graphql_source: endpoint + headers + display name per source -// - graphql_operation: per-tool OperationBinding blob keyed by tool id +// Schema — four tables: +// - graphql_source: endpoint + auth + display name per source. Auth is +// flattened (kind enum + nullable connection_id) so the +// `usagesForConnection` query is one indexed SELECT. +// - graphql_source_header / graphql_source_query_param: one row per +// header/param entry. `kind` discriminates literal text from a +// secret reference; `secret_id` is indexed so `usagesForSecret` +// reads the index directly. PK is `(scope_id, id)` where id is the +// synthetic `${source_id}:${name}`. +// - graphql_operation: per-tool OperationBinding blob. Operation +// bindings don't reference secrets/connections, so they stay as +// JSON — that's a legit JSON case (the binding shape is plugin- +// internal opaque data). // --------------------------------------------------------------------------- export const graphqlSchema = defineSchema({ @@ -26,9 +36,46 @@ export const graphqlSchema = defineSchema({ scope_id: { type: "string", required: true, index: true }, name: { type: "string", required: true }, endpoint: { type: "string", required: true }, - headers: { type: "json", required: false }, - query_params: { type: "json", required: false }, - auth: { type: "json", required: false }, + auth_kind: { + type: ["none", "oauth2"], + required: true, + defaultValue: "none", + }, + auth_connection_id: { + type: "string", + required: false, + index: true, + }, + }, + }, + graphql_source_header: { + fields: { + id: { type: "string", required: true }, + scope_id: { type: "string", required: true, index: true }, + source_id: { type: "string", required: true, index: true }, + name: { type: "string", required: true }, + kind: { + type: ["text", "secret"], + required: true, + }, + text_value: { type: "string", required: false }, + secret_id: { type: "string", required: false, index: true }, + secret_prefix: { type: "string", required: false }, + }, + }, + graphql_source_query_param: { + fields: { + id: { type: "string", required: true }, + scope_id: { type: "string", required: true, index: true }, + source_id: { type: "string", required: true, index: true }, + name: { type: "string", required: true }, + kind: { + type: ["text", "secret"], + required: true, + }, + text_value: { type: "string", required: false }, + secret_id: { type: "string", required: false, index: true }, + secret_prefix: { type: "string", required: false }, }, }, graphql_operation: { @@ -98,29 +145,69 @@ const encodeBinding = (binding: OperationBinding): BindingJson => ({ const toJsonRecord = (value: unknown): Record => value as Record; -const decodeHeaders = (value: unknown): Record => { - if (value == null) return {}; - if (typeof value === "string") - return JSON.parse(value) as Record; - return value as Record; +// Header / query-param rows: collapse the flat columns back into a +// `SecretBackedValue` map keyed by header name. `kind` discriminates the +// shape; `secret_prefix` is optional and only populated when present in +// the original config. +const rowsToValueMap = ( + rows: readonly Record[], +): Record => { + const out: Record = {}; + for (const row of rows) { + const name = row.name as string; + if (row.kind === "secret" && typeof row.secret_id === "string") { + const prefix = row.secret_prefix as string | undefined | null; + out[name] = prefix + ? { secretId: row.secret_id, prefix } + : { secretId: row.secret_id }; + } else if (row.kind === "text" && typeof row.text_value === "string") { + out[name] = row.text_value; + } + } + return out; }; -const decodeQueryParams = (value: unknown): Record => { - if (value == null) return {}; - if (typeof value === "string") - return JSON.parse(value) as Record; - return value as Record; +// Encode one entry of a SecretBackedValue map into a child row. Used by +// the writer for both `graphql_source_header` and +// `graphql_source_query_param`. Returns a `Record` so +// the result is structurally assignable to the typed adapter's +// `RowInput` shape (which has its own index signature). +const valueToChildRow = ( + sourceId: string, + scope: string, + name: string, + value: HeaderValue, +): Record => { + const id = `${sourceId}:${name}`; + if (typeof value === "string") { + return { + id, + scope_id: scope, + source_id: sourceId, + name, + kind: "text", + text_value: value, + }; + } + return { + id, + scope_id: scope, + source_id: sourceId, + name, + kind: "secret", + secret_id: value.secretId, + secret_prefix: value.prefix, + }; }; -const decodeAuth = (value: unknown): GraphqlSourceAuth => { - if (value == null) return { kind: "none" }; - const parsed = - typeof value === "string" - ? (JSON.parse(value) as GraphqlSourceAuth) - : (value as GraphqlSourceAuth); - return parsed?.kind === "oauth2" && typeof parsed.connectionId === "string" - ? { kind: "oauth2", connectionId: parsed.connectionId } - : { kind: "none" }; +const rowToAuth = (row: Record): GraphqlSourceAuth => { + if ( + row.auth_kind === "oauth2" && + typeof row.auth_connection_id === "string" + ) { + return { kind: "oauth2", connectionId: row.auth_connection_id }; + } + return { kind: "none" }; }; // --------------------------------------------------------------------------- @@ -138,6 +225,15 @@ const decodeAuth = (value: unknown): GraphqlSourceAuth => { // `path.scopeId` for HTTP, `toolRow.scope_id` / `input.scope` for // invokeTool/lifecycle) so every keyed mutation targets exactly one // row. +/** Flat row shape returned by the usage-lookup helpers. Mirrors the new + * child-table columns so callers can build a `Usage` without + * re-decoding. */ +export interface ChildUsageRow { + readonly source_id: string; + readonly scope_id: string; + readonly name: string; +} + export interface GraphqlStore { readonly upsertSource: ( input: StoredGraphqlSource, @@ -180,6 +276,34 @@ export interface GraphqlStore { namespace: string, scope: string, ) => Effect.Effect; + + // --------------------------------------------------------------------- + // Usage lookups — power `usagesForSecret` / `usagesForConnection`. + // Each is one indexed SELECT against the new normalized columns. + // --------------------------------------------------------------------- + + /** Header rows that reference the given secret id, across every scope + * visible to the executor. */ + readonly findHeaderRowsBySecret: ( + secretId: string, + ) => Effect.Effect; + + /** Query-param rows that reference the given secret id. */ + readonly findQueryParamRowsBySecret: ( + secretId: string, + ) => Effect.Effect; + + /** Source rows whose oauth2 auth points at the given connection id. */ + readonly findSourcesByConnection: ( + connectionId: string, + ) => Effect.Effect; + + /** Resolve the display name for one or more `(scope_id, source_id)` + * pairs in a single round trip. Returned map is keyed by + * `${scope_id}:${source_id}`; missing entries are simply absent. */ + readonly lookupSourceNames: ( + keys: readonly string[], + ) => Effect.Effect, StorageFailure>; } // --------------------------------------------------------------------------- @@ -189,15 +313,46 @@ export interface GraphqlStore { export const makeDefaultGraphqlStore = ({ adapter: db, }: StorageDeps): GraphqlStore => { - const rowToSource = (row: Record): StoredGraphqlSource => ({ - namespace: row.id as string, - scope: row.scope_id as string, - name: row.name as string, - endpoint: row.endpoint as string, - headers: decodeHeaders(row.headers), - queryParams: decodeQueryParams(row.query_params), - auth: decodeAuth(row.auth), - }); + const loadHeaders = (sourceId: string, scope: string) => + db + .findMany({ + model: "graphql_source_header", + where: [ + { field: "source_id", value: sourceId }, + { field: "scope_id", value: scope }, + ], + }) + .pipe(Effect.map(rowsToValueMap)); + + const loadQueryParams = (sourceId: string, scope: string) => + db + .findMany({ + model: "graphql_source_query_param", + where: [ + { field: "source_id", value: sourceId }, + { field: "scope_id", value: scope }, + ], + }) + .pipe(Effect.map(rowsToValueMap)); + + const rowToSourceWithChildren = ( + row: Record, + ): Effect.Effect => + Effect.gen(function* () { + const sourceId = row.id as string; + const scope = row.scope_id as string; + const headers = yield* loadHeaders(sourceId, scope); + const queryParams = yield* loadQueryParams(sourceId, scope); + return { + namespace: sourceId, + scope, + name: row.name as string, + endpoint: row.endpoint as string, + headers, + queryParams, + auth: rowToAuth(row), + }; + }); const rowToOperation = (row: Record): StoredOperation => ({ toolId: row.id as string, @@ -205,6 +360,34 @@ export const makeDefaultGraphqlStore = ({ binding: decodeBinding(row.binding), }); + // Replace child rows for a source by deleting then bulk-inserting. Used + // by both upsertSource (full rewrite) and updateSourceMeta (partial + // patch when headers/queryParams is supplied). + const replaceChildren = ( + model: "graphql_source_header" | "graphql_source_query_param", + sourceId: string, + scope: string, + values: Record, + ) => + Effect.gen(function* () { + yield* db.deleteMany({ + model, + where: [ + { field: "source_id", value: sourceId }, + { field: "scope_id", value: scope }, + ], + }); + const entries = Object.entries(values); + if (entries.length === 0) return; + yield* db.createMany({ + model, + data: entries.map(([name, value]) => + valueToChildRow(sourceId, scope, name, value), + ), + forceAllowId: true, + }); + }); + const deleteSource = (namespace: string, scope: string) => Effect.gen(function* () { yield* db.deleteMany({ @@ -214,6 +397,20 @@ export const makeDefaultGraphqlStore = ({ { field: "scope_id", value: scope }, ], }); + yield* db.deleteMany({ + model: "graphql_source_header", + where: [ + { field: "source_id", value: namespace }, + { field: "scope_id", value: scope }, + ], + }); + yield* db.deleteMany({ + model: "graphql_source_query_param", + where: [ + { field: "source_id", value: namespace }, + { field: "scope_id", value: scope }, + ], + }); yield* db.delete({ model: "graphql_source", where: [ @@ -234,12 +431,24 @@ export const makeDefaultGraphqlStore = ({ scope_id: input.scope, name: input.name, endpoint: input.endpoint, - headers: input.headers, - query_params: input.queryParams, - auth: toJsonRecord(input.auth), + auth_kind: input.auth.kind, + auth_connection_id: + input.auth.kind === "oauth2" ? input.auth.connectionId : undefined, }, forceAllowId: true, }); + yield* replaceChildren( + "graphql_source_header", + input.namespace, + input.scope, + input.headers, + ); + yield* replaceChildren( + "graphql_source_query_param", + input.namespace, + input.scope, + input.queryParams, + ); if (operations.length > 0) { yield* db.createMany({ model: "graphql_operation", @@ -267,41 +476,59 @@ export const makeDefaultGraphqlStore = ({ const update: Record = {}; if (patch.name !== undefined) update.name = patch.name; if (patch.endpoint !== undefined) update.endpoint = patch.endpoint; + if (patch.auth !== undefined) { + update.auth_kind = patch.auth.kind; + update.auth_connection_id = + patch.auth.kind === "oauth2" ? patch.auth.connectionId : null; + } + if (Object.keys(update).length > 0) { + yield* db.update({ + model: "graphql_source", + where: [ + { field: "id", value: namespace }, + { field: "scope_id", value: scope }, + ], + update, + }); + } if (patch.headers !== undefined) { - update.headers = patch.headers; + yield* replaceChildren( + "graphql_source_header", + namespace, + scope, + patch.headers, + ); } if (patch.queryParams !== undefined) { - update.query_params = patch.queryParams; + yield* replaceChildren( + "graphql_source_query_param", + namespace, + scope, + patch.queryParams, + ); } - if (patch.auth !== undefined) { - update.auth = toJsonRecord(patch.auth); - } - if (Object.keys(update).length === 0) return; - yield* db.update({ - model: "graphql_source", - where: [ - { field: "id", value: namespace }, - { field: "scope_id", value: scope }, - ], - update, - }); }), getSource: (namespace, scope) => - db - .findOne({ + Effect.gen(function* () { + const row = yield* db.findOne({ model: "graphql_source", where: [ { field: "id", value: namespace }, { field: "scope_id", value: scope }, ], - }) - .pipe(Effect.map((row) => (row ? rowToSource(row) : null))), + }); + if (!row) return null; + return yield* rowToSourceWithChildren(row); + }), listSources: () => - db - .findMany({ model: "graphql_source" }) - .pipe(Effect.map((rows) => rows.map(rowToSource))), + Effect.gen(function* () { + const rows = yield* db.findMany({ model: "graphql_source" }); + return yield* Effect.forEach(rows, rowToSourceWithChildren, { + concurrency: "unbounded", + }); + }), getOperationByToolId: (toolId, scope) => db @@ -326,5 +553,83 @@ export const makeDefaultGraphqlStore = ({ .pipe(Effect.map((rows) => rows.map(rowToOperation))), removeSource: (namespace, scope) => deleteSource(namespace, scope), + + findHeaderRowsBySecret: (secretId) => + db + .findMany({ + model: "graphql_source_header", + where: [{ field: "secret_id", value: secretId }], + }) + .pipe( + Effect.map((rows) => + rows.map( + (r): ChildUsageRow => ({ + source_id: r.source_id as string, + scope_id: r.scope_id as string, + name: r.name as string, + }), + ), + ), + ), + + findQueryParamRowsBySecret: (secretId) => + db + .findMany({ + model: "graphql_source_query_param", + where: [{ field: "secret_id", value: secretId }], + }) + .pipe( + Effect.map((rows) => + rows.map( + (r): ChildUsageRow => ({ + source_id: r.source_id as string, + scope_id: r.scope_id as string, + name: r.name as string, + }), + ), + ), + ), + + findSourcesByConnection: (connectionId) => + Effect.gen(function* () { + const rows = yield* db.findMany({ + model: "graphql_source", + where: [ + { field: "auth_connection_id", value: connectionId }, + ], + }); + // Skip the children load — usage callers only need the parent + // row's name + scope. Synthesize a minimal StoredGraphqlSource + // shape with empty headers/params so the type matches without + // a wasted child fetch. + return rows.map( + (row): StoredGraphqlSource => ({ + namespace: row.id as string, + scope: row.scope_id as string, + name: row.name as string, + endpoint: row.endpoint as string, + headers: {}, + queryParams: {}, + auth: rowToAuth(row), + }), + ); + }), + + lookupSourceNames: (keys) => + Effect.gen(function* () { + if (keys.length === 0) return new Map(); + // Pull every source the executor's scope walk surfaces, then + // index by composite key. Cheaper than per-key findOne and the + // graphql source table is small in practice (one row per + // endpoint). + const rows = yield* db.findMany({ model: "graphql_source" }); + const requested = new Set(keys); + const out = new Map(); + for (const r of rows) { + const key = `${r.scope_id as string}:${r.id as string}`; + if (requested.has(key)) out.set(key, r.name as string); + } + return out; + }), }; };