Skip to content

Commit 1d70712

Browse files
feat(node-sql): add SQL-based function template with example
Extracted from lucas/node-sql-template-v2 branch and adapted to dynamic registry architecture. Changes: - Add templates/node-sql for direct PostgreSQL access functions - Add functions/sql-example using the node-sql template - Add e2e test for sql-example The node-sql template provides withUserContext() for executing queries with proper actor context, unlike node-graphql which uses GraphQL clients. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent dad7293 commit 1d70712

12 files changed

Lines changed: 476 additions & 0 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
const mockQuery = jest.fn();
2+
const mockRelease = jest.fn();
3+
4+
jest.mock('pg', () => ({
5+
Pool: jest.fn().mockImplementation(() => ({
6+
connect: jest.fn().mockResolvedValue({
7+
query: mockQuery,
8+
release: mockRelease,
9+
}),
10+
})),
11+
Client: jest.fn().mockImplementation(() => ({
12+
connect: jest.fn(),
13+
query: mockQuery,
14+
end: jest.fn(),
15+
})),
16+
}));
17+
18+
const createMockContext = () => {
19+
const mockClient = {
20+
query: mockQuery,
21+
release: mockRelease,
22+
};
23+
24+
return {
25+
job: {
26+
jobId: 'test-job-id',
27+
workerId: 'test-worker',
28+
databaseId: 'test-db',
29+
},
30+
pool: {
31+
connect: jest.fn().mockResolvedValue(mockClient),
32+
},
33+
withUserContext: jest.fn(async (_actorId: string | undefined, fn: (client: typeof mockClient) => Promise<unknown>) => {
34+
return fn(mockClient);
35+
}),
36+
log: {
37+
info: jest.fn(),
38+
error: jest.fn(),
39+
warn: jest.fn(),
40+
},
41+
env: {},
42+
};
43+
};
44+
45+
const loadHandler = () => {
46+
const mod = require('../handler');
47+
return mod.default ?? mod;
48+
};
49+
50+
describe('sql-example handler', () => {
51+
beforeEach(() => {
52+
jest.clearAllMocks();
53+
mockQuery.mockReset();
54+
});
55+
56+
it('should execute default query (SELECT version()) when no query provided', async () => {
57+
const handler = loadHandler();
58+
const context = createMockContext();
59+
mockQuery.mockResolvedValueOnce({ rows: [{ version: 'PostgreSQL 15.0' }] });
60+
61+
const result = await handler({}, context);
62+
63+
expect(result.success).toBe(true);
64+
expect(result.message).toBe('Query executed successfully');
65+
expect(context.withUserContext).toHaveBeenCalledWith(undefined, expect.any(Function));
66+
});
67+
68+
it('should execute custom query', async () => {
69+
const handler = loadHandler();
70+
const context = createMockContext();
71+
mockQuery.mockResolvedValueOnce({ rows: [{ count: 5 }] });
72+
73+
const result = await handler({ query: 'SELECT count(*) FROM users' }, context);
74+
75+
expect(result.success).toBe(true);
76+
expect(result.data).toEqual([{ count: 5 }]);
77+
});
78+
79+
it('should pass actor_id to withUserContext', async () => {
80+
const handler = loadHandler();
81+
const context = createMockContext();
82+
mockQuery.mockResolvedValueOnce({ rows: [] });
83+
84+
await handler({ actor_id: 'user-123' }, context);
85+
86+
expect(context.withUserContext).toHaveBeenCalledWith('user-123', expect.any(Function));
87+
});
88+
});

functions/sql-example/handler.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "sql-example",
3+
"version": "1.0.0",
4+
"type": "node-sql",
5+
"description": "Example function using node-sql template for direct PostgreSQL access"
6+
}

functions/sql-example/handler.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { FunctionHandler } from './types';
2+
3+
type Params = {
4+
query?: string;
5+
actor_id?: string;
6+
};
7+
8+
type Result = {
9+
success: boolean;
10+
message: string;
11+
data?: unknown;
12+
};
13+
14+
const handler: FunctionHandler<Params, Result> = async (params, context) => {
15+
const { log, withUserContext } = context;
16+
const { query = 'SELECT version()', actor_id } = params;
17+
18+
log.info('[sql-example] Executing query', { query, actor_id });
19+
20+
const result = await withUserContext(actor_id, async (client) => {
21+
const res = await client.query(query);
22+
return res.rows;
23+
});
24+
25+
log.info('[sql-example] Query complete', { rowCount: result.length });
26+
27+
return {
28+
success: true,
29+
message: 'Query executed successfully',
30+
data: result,
31+
};
32+
};
33+
34+
export default handler;

skaffold.yaml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,50 @@ profiles:
193193
namespace: constructive-functions
194194
port: 3000
195195
localPort: 3002
196+
- name: sql-example
197+
build:
198+
artifacts:
199+
- image: constructive-functions
200+
context: .
201+
docker:
202+
dockerfile: Dockerfile.dev
203+
sync:
204+
manual:
205+
- src: 'functions/**/*.ts'
206+
dest: /usr/src/app
207+
local:
208+
push: false
209+
manifests:
210+
kustomize:
211+
paths:
212+
- k8s/overlays/local-simple
213+
rawYaml:
214+
- generated/sql-example/k8s/local-deployment.yaml
215+
- generated/sql-example/k8s/functions-configmap.yaml
216+
deploy:
217+
kubectl:
218+
defaultNamespace: constructive-functions
219+
portForward:
220+
- resourceType: service
221+
resourceName: sql-example
222+
namespace: constructive-functions
223+
port: 80
224+
localPort: 8085
225+
- resourceType: service
226+
resourceName: knative-job-service
227+
namespace: constructive-functions
228+
port: 8080
229+
localPort: 8080
230+
- resourceType: service
231+
resourceName: postgres
232+
namespace: constructive-functions
233+
port: 5432
234+
localPort: 5432
235+
- resourceType: service
236+
resourceName: constructive-server
237+
namespace: constructive-functions
238+
port: 3000
239+
localPort: 3002
196240

197241
# All functions together.
198242
- name: local-simple
@@ -227,6 +271,7 @@ profiles:
227271
- generated/python-example/k8s/local-deployment.yaml
228272
- generated/send-email/k8s/local-deployment.yaml
229273
- generated/send-verification-link/k8s/local-deployment.yaml
274+
- generated/sql-example/k8s/local-deployment.yaml
230275
- generated/functions-configmap.yaml
231276
deploy:
232277
kubectl:
@@ -252,6 +297,11 @@ profiles:
252297
namespace: constructive-functions
253298
port: 80
254299
localPort: 8082
300+
- resourceType: service
301+
resourceName: sql-example
302+
namespace: constructive-functions
303+
port: 80
304+
localPort: 8085
255305
- resourceType: service
256306
resourceName: knative-job-service
257307
namespace: constructive-functions
@@ -307,6 +357,11 @@ profiles:
307357
namespace: constructive-functions
308358
port: 80
309359
localPort: 8082
360+
- resourceType: service
361+
resourceName: sql-example
362+
namespace: constructive-functions
363+
port: 80
364+
localPort: 8085
310365
- resourceType: service
311366
resourceName: knative-job-service
312367
namespace: constructive-functions

templates/node-sql/Dockerfile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
FROM node:22-alpine AS build
2+
RUN npm install -g pnpm@10.12.2
3+
WORKDIR /app
4+
COPY . .
5+
RUN node --experimental-strip-types scripts/generate.ts \
6+
&& pnpm install --frozen-lockfile \
7+
&& pnpm --filter @constructive-io/{{name}}-fn... build
8+
9+
FROM node:22-alpine AS deploy
10+
RUN npm install -g pnpm@10.12.2
11+
COPY --from=build /app /app
12+
WORKDIR /app
13+
RUN pnpm --filter @constructive-io/{{name}}-fn deploy --legacy /deploy --prod
14+
15+
FROM node:22-alpine
16+
WORKDIR /app
17+
COPY --from=deploy /deploy .
18+
ENV NODE_ENV=production
19+
EXPOSE 8080
20+
CMD ["node", "dist/index.js"]

templates/node-sql/index.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { createJobApp } from '@constructive-io/knative-job-fn';
2+
import { createLogger } from '@pgpmjs/logger';
3+
import { Pool, PoolClient } from 'pg';
4+
import handler from './handler';
5+
6+
let pool: Pool | null = null;
7+
8+
function getPool(): Pool {
9+
if (!pool) {
10+
pool = new Pool({
11+
host: process.env.PGHOST,
12+
port: Number(process.env.PGPORT || 5432),
13+
database: process.env.PGDATABASE,
14+
user: process.env.PGUSER,
15+
password: process.env.PGPASSWORD,
16+
max: Number(process.env.PGPOOL_MAX || 10),
17+
idleTimeoutMillis: Number(process.env.PGPOOL_IDLE_TIMEOUT || 30000),
18+
connectionTimeoutMillis: Number(process.env.PGPOOL_CONNECTION_TIMEOUT || 5000),
19+
});
20+
}
21+
return pool;
22+
}
23+
24+
function createWithUserContext(pool: Pool, databaseId: string | undefined) {
25+
return async function withUserContext<T>(
26+
actorId: string | undefined,
27+
fn: (client: PoolClient) => Promise<T>
28+
): Promise<T> {
29+
const client = await pool.connect();
30+
try {
31+
await client.query('BEGIN');
32+
33+
if (databaseId) {
34+
await client.query(`SELECT set_config('jwt.claims.database_id', $1, true)`, [databaseId]);
35+
}
36+
if (actorId) {
37+
await client.query(`SELECT set_config('jwt.claims.user_id', $1, true)`, [actorId]);
38+
await client.query('SET LOCAL ROLE authenticated');
39+
}
40+
41+
const result = await fn(client);
42+
43+
await client.query('COMMIT');
44+
return result;
45+
} catch (err) {
46+
try {
47+
await client.query('ROLLBACK');
48+
} catch {
49+
// Ignore rollback errors
50+
}
51+
throw err;
52+
} finally {
53+
client.release();
54+
}
55+
};
56+
}
57+
58+
const app = createJobApp();
59+
const log = createLogger('{{name}}');
60+
61+
app.post('/', async (req: any, res: any, next: any) => {
62+
try {
63+
const databaseId = req.get('X-Database-Id') || req.get('x-database-id') || process.env.DEFAULT_DATABASE_ID;
64+
const currentPool = getPool();
65+
66+
const context = {
67+
job: {
68+
jobId: req.get('X-Job-Id') || req.get('x-job-id'),
69+
workerId: req.get('X-Worker-Id') || req.get('x-worker-id'),
70+
databaseId,
71+
},
72+
pool: currentPool,
73+
withUserContext: createWithUserContext(currentPool, databaseId),
74+
log,
75+
env: process.env as Record<string, string | undefined>,
76+
};
77+
78+
const params = req.body || {};
79+
const result = await handler(params, context);
80+
81+
res.status(200).json(result);
82+
} catch (err) {
83+
next(err);
84+
}
85+
});
86+
87+
export default app;
88+
89+
if (require.main === module) {
90+
app.listen(Number(process.env.PORT || 8080));
91+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
apiVersion: serving.knative.dev/v1
2+
kind: Service
3+
metadata:
4+
name: {{name}}
5+
namespace: constructive-functions
6+
spec:
7+
template:
8+
spec:
9+
containers:
10+
- image: ghcr.io/constructive-io/{{name}}-fn:latest
11+
ports:
12+
- containerPort: 8080
13+
envFrom:
14+
- secretRef:
15+
name: pg-credentials
16+
env:
17+
- name: NODE_ENV
18+
value: "production"
19+
- name: LOG_LEVEL
20+
value: "info"
21+
resources:
22+
requests:
23+
memory: "128Mi"
24+
cpu: "100m"
25+
limits:
26+
memory: "512Mi"
27+
cpu: "500m"

0 commit comments

Comments
 (0)