diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index e3bcf17e3..444529ea0 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -168,6 +168,8 @@ jobs: env: {} - package: graphile/graphile-bulk-mutations env: {} + - package: graphile/graphile-function-bindings + env: {} - package: graphql/orm-test env: {} - package: graphql/test diff --git a/__fixtures__/seed/compute/setup.sql b/__fixtures__/seed/compute/setup.sql new file mode 100644 index 000000000..cf05338ee --- /dev/null +++ b/__fixtures__/seed/compute/setup.sql @@ -0,0 +1,122 @@ +-- Shared fixture: compute (functions) module DDL +-- +-- Creates the minimum tables needed to emulate the compute module in a +-- production Constructive database: the metaschema module registration +-- tables (function_module, function_invocation_module) plus the generated +-- compute tables (function_definitions, function_api_bindings, +-- function_invocations). +-- +-- The function_invocations INSERT policy mirrors the api arm of the +-- production policy: the row is only insertable when the transaction-local +-- `jwt.claims.api_id` claim matches the binding (`api_binding_id`) recorded +-- on the invocation and that binding points at the invocation's +-- `function_definition_id`. +-- +-- Depends on: services/setup.sql + +-- ===================================================== +-- METASCHEMA MODULE REGISTRATION TABLES +-- ===================================================== + +-- The physical bindings table name is recorded on the module config row +-- (bindings_table_name) — consumers read it as a fact, never derive it. +CREATE TABLE IF NOT EXISTS metaschema_modules_public.function_module ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + schema_id uuid NOT NULL, + definitions_table_name text NOT NULL DEFAULT 'function_definitions', + bindings_table_name text NOT NULL DEFAULT 'function_api_bindings', + scope text NOT NULL DEFAULT 'app' +); + +-- entity_field records the invocations table's scope-key column: NULL for +-- global scopes (app/platform), 'database_id' for the database scope, or the +-- entity key column for entity scopes. +CREATE TABLE IF NOT EXISTS metaschema_modules_public.function_invocation_module ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + schema_id uuid NOT NULL, + invocations_table_name text NOT NULL DEFAULT 'function_invocations', + scope text NOT NULL DEFAULT 'app', + entity_field text +); + +GRANT SELECT ON metaschema_modules_public.function_module TO administrator, authenticated, anonymous; +GRANT SELECT ON metaschema_modules_public.function_invocation_module TO administrator, authenticated, anonymous; + +-- ===================================================== +-- COMPUTE SCHEMA + GENERATED TABLES +-- ===================================================== + +CREATE SCHEMA IF NOT EXISTS compute_public; +GRANT USAGE ON SCHEMA compute_public TO administrator, authenticated, anonymous; + +-- task_identifier is a generated column = category || ':' || name, matching +-- the merged function_module generator (category:name, not scope:name). The +-- app scope is global, so the definitions table carries no scope-key column. +CREATE TABLE IF NOT EXISTS compute_public.function_definitions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + category text NOT NULL, + name text NOT NULL, + task_identifier text GENERATED ALWAYS AS (category || ':' || name) STORED, + description text, + payload_args jsonb +); + +CREATE TABLE IF NOT EXISTS compute_public.function_api_bindings ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + function_definition_id uuid NOT NULL REFERENCES compute_public.function_definitions (id) ON DELETE CASCADE, + api_id uuid NOT NULL, + alias text NOT NULL, + config jsonb NOT NULL DEFAULT '{}', + UNIQUE (api_id, alias) +); + +-- Invocations carry a hard FK to the definition (function_definition_id, +-- ON DELETE SET NULL) and to the binding they came through (api_binding_id). +-- The app scope is global, so there is no scope-key column. +CREATE TABLE IF NOT EXISTS compute_public.function_invocations ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + function_definition_id uuid REFERENCES compute_public.function_definitions (id) ON DELETE SET NULL, + api_binding_id uuid REFERENCES compute_public.function_api_bindings (id), + task_identifier text NOT NULL, + payload jsonb, + status text NOT NULL DEFAULT 'pending', + result jsonb, + error jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + completed_at timestamptz, + duration_ms int +); + +GRANT SELECT ON compute_public.function_definitions TO administrator, authenticated, anonymous; +GRANT SELECT ON compute_public.function_api_bindings TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT ON compute_public.function_invocations TO administrator, authenticated, anonymous; + +-- ===================================================== +-- RLS — api-arm provenance policy +-- ===================================================== + +ALTER TABLE compute_public.function_invocations ENABLE ROW LEVEL SECURITY; + +-- INSERT is only allowed when the invocation carries the binding +-- (api_binding_id) it came through, that binding belongs to the +-- server-injected jwt.claims.api_id, and it points at the same definition +-- recorded on the invocation (function_definition_id). Mirrors the merged +-- api provenance arm. +CREATE POLICY function_invocations_api_insert ON compute_public.function_invocations + FOR INSERT TO administrator, authenticated, anonymous + WITH CHECK ( + EXISTS ( + SELECT 1 + FROM compute_public.function_api_bindings b + WHERE b.id = function_invocations.api_binding_id + AND b.function_definition_id = function_invocations.function_definition_id + AND b.api_id = nullif(current_setting('jwt.claims.api_id', true), '')::uuid + ) + ); + +CREATE POLICY function_invocations_select ON compute_public.function_invocations + FOR SELECT TO administrator, authenticated, anonymous + USING (true); diff --git a/__fixtures__/seed/compute/test-data.sql b/__fixtures__/seed/compute/test-data.sql new file mode 100644 index 000000000..1be87f76a --- /dev/null +++ b/__fixtures__/seed/compute/test-data.sql @@ -0,0 +1,128 @@ +-- Shared fixture: compute (functions) module test data +-- +-- Registers the compute_public schema in the metaschema, provisions the +-- function/invocation modules, and seeds one function definition with two +-- API bindings on the "app" API (6c9997a4-591b-4cb3-9313-4ef45d6f134e): +-- - alias "resize" → rest enabled, payload_args-derived GraphQL input +-- - alias "graphql-only" → rest disabled (absent "rest" key) +-- - alias "send_email" → fallback JSON payload GraphQL input +-- - alias "secret_fn" → graphql disabled (must not be exposed) +-- - alias "validate_order" → JSON-Schema-derived GraphQL input (enum + required) +-- plus one binding on the "public" API (28199444-da40-40b1-8a4c-53edbf91c738) +-- that must not be exposed on the "app" API. +-- +-- Depends on: services/setup.sql, services/test-data.sql, compute/setup.sql + +SET session_replication_role TO replica; + +-- Register compute_public in the metaschema +INSERT INTO metaschema_public.schema (id, database_id, name, schema_name, description, is_public) +VALUES ( + '9dba0001-0000-4000-8000-000000000001', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'compute_public', + 'compute_public', + NULL, + true +) ON CONFLICT (id) DO NOTHING; + +-- Module registrations (what the compute loader resolves) +INSERT INTO metaschema_modules_public.function_module (id, database_id, schema_id, definitions_table_name, scope) +VALUES ( + '9dba0002-0000-4000-8000-000000000002', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '9dba0001-0000-4000-8000-000000000001', + 'function_definitions', + 'app' +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO metaschema_modules_public.function_invocation_module (id, database_id, schema_id, invocations_table_name, scope) +VALUES ( + '9dba0003-0000-4000-8000-000000000003', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '9dba0001-0000-4000-8000-000000000001', + 'function_invocations', + 'app' +) ON CONFLICT (id) DO NOTHING; + +-- Function definitions (task_identifier is generated = category || ':' || name) +INSERT INTO compute_public.function_definitions (id, category, name, description, payload_args) +VALUES + ( + '9dba0004-0000-4000-8000-000000000004', + 'app', + 'resize_image', + 'Resize an image', + '[{"name": "url", "type": "text"}, {"name": "width", "type": "int"}]' + ), + ( + '9dba0007-0000-4000-8000-000000000007', + 'app', + 'send_email', + NULL, + '[]' + ), + ( + '9dba0008-0000-4000-8000-000000000008', + 'app', + 'secret_fn', + NULL, + '[]' + ), + ( + '9dba0009-0000-4000-8000-000000000009', + 'app', + 'validate_order', + NULL, + '[]' + ) +ON CONFLICT (id) DO NOTHING; + +-- Bindings on the "app" API +INSERT INTO compute_public.function_api_bindings (id, function_definition_id, api_id, alias, config) +VALUES + ( + '9dba0005-0000-4000-8000-000000000005', + '9dba0004-0000-4000-8000-000000000004', + '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + 'resize', + '{"graphql": true, "rest": {"path": "/resize", "methods": ["POST"]}}' + ), + ( + '9dba0006-0000-4000-8000-000000000006', + '9dba0004-0000-4000-8000-000000000004', + '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + 'graphql-only', + '{"graphql": true}' + ), + ( + '9dba000a-0000-4000-8000-00000000000a', + '9dba0007-0000-4000-8000-000000000007', + '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + 'send_email', + '{}' + ), + ( + '9dba000b-0000-4000-8000-00000000000b', + '9dba0008-0000-4000-8000-000000000008', + '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + 'secret_fn', + '{"graphql": false}' + ), + ( + '9dba000c-0000-4000-8000-00000000000c', + '9dba0009-0000-4000-8000-000000000009', + '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + 'validate_order', + '{"schema": {"type": "object", "required": ["mode"], "properties": {"mode": {"type": "string", "enum": ["fast", "slow"]}, "count": {"type": "integer"}}}}' + ), + ( + '9dba000d-0000-4000-8000-00000000000d', + '9dba0004-0000-4000-8000-000000000004', + '28199444-da40-40b1-8a4c-53edbf91c738', + 'other_api_fn', + '{"graphql": true}' + ) +ON CONFLICT (id) DO NOTHING; + +SET session_replication_role TO DEFAULT; diff --git a/graphile/graphile-function-bindings/README.md b/graphile/graphile-function-bindings/README.md new file mode 100644 index 000000000..760f503e8 --- /dev/null +++ b/graphile/graphile-function-bindings/README.md @@ -0,0 +1,76 @@ +# graphile-function-bindings + +PostGraphile v5 plugin that exposes API-bound compute functions as typed +GraphQL mutations. + +At schema build (gather phase), the plugin queries `function_api_bindings` +joined to `function_definitions` for the configured `apiId` and emits one +mutation per graphql-enabled binding: + +```graphql +(input: Input!): Payload +``` + +The payload returns the created invocation (`invocationId`, `status`, and the +full `FunctionInvocation` when the table is exposed in the schema). + +## Binding config + +One binding serves both REST and GraphQL. Per-protocol behavior lives in the +binding's `config` jsonb: + +```json +{ + "graphql": true, + "rest": { "path": "/resize", "methods": ["POST"] } +} +``` + +- Absent `graphql` key ⇒ the mutation is enabled. +- `"graphql": false` ⇒ the binding is not exposed as a GraphQL mutation. + +## Input type derivation + +Derivation lives in `src/derive.ts` (protocol-agnostic — intended to be the +shared source for future REST payload validation). Priority: + +1. A JSON Schema on the binding config (`config.schema` / + `config.payloadSchema`): object properties become input fields + (string/number/boolean/enum/array/required mapped to GraphQL equivalents). +2. The definition's `payload_args` (`[{ "name": ..., "type": ... }]`): each + declared arg becomes a nullable scalar field. +3. Fallback: a single `payload: JSON` field passed through verbatim. + +No runtime payload validation is performed — derivation shapes the input +types only. + +## Resolver + +Invoking a function is a plain, RLS-enforced `INSERT INTO +function_invocations` executed on the normal authenticated connection +(`withPgClient` + `pgSettings` from the Grafast context). The server layer is +responsible for pgSettings (including the `jwt.claims.api_id` provenance +claim) — the plugin never sets claims, never uses a privileged connection, +and never bypasses RLS. + +## Usage + +```ts +import { createFunctionBindingsPlugin } from 'graphile-function-bindings'; + +const preset = { + // ... + plugins: [createFunctionBindingsPlugin({ apiId })], +}; +``` + +The GraphQL server (`graphql/server`) wires this automatically per API using +the resolved `api.apiId`. + +## Schema invalidation + +Bindings are loaded once per schema build. New or changed bindings appear +after the next schema rebuild — i.e. when the server's cached PostGraphile +handler for the API is invalidated (graphile-cache LISTEN/NOTIFY) or the +server restarts. TODO: emit a cache invalidation NOTIFY when +`function_api_bindings` rows change so rebuilds happen automatically. diff --git a/graphile/graphile-function-bindings/jest.config.js b/graphile/graphile-function-bindings/jest.config.js new file mode 100644 index 000000000..f58239fdd --- /dev/null +++ b/graphile/graphile-function-bindings/jest.config.js @@ -0,0 +1,19 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testTimeout: 60000, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json' + } + ] + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/graphile/graphile-function-bindings/package.json b/graphile/graphile-function-bindings/package.json new file mode 100644 index 000000000..c67483485 --- /dev/null +++ b/graphile/graphile-function-bindings/package.json @@ -0,0 +1,62 @@ +{ + "name": "graphile-function-bindings", + "version": "0.1.0", + "description": "PostGraphile v5 plugin — exposes API-bound compute functions as typed GraphQL mutations backed by function_invocations inserts", + "author": "Constructive ", + "homepage": "https://github.com/constructive-io/constructive", + "license": "MIT", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest --forceExit", + "test:watch": "jest --watch" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "keywords": [ + "postgraphile", + "graphile", + "constructive", + "plugin", + "postgres", + "graphql", + "functions", + "mutations" + ], + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "dependencies": { + "@constructive-io/query-builder": "workspace:^", + "@pgpmjs/logger": "workspace:^", + "inflekt": "^0.7.1", + "pg": "^8.21.0" + }, + "peerDependencies": { + "@dataplan/pg": "1.0.3", + "grafast": "1.0.2", + "graphile-build": "5.0.2", + "graphile-build-pg": "5.0.2", + "graphile-config": "1.0.1", + "graphql": "16.13.0" + }, + "devDependencies": { + "@types/node": "^22.19.11", + "@types/pg": "^8.20.0", + "graphile-test": "workspace:^", + "makage": "^0.3.0", + "pgsql-test": "workspace:^" + } +} diff --git a/graphile/graphile-function-bindings/src/__tests__/__snapshots__/plugin.test.ts.snap b/graphile/graphile-function-bindings/src/__tests__/__snapshots__/plugin.test.ts.snap new file mode 100644 index 000000000..b17863591 --- /dev/null +++ b/graphile/graphile-function-bindings/src/__tests__/__snapshots__/plugin.test.ts.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`function bindings plugin derives a typed input (enum + required) from a config JSON Schema 1`] = ` +{ + "inputFields": [ + { + "name": "clientMutationId", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + { + "name": "mode", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ValidateOrderModeEnum", + }, + }, + }, + { + "name": "count", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null, + }, + }, + ], + "name": "ValidateOrderInput", +} +`; + +exports[`function bindings plugin derives a typed input from payload_args 1`] = ` +{ + "inputFields": [ + { + "name": "clientMutationId", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + { + "name": "url", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + { + "name": "width", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null, + }, + }, + ], + "name": "ResizeInput", +} +`; diff --git a/graphile/graphile-function-bindings/src/__tests__/derive.test.ts b/graphile/graphile-function-bindings/src/__tests__/derive.test.ts new file mode 100644 index 000000000..25058094a --- /dev/null +++ b/graphile/graphile-function-bindings/src/__tests__/derive.test.ts @@ -0,0 +1,164 @@ +import { + buildInvocationPayload, + deriveInputFields, + isGraphqlEnabled +} from '../derive'; +import type { FunctionBindingRow } from '../types'; + +const baseBinding: FunctionBindingRow = { + bindingId: 'b1', + alias: 'resize_image', + config: null, + functionDefinitionId: 'd1', + taskIdentifier: 'images:resize', + description: null, + payloadArgs: null +}; + +describe('isGraphqlEnabled', () => { + it('is enabled when config is null', () => { + expect(isGraphqlEnabled(null)).toBe(true); + }); + + it('is enabled when graphql key is absent', () => { + expect(isGraphqlEnabled({ rest: { path: '/resize' } })).toBe(true); + }); + + it('is enabled when graphql is true', () => { + expect(isGraphqlEnabled({ graphql: true })).toBe(true); + }); + + it('is disabled when graphql is false', () => { + expect(isGraphqlEnabled({ graphql: false })).toBe(false); + }); +}); + +describe('deriveInputFields', () => { + it('falls back to a single JSON payload field with no metadata', () => { + const derived = deriveInputFields(baseBinding); + expect(derived.source).toBe('fallback'); + expect(derived.fields).toHaveLength(1); + expect(derived.fields[0]).toMatchObject({ + name: 'payload', + scalar: 'JSON', + required: false, + list: false + }); + }); + + it('falls back when payload_args is an empty array', () => { + const derived = deriveInputFields({ ...baseBinding, payloadArgs: [] }); + expect(derived.source).toBe('fallback'); + }); + + it('derives nullable scalar fields from payload_args', () => { + const derived = deriveInputFields({ + ...baseBinding, + payloadArgs: [ + { name: 'url', type: 'text' }, + { name: 'width', type: 'int' }, + { name: 'id', type: 'uuid' }, + { name: 'ratio', type: 'numeric' }, + { name: 'force', type: 'boolean' }, + { name: 'meta', type: 'jsonb' }, + { name: 'at', type: 'timestamptz' }, + { name: 'other', type: 'unknown_type' } + ] + }); + expect(derived.source).toBe('payload-args'); + expect(derived.fields.map((f) => [f.name, f.scalar, f.required])).toEqual([ + ['url', 'String', false], + ['width', 'Int', false], + ['id', 'UUID', false], + ['ratio', 'BigFloat', false], + ['force', 'Boolean', false], + ['meta', 'JSON', false], + ['at', 'Datetime', false], + ['other', 'JSON', false] + ]); + }); + + it('falls back when payload_args entries are malformed', () => { + const derived = deriveInputFields({ + ...baseBinding, + payloadArgs: [{ name: '', type: 'text' }] + }); + expect(derived.source).toBe('fallback'); + }); + + it('derives fields from a JSON Schema in binding config', () => { + const derived = deriveInputFields({ + ...baseBinding, + config: { + schema: { + type: 'object', + required: ['mode'], + properties: { + mode: { type: 'string', enum: ['fast', 'slow'] }, + count: { type: 'integer' }, + price: { type: 'number' }, + flag: { type: 'boolean' }, + tags: { type: 'array', items: { type: 'string' } }, + nested: { type: 'object' } + } + } + } + }); + expect(derived.source).toBe('schema'); + const byName = Object.fromEntries(derived.fields.map((f) => [f.name, f])); + expect(byName.mode).toMatchObject({ required: true, enumValues: ['fast', 'slow'] }); + expect(byName.count).toMatchObject({ scalar: 'Int', required: false }); + expect(byName.price).toMatchObject({ scalar: 'Float' }); + expect(byName.flag).toMatchObject({ scalar: 'Boolean' }); + expect(byName.tags).toMatchObject({ scalar: 'String', list: true }); + expect(byName.nested).toMatchObject({ scalar: 'JSON' }); + }); + + it('prefers config schema over payload_args', () => { + const derived = deriveInputFields({ + ...baseBinding, + config: { + schema: { + type: 'object', + properties: { only: { type: 'string' } } + } + }, + payloadArgs: [{ name: 'url', type: 'text' }] + }); + expect(derived.source).toBe('schema'); + expect(derived.fields.map((f) => f.name)).toEqual(['only']); + }); + + it('ignores non-object config schemas', () => { + const derived = deriveInputFields({ + ...baseBinding, + config: { schema: { type: 'string' } } + }); + expect(derived.source).toBe('fallback'); + }); +}); + +describe('buildInvocationPayload', () => { + it('passes the payload through verbatim in fallback mode', () => { + const derived = deriveInputFields(baseBinding); + expect(buildInvocationPayload(derived, { payload: { a: 1 } })).toEqual({ a: 1 }); + expect(buildInvocationPayload(derived, {})).toBeNull(); + }); + + it('builds a keyed payload from typed fields, omitting undefined', () => { + const derived = deriveInputFields({ + ...baseBinding, + payloadArgs: [ + { name: 'url', type: 'text' }, + { name: 'width', type: 'int' } + ] + }); + expect(buildInvocationPayload(derived, { url: 'x', width: undefined })).toEqual({ + url: 'x' + }); + expect(buildInvocationPayload(derived, { url: 'x', width: null })).toEqual({ + url: 'x', + width: null + }); + }); +}); diff --git a/graphile/graphile-function-bindings/src/__tests__/plugin.test.ts b/graphile/graphile-function-bindings/src/__tests__/plugin.test.ts new file mode 100644 index 000000000..7fea8a3d6 --- /dev/null +++ b/graphile/graphile-function-bindings/src/__tests__/plugin.test.ts @@ -0,0 +1,266 @@ +/** + * Integration tests for the function bindings plugin. + * + * Uses graphile-test with a real PostgreSQL database seeded with the shared + * compute fixtures (__fixtures__/seed/{services,compute}) to verify: + * - one mutation per graphql-enabled binding for the configured api + * - graphql-disabled bindings and other-api bindings are not exposed + * - payload_args-derived and JSON-Schema-derived input types + * - fallback `payload: JSON` input + * - the resolver inserts an RLS-visible function_invocations row + */ + +import type { GraphQLResponse } from 'graphile-test'; +import { getConnections, seed } from 'graphile-test'; +import { join } from 'path'; + +import { createFunctionBindingsPlugin } from '../plugin'; + +// The "app" API from the shared services fixture +const API_ID = '6c9997a4-591b-4cb3-9313-4ef45d6f134e'; + +const sharedSeedRoot = join(__dirname, '..', '..', '..', '..', '__fixtures__', 'seed'); +const shared = (...segments: string[]) => join(sharedSeedRoot, ...segments); + +const seedFiles = [ + shared('services', 'setup.sql'), + shared('compute', 'setup.sql'), + shared('services', 'test-data.sql'), + shared('compute', 'test-data.sql') +]; + +type QueryFn = ( + query: string, + variables?: Record, + commit?: boolean, + reqOptions?: Record +) => Promise>; + +interface MutationFieldsResult { + __schema: { + mutationType: { + fields: { name: string }[]; + }; + }; +} + +interface InputTypeResult { + __type: { + name: string; + inputFields: { + name: string; + type: { + kind: string; + name: string | null; + ofType: { kind: string; name: string | null } | null; + }; + }[]; + } | null; +} + +describe('function bindings plugin', () => { + let pg: any; + let teardown: () => Promise; + let query: QueryFn; + + beforeAll(async () => { + // In the server these names come from the express-context compute + // module loader (constructive metaschema); tests supply them directly. + const plugin = createFunctionBindingsPlugin({ + apiId: API_ID, + modules: [ + { + computeSchema: 'compute_public', + bindingsTable: 'function_api_bindings', + definitionsTable: 'function_definitions', + invocationsSchema: 'compute_public', + invocationsTable: 'function_invocations', + invocationsEntityField: null + } + ] + }); + + const connections = await (getConnections as any)( + { + schemas: ['compute_public'], + preset: { plugins: [plugin] }, + useRoot: true, + authRole: 'postgres' + }, + [seed.sqlfile(seedFiles)] + ); + + pg = connections.pg; + teardown = connections.teardown; + query = connections.query; + }); + + afterAll(async () => { + await teardown(); + }); + + it('exposes one mutation per graphql-enabled binding for the api', async () => { + const result = await query(` + { __schema { mutationType { fields { name } } } } + `); + expect(result.errors).toBeUndefined(); + const names = result.data!.__schema.mutationType.fields.map((f) => f.name); + expect(names).toContain('resize'); + expect(names).toContain('sendEmail'); + expect(names).toContain('validateOrder'); + }); + + it('does not expose graphql-disabled bindings', async () => { + const result = await query(` + { __schema { mutationType { fields { name } } } } + `); + const names = result.data!.__schema.mutationType.fields.map((f) => f.name); + expect(names).not.toContain('secretFn'); + }); + + it('does not expose bindings belonging to other apis', async () => { + const result = await query(` + { __schema { mutationType { fields { name } } } } + `); + const names = result.data!.__schema.mutationType.fields.map((f) => f.name); + expect(names).not.toContain('otherApiFn'); + }); + + it('derives a typed input from payload_args', async () => { + const result = await query(` + { + __type(name: "ResizeInput") { + name + inputFields { + name + type { kind name ofType { kind name } } + } + } + } + `); + expect(result.errors).toBeUndefined(); + expect(result.data!.__type).toMatchSnapshot(); + }); + + it('derives a typed input (enum + required) from a config JSON Schema', async () => { + const result = await query(` + { + __type(name: "ValidateOrderInput") { + name + inputFields { + name + type { kind name ofType { kind name } } + } + } + } + `); + expect(result.errors).toBeUndefined(); + expect(result.data!.__type).toMatchSnapshot(); + const mode = result.data!.__type!.inputFields.find((f) => f.name === 'mode'); + expect(mode!.type.kind).toBe('NON_NULL'); + expect(mode!.type.ofType!.kind).toBe('ENUM'); + }); + + it('uses a JSON payload fallback input when no metadata is available', async () => { + const result = await query(` + { + __type(name: "SendEmailInput") { + name + inputFields { + name + type { kind name ofType { kind name } } + } + } + } + `); + expect(result.errors).toBeUndefined(); + const fieldNames = result.data!.__type!.inputFields.map((f) => f.name); + expect(fieldNames).toEqual(['clientMutationId', 'payload']); + }); + + it('inserts a function invocation row on mutation', async () => { + const result = await query<{ + resize: { invocationId: string; status: string; clientMutationId: string | null }; + }>( + ` + mutation ($input: ResizeInput!) { + resize(input: $input) { + clientMutationId + invocationId + status + } + } + `, + { input: { url: 'https://example.com/a.png', width: 640, clientMutationId: 'cm1' } } + ); + expect(result.errors).toBeUndefined(); + const payload = result.data!.resize; + expect(payload.status).toBe('pending'); + expect(payload.clientMutationId).toBe('cm1'); + expect(payload.invocationId).toBeTruthy(); + + const { rows } = await pg.query( + `SELECT task_identifier, payload, status FROM compute_public.function_invocations WHERE id = $1`, + [payload.invocationId] + ); + expect(rows).toHaveLength(1); + expect(rows[0].task_identifier).toBe('app:resize_image'); + expect(rows[0].status).toBe('pending'); + expect(rows[0].payload).toEqual({ url: 'https://example.com/a.png', width: 640 }); + }); + + it('passes the fallback JSON payload through verbatim', async () => { + const result = await query<{ + sendEmail: { invocationId: string; status: string }; + }>( + ` + mutation ($input: SendEmailInput!) { + sendEmail(input: $input) { + invocationId + status + } + } + `, + { input: { payload: { to: 'a@b.c', subject: 'hi' } } } + ); + expect(result.errors).toBeUndefined(); + const payload = result.data!.sendEmail; + + const { rows } = await pg.query( + `SELECT task_identifier, payload FROM compute_public.function_invocations WHERE id = $1`, + [payload.invocationId] + ); + expect(rows).toHaveLength(1); + expect(rows[0].task_identifier).toBe('app:send_email'); + expect(rows[0].payload).toEqual({ to: 'a@b.c', subject: 'hi' }); + }); + + it('returns the created invocation via the FunctionInvocation type', async () => { + const result = await query<{ + resize: { + invocationId: string; + invocation: { rowId: string; status: string; taskIdentifier: string } | null; + }; + }>( + ` + mutation ($input: ResizeInput!) { + resize(input: $input) { + invocationId + invocation { + rowId + status + taskIdentifier + } + } + } + `, + { input: { url: 'https://example.com/b.png' } } + ); + expect(result.errors).toBeUndefined(); + const payload = result.data!.resize; + expect(payload.invocation).not.toBeNull(); + expect(payload.invocation!.rowId).toBe(payload.invocationId); + expect(payload.invocation!.status).toBe('pending'); + expect(payload.invocation!.taskIdentifier).toBe('app:resize_image'); + }); +}); diff --git a/graphile/graphile-function-bindings/src/derive.ts b/graphile/graphile-function-bindings/src/derive.ts new file mode 100644 index 000000000..e4571ab7d --- /dev/null +++ b/graphile/graphile-function-bindings/src/derive.ts @@ -0,0 +1,193 @@ +/** + * Input type derivation for function binding mutations. + * + * Derives a protocol-agnostic field list from a function's payload metadata. + * Priority: + * 1. A JSON Schema on the binding config (`config.schema` or + * `config.payloadSchema`) — object properties become input fields. + * 2. The definition's `payload_args` ([{name, type}]) — each declared arg + * becomes a nullable scalar field. + * 3. Fallback: a single `payload: JSON` field. + * + * The derived shape is deliberately independent of graphql-js so it can be + * reused for future REST payload validation. No runtime validation is + * performed here — this is type derivation only. + */ + +import type { FunctionBindingRow, JsonSchemaNode, PayloadArg } from './types'; + +/** Named scalar kinds the plugin knows how to resolve against the schema. */ +export type DerivedScalar = + | 'String' + | 'Int' + | 'Float' + | 'Boolean' + | 'UUID' + | 'Datetime' + | 'BigFloat' + | 'JSON'; + +export interface DerivedField { + name: string; + scalar: DerivedScalar; + required: boolean; + list: boolean; + description?: string; + /** When set, the field is an enum of these string values. */ + enumValues?: string[]; +} + +export interface DerivedInput { + /** 'schema' | 'payload-args' | 'fallback' */ + source: 'schema' | 'payload-args' | 'fallback'; + fields: DerivedField[]; +} + +/** Postgres payload_args type → GraphQL scalar name. */ +const PG_TYPE_SCALARS: Record = { + uuid: 'UUID', + text: 'String', + int: 'Int', + boolean: 'Boolean', + numeric: 'BigFloat', + jsonb: 'JSON', + timestamptz: 'Datetime' +}; + +/** JSON Schema type → GraphQL scalar name. */ +const JSON_SCHEMA_SCALARS: Record = { + string: 'String', + integer: 'Int', + number: 'Float', + boolean: 'Boolean', + object: 'JSON', + null: 'JSON' +}; + +const FALLBACK_FIELD: DerivedField = { + name: 'payload', + scalar: 'JSON', + required: false, + list: false, + description: 'Raw JSON payload passed to the function' +}; + +function schemaNodeType(node: JsonSchemaNode): string | undefined { + if (Array.isArray(node.type)) { + return node.type.find((t) => t !== 'null'); + } + return node.type; +} + +function deriveFromSchemaNode( + name: string, + node: JsonSchemaNode, + required: boolean +): DerivedField { + const base: DerivedField = { + name, + scalar: 'JSON', + required, + list: false, + ...(node.description ? { description: node.description } : {}) + }; + + const enumValues = node.enum?.filter( + (v): v is string => typeof v === 'string' + ); + if (enumValues && enumValues.length > 0 && enumValues.length === node.enum!.length) { + return { ...base, scalar: 'String', enumValues }; + } + + const type = schemaNodeType(node); + if (type === 'array') { + const item = node.items + ? deriveFromSchemaNode(name, node.items, false) + : base; + return { ...base, scalar: item.scalar, enumValues: item.enumValues, list: true }; + } + + const scalar = type ? JSON_SCHEMA_SCALARS[type] : undefined; + return { ...base, scalar: scalar ?? 'JSON' }; +} + +function deriveFromJsonSchema(schema: JsonSchemaNode): DerivedField[] | null { + if (schemaNodeType(schema) !== 'object' || !schema.properties) return null; + const required = new Set(schema.required ?? []); + const fields = Object.entries(schema.properties).map(([name, node]) => + deriveFromSchemaNode(name, node, required.has(name)) + ); + return fields.length > 0 ? fields : null; +} + +function deriveFromPayloadArgs(args: PayloadArg[]): DerivedField[] | null { + const fields: DerivedField[] = []; + for (const arg of args) { + if (!arg || typeof arg.name !== 'string' || arg.name.length === 0) { + return null; + } + fields.push({ + name: arg.name, + scalar: PG_TYPE_SCALARS[arg.type] ?? 'JSON', + required: false, + list: false + }); + } + return fields.length > 0 ? fields : null; +} + +function bindingJsonSchema(binding: FunctionBindingRow): JsonSchemaNode | null { + const config = binding.config; + if (!config) return null; + const candidate = config.schema ?? config.payloadSchema; + if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) { + return candidate as JsonSchemaNode; + } + return null; +} + +/** + * Derive the input field list for a binding's mutation. + */ +export function deriveInputFields(binding: FunctionBindingRow): DerivedInput { + const jsonSchema = bindingJsonSchema(binding); + if (jsonSchema) { + const fields = deriveFromJsonSchema(jsonSchema); + if (fields) return { source: 'schema', fields }; + } + + if (Array.isArray(binding.payloadArgs) && binding.payloadArgs.length > 0) { + const fields = deriveFromPayloadArgs(binding.payloadArgs); + if (fields) return { source: 'payload-args', fields }; + } + + return { source: 'fallback', fields: [FALLBACK_FIELD] }; +} + +/** + * Assemble the invocation payload jsonb from the mutation input values. + * Fallback inputs pass `payload` through verbatim; typed inputs build an + * object keyed by field name, omitting undefined values. + */ +export function buildInvocationPayload( + derived: DerivedInput, + input: Record +): unknown { + if (derived.source === 'fallback') { + return input.payload ?? null; + } + const payload: Record = {}; + for (const field of derived.fields) { + const value = input[field.name]; + if (value !== undefined) { + payload[field.name] = value; + } + } + return payload; +} + +/** Whether a binding's config enables GraphQL exposure (absent ⇒ enabled). */ +export function isGraphqlEnabled(config: Record | null): boolean { + if (!config) return true; + return config.graphql !== false; +} diff --git a/graphile/graphile-function-bindings/src/index.ts b/graphile/graphile-function-bindings/src/index.ts new file mode 100644 index 000000000..dc947b2c6 --- /dev/null +++ b/graphile/graphile-function-bindings/src/index.ts @@ -0,0 +1,18 @@ +export type { + DerivedField, + DerivedInput, + DerivedScalar +} from './derive'; +export { + buildInvocationPayload, + deriveInputFields, + isGraphqlEnabled +} from './derive'; +export { createFunctionBindingsPlugin } from './plugin'; +export type { + ComputeModuleNames, + FunctionBindingRow, + FunctionBindingsPluginOptions, + JsonSchemaNode, + PayloadArg +} from './types'; diff --git a/graphile/graphile-function-bindings/src/plugin.ts b/graphile/graphile-function-bindings/src/plugin.ts new file mode 100644 index 000000000..93f0b820d --- /dev/null +++ b/graphile/graphile-function-bindings/src/plugin.ts @@ -0,0 +1,370 @@ +/** + * PostGraphile v5 Function Bindings Plugin + * + * Exposes API-bound compute functions as GraphQL mutations. At gather time + * the plugin queries the bindings table joined to the definitions table + * (schema/table names resolved from the constructive metaschema via the + * express-context compute module loader — never guessed or hard-coded) + * for the configured api_id and emits one mutation per graphql-enabled + * binding: + * + * (input: Input!): Payload + * + * The input type is derived from the function's payload metadata (JSON Schema + * on the binding config, else payload_args, else a raw `payload: JSON` + * field — see derive.ts). The payload returns the created invocation row + * (id/status), reusing the FunctionInvocation type when the table is exposed. + * + * Invoking = a plain, RLS-enforced INSERT into function_invocations on the + * normal authenticated connection. pgSettings (including jwt.claims.api_id + * provenance) are supplied by the server layer — this plugin never sets + * claims or bypasses RLS. No runtime payload validation is performed. + * + * Bindings are loaded once per schema build (gather phase). New or changed + * bindings appear after the next schema rebuild — the server's existing + * schema cache invalidation (LISTEN/NOTIFY via graphile-cache) or a restart. + * TODO: emit a cache invalidation NOTIFY on function_api_bindings changes so + * rebuilds happen automatically. + */ + +import 'graphile-build'; +import 'graphile-build-pg'; + +import { withPgClientFromPgService } from '@dataplan/pg'; +import { Logger } from '@pgpmjs/logger'; +import { QueryBuilder, type SqlValue } from '@constructive-io/query-builder'; +import { access, context as grafastContext, lambda, object } from 'grafast'; +import type { GraphileConfig } from 'graphile-config'; +import { toCamelCase, toConstantCase, toPascalCase } from 'inflekt'; + +import type { DerivedField, DerivedInput } from './derive'; +import { buildInvocationPayload, deriveInputFields, isGraphqlEnabled } from './derive'; +import type { ComputeModuleNames, FunctionBindingRow, FunctionBindingsPluginOptions } from './types'; + +const log = new Logger('graphile-function-bindings'); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace GraphileConfig { + interface Plugins { + FunctionBindingsPlugin: true; + } + interface GatherHelpers { + functionBindings: Record; + } + } +} + +/** A binding together with the module (scope) it was loaded from. */ +interface LoadedBinding extends FunctionBindingRow { + module: ComputeModuleNames; +} + +interface FunctionBindingsBuildInput { + bindings: LoadedBinding[]; +} + +const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +async function loadBindings( + pgService: GraphileConfig.PgServiceConfiguration, + options: FunctionBindingsPluginOptions +): Promise { + return withPgClientFromPgService(pgService, null, async (client) => { + const bindings: LoadedBinding[] = []; + for (const module of options.modules) { + const { computeSchema, bindingsTable, definitionsTable } = module; + const { text, values } = new QueryBuilder() + .schema(computeSchema) + .table(bindingsTable, 'b') + .select([ + 'b.id', + 'b.alias', + 'b.config', + 'b.function_definition_id', + 'd.task_identifier', + 'd.description', + 'd.payload_args' + ]) + .innerJoin(definitionsTable, 'b.function_definition_id', '=', 'd.id', { + schema: computeSchema, + alias: 'd' + }) + .where('b.api_id', '=', options.apiId) + .orderBy('b.alias', 'ASC') + .build(); + const { rows } = await client.query<{ + id: string; + alias: string; + config: Record | null; + function_definition_id: string; + task_identifier: string; + description: string | null; + payload_args: FunctionBindingRow['payloadArgs']; + }>({ text, values }); + + for (const row of rows) { + if (!isGraphqlEnabled(row.config)) continue; + bindings.push({ + bindingId: row.id, + alias: row.alias, + config: row.config, + functionDefinitionId: row.function_definition_id, + taskIdentifier: row.task_identifier, + description: row.description, + payloadArgs: row.payload_args, + module + }); + } + } + + return { bindings }; + }); +} + +export function createFunctionBindingsPlugin( + options: FunctionBindingsPluginOptions +): GraphileConfig.Plugin { + return { + name: 'FunctionBindingsPlugin', + version: '0.1.0', + description: + 'Exposes API-bound compute functions as GraphQL mutations backed by RLS-enforced function_invocations inserts', + + after: ['PgTablesPlugin', 'PgMutationCreatePlugin'], + + gather: { + namespace: 'functionBindings', + helpers: {}, + async main(output, info) { + const pgService = info.resolvedPreset.pgServices?.[0]; + if (!pgService) { + throw new Error('FunctionBindingsPlugin: no pgService configured'); + } + if (!options.apiId) { + throw new Error('FunctionBindingsPlugin: apiId is required'); + } + if (!options.modules?.length) { + throw new Error('FunctionBindingsPlugin: at least one compute module is required'); + } + const result = await loadBindings(pgService, options); + (output as Record).functionApiBindings = result; + log.debug( + `Loaded ${result.bindings.length} graphql-enabled function binding(s) for api ${options.apiId}` + ); + } + }, + + schema: { + hooks: { + GraphQLObjectType_fields(fields, build, context) { + const { scope, fieldWithHooks } = context; + if (!scope.isRootMutation) return fields; + + const loaded = (build.input as unknown as Record) + .functionApiBindings as FunctionBindingsBuildInput | undefined; + if (!loaded || loaded.bindings.length === 0) return fields; + + const { + graphql: { + GraphQLString, + GraphQLNonNull, + GraphQLObjectType, + GraphQLInputObjectType, + GraphQLEnumType, + GraphQLList + } + } = build; + + const namedType = (name: string) => { + try { + return build.getTypeByName(name) ?? GraphQLString; + } catch { + return GraphQLString; + } + }; + + const findInvocationsResource = (module: ComputeModuleNames) => + Object.values( + (build.input.pgRegistry?.pgResources ?? {}) as Record + ).find( + (resource: any) => + resource?.codec?.extensions?.pg?.name === module.invocationsTable && + resource?.codec?.extensions?.pg?.schemaName === module.invocationsSchema && + !resource.parameters + ); + + const newFields: Record = {}; + + for (const binding of loaded.bindings) { + if (!IDENT_RE.test(toCamelCase(binding.alias))) { + log.warn(`Skipping binding "${binding.alias}": alias is not a valid GraphQL name`); + continue; + } + const fieldName = toCamelCase(binding.alias); + const typePrefix = toPascalCase(binding.alias); + if (fields[fieldName] || newFields[fieldName]) { + log.warn(`Skipping binding "${binding.alias}": mutation field "${fieldName}" already exists`); + continue; + } + + const invocationsResource = findInvocationsResource(binding.module); + const invocationType = invocationsResource + ? (build.getGraphQLTypeByPgCodec?.(invocationsResource.codec, 'output') as + | import('graphql').GraphQLObjectType + | undefined) + : undefined; + + const derived: DerivedInput = deriveInputFields(binding); + + const inputFieldConfig = (field: DerivedField) => { + let type: import('graphql').GraphQLInputType; + if (field.enumValues) { + type = new GraphQLEnumType({ + name: `${typePrefix}${toPascalCase(field.name)}Enum`, + values: Object.fromEntries( + field.enumValues.map((v) => [toConstantCase(v), { value: v }]) + ) + }); + } else { + type = namedType(field.scalar) as import('graphql').GraphQLInputType; + } + if (field.list) { + type = new GraphQLList(new GraphQLNonNull(type)); + } + if (field.required) { + type = new GraphQLNonNull(type); + } + return { + type, + ...(field.description ? { description: field.description } : {}) + }; + }; + + const InputType = new GraphQLInputObjectType({ + name: `${typePrefix}Input`, + fields: { + clientMutationId: { type: GraphQLString }, + ...Object.fromEntries( + derived.fields.map((field) => [field.name, inputFieldConfig(field)]) + ) + } + }); + + const capturedBinding = binding; + const capturedDerived = derived; + const capturedInvocationsSchema = binding.module.invocationsSchema; + const capturedInvocationsTable = binding.module.invocationsTable; + const capturedInvocationsEntityField = binding.module.invocationsEntityField; + const capturedResource = invocationsResource; + + const PayloadType = new GraphQLObjectType({ + name: `${typePrefix}Payload`, + fields: { + clientMutationId: { type: GraphQLString }, + invocationId: { + type: namedType('UUID') as import('graphql').GraphQLOutputType + }, + status: { type: GraphQLString }, + ...(invocationType && capturedResource + ? { + invocation: { + type: invocationType, + description: 'The created function invocation', + extensions: { + grafast: { + plan($payload: any) { + const $id = access($payload, 'invocationId'); + return capturedResource.get({ id: $id }); + } + } + } + } + } + : {}) + } + }); + + log.debug(`Adding function binding mutation "${fieldName}" (${binding.taskIdentifier})`); + + newFields[fieldName] = fieldWithHooks( + { fieldName } as any, + { + description: + binding.description ?? + `Invoke the "${binding.alias}" function (${binding.taskIdentifier})`, + type: PayloadType, + args: { + input: { type: new GraphQLNonNull(InputType) } + }, + plan(_$mutation: any, fieldArgs: any) { + const $input = fieldArgs.getRaw('input'); + const inputSteps: Record = { + clientMutationId: access($input, 'clientMutationId') + }; + for (const field of capturedDerived.fields) { + inputSteps[field.name] = access($input, field.name); + } + const $withPgClient = (grafastContext() as any).get('withPgClient'); + const $pgSettings = (grafastContext() as any).get('pgSettings'); + + const $combined = object({ + ...inputSteps, + withPgClient: $withPgClient, + pgSettings: $pgSettings + }); + + const $result = lambda($combined, async (vals: any) => { + const { withPgClient, pgSettings, clientMutationId, ...input } = vals; + const payload = buildInvocationPayload(capturedDerived, input); + // API-channel provenance: set both the definition and the + // binding the invocation came through, at status 'pending'. + // The database's AFTER INSERT enqueue trigger schedules the + // job — the plugin never enqueues. + const insertData: Record = { + task_identifier: capturedBinding.taskIdentifier, + function_definition_id: capturedBinding.functionDefinitionId, + api_binding_id: capturedBinding.bindingId, + status: 'pending', + payload: payload === null ? null : JSON.stringify(payload) + }; + // Scope-key column driven by the module's recorded + // entity_field: set for the database scope (database_id + // from the request's jwt claim), absent for global scopes. + if (capturedInvocationsEntityField) { + const dbId = pgSettings?.['jwt.claims.database_id']; + insertData[capturedInvocationsEntityField] = dbId ? dbId : null; + } + const { text, values } = new QueryBuilder() + .schema(capturedInvocationsSchema) + .table(capturedInvocationsTable) + .insert(insertData) + .returning(['id', 'status']) + .build(); + return withPgClient(pgSettings, async (pgClient: any) => { + const { rows } = await pgClient.query({ text, values }); + const row = rows[0]; + return { + clientMutationId: clientMutationId ?? null, + invocationId: row.id, + status: row.status + }; + }); + }); + // The insert is a side effect — grafast must not dedupe, + // reorder, or defer it relative to dependent output plans. + ($result as any).hasSideEffects = true; + return $result; + } + } + ); + } + + if (Object.keys(newFields).length === 0) return fields; + + return build.extend(fields, newFields, 'Adding function binding mutations'); + } + } + } + }; +} diff --git a/graphile/graphile-function-bindings/src/types.ts b/graphile/graphile-function-bindings/src/types.ts new file mode 100644 index 000000000..1fd3576ea --- /dev/null +++ b/graphile/graphile-function-bindings/src/types.ts @@ -0,0 +1,71 @@ +/** + * A single ordered payload argument declaration from + * function_definitions.payload_args ([{name, type}]). + */ +export interface PayloadArg { + name: string; + type: string; +} + +/** + * Minimal JSON Schema subset used for GraphQL input type derivation. + * Runtime validation is intentionally NOT performed against this schema — + * it is only used to shape the generated input types. + */ +export interface JsonSchemaNode { + type?: string | string[]; + properties?: Record; + required?: string[]; + enum?: unknown[]; + items?: JsonSchemaNode; + description?: string; +} + +/** + * A graphql-enabled function_api_bindings row joined to its + * function_definitions row, loaded at gather time. + */ +export interface FunctionBindingRow { + bindingId: string; + alias: string; + config: Record | null; + functionDefinitionId: string; + taskIdentifier: string; + description: string | null; + payloadArgs: PayloadArg[] | null; +} + +/** + * Physical names of one function-module scope's compute tables, resolved + * from the constructive metaschema (metaschema_modules_public.function_module + * / function_invocation_module) by the express-context compute module loader. + * The plugin never guesses or hard-codes physical names. + */ +export interface ComputeModuleNames { + /** Schema containing the bindings and definitions tables. */ + computeSchema: string; + /** Bindings table name. */ + bindingsTable: string; + /** Definitions table name. */ + definitionsTable: string; + /** Schema containing the invocations table. */ + invocationsSchema: string; + /** Invocations table name. */ + invocationsTable: string; + /** + * Scope-key column of the invocations table (metaschema `entity_field`): + * `database_id` for the database scope, `null` for global scopes. Set on + * the invocation insert instead of switching on scope name. + */ + invocationsEntityField: string | null; +} + +export interface FunctionBindingsPluginOptions { + /** Only bindings for this api are exposed as mutations. */ + apiId: string; + /** + * One entry per provisioned function-module scope. Bindings from every + * module are exposed; RLS on the underlying tables governs access. + */ + modules: ComputeModuleNames[]; +} diff --git a/graphile/graphile-function-bindings/tsconfig.esm.json b/graphile/graphile-function-bindings/tsconfig.esm.json new file mode 100644 index 000000000..aa8f22bf7 --- /dev/null +++ b/graphile/graphile-function-bindings/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "dist/esm" + } +} diff --git a/graphile/graphile-function-bindings/tsconfig.json b/graphile/graphile-function-bindings/tsconfig.json new file mode 100644 index 000000000..63ca6be40 --- /dev/null +++ b/graphile/graphile-function-bindings/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} diff --git a/graphql/server-test/__tests__/fn-routes.integration.test.ts b/graphql/server-test/__tests__/fn-routes.integration.test.ts new file mode 100644 index 000000000..4e8d76ac5 --- /dev/null +++ b/graphql/server-test/__tests__/fn-routes.integration.test.ts @@ -0,0 +1,147 @@ +/** + * REST Function Route Integration Tests + * + * Verifies the /fn REST routes and the server-injected API provenance + * claim (`jwt.claims.api_id`): + * + * POST /fn/:alias → 202 { invocationId } for a rest-enabled binding + * GET /fn/invocations/:id → invocation status/result (RLS-guarded read) + * + * The seeded function_invocations INSERT policy only passes when + * `jwt.claims.api_id` (set transaction-locally by the server from the + * hostname → services_public.domains → api_id resolution) matches the + * binding's api_id — so the happy path also proves the claim is injected. + * + * Run tests: + * pnpm test -- --testPathPattern=fn-routes.integration + */ + +import path from 'path'; +import { getConnections, seed } from '../src'; +import type supertest from 'supertest'; + +jest.setTimeout(30000); + +const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed'); +const shared = (...segments: string[]) => + path.join(sharedSeedRoot, ...segments); +const schemas = ['simple-pets-public', 'simple-pets-pets-public']; +const metaSchemas = [ + 'services_public', + 'metaschema_public', + 'metaschema_modules_public', +]; + +const seedFiles = [ + shared('services', 'setup.sql'), + shared('compute', 'setup.sql'), + shared('app-schemas', 'simple-pets', 'schema.sql'), + shared('services', 'test-data.sql'), + shared('compute', 'test-data.sql'), + shared('app-schemas', 'simple-pets', 'test-data.sql'), +]; + +const API_HOST = 'app.test.constructive.io'; + +let request: supertest.Agent; +let teardown: () => Promise; + +beforeAll(async () => { + ({ request, teardown } = await getConnections( + { + schemas, + authRole: 'anonymous', + server: { + api: { + enableServicesApi: true, + isPublic: true, + metaSchemas, + }, + }, + }, + [seed.sqlfile(seedFiles)] + )); +}); + +afterAll(async () => { + await teardown(); +}); + +describe('POST /fn/:alias', () => { + it('returns 202 with an invocationId for a rest-enabled binding', async () => { + const res = await request + .post('/fn/resize') + .set('Host', API_HOST) + .send({ width: 100, height: 100 }); + + expect(res.status).toBe(202); + expect(res.body.invocationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }); + + it('the created invocation passed the jwt.claims.api_id RLS policy', async () => { + // The INSERT policy on function_invocations requires + // jwt.claims.api_id to match the binding's api_id, so a successful + // insert proves the server injected the claim transaction-locally. + const create = await request + .post('/fn/resize') + .set('Host', API_HOST) + .send({ check: 'claim' }); + + expect(create.status).toBe(202); + + const read = await request + .get(`/fn/invocations/${create.body.invocationId}`) + .set('Host', API_HOST); + + expect(read.status).toBe(200); + expect(read.body.id).toBe(create.body.invocationId); + expect(read.body.status).toBe('pending'); + }); + + it('returns 404 for a missing binding', async () => { + const res = await request + .post('/fn/nonexistent') + .set('Host', API_HOST) + .send({}); + + expect(res.status).toBe(404); + }); + + it('returns 404 for a rest-disabled binding (absent rest config)', async () => { + const res = await request + .post('/fn/graphql-only') + .set('Host', API_HOST) + .send({}); + + expect(res.status).toBe(404); + }); + + it('returns 404 when the HTTP method is not allowed by config.rest', async () => { + const res = await request + .put('/fn/resize') + .set('Host', API_HOST) + .send({}); + + expect(res.status).toBe(404); + }); +}); + +describe('GET /fn/invocations/:id', () => { + it('returns 404 for an unknown invocation id', async () => { + const res = await request + .get('/fn/invocations/00000000-0000-4000-8000-000000000000') + .set('Host', API_HOST); + + expect(res.status).toBe(404); + }); + + it('returns 404 for a non-uuid id', async () => { + const res = await request + .get('/fn/invocations/not-a-uuid') + .set('Host', API_HOST); + + expect(res.status).toBe(404); + }); +}); diff --git a/graphql/server/package.json b/graphql/server/package.json index 9999827ee..5eea7a837 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -45,6 +45,7 @@ "@constructive-io/express-context": "workspace:^", "@constructive-io/graphql-env": "workspace:^", "@constructive-io/graphql-types": "workspace:^", + "@constructive-io/query-builder": "workspace:^", "@constructive-io/s3-utils": "workspace:^", "@constructive-io/url-domains": "workspace:^", "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2", @@ -63,6 +64,7 @@ "graphile-build-pg": "5.0.2", "graphile-cache": "workspace:^", "graphile-config": "1.0.1", + "graphile-function-bindings": "workspace:^", "graphile-settings": "workspace:^", "graphile-utils": "5.0.1", "graphql": "16.13.0", diff --git a/graphql/server/src/middleware/fn.ts b/graphql/server/src/middleware/fn.ts new file mode 100644 index 000000000..c431acfa1 --- /dev/null +++ b/graphql/server/src/middleware/fn.ts @@ -0,0 +1,240 @@ +/** + * fn — REST function invocation routes + * + * POST /fn/:alias → invoke a function bound to this API (202 { invocationId }) + * GET /fn/invocations/:id → read invocation status/result + * + * Routing is per-API: bindings are looked up by (api_id, alias) across the + * bindings tables of every provisioned function-module scope, where api_id + * comes from the server-side domain resolution (req.constructive.api.apiId) + * — never from client input. RLS on the underlying tables governs access. + * + * Per-protocol enablement lives in the binding's `config` jsonb: + * { "graphql": true, "rest": { "path": "/...", "methods": ["POST"] } } + * An absent `rest` key means REST is disabled for the binding. + * + * All queries run through req.constructive.withPgClient, which applies the + * request's pgSettings (role + jwt.claims.* incl. jwt.claims.api_id) in a + * transaction — RLS is fully enforced; no superuser or bypass path is used. + */ + +import { Logger } from '@pgpmjs/logger'; +import { QueryBuilder, type SqlValue } from '@constructive-io/query-builder'; +import express, { Request, Response, Router } from 'express'; + +const log = new Logger('fn'); + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +interface BindingRow { + id: string; + function_definition_id: string; + config: RestBindingConfig | null; + task_identifier: string; +} + +interface RestBindingConfig { + graphql?: boolean; + rest?: { + path?: string; + methods?: string[]; + }; +} + +interface InvocationRow { + id: string; + status: string; + payload: unknown; + result: unknown; + error: unknown; + created_at: string; + started_at: string | null; + completed_at: string | null; + duration_ms: number | null; +} + +const notFound = (res: Response): void => { + res.status(404).json({ error: 'Not found' }); +}; + +async function handleInvoke(req: Request, res: Response): Promise { + const ctx = req.constructive; + if (!ctx?.api?.apiId) { + notFound(res); + return; + } + + const compute = await ctx.useModule('compute'); + if (!compute?.modules.length) { + notFound(res); + return; + } + + const alias = req.params.alias; + + try { + // Resolve the alias across every function-module scope's bindings table. + const resolved = await ctx.withPgClient(async (client) => { + const matches: Array<{ binding: BindingRow; module: (typeof compute.modules)[number] }> = []; + for (const module of compute.modules) { + // Binding lookup carries everything the invocation insert needs: + // the binding id (api_binding_id), the definition it points at + // (function_definition_id), the resolved task_identifier, and config. + const { text, values } = new QueryBuilder() + .schema(module.schemaName) + .table(module.bindingsTableName, 'b') + .select(['b.id', 'b.function_definition_id', 'b.config', 'd.task_identifier']) + .innerJoin(module.definitionsTableName, 'b.function_definition_id', '=', 'd.id', { + schema: module.schemaName, + alias: 'd', + }) + .where('b.api_id', '=', ctx.api.apiId) + .where('b.alias', '=', alias) + .build(); + const { rows } = await client.query(text, values); + for (const row of rows) { + matches.push({ binding: row, module }); + } + } + return matches; + }); + + if (resolved.length > 1) { + // Same alias bound to this API from more than one scope — ambiguous. + res.status(409).json({ error: `Alias "${alias}" is ambiguous for this API` }); + return; + } + + const binding = resolved[0]?.binding; + + // 404 when the binding doesn't exist, REST is disabled for the binding + // (absent `rest` config), or the HTTP method isn't allowed. + const rest = binding?.config?.rest; + if (!binding || !rest) { + notFound(res); + return; + } + const methods = (rest.methods ?? ['POST']).map((m) => m.toUpperCase()); + if (!methods.includes(req.method)) { + notFound(res); + return; + } + + // TODO: JSON-Schema (ajv) payload validation hook — intentionally deferred. + // When enabled it should validate req.body against the binding/function + // input schema here, before the invocation row is inserted. + const payload = req.body ?? {}; + + const { invocationsSchemaName, invocationsTableName, invocationsEntityField } = resolved[0].module; + + // API-channel provenance: set both the definition and the binding the + // invocation came through, at status 'pending'. The database's AFTER + // INSERT enqueue trigger schedules the job — the server never enqueues. + const insertData: Record = { + task_identifier: binding.task_identifier, + function_definition_id: binding.function_definition_id, + api_binding_id: binding.id, + status: 'pending', + payload: JSON.stringify(payload), + }; + // Scope-key column driven by the module's recorded entity_field: set for + // the database scope (database_id), absent for global scopes. Never a + // switch on scope name. + if (invocationsEntityField) { + insertData[invocationsEntityField] = ctx.api.databaseId ?? ctx.databaseId; + } + + const { text, values } = new QueryBuilder() + .schema(invocationsSchemaName) + .table(invocationsTableName) + .insert(insertData) + .returning(['id']) + .build(); + + const invocationId = await ctx.withPgClient(async (client) => { + const { rows } = await client.query<{ id: string }>(text, values); + return rows[0].id; + }); + + res.status(202).json({ invocationId }); + } catch (err: any) { + if (err?.code === '42501') { + // insufficient_privilege — RLS rejected the insert for this caller + res.status(403).json({ error: 'Forbidden' }); + return; + } + log.error({ event: 'fn_invoke_failed', alias, requestId: req.requestId, error: err?.message }); + res.status(500).json({ error: 'Internal server error' }); + } +} + +async function handleGetInvocation(req: Request, res: Response): Promise { + const ctx = req.constructive; + if (!ctx?.api?.apiId) { + notFound(res); + return; + } + + const id = req.params.id; + if (!UUID_RE.test(id)) { + notFound(res); + return; + } + + const compute = await ctx.useModule('compute'); + if (!compute?.modules.length) { + notFound(res); + return; + } + + try { + const invocation = await ctx.withPgClient(async (client) => { + // Invocation tables are per-scope; search each distinct one. + const seen = new Set(); + for (const module of compute.modules) { + const key = `${module.invocationsSchemaName}.${module.invocationsTableName}`; + if (seen.has(key)) continue; + seen.add(key); + const { text, values } = new QueryBuilder() + .schema(module.invocationsSchemaName) + .table(module.invocationsTableName) + .select(['id', 'status', 'result', 'error', 'created_at', 'started_at', 'completed_at', 'duration_ms']) + .where('id', '=', id) + .build(); + const { rows } = await client.query(text, values); + if (rows[0]) return rows[0]; + } + return undefined; + }); + + // RLS-filtered read: rows not visible to the caller simply aren't returned + if (!invocation) { + notFound(res); + return; + } + + res.status(200).json({ + id: invocation.id, + status: invocation.status, + result: invocation.result, + error: invocation.error, + createdAt: invocation.created_at, + startedAt: invocation.started_at, + completedAt: invocation.completed_at, + durationMs: invocation.duration_ms + }); + } catch (err: any) { + log.error({ event: 'fn_get_invocation_failed', id, requestId: req.requestId, error: err?.message }); + res.status(500).json({ error: 'Internal server error' }); + } +} + +export function createFnRouter(): Router { + const router = Router(); + + router.use('/fn', express.json()); + router.get('/fn/invocations/:id', handleGetInvocation); + router.all('/fn/:alias', handleInvoke); + + return router; +} diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index 477e61288..674fca1e5 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -1,11 +1,13 @@ import crypto from 'node:crypto'; import { getNodeEnv } from '@pgpmjs/env'; +import type { ComputeConfig } from '@constructive-io/express-context'; import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import { Logger } from '@pgpmjs/logger'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { GraphQLError, GraphQLFormattedError } from 'grafast/graphql'; import { createGraphileInstance, type GraphileCacheEntry, graphileCache } from 'graphile-cache'; import type { GraphileConfig } from 'graphile-config'; +import { createFunctionBindingsPlugin } from 'graphile-function-bindings'; import { createConstructivePreset, makePgService } from 'graphile-settings'; import { getPgPool } from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; @@ -208,10 +210,33 @@ const buildPreset = ( anonRole: string, roleName: string, databaseSettings?: DatabaseSettings, + apiId?: string, + compute?: ComputeConfig, ): GraphileConfig.Preset => { return { extends: [createConstructivePreset(databaseSettings)], - plugins: [AuthCookiePlugin], + plugins: [ + AuthCookiePlugin, + // Only registered when the compute module is provisioned for this + // database — all schema/table names come from the constructive + // metaschema (express-context compute module loader); the plugin has + // no fallbacks or discovery of its own. + ...(apiId && compute?.modules.length + ? [ + createFunctionBindingsPlugin({ + apiId, + modules: compute.modules.map((m) => ({ + computeSchema: m.schemaName, + bindingsTable: m.bindingsTableName, + definitionsTable: m.definitionsTableName, + invocationsSchema: m.invocationsSchemaName, + invocationsTable: m.invocationsTableName, + invocationsEntityField: m.invocationsEntityField, + })), + }), + ] + : []), + ], pgServices: [ makePgService({ pool, @@ -236,6 +261,12 @@ const buildPreset = ( if (req.databaseId) { context['jwt.claims.database_id'] = req.databaseId; } + // API provenance — which API surface this request arrived through. + // Derived server-side from hostname -> services_public.domains -> api_id; + // never taken from client-supplied headers, body, or token payload. + if (req.api?.apiId) { + context['jwt.claims.api_id'] = req.api.apiId; + } if (req.clientIp) { context['jwt.claims.ip_address'] = req.clientIp; } @@ -380,7 +411,8 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { const pool = getPgPool(pgConfig); // Create promise and store in in-flight map BEFORE try block - const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings); + const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; + const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute); const creationPromise = observeGraphileBuild( { cacheKey: key, diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index a88b48d9f..6f08fd702 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -27,6 +27,7 @@ import { createAuthenticateMiddleware } from './middleware/auth'; import { cors } from './middleware/cors'; import { errorHandler, notFoundHandler } from './middleware/error-handler'; import { favicon } from './middleware/favicon'; +import { createFnRouter } from './middleware/fn'; import { flush, flushService } from './middleware/flush'; import { graphile } from './middleware/graphile'; import { multipartBridge } from './middleware/multipart-bridge'; @@ -38,7 +39,7 @@ import { createRequestLogger } from './middleware/observability/request-logger'; import { createCaptchaMiddleware } from './middleware/captcha'; import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie'; import { createAgenticRouter } from 'agentic-server'; -import { createContextMiddleware, requestIdMiddleware } from '@constructive-io/express-context'; +import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context'; import { startDebugSampler } from './diagnostics/debug-sampler'; const log = new Logger('server'); @@ -164,7 +165,7 @@ class Server { app.use(requestLogger); app.use(api); app.use(authenticate); - app.use(createContextMiddleware({ pg: effectiveOpts.pg })); + app.use(createContextMiddleware({ pg: effectiveOpts.pg, loaders: createDefaultRegistry() })); app.use(createCaptchaMiddleware()); // CSRF protection for cookie-authenticated requests @@ -200,6 +201,9 @@ class Server { // routes are handled without going through PostGraphile app.use(createAgenticRouter()); + // REST function invocation routes (POST /fn/:alias, GET /fn/invocations/:id) + app.use(createFnRouter()); + app.use(graphile(effectiveOpts)); app.use(flush); diff --git a/packages/express-context/__tests__/pg-settings.test.ts b/packages/express-context/__tests__/pg-settings.test.ts new file mode 100644 index 000000000..b1f4ec3e0 --- /dev/null +++ b/packages/express-context/__tests__/pg-settings.test.ts @@ -0,0 +1,52 @@ +import { buildPgSettings } from '../src/pg-settings'; +import type { ApiStructure, ConstructiveAPIToken } from '../src/types'; + +const api: ApiStructure = { + apiId: '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + dbname: 'testdb', + anonRole: 'anonymous', + roleName: 'authenticated', + schema: ['public'], + apiModules: [], + domains: [], + databaseId: '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + isPublic: true +}; + +describe('buildPgSettings — jwt.claims.api_id provenance', () => { + it('sets jwt.claims.api_id from the resolved api for anonymous requests', () => { + const settings = buildPgSettings({ api, token: null, requestId: 'r1' }); + + expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['role']).toBe('anonymous'); + }); + + it('sets jwt.claims.api_id from the resolved api for authenticated requests', () => { + const token = { user_id: 'u1' } as ConstructiveAPIToken; + const settings = buildPgSettings({ api, token, requestId: 'r1' }); + + expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['role']).toBe('authenticated'); + expect(settings['jwt.claims.user_id']).toBe('u1'); + }); + + it('omits jwt.claims.api_id when the api has no apiId (non-API surface)', () => { + const settings = buildPgSettings({ + api: { ...api, apiId: undefined }, + token: null, + requestId: 'r1' + }); + + expect(settings['jwt.claims.api_id']).toBeUndefined(); + }); + + it('is derived only from the resolved api, never from the token', () => { + const token = { + user_id: 'u1', + api_id: 'attacker-controlled' + } as unknown as ConstructiveAPIToken; + const settings = buildPgSettings({ api, token, requestId: 'r1' }); + + expect(settings['jwt.claims.api_id']).toBe(api.apiId); + }); +}); diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 02a6465ed..2e8c5697c 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -45,6 +45,8 @@ export type { AuthSettings, BillingConfig, BuiltinModuleMap, + ComputeConfig, + ComputeModuleConfig, ConstructiveAPIToken, ConstructiveContext, CorsModuleData, @@ -88,6 +90,7 @@ export { agentChatLoader, authSettingsLoader, billingLoader, + computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, diff --git a/packages/express-context/src/loaders/compute.ts b/packages/express-context/src/loaders/compute.ts new file mode 100644 index 000000000..ec11b7aec --- /dev/null +++ b/packages/express-context/src/loaders/compute.ts @@ -0,0 +1,79 @@ +/** + * Compute Module Loader + * + * Resolves per-database compute (functions) config from + * metaschema_modules_public.function_module and function_invocation_module. + * Returns the schema and table names for function definitions, API bindings, + * and invocations so REST routes can address the generated tables. + * + * Function modules are scoped: a database may provision one per scope, each + * with its own schema and tables. All of them are returned — REST routes and + * the GraphQL bindings plugin expose bindings from every module, and RLS on + * the underlying tables governs access. + */ + +import type { ComputeConfig } from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const COMPUTE_MODULE_SQL = ` + SELECT + fs.schema_name AS functions_schema_name, + fm.definitions_table_name, + fm.bindings_table_name, + ivs.schema_name AS invocations_schema_name, + ivm.invocations_table_name, + ivm.entity_field AS invocations_entity_field + FROM metaschema_modules_public.function_module fm + JOIN metaschema_public.schema fs ON fs.id = fm.schema_id + JOIN metaschema_modules_public.function_invocation_module ivm + ON ivm.database_id = fm.database_id AND ivm.scope = fm.scope + JOIN metaschema_public.schema ivs ON ivs.id = ivm.schema_id + WHERE fm.database_id = $1 + ORDER BY fs.schema_name +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface ComputeModuleRow { + functions_schema_name: string; + definitions_table_name: string; + bindings_table_name: string; + invocations_schema_name: string; + invocations_table_name: string; + invocations_entity_field: string | null; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const computeLoader: ModuleLoader = createModuleLoader({ + name: 'compute', + ttlMs: 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + + const result = await tenantPool.query( + COMPUTE_MODULE_SQL, + [databaseId], + ); + if (result.rows.length === 0) return undefined; + + return { + modules: result.rows.map((row) => ({ + schemaName: row.functions_schema_name, + definitionsTableName: row.definitions_table_name, + // Physical bindings table name, recorded by the metaschema generator + // on the function_module config row — read as a fact, never derived + // from the definitions table name. + bindingsTableName: row.bindings_table_name, + invocationsSchemaName: row.invocations_schema_name, + invocationsTableName: row.invocations_table_name, + // Scope-key column of the invocations table (entity_field), or NULL + // for global scopes. Consumers set this column on inserts. + invocationsEntityField: row.invocations_entity_field, + })), + }; + }, +}); diff --git a/packages/express-context/src/loaders/create-loader.ts b/packages/express-context/src/loaders/create-loader.ts index db2b55a82..68dc1ccf4 100644 --- a/packages/express-context/src/loaders/create-loader.ts +++ b/packages/express-context/src/loaders/create-loader.ts @@ -46,13 +46,22 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade } log.debug(`Cache MISS databaseId=${key}, resolving`); + // "Not provisioned" is expressed by the loader returning undefined, or + // by the module's tables not existing at all (42P01 undefined_table). + // Any other resolution error (bad query, ambiguous config) propagates — + // never silently coerced into "module absent". try { const value = await opts.resolve(ctx); cache.set(key, value); return value; } catch (e: any) { + if (e.code === '42P01') { + log.debug(`Module tables absent for databaseId=${key}: ${e.message}`); + cache.set(key, undefined); + return undefined; + } log.warn(`Failed to resolve databaseId=${key}: ${e.message}`); - return undefined; + throw e; } }, diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index c79efe3e6..794d5d197 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -48,6 +48,7 @@ export { billingLoader } from './billing'; export { inferenceLogLoader } from './inference-log'; export { agentChatLoader } from './agent-chat'; export { llmLoader } from './llm'; +export { computeLoader } from './compute'; /** * Convenience: create a registry pre-loaded with all built-in loaders. @@ -63,6 +64,7 @@ import { billingLoader } from './billing'; import { inferenceLogLoader } from './inference-log'; import { agentChatLoader } from './agent-chat'; import { llmLoader } from './llm'; +import { computeLoader } from './compute'; export function createDefaultRegistry() { const registry = createLoaderRegistry(); @@ -76,5 +78,6 @@ export function createDefaultRegistry() { registry.register(inferenceLogLoader); registry.register(agentChatLoader); registry.register(llmLoader); + registry.register(computeLoader); return registry; } diff --git a/packages/express-context/src/pg-settings.ts b/packages/express-context/src/pg-settings.ts index ff226e593..86788474a 100644 --- a/packages/express-context/src/pg-settings.ts +++ b/packages/express-context/src/pg-settings.ts @@ -56,6 +56,13 @@ export function buildPgSettings(input: PgSettingsInput): Record settings['jwt.claims.database_id'] = api.databaseId; } + // API provenance — which API surface this request arrived through. + // Derived server-side from hostname -> services_public.domains -> api_id; + // never taken from client-supplied headers, body, or token payload. + if (api.apiId) { + settings['jwt.claims.api_id'] = api.apiId; + } + // Distributed tracing settings['request.id'] = requestId; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 39e0a79c5..39bb6254f 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -154,6 +154,31 @@ export interface AgentChatConfig { taskTableName: string | null; } +/** One provisioned function-module scope's compute table names. */ +export interface ComputeModuleConfig { + schemaName: string; + definitionsTableName: string; + bindingsTableName: string; + invocationsSchemaName: string; + invocationsTableName: string; + /** + * Scope-key column of the invocations table, read from the metaschema + * (`function_invocation_module.entity_field`): `database_id` for the + * database scope, `NULL` for global scopes (platform/app), or the entity + * key column for entity scopes. Consumers set this column on invocation + * inserts instead of switching on scope name. + */ + invocationsEntityField: string | null; +} + +/** + * All function modules provisioned on the database. A database may have one + * per scope; every module is exposed and RLS governs access to each. + */ +export interface ComputeConfig { + modules: ComputeModuleConfig[]; +} + export interface LlmConfig { embeddingProvider: string; embeddingModel: string; @@ -188,6 +213,7 @@ export interface BuiltinModuleMap { inferenceLog: InferenceLogConfig; agentChat: AgentChatConfig; llm: LlmConfig; + compute: ComputeConfig; } // ─── Constructive Context ─────────────────────────────────────────────────── diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1937c93cf..68cd22e37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,6 +476,56 @@ importers: version: link:../../postgres/pgsql-test/dist publishDirectory: dist + graphile/graphile-function-bindings: + dependencies: + '@constructive-io/query-builder': + specifier: workspace:^ + version: link:../../postgres/query-builder/dist + '@dataplan/pg': + specifier: 1.0.3 + version: 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist + grafast: + specifier: 1.0.2 + version: 1.0.2(graphql@16.13.0) + graphile-build: + specifier: 5.0.2 + version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) + graphile-build-pg: + specifier: 5.0.2 + version: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + graphile-config: + specifier: 1.0.1 + version: 1.0.1 + graphql: + specifier: 16.13.0 + version: 16.13.0 + inflekt: + specifier: ^0.7.1 + version: 0.7.1 + pg: + specifier: ^8.21.0 + version: 8.21.0 + devDependencies: + '@types/node': + specifier: ^22.19.11 + version: 22.19.19 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + graphile-test: + specifier: workspace:^ + version: link:../graphile-test/dist + makage: + specifier: ^0.3.0 + version: 0.3.0 + pgsql-test: + specifier: workspace:^ + version: link:../../postgres/pgsql-test/dist + publishDirectory: dist + graphile/graphile-i18n: dependencies: '@dataplan/pg': @@ -1682,6 +1732,9 @@ importers: '@constructive-io/graphql-types': specifier: workspace:^ version: link:../types/dist + '@constructive-io/query-builder': + specifier: workspace:^ + version: link:../../postgres/query-builder/dist '@constructive-io/s3-utils': specifier: workspace:^ version: link:../../uploads/s3-utils/dist @@ -1736,6 +1789,9 @@ importers: graphile-config: specifier: 1.0.1 version: 1.0.1 + graphile-function-bindings: + specifier: workspace:^ + version: link:../../graphile/graphile-function-bindings/dist graphile-settings: specifier: workspace:^ version: link:../../graphile/graphile-settings/dist