Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions __fixtures__/seed/compute/setup.sql
Original file line number Diff line number Diff line change
@@ -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);
128 changes: 128 additions & 0 deletions __fixtures__/seed/compute/test-data.sql
Original file line number Diff line number Diff line change
@@ -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;
76 changes: 76 additions & 0 deletions graphile/graphile-function-bindings/README.md
Original file line number Diff line number Diff line change
@@ -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
<alias>(input: <Alias>Input!): <Alias>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.
19 changes: 19 additions & 0 deletions graphile/graphile-function-bindings/jest.config.js
Original file line number Diff line number Diff line change
@@ -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/*']
};
Loading
Loading