constructive-functions
npx skills add https://github.com/constructive-io/constructive-skills --skill constructive-functions
Agent 安装分布
Skill 文档
Build and deploy Knative-style HTTP cloud functions for the Constructive platform.
When to Apply
Use this skill when:
- Creating serverless functions for Constructive (email, webhooks, background jobs)
- Building functions that interact with the Constructive GraphQL API
- Running PGPM commands programmatically in cloud functions
- Executing SQL scripts or database operations as cloud functions
Architecture Overview
Constructive functions are TypeScript HTTP handlers deployed as Knative services on Kubernetes. Each function:
- Exports a default async handler receiving
(params, context) - Runs on port 8080 (Knative default)
- Gets a GraphQL client via
context.clientfor database access - Builds to
dist/and runs via a shared Node.js runner
Project Structure
constructive-functions/
functions/
my-function/
src/
index.ts # Handler implementation
__tests__/
index.test.ts # Tests
package.json
tsconfig.json
Dockerfile # Optional, for custom images
_runtimes/
node/
runner.js # Shared HTTP server wrapper
k8s/
base/ # Base K8s manifests
overlays/
local/ # Local development overlay
dev/ # Dev cluster overlay
pnpm-workspace.yaml
Makefile
Function Handler Pattern
Every function exports a default async handler:
import { createClient } from './generated/orm';
export default async (params: any, context: any) => {
const db = createClient({
endpoint: process.env.GRAPHQL_ENDPOINT || 'http://constructive-server:3000/graphql',
headers: context.headers,
});
// Type-safe query using generated ORM client
const result = await db.user
.findMany({
select: { id: true, username: true },
first: 10,
})
.execute();
if (!result.ok) {
return { error: 'Query failed', details: result.errors };
}
return { success: true, users: result.data.users.nodes };
};
The context object provides:
clientâ Legacy GraphQL client (prefer using generated ORM client instead)headersâ Request headers from the incoming HTTP request
Important: Use the typed SDK from @constructive-io/graphql-codegen instead of raw gql strings. See the graphql-codegen skill for setup.
Creating a New Function
- Create the function directory:
mkdir -p functions/my-function/src
mkdir -p functions/my-function/__tests__
- Create
functions/my-function/package.json:
{
"name": "@constructive-io/my-function-fn",
"version": "0.1.0",
"description": "My Knative function",
"author": "Constructive <developers@constructive.io>",
"private": false,
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"publishConfig": {
"access": "public",
"directory": "dist"
},
"files": ["dist"],
"scripts": {
"copy": "makage assets",
"clean": "makage clean",
"prepublishOnly": "npm run build",
"build": "makage build",
"test": "jest --forceExit __tests__/index.test.ts",
"start": "node ../../_runtimes/node/runner.js dist/index.js"
},
"dependencies": {
"@constructive-io/knative-job-fn": "latest",
"@pgpmjs/env": "latest",
"graphql-tag": "^2.12.6",
"cross-fetch": "^4.0.0",
"graphql-request": "^6.1.0"
},
"devDependencies": {
"@types/node": "latest",
"@types/jest": "latest",
"jest": "latest",
"makage": "0.1.10",
"ts-jest": "latest",
"typescript": "latest",
"pgsql-test": "latest"
}
}
Critical fields for publishing:
publishConfig.directory: "dist"â Publish from dist folder (prevents tree-shaking into weird paths)main: "index.js"â Points to CJS build (in dist)module: "esm/index.js"â Points to ESM build (in dist)types: "index.d.ts"â Points to type declarations (in dist)
- Create
functions/my-function/tsconfig.json:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "__tests__"]
}
- Create
functions/my-function/src/index.ts:
import { parseEnvBoolean } from '@pgpmjs/env';
const isDryRun = parseEnvBoolean(process.env.MY_FUNCTION_DRY_RUN) ?? false;
export default async (params: any, context: any) => {
const { client } = context;
console.log('[my-function] processing request');
if (isDryRun) {
console.log('[my-function] DRY RUN', params);
return { dryRun: true, params };
}
// Your function logic here
return { success: true };
};
Using PGPM in Functions
Functions can use the pgpm library to run PGPM commands programmatically:
import { dump } from 'pgpm';
export default async (params: any, context: any) => {
const argv = {
_: [],
database: params.database,
out: params.out,
'database-id': params.database_id
};
const prompter = {
prompt: () => { throw new Error('Interactive prompt not supported'); }
};
await dump(argv, prompter, {});
return { message: 'PGPM dump completed', args: argv };
};
Direct Database Access
For SQL execution, use pg-cache for connection pool management. It provides automatic caching, cleanup callbacks, and graceful shutdown handling:
import { getPgPool, close } from 'pg-cache';
export default async (params: any, context: any) => {
const { query } = params;
if (!query) {
return { error: 'Missing "query" in payload' };
}
// Get or create a cached pool (automatically reused across requests)
const pool = getPgPool({
host: process.env.PGHOST,
port: Number(process.env.PGPORT || 5432),
database: process.env.PGDATABASE || 'launchql',
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
});
const result = await pool.query(query);
return { rowCount: result.rowCount, rows: result.rows };
// Note: No manual cleanup needed - pg-cache handles pool lifecycle
};
Why pg-cache?
- Automatic pool caching â Pools are reused across function invocations
- Graceful shutdown â Handles idle connection errors during cleanup
- LRU eviction â Automatically disposes unused pools
- Cleanup callbacks â Register callbacks for resource cleanup when pools are disposed
For graceful shutdown in long-running processes:
import { close } from 'pg-cache';
process.on('SIGTERM', async () => {
await close(); // Gracefully close all cached pools
process.exit(0);
});
Build Workflow
Functions use makage for builds, which handles TypeScript compilation and asset copying:
# Install dependencies
pnpm install
# Build all functions (from workspace root)
pnpm -r run build
# Build a specific function
cd functions/my-function
pnpm build
# Clean build artifacts
pnpm clean
Build Output Structure
After makage build:
my-function/
âââ src/
â âââ index.ts
âââ dist/
â âââ index.js # CJS build
â âââ index.d.ts # Type declarations
â âââ esm/
â â âââ index.js # ESM build
â âââ package.json # Copied from root
â âââ README.md # Copied from root
â âââ LICENSE # Copied from root
âââ package.json
Local Development
Run functions locally:
# Run a specific function locally
cd functions/my-function
pnpm start
# Function listens on http://localhost:8080
# Test with curl
curl -X POST http://localhost:8080 \
-H "Content-Type: application/json" \
-d '{"key": "value"}'
Publishing Functions to npm
Functions follow the same publishing workflow as other PNPM packages. See the pnpm-publishing skill for full details.
Quick Publishing Workflow
# 1. Build all functions
pnpm -r run build
# 2. Run tests
pnpm -r run test
# 3. Version (interactive)
pnpm lerna version
# 4. Publish to npm
pnpm lerna publish from-package
Dry Run
Test publishing without making changes:
# Test versioning
pnpm lerna version --no-git-tag-version --no-push
# Test publishing
pnpm lerna publish from-package --dry-run
Docker Build
Build Docker images for deployment:
# Build all function images
make docker-build
# Build a specific function
docker build -t ghcr.io/constructive-io/constructive-functions/my-function:latest functions/my-function
# Push to registry
docker push ghcr.io/constructive-io/constructive-functions/my-function:latest
Dockerfile Pattern
FROM node:22-alpine
WORKDIR /usr/src/app
COPY package.json ./
RUN npm install -g pnpm@10.12.2 && pnpm install --prod
COPY dist ./dist
ENV NODE_ENV=production
ENV PORT=8080
USER node
CMD ["node", "dist/index.js"]
Kubernetes Deployment
Local Development with Kind/Minikube
cd k8s
# Install Knative Serving + Kourier
make operators-knative-only
# Apply local overlay (Postgres, MinIO, functions)
make kustomize-local
# Port-forward to services
make proxy-server # GraphQL API -> localhost:8080
make proxy-web # Dashboard UI -> localhost:3000
Knative Service Manifest
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: my-function
namespace: interweb
spec:
template:
spec:
containers:
- image: ghcr.io/constructive-io/constructive-functions/my-function:latest
ports:
- containerPort: 8080
env:
- name: MY_FUNCTION_DRY_RUN
value: "false"
- name: GRAPHQL_ENDPOINT
value: "http://constructive-server:3000/graphql"
Environment Variables
Common environment variables for functions:
| Variable | Description |
|---|---|
PORT |
HTTP port (default: 8080) |
GRAPHQL_ENDPOINT |
GraphQL API URL |
PGHOST |
PostgreSQL host |
PGPORT |
PostgreSQL port |
PGUSER |
PostgreSQL user |
PGPASSWORD |
PostgreSQL password |
PGDATABASE |
PostgreSQL database |
*_DRY_RUN |
Enable dry-run mode (per function) |
Testing Functions
// __tests__/index.test.ts
import handler from '../src/index';
describe('my-function', () => {
const mockClient = {
request: jest.fn()
};
it('should process request', async () => {
const params = { key: 'value' };
const context = { client: mockClient };
const result = await handler(params, context);
expect(result.success).toBe(true);
});
});
Run tests:
cd functions/my-function
pnpm test
Error Handling
Return errors as JSON with appropriate status codes:
export default async (params: any, context: any) => {
const { requiredField } = params;
// 400 Bad Request - missing required field
if (!requiredField) {
return { error: 'Missing required field' };
}
try {
// Function logic
return { success: true };
} catch (e: any) {
// 500 Internal Server Error
console.error('Function failed:', e);
return { error: e.message };
}
};
The runner automatically maps certain error messages to 400 status codes:
- “Missing prompt”
- “Missing required field”
- “Missing “query” in payload”
All other errors return 500.
Best Practices
- Use typed SDK â Use
@constructive-io/graphql-codegenORM client instead of raw gql strings - Use pg-cache â Use
pg-cachefor database connections instead of manual Pool management - Use makage for builds â Consistent build tooling across all packages
- Publish from dist/ â Prevents tree-shaking into weird import paths
- Use dry-run mode â Support
*_DRY_RUNenv var for testing without side effects - Log context â Log request details for debugging (but not sensitive data)
- Handle errors â Use discriminated unions (
.execute()) for explicit error handling - Validate input â Check required fields early and return clear error messages
- Use @pgpmjs/env â Parse boolean env vars consistently with
parseEnvBoolean()
References
- Related skill:
graphql-codegenfor typed GraphQL SDK generation - Related skill:
pnpm-publishingfor full npm publishing workflow - Related skill:
pnpm-workspacefor workspace setup - constructive-functions repo
- Knative Serving docs
- @constructive-io/knative-job-fn
- pg-cache on npm
- makage on npm