diff --git a/packages/tools/official/blocks.yml b/packages/tools/official/blocks.yml index 49aeffc..06d6342 100644 --- a/packages/tools/official/blocks.yml +++ b/packages/tools/official/blocks.yml @@ -13387,6 +13387,272 @@ blocks: description: "Sent message details" measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + # --------------------------------------------------------------------------- + # Supabase Tools (database REST API for AI agents) + # --------------------------------------------------------------------------- + ops.supabaseQueryRows: + type: utility + description: "Query rows from a Supabase table with filtering, ordering, and pagination using PostgREST syntax." + path: "supabase" + domain_rules: + - id: api_integration + description: "Must call Supabase PostgREST GET /rest/v1/{table} endpoint with filter params" + inputs: + - name: table + type: string + description: "Table name to query" + - name: select + type: string + optional: true + description: "Columns to select" + - name: filter + type: string + optional: true + description: "PostgREST filter string" + - name: order + type: string + optional: true + description: "Order by column(s)" + - name: limit + type: number + optional: true + description: "Maximum rows to return" + - name: offset + type: number + optional: true + description: "Rows to skip for pagination" + outputs: + - name: result + type: QueryResult + description: "Array of rows with count" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.supabaseGetRowById: + type: utility + description: "Get a single row from a Supabase table by its primary key value." + path: "supabase" + domain_rules: + - id: api_integration + description: "Must call Supabase PostgREST GET /rest/v1/{table} with eq filter and limit=1" + inputs: + - name: table + type: string + description: "Table name" + - name: value + type: string + description: "Primary key value" + - name: column + type: string + optional: true + description: "Primary key column name" + - name: select + type: string + optional: true + description: "Columns to select" + outputs: + - name: result + type: RowResult + description: "Single row or null" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.supabaseInsertRows: + type: utility + description: "Insert one or more rows into a Supabase table and return the inserted data." + path: "supabase" + domain_rules: + - id: api_integration + description: "Must call Supabase PostgREST POST /rest/v1/{table} with Prefer: return=representation" + inputs: + - name: table + type: string + description: "Table name" + - name: rows + type: array + description: "Array of row objects to insert" + - name: on_conflict + type: string + optional: true + description: "Conflict resolution columns" + - name: returning + type: string + optional: true + description: "Columns to return" + outputs: + - name: result + type: InsertResult + description: "Inserted rows with count" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.supabaseUpdateRows: + type: utility + description: "Update rows in a Supabase table matching a PostgREST filter condition." + path: "supabase" + domain_rules: + - id: api_integration + description: "Must call Supabase PostgREST PATCH /rest/v1/{table} with filter params and Prefer: return=representation" + inputs: + - name: table + type: string + description: "Table name" + - name: filter + type: string + description: "PostgREST filter string (required)" + - name: data + type: object + description: "Fields to update" + - name: returning + type: string + optional: true + description: "Columns to return" + outputs: + - name: result + type: UpdateResult + description: "Updated rows with count" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.supabaseDeleteRows: + type: utility + description: "Delete rows from a Supabase table matching a PostgREST filter condition." + path: "supabase" + domain_rules: + - id: api_integration + description: "Must call Supabase PostgREST DELETE /rest/v1/{table} with filter params and Prefer: return=representation" + inputs: + - name: table + type: string + description: "Table name" + - name: filter + type: string + description: "PostgREST filter string (required)" + - name: returning + type: string + optional: true + description: "Columns to return" + outputs: + - name: result + type: DeleteResult + description: "Deleted rows with count" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.supabaseUpsertRows: + type: utility + description: "Insert or update rows in a Supabase table using merge-duplicates strategy." + path: "supabase" + domain_rules: + - id: api_integration + description: "Must call Supabase PostgREST POST /rest/v1/{table} with Prefer: resolution=merge-duplicates" + inputs: + - name: table + type: string + description: "Table name" + - name: rows + type: array + description: "Array of row objects to upsert" + - name: on_conflict + type: string + optional: true + description: "Conflict resolution columns" + - name: returning + type: string + optional: true + description: "Columns to return" + outputs: + - name: result + type: UpsertResult + description: "Upserted rows with count" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.supabaseCallRpc: + type: utility + description: "Call a Supabase PostgreSQL function (RPC) with optional parameters." + path: "supabase" + domain_rules: + - id: api_integration + description: "Must call Supabase PostgREST POST /rest/v1/rpc/{function_name} endpoint" + inputs: + - name: function_name + type: string + description: "PostgreSQL function name" + - name: params + type: object + optional: true + description: "Function parameters" + outputs: + - name: result + type: RpcResult + description: "Function return value" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.supabaseCountRows: + type: utility + description: "Count rows in a Supabase table, optionally filtered by conditions." + path: "supabase" + domain_rules: + - id: api_integration + description: "Must call Supabase PostgREST HEAD /rest/v1/{table} with Prefer: count=exact and parse content-range header" + inputs: + - name: table + type: string + description: "Table name" + - name: filter + type: string + optional: true + description: "PostgREST filter string" + outputs: + - name: result + type: CountResult + description: "Row count and table name" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.supabaseListTables: + type: utility + description: "List all available tables in the Supabase database via the OpenAPI schema." + path: "supabase" + domain_rules: + - id: api_integration + description: "Must call Supabase PostgREST GET /rest/v1/ endpoint and extract table names from paths" + inputs: [] + outputs: + - name: result + type: ListTablesResult + description: "Sorted list of table names" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.supabaseSearchRows: + type: utility + description: "Search rows in a Supabase table by column+query pattern matching (ilike). Uses column and query inputs to build the ilike filter internally — no separate filter param needed." + path: "supabase" + domain_rules: + - id: api_integration + description: "Must call Supabase PostgREST GET /rest/v1/{table} with ilike.* filter built from column and query inputs" + inputs: + - name: table + type: string + description: "Table name to search" + - name: column + type: string + description: "Column to apply ilike search on" + - name: query + type: string + description: "Search term (wrapped in wildcards for contains matching)" + - name: select + type: string + optional: true + description: "Columns to select" + - name: limit + type: number + optional: true + description: "Maximum rows to return" + - name: order + type: string + optional: true + description: "Order by column(s)" + outputs: + - name: result + type: SearchResult + description: "Matching rows with count" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + # ============================================================================= # VALIDATORS - Which validators to run against each block # ============================================================================= diff --git a/packages/tools/official/supabase/README.md b/packages/tools/official/supabase/README.md new file mode 100644 index 0000000..dd7ba6f --- /dev/null +++ b/packages/tools/official/supabase/README.md @@ -0,0 +1,115 @@ +# @tpmjs/tools-supabase + +Supabase REST API tools for AI agents. Query, insert, update, delete rows, call RPC functions, and more using the PostgREST API. + +## Installation + +```bash +npm install @tpmjs/tools-supabase +``` + +## Setup + +Set these environment variables: + +```bash +SUPABASE_URL="https://your-project.supabase.co" +SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" +``` + +The service role key bypasses Row Level Security (RLS) and grants full database access to AI agents. + +## Usage + +```typescript +import { generateText } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { queryRows, insertRows } from '@tpmjs/tools-supabase'; + +const result = await generateText({ + model: openai('gpt-4o'), + prompt: 'Get the first 5 users from the database', + tools: { queryRows, insertRows }, +}); +``` + +### Query Rows + +```typescript +import { queryRows } from '@tpmjs/tools-supabase'; + +// Query with filtering and ordering +await queryRows.execute({ + table: 'users', + select: 'id,name,email', + filter: 'age=gte.18&status=eq.active', + order: 'created_at.desc', + limit: 10, +}); +``` + +### Insert Rows + +```typescript +import { insertRows } from '@tpmjs/tools-supabase'; + +// Insert a single row +await insertRows.execute({ + table: 'users', + rows: [{ name: 'Alice', email: 'alice@example.com' }], +}); + +// Bulk insert +await insertRows.execute({ + table: 'users', + rows: [ + { name: 'Bob', email: 'bob@example.com' }, + { name: 'Carol', email: 'carol@example.com' }, + ], +}); +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `queryRows` | Query rows from a table with filtering, ordering, and pagination | +| `getRowById` | Get a single row by its primary key value | +| `insertRows` | Insert one or more rows into a table | +| `updateRows` | Update rows matching a filter condition | +| `deleteRows` | Delete rows matching a filter condition | +| `upsertRows` | Insert or update rows using merge-duplicates strategy | +| `callRpc` | Call a PostgreSQL function (RPC) with parameters | +| `countRows` | Count rows in a table with optional filtering | +| `listTables` | List all available tables in the database | +| `searchRows` | Search rows using case-insensitive pattern matching | + +## PostgREST Filter Syntax + +Supabase uses PostgREST filter syntax for queries: + +- **Equality:** `status=eq.active` +- **Greater than:** `age=gt.18` +- **Greater than or equal:** `age=gte.18` +- **Less than:** `price=lt.100` +- **Pattern matching:** `name=like.*smith*` +- **Case-insensitive:** `email=ilike.*@gmail.com` +- **Multiple filters:** `age=gte.18&status=eq.active` (AND) + +See [PostgREST documentation](https://postgrest.org/en/stable/references/api/tables_views.html#horizontal-filtering-rows) for more operators. + +## Safety Features + +**Important:** The `updateRows` and `deleteRows` tools require a non-empty filter to prevent accidental full-table operations. This is a safety requirement to protect your data. + +```typescript +// ❌ This will throw an error +await deleteRows.execute({ table: 'users', filter: '' }); + +// ✅ This is safe +await deleteRows.execute({ table: 'users', filter: 'id=eq.123' }); +``` + +## License + +MIT diff --git a/packages/tools/official/supabase/package.json b/packages/tools/official/supabase/package.json new file mode 100644 index 0000000..a0b847e --- /dev/null +++ b/packages/tools/official/supabase/package.json @@ -0,0 +1,107 @@ +{ + "name": "@tpmjs/tools-supabase", + "version": "0.1.0", + "description": "Supabase REST API tools for AI agents — query, insert, update, delete rows, call RPC functions, and more", + "type": "module", + "keywords": [ + "tpmjs", + "ops", + "ai", + "supabase", + "database", + "postgres" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "type-check": "tsc --noEmit", + "clean": "rm -rf dist .turbo" + }, + "devDependencies": { + "@tpmjs/tsconfig": "workspace:*", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "dependencies": { + "ai": "6.0.49" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/tpmjs/tpmjs.git", + "directory": "packages/tools/official/supabase" + }, + "homepage": "https://tpmjs.com", + "license": "MIT", + "tpmjs": { + "category": "ops", + "frameworks": [ + "vercel-ai" + ], + "env": [ + { + "name": "SUPABASE_URL", + "description": "Supabase project URL (e.g. https://xyz.supabase.co)", + "required": true + }, + { + "name": "SUPABASE_SERVICE_ROLE_KEY", + "description": "Supabase service role key for full database access", + "required": true + } + ], + "tools": [ + { + "name": "queryRows", + "description": "Query rows from a Supabase table with filtering, ordering, and pagination." + }, + { + "name": "getRowById", + "description": "Get a single row from a Supabase table by its primary key value." + }, + { + "name": "insertRows", + "description": "Insert one or more rows into a Supabase table and return the inserted data." + }, + { + "name": "updateRows", + "description": "Update rows in a Supabase table matching a PostgREST filter condition." + }, + { + "name": "deleteRows", + "description": "Delete rows from a Supabase table matching a PostgREST filter condition." + }, + { + "name": "upsertRows", + "description": "Insert or update rows in a Supabase table using merge-duplicates strategy." + }, + { + "name": "callRpc", + "description": "Call a Supabase PostgreSQL function (RPC) with optional parameters." + }, + { + "name": "countRows", + "description": "Count rows in a Supabase table, optionally filtered by conditions." + }, + { + "name": "listTables", + "description": "List all available tables in the Supabase database via the OpenAPI schema." + }, + { + "name": "searchRows", + "description": "Search rows in a Supabase table using case-insensitive pattern matching." + } + ] + } +} diff --git a/packages/tools/official/supabase/src/index.ts b/packages/tools/official/supabase/src/index.ts new file mode 100644 index 0000000..7466c7e --- /dev/null +++ b/packages/tools/official/supabase/src/index.ts @@ -0,0 +1,926 @@ +/** + * @tpmjs/tools-supabase — Supabase REST API Tools for AI Agents + * + * Complete database management for AI agents: query, insert, update, delete rows, + * call RPC functions, count records, and more using the PostgREST API. + * + * @requires SUPABASE_URL environment variable + * @requires SUPABASE_SERVICE_ROLE_KEY environment variable + */ + +import { jsonSchema, tool } from 'ai'; + +// ─── Client Infrastructure ────────────────────────────────────────────────── + +interface SupabaseConfig { + url: string; + key: string; +} + +function getConfig(): SupabaseConfig { + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!url) { + throw new Error( + 'SUPABASE_URL environment variable is required. Get it from your Supabase project settings.' + ); + } + + if (!key) { + throw new Error( + 'SUPABASE_SERVICE_ROLE_KEY environment variable is required. Get it from your Supabase project settings.' + ); + } + + return { url, key }; +} + +interface ApiRequestOptions { + body?: unknown; + headers?: Record; + params?: Record; +} + +async function apiRequest( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'HEAD', + path: string, + options?: ApiRequestOptions +): Promise { + const config = getConfig(); + + const headers: Record = { + apikey: config.key, + Authorization: `Bearer ${config.key}`, + 'Content-Type': 'application/json', + ...options?.headers, + }; + + const url = new URL(`${config.url}${path}`); + if (options?.params) { + for (const [key, value] of Object.entries(options.params)) { + url.searchParams.append(key, value); + } + } + + const requestOptions: RequestInit = { + method, + headers, + }; + + if (options?.body !== undefined) { + requestOptions.body = JSON.stringify(options.body); + } + + const response = await fetch(url.toString(), requestOptions); + + // Handle HEAD requests (used for counting) + if (method === 'HEAD') { + if (!response.ok) { + throw new Error(`Supabase HTTP error ${response.status}: ${response.statusText}`); + } + // Return headers for HEAD requests + return { headers: Object.fromEntries(response.headers.entries()) } as T; + } + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Supabase HTTP error ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}` + ); + } + + // Handle empty responses + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + return {} as T; + } + + return (await response.json()) as T; +} + +// ─── Output Types ──────────────────────────────────────────────────────────── + +export interface QueryResult { + rows: Record[]; + count: number; +} + +export interface RowResult { + row: Record | null; +} + +export interface InsertResult { + rows: Record[]; + count: number; +} + +export interface UpdateResult { + rows: Record[]; + count: number; +} + +export interface DeleteResult { + rows: Record[]; + count: number; +} + +export interface UpsertResult { + rows: Record[]; + count: number; +} + +export interface RpcResult { + data: unknown; +} + +export interface CountResult { + count: number; + table: string; +} + +export interface ListTablesResult { + tables: string[]; +} + +export interface SearchResult { + rows: Record[]; + count: number; +} + +// ─── Query Rows ────────────────────────────────────────────────────────────── + +export interface QueryRowsInput { + table: string; + select?: string; + filter?: string; + order?: string; + limit?: number; + offset?: number; +} + +export const queryRows = tool({ + description: + 'Query rows from a Supabase table with filtering, ordering, and pagination using PostgREST syntax.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + table: { + type: 'string', + description: 'The table name to query.', + }, + select: { + type: 'string', + description: 'Columns to select (default: "*"). Example: "id,name,email".', + }, + filter: { + type: 'string', + description: 'PostgREST filter string. Example: "age=gte.18&status=eq.active".', + }, + order: { + type: 'string', + description: 'Order by column(s). Example: "created_at.desc" or "name.asc".', + }, + limit: { + type: 'number', + description: 'Maximum number of rows to return (1-1000, default: 100).', + }, + offset: { + type: 'number', + description: 'Number of rows to skip for pagination (default: 0).', + }, + }, + required: ['table'], + additionalProperties: false, + }), + async execute(input: QueryRowsInput): Promise { + try { + if (!input.table || input.table.trim() === '') { + throw new Error('table is required and must be non-empty'); + } + + if (input.limit !== undefined && (input.limit < 1 || input.limit > 1000)) { + throw new Error('limit must be between 1 and 1000'); + } + + if (input.offset !== undefined && input.offset < 0) { + throw new Error('offset must be non-negative'); + } + + const params = new URLSearchParams(); + params.append('select', input.select || '*'); + + if (input.filter) { + // Parse filter and add as individual params + const filterParts = input.filter.split('&'); + for (const part of filterParts) { + const [key, value] = part.split('='); + if (key && value) { + params.append(key, value); + } + } + } + + if (input.order) { + params.append('order', input.order); + } + + if (input.limit !== undefined) { + params.append('limit', String(input.limit)); + } + + if (input.offset !== undefined) { + params.append('offset', String(input.offset)); + } + + const path = `/rest/v1/${encodeURIComponent(input.table)}?${params.toString()}`; + const rows = await apiRequest[]>('GET', path); + + return { + rows: rows || [], + count: (rows || []).length, + }; + } catch (error) { + throw new Error(`Failed to query rows: ${(error as Error).message}`); + } + }, +}); + +// ─── Get Row By ID ─────────────────────────────────────────────────────────── + +export interface GetRowByIdInput { + table: string; + column?: string; + value: string; + select?: string; +} + +export const getRowById = tool({ + description: 'Get a single row from a Supabase table by its primary key value.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + table: { + type: 'string', + description: 'The table name to query.', + }, + column: { + type: 'string', + description: 'Primary key column name (default: "id").', + }, + value: { + type: 'string', + description: 'The ID value to search for.', + }, + select: { + type: 'string', + description: 'Columns to select (default: "*").', + }, + }, + required: ['table', 'value'], + additionalProperties: false, + }), + async execute(input: GetRowByIdInput): Promise { + try { + if (!input.table || input.table.trim() === '') { + throw new Error('table is required and must be non-empty'); + } + + if (!input.value || input.value.trim() === '') { + throw new Error('value is required and must be non-empty'); + } + + const column = input.column || 'id'; + const select = input.select || '*'; + + const params = new URLSearchParams(); + params.append('select', select); + params.append(column, `eq.${input.value}`); + params.append('limit', '1'); + + const path = `/rest/v1/${encodeURIComponent(input.table)}?${params.toString()}`; + const rows = await apiRequest[]>('GET', path); + + return { + row: rows && rows.length > 0 ? rows[0] || null : null, + }; + } catch (error) { + throw new Error(`Failed to get row by ID: ${(error as Error).message}`); + } + }, +}); + +// ─── Insert Rows ───────────────────────────────────────────────────────────── + +export interface InsertRowsInput { + table: string; + rows: Record[]; + on_conflict?: string; + returning?: string; +} + +export const insertRows = tool({ + description: 'Insert one or more rows into a Supabase table and return the inserted data.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + table: { + type: 'string', + description: 'The table name to insert into.', + }, + rows: { + type: 'array', + items: { type: 'object' }, + description: 'Array of row objects to insert (single or bulk).', + }, + on_conflict: { + type: 'string', + description: 'Column name(s) for upsert conflict resolution.', + }, + returning: { + type: 'string', + description: 'Columns to return (default: "*").', + }, + }, + required: ['table', 'rows'], + additionalProperties: false, + }), + async execute(input: InsertRowsInput): Promise { + try { + if (!input.table || input.table.trim() === '') { + throw new Error('table is required and must be non-empty'); + } + + if (!Array.isArray(input.rows) || input.rows.length === 0) { + throw new Error('rows must be a non-empty array'); + } + + const params = new URLSearchParams(); + if (input.returning) { + params.append('select', input.returning); + } + + if (input.on_conflict) { + params.append('on_conflict', input.on_conflict); + } + + const queryString = params.toString(); + const path = queryString + ? `/rest/v1/${encodeURIComponent(input.table)}?${queryString}` + : `/rest/v1/${encodeURIComponent(input.table)}`; + + const rows = await apiRequest[]>('POST', path, { + body: input.rows, + headers: { + Prefer: 'return=representation', + }, + }); + + return { + rows: rows || [], + count: (rows || []).length, + }; + } catch (error) { + throw new Error(`Failed to insert rows: ${(error as Error).message}`); + } + }, +}); + +// ─── Update Rows ───────────────────────────────────────────────────────────── + +export interface UpdateRowsInput { + table: string; + filter: string; + data: Record; + returning?: string; +} + +export const updateRows = tool({ + description: + 'Update rows in a Supabase table matching a filter condition. SAFETY: Filter is required to prevent full-table updates.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + table: { + type: 'string', + description: 'The table name to update.', + }, + filter: { + type: 'string', + description: 'PostgREST filter string (REQUIRED, must be non-empty). Example: "id=eq.123".', + }, + data: { + type: 'object', + description: 'Object containing fields to update.', + }, + returning: { + type: 'string', + description: 'Columns to return (default: "*").', + }, + }, + required: ['table', 'filter', 'data'], + additionalProperties: false, + }), + async execute(input: UpdateRowsInput): Promise { + try { + if (!input.table || input.table.trim() === '') { + throw new Error('table is required and must be non-empty'); + } + + if (!input.filter || input.filter.trim() === '') { + throw new Error( + 'filter is required and must be non-empty to prevent accidental full-table updates. Use a specific filter like "id=eq.123".' + ); + } + + if (!input.data || typeof input.data !== 'object') { + throw new Error('data is required and must be an object'); + } + + const params = new URLSearchParams(); + if (input.returning) { + params.append('select', input.returning); + } + + // Parse filter and add as individual params + const filterParts = input.filter.split('&'); + for (const part of filterParts) { + const [key, value] = part.split('='); + if (key && value) { + params.append(key, value); + } + } + + const path = `/rest/v1/${encodeURIComponent(input.table)}?${params.toString()}`; + + const rows = await apiRequest[]>('PATCH', path, { + body: input.data, + headers: { + Prefer: 'return=representation', + }, + }); + + return { + rows: rows || [], + count: (rows || []).length, + }; + } catch (error) { + throw new Error(`Failed to update rows: ${(error as Error).message}`); + } + }, +}); + +// ─── Delete Rows ───────────────────────────────────────────────────────────── + +export interface DeleteRowsInput { + table: string; + filter: string; + returning?: string; +} + +export const deleteRows = tool({ + description: + 'Delete rows from a Supabase table matching a filter condition. SAFETY: Filter is required to prevent full-table deletion.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + table: { + type: 'string', + description: 'The table name to delete from.', + }, + filter: { + type: 'string', + description: 'PostgREST filter string (REQUIRED, must be non-empty). Example: "id=eq.123".', + }, + returning: { + type: 'string', + description: 'Columns to return from deleted rows (default: "*").', + }, + }, + required: ['table', 'filter'], + additionalProperties: false, + }), + async execute(input: DeleteRowsInput): Promise { + try { + if (!input.table || input.table.trim() === '') { + throw new Error('table is required and must be non-empty'); + } + + if (!input.filter || input.filter.trim() === '') { + throw new Error( + 'filter is required and must be non-empty to prevent accidental full-table deletion. Use a specific filter like "id=eq.123".' + ); + } + + const params = new URLSearchParams(); + if (input.returning) { + params.append('select', input.returning); + } + + // Parse filter and add as individual params + const filterParts = input.filter.split('&'); + for (const part of filterParts) { + const [key, value] = part.split('='); + if (key && value) { + params.append(key, value); + } + } + + const path = `/rest/v1/${encodeURIComponent(input.table)}?${params.toString()}`; + + const rows = await apiRequest[]>('DELETE', path, { + headers: { + Prefer: 'return=representation', + }, + }); + + return { + rows: rows || [], + count: (rows || []).length, + }; + } catch (error) { + throw new Error(`Failed to delete rows: ${(error as Error).message}`); + } + }, +}); + +// ─── Upsert Rows ───────────────────────────────────────────────────────────── + +export interface UpsertRowsInput { + table: string; + rows: Record[]; + on_conflict?: string; + returning?: string; +} + +export const upsertRows = tool({ + description: + 'Insert or update rows in a Supabase table using merge-duplicates strategy for conflict resolution.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + table: { + type: 'string', + description: 'The table name to upsert into.', + }, + rows: { + type: 'array', + items: { type: 'object' }, + description: 'Array of row objects to upsert.', + }, + on_conflict: { + type: 'string', + description: 'Column name(s) for conflict resolution.', + }, + returning: { + type: 'string', + description: 'Columns to return (default: "*").', + }, + }, + required: ['table', 'rows'], + additionalProperties: false, + }), + async execute(input: UpsertRowsInput): Promise { + if (!input.table || input.table.trim() === '') { + throw new Error('table is required and must be non-empty'); + } + + if (!Array.isArray(input.rows) || input.rows.length === 0) { + throw new Error('rows must be a non-empty array'); + } + + // Validate each row is a non-null object with at least one field + for (let i = 0; i < input.rows.length; i++) { + const row = input.rows[i]; + if (!row || typeof row !== 'object' || Array.isArray(row)) { + throw new Error(`rows[${i}] must be a non-null object with column-value pairs`); + } + if (Object.keys(row).length === 0) { + throw new Error(`rows[${i}] must have at least one column-value pair`); + } + } + + try { + const params = new URLSearchParams(); + if (input.returning) { + params.append('select', input.returning); + } + + if (input.on_conflict) { + params.append('on_conflict', input.on_conflict); + } + + const queryString = params.toString(); + const path = queryString + ? `/rest/v1/${encodeURIComponent(input.table)}?${queryString}` + : `/rest/v1/${encodeURIComponent(input.table)}`; + + const rows = await apiRequest[]>('POST', path, { + body: input.rows, + headers: { + Prefer: 'return=representation,resolution=merge-duplicates', + }, + }); + + return { + rows: rows || [], + count: (rows || []).length, + }; + } catch (error) { + if (error instanceof Error && error.message.startsWith('rows')) { + throw error; + } + throw new Error(`Failed to upsert rows in "${input.table}": ${(error as Error).message}`); + } + }, +}); + +// ─── Call RPC ──────────────────────────────────────────────────────────────── + +export interface CallRpcInput { + function_name: string; + params?: Record; +} + +export const callRpc = tool({ + description: 'Call a Supabase PostgreSQL function (RPC) with parameters and return the result.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + function_name: { + type: 'string', + description: 'The PostgreSQL function name to call.', + }, + params: { + type: 'object', + description: 'Object containing function parameters.', + }, + }, + required: ['function_name'], + additionalProperties: false, + }), + async execute(input: CallRpcInput): Promise { + try { + if (!input.function_name || input.function_name.trim() === '') { + throw new Error('function_name is required and must be non-empty'); + } + + const path = `/rest/v1/rpc/${encodeURIComponent(input.function_name)}`; + + const data = await apiRequest('POST', path, { + body: input.params || {}, + }); + + return { data }; + } catch (error) { + throw new Error(`Failed to call RPC function: ${(error as Error).message}`); + } + }, +}); + +// ─── Count Rows ────────────────────────────────────────────────────────────── + +export interface CountRowsInput { + table: string; + filter?: string; +} + +export const countRows = tool({ + description: + 'Count rows in a Supabase table, optionally filtered by conditions, using exact count.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + table: { + type: 'string', + description: 'The table name to count rows in.', + }, + filter: { + type: 'string', + description: 'Optional PostgREST filter string. Example: "age=gte.18&status=eq.active".', + }, + }, + required: ['table'], + additionalProperties: false, + }), + async execute(input: CountRowsInput): Promise { + if (!input.table || input.table.trim() === '') { + throw new Error('table is required and must be non-empty'); + } + + try { + const params = new URLSearchParams(); + + if (input.filter) { + const filterParts = input.filter.split('&'); + for (const part of filterParts) { + const eqIdx = part.indexOf('='); + if (eqIdx === -1) { + throw new Error( + `Invalid filter segment "${part}". Expected format: "column=operator.value"` + ); + } + const key = part.substring(0, eqIdx); + const value = part.substring(eqIdx + 1); + if (key && value) { + params.append(key, value); + } + } + } + + const queryString = params.toString(); + const pathSuffix = queryString ? `?${queryString}` : ''; + const path = `/rest/v1/${encodeURIComponent(input.table)}${pathSuffix}`; + + const response = await apiRequest<{ headers: Record }>('HEAD', path, { + headers: { + Prefer: 'count=exact', + }, + }); + + // Extract count from content-range header (format: "0-24/3573" or "*/0") + const contentRange = response.headers['content-range']; + let count = 0; + + if (contentRange) { + const match = contentRange.match(/\/(\d+)$/); + if (match && match[1]) { + count = Number.parseInt(match[1], 10); + } + } + + return { + count, + table: input.table, + }; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Invalid filter')) { + throw error; + } + throw new Error(`Failed to count rows in "${input.table}": ${(error as Error).message}`); + } + }, +}); + +// ─── List Tables ───────────────────────────────────────────────────────────── + +export type ListTablesInput = {}; + +export const listTables = tool({ + description: 'List all available tables in the Supabase database via the OpenAPI schema.', + inputSchema: jsonSchema({ + type: 'object', + properties: {}, + additionalProperties: false, + }), + async execute(_input: ListTablesInput): Promise { + try { + const path = '/rest/v1/'; + + // Fetch the OpenAPI spec from the root endpoint + const spec = await apiRequest<{ + paths?: Record; + }>('GET', path); + + const tables: string[] = []; + + if (spec.paths) { + for (const pathKey of Object.keys(spec.paths)) { + // Path format: /{table_name} or /rpc/{function_name} + // We want tables, not RPC functions + if (pathKey.startsWith('/') && !pathKey.startsWith('/rpc/')) { + const tableName = pathKey.substring(1); // Remove leading slash + if (tableName && !tableName.includes('/')) { + tables.push(tableName); + } + } + } + } + + return { tables: tables.sort() }; + } catch (error) { + throw new Error(`Failed to list tables: ${(error as Error).message}`); + } + }, +}); + +// ─── Search Rows ───────────────────────────────────────────────────────────── + +export interface SearchRowsInput { + table: string; + column: string; + query: string; + select?: string; + limit?: number; + order?: string; +} + +export const searchRows = tool({ + description: + 'Search rows in a Supabase table using case-insensitive pattern matching (ilike operator).', + inputSchema: jsonSchema({ + type: 'object', + properties: { + table: { + type: 'string', + description: 'The table name to search in.', + }, + column: { + type: 'string', + description: 'The column name to search in.', + }, + query: { + type: 'string', + description: 'The search term to match (case-insensitive).', + }, + select: { + type: 'string', + description: 'Columns to select (default: "*").', + }, + limit: { + type: 'number', + description: 'Maximum number of rows to return (1-1000, default: 100).', + }, + order: { + type: 'string', + description: 'Order by column(s). Example: "created_at.desc".', + }, + }, + required: ['table', 'column', 'query'], + additionalProperties: false, + }), + async execute(input: SearchRowsInput): Promise { + if (!input.table || input.table.trim() === '') { + throw new Error('table is required and must be non-empty'); + } + + if (!input.column || input.column.trim() === '') { + throw new Error('column is required and must be non-empty'); + } + + // Validate column name contains only valid characters (letters, digits, underscores) + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(input.column)) { + throw new Error( + `Invalid column name "${input.column}". Column names must start with a letter or underscore and contain only letters, digits, and underscores.` + ); + } + + if (!input.query || input.query.trim() === '') { + throw new Error('query is required and must be non-empty'); + } + + if (input.limit !== undefined && (input.limit < 1 || input.limit > 1000)) { + throw new Error('limit must be between 1 and 1000'); + } + + try { + const params = new URLSearchParams(); + params.append('select', input.select || '*'); + // PostgREST handles ilike safely via parameterized queries on the server side + params.append(input.column, `ilike.*${input.query}*`); + + if (input.order) { + params.append('order', input.order); + } + + if (input.limit !== undefined) { + params.append('limit', String(input.limit)); + } + + const path = `/rest/v1/${encodeURIComponent(input.table)}?${params.toString()}`; + const rows = await apiRequest[]>('GET', path); + + return { + rows: rows || [], + count: (rows || []).length, + }; + } catch (error) { + if ( + error instanceof Error && + (error.message.startsWith('Invalid column') || + error.message.startsWith('query') || + error.message.startsWith('column') || + error.message.startsWith('table') || + error.message.startsWith('limit')) + ) { + throw error; + } + throw new Error(`Failed to search rows in "${input.table}": ${(error as Error).message}`); + } + }, +}); + +// ─── Default Export ───────────────────────────────────────────────────────── + +export default { + queryRows, + getRowById, + insertRows, + updateRows, + deleteRows, + upsertRows, + callRpc, + countRows, + listTables, + searchRows, +}; diff --git a/packages/tools/official/supabase/tsconfig.json b/packages/tools/official/supabase/tsconfig.json new file mode 100644 index 0000000..84965a7 --- /dev/null +++ b/packages/tools/official/supabase/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@tpmjs/tsconfig/react-library.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "incremental": false, + "composite": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/tools/official/supabase/tsup.config.ts b/packages/tools/official/supabase/tsup.config.ts new file mode 100644 index 0000000..2260c9e --- /dev/null +++ b/packages/tools/official/supabase/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, + target: 'es2022', + treeshake: true, + splitting: false, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8df04c..5e7a3e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3673,6 +3673,22 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/tools/official/supabase: + dependencies: + ai: + specifier: 6.0.49 + version: 6.0.49(zod@4.3.5) + devDependencies: + '@tpmjs/tsconfig': + specifier: workspace:* + version: link:../../../config/tsconfig + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/tools/official/survey-analyze: dependencies: ai: