Skip to content
Closed
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
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/*']
};
60 changes: 60 additions & 0 deletions graphile/graphile-function-bindings/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"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 <developers@constructive.io>",
"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": {
"@pgpmjs/logger": "workspace:^"
},
"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",
"pg": "^8.21.0",
"pgsql-test": "workspace:^"
}
}
Original file line number Diff line number Diff line change
@@ -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": "ResizeImageInput",
}
`;
Loading
Loading