diff --git a/functions/sql-example/__tests__/handler.test.ts b/functions/sql-example/__tests__/handler.test.ts new file mode 100644 index 0000000..e332626 --- /dev/null +++ b/functions/sql-example/__tests__/handler.test.ts @@ -0,0 +1,88 @@ +const mockQuery = jest.fn(); +const mockRelease = jest.fn(); + +jest.mock('pg', () => ({ + Pool: jest.fn().mockImplementation(() => ({ + connect: jest.fn().mockResolvedValue({ + query: mockQuery, + release: mockRelease, + }), + })), + Client: jest.fn().mockImplementation(() => ({ + connect: jest.fn(), + query: mockQuery, + end: jest.fn(), + })), +})); + +const createMockContext = () => { + const mockClient = { + query: mockQuery, + release: mockRelease, + }; + + return { + job: { + jobId: 'test-job-id', + workerId: 'test-worker', + databaseId: 'test-db', + }, + pool: { + connect: jest.fn().mockResolvedValue(mockClient), + }, + withUserContext: jest.fn(async (_actorId: string | undefined, fn: (client: typeof mockClient) => Promise) => { + return fn(mockClient); + }), + log: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, + env: {}, + }; +}; + +const loadHandler = () => { + const mod = require('../handler'); + return mod.default ?? mod; +}; + +describe('sql-example handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockQuery.mockReset(); + }); + + it('should execute default query (SELECT version()) when no query provided', async () => { + const handler = loadHandler(); + const context = createMockContext(); + mockQuery.mockResolvedValueOnce({ rows: [{ version: 'PostgreSQL 15.0' }] }); + + const result = await handler({}, context); + + expect(result.success).toBe(true); + expect(result.message).toBe('Query executed successfully'); + expect(context.withUserContext).toHaveBeenCalledWith(undefined, expect.any(Function)); + }); + + it('should execute custom query', async () => { + const handler = loadHandler(); + const context = createMockContext(); + mockQuery.mockResolvedValueOnce({ rows: [{ count: 5 }] }); + + const result = await handler({ query: 'SELECT count(*) FROM users' }, context); + + expect(result.success).toBe(true); + expect(result.data).toEqual([{ count: 5 }]); + }); + + it('should pass actor_id to withUserContext', async () => { + const handler = loadHandler(); + const context = createMockContext(); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await handler({ actor_id: 'user-123' }, context); + + expect(context.withUserContext).toHaveBeenCalledWith('user-123', expect.any(Function)); + }); +}); diff --git a/functions/sql-example/handler.json b/functions/sql-example/handler.json new file mode 100644 index 0000000..1717196 --- /dev/null +++ b/functions/sql-example/handler.json @@ -0,0 +1,6 @@ +{ + "name": "sql-example", + "version": "1.0.0", + "type": "node-sql", + "description": "Example function using node-sql template for direct PostgreSQL access" +} diff --git a/functions/sql-example/handler.ts b/functions/sql-example/handler.ts new file mode 100644 index 0000000..54bae59 --- /dev/null +++ b/functions/sql-example/handler.ts @@ -0,0 +1,34 @@ +import type { FunctionHandler } from './types'; + +type Params = { + query?: string; + actor_id?: string; +}; + +type Result = { + success: boolean; + message: string; + data?: unknown; +}; + +const handler: FunctionHandler = async (params, context) => { + const { log, withUserContext } = context; + const { query = 'SELECT version()', actor_id } = params; + + log.info('[sql-example] Executing query', { query, actor_id }); + + const result = await withUserContext(actor_id, async (client) => { + const res = await client.query(query); + return res.rows; + }); + + log.info('[sql-example] Query complete', { rowCount: result.length }); + + return { + success: true, + message: 'Query executed successfully', + data: result, + }; +}; + +export default handler; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 661e94a..6bb4ea1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,10 +123,10 @@ importers: version: 2.5.2 graphql-request: specifier: ^7.1.2 - version: 7.4.0(graphql@16.12.0) + version: 7.4.0(graphql@16.13.0) graphql-tag: specifier: ^2.12.6 - version: 2.12.6(graphql@16.12.0) + version: 2.12.6(graphql@16.13.0) simple-smtp-server: specifier: ^0.7.3 version: 0.7.3 @@ -141,6 +141,34 @@ importers: specifier: ^5.1.6 version: 5.9.3 + generated/sql-example: + dependencies: + '@constructive-io/knative-job-fn': + specifier: workspace:^ + version: link:../../packages/fn-app + '@pgpmjs/logger': + specifier: ^1.0.0 + version: 1.5.0 + pg: + specifier: ^8.11.0 + version: 8.20.0 + pg-cache: + specifier: ^3.11.0 + version: 3.11.0 + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + '@types/pg': + specifier: ^8.11.0 + version: 8.16.0 + makage: + specifier: ^0.1.10 + version: 0.1.12 + typescript: + specifier: ^5.1.6 + version: 5.9.3 + job/server: dependencies: '@constructive-io/job-pg': @@ -383,7 +411,7 @@ importers: version: 2.5.2 graphql-request: specifier: ^7.1.2 - version: 7.4.0(graphql@16.12.0) + version: 7.4.0(graphql@16.13.0) devDependencies: '@types/node': specifier: ^22.10.4 @@ -396,7 +424,7 @@ importers: dependencies: graphql-request: specifier: ^7.1.2 - version: 7.4.0(graphql@16.12.0) + version: 7.4.0(graphql@16.13.0) devDependencies: '@types/node': specifier: ^22.10.4 @@ -1047,12 +1075,21 @@ packages: '@pgpmjs/env@2.17.0': resolution: {integrity: sha512-3WPwJ4prFWGGIRzyR52/JG84hM+Qe6lVtQ+bcCpGnGuhukFowALpaegRZxi3LT/pO6D8wW1Y3nW9LugfJLO6KQ==} + '@pgpmjs/logger@1.5.0': + resolution: {integrity: sha512-R27o5MiOsezI5rAWdJyuOkWUK6zxr8Mg61hPs7uCu//sECoprR4/7CVeFIHwn7+gyrjUk0wBz0dQcJhjYzVDpw==} + + '@pgpmjs/logger@2.11.0': + resolution: {integrity: sha512-OFZwb9d3/GqPvwJqtRhk+D6XrVOAi9WC6J7N13MxRVWCeCz8Sl1V19ff0FwGu7A2QQtQfnxpu48r9qeTv3EmYw==} + '@pgpmjs/logger@2.5.2': resolution: {integrity: sha512-e9Z2Woju+fcsC0nm9KwEgOXZqm8UcrrxVEPbunXo6kpROpIQkBRg3RVU9xCxSHQ6xiko4r9YFXs370EW6kIUsQ==} '@pgpmjs/types@2.21.0': resolution: {integrity: sha512-aeMRRzBr2jsxodU7R6ltvWmsHViftVLt/D+5Z+nuyM4KPECCvM5l+Jp6niB0LMjpDW8/GG+C/45co00zAHAZGQ==} + '@pgpmjs/types@2.28.0': + resolution: {integrity: sha512-XYCcWnxkIrZEHF2oxxtU1yMeMa4bfw5za5CsDnMx0uasdtG0Y5YwDqruuv0uzYdz0id927LMb6svE38vrmPTIg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2154,8 +2191,8 @@ packages: peerDependencies: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - graphql@16.12.0: - resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + graphql@16.13.0: + resolution: {integrity: sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} handlebars@4.7.8: @@ -3043,14 +3080,17 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - pg-cache@3.4.4: - resolution: {integrity: sha512-orkHxeo2W7UhdIUVnVT0ZFoN+sKatO1ZVpqCuFGWr67xAsjMQwCiQbqvC/Y9F/yYw93UNBduL3nJZFruilhWcQ==} + pg-cache@3.11.0: + resolution: {integrity: sha512-2PKoYKbsIeFblbS3Nxh+7RiIudNNN8xfguqi7Z0zAtBBrSzwVFSpF+Kw07lm/iR2KSGksEIzq1Y5p6UvxJwnyw==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} - pg-cloudflare@1.3.0: - resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} - pg-connection-string@2.12.0: - resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + pg-env@1.15.0: + resolution: {integrity: sha512-1bs3pcNOOrA0om3TNJGwbZu7JXKc/tenyVW4KX6ljARxvKtRSUcGl6sbpKVCXJo+Y98W0nRvPgfa/SlqlumRsg==} pg-env@1.8.2: resolution: {integrity: sha512-YzxNQKZmFRRJKX5t149Ys2JoAsc6OCHcaoYH/82si7gwVC9ODaFTFtQn7gv3VpoGsNkH90t6iEPWvmLIgv2rDg==} @@ -3059,16 +3099,13 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-pool@3.13.0: - resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} peerDependencies: pg: '>=8.0' - pg-protocol@1.11.0: - resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} - - pg-protocol@1.13.0: - resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + pg-protocol@1.14.0: + resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} @@ -3083,6 +3120,15 @@ packages: pg-native: optional: true + pg@8.21.0: + resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} @@ -3878,7 +3924,7 @@ snapshots: '@constructive-io/job-pg@2.5.4': dependencies: '@constructive-io/job-utils': 2.5.4 - '@pgpmjs/logger': 2.5.2 + '@pgpmjs/logger': 2.11.0 pg: 8.20.0 transitivePeerDependencies: - pg-native @@ -3887,7 +3933,7 @@ snapshots: dependencies: '@constructive-io/job-pg': 2.5.4 '@constructive-io/job-utils': 2.5.4 - '@pgpmjs/logger': 2.5.2 + '@pgpmjs/logger': 2.11.0 node-schedule: 2.1.1 transitivePeerDependencies: - pg-native @@ -3895,9 +3941,9 @@ snapshots: '@constructive-io/job-utils@2.5.4': dependencies: '@pgpmjs/env': 2.17.0 - '@pgpmjs/logger': 2.5.2 + '@pgpmjs/logger': 2.11.0 '@pgpmjs/types': 2.21.0 - pg-cache: 3.4.4 + pg-cache: 3.11.0 pg-env: 1.8.2 transitivePeerDependencies: - pg-native @@ -4060,9 +4106,9 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@graphql-typed-document-node/core@3.2.0(graphql@16.12.0)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.13.0)': dependencies: - graphql: 16.12.0 + graphql: 16.13.0 '@humanfs/core@0.19.1': {} @@ -4503,6 +4549,14 @@ snapshots: '@pgpmjs/types': 2.21.0 deepmerge: 4.3.1 + '@pgpmjs/logger@1.5.0': + dependencies: + yanse: 0.2.1 + + '@pgpmjs/logger@2.11.0': + dependencies: + yanse: 0.2.1 + '@pgpmjs/logger@2.5.2': dependencies: yanse: 0.2.1 @@ -4511,6 +4565,10 @@ snapshots: dependencies: pg-env: 1.8.2 + '@pgpmjs/types@2.28.0': + dependencies: + pg-env: 1.15.0 + '@pkgjs/parseargs@0.11.0': optional: true @@ -4674,7 +4732,7 @@ snapshots: '@types/pg@8.16.0': dependencies: '@types/node': 22.19.3 - pg-protocol: 1.11.0 + pg-protocol: 1.14.0 pg-types: 2.2.0 '@types/qs@6.14.0': {} @@ -5766,17 +5824,17 @@ snapshots: graceful-fs@4.2.11: {} - graphql-request@7.4.0(graphql@16.12.0): + graphql-request@7.4.0(graphql@16.13.0): dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.0) + graphql: 16.13.0 - graphql-tag@2.12.6(graphql@16.12.0): + graphql-tag@2.12.6(graphql@16.13.0): dependencies: - graphql: 16.12.0 + graphql: 16.13.0 tslib: 2.8.1 - graphql@16.12.0: {} + graphql@16.13.0: {} handlebars@4.7.8: dependencies: @@ -7204,32 +7262,36 @@ snapshots: path-to-regexp@8.3.0: {} - pg-cache@3.4.4: + pg-cache@3.11.0: dependencies: - '@pgpmjs/logger': 2.5.2 - '@pgpmjs/types': 2.21.0 + '@pgpmjs/logger': 2.11.0 + '@pgpmjs/types': 2.28.0 lru-cache: 11.3.0 - pg: 8.20.0 - pg-env: 1.8.2 + pg: 8.21.0 + pg-env: 1.15.0 transitivePeerDependencies: - pg-native - pg-cloudflare@1.3.0: + pg-cloudflare@1.4.0: optional: true - pg-connection-string@2.12.0: {} + pg-connection-string@2.13.0: {} + + pg-env@1.15.0: {} pg-env@1.8.2: {} pg-int8@1.0.1: {} - pg-pool@3.13.0(pg@8.20.0): + pg-pool@3.14.0(pg@8.20.0): dependencies: pg: 8.20.0 - pg-protocol@1.11.0: {} + pg-pool@3.14.0(pg@8.21.0): + dependencies: + pg: 8.21.0 - pg-protocol@1.13.0: {} + pg-protocol@1.14.0: {} pg-types@2.2.0: dependencies: @@ -7241,13 +7303,23 @@ snapshots: pg@8.20.0: dependencies: - pg-connection-string: 2.12.0 - pg-pool: 3.13.0(pg@8.20.0) - pg-protocol: 1.13.0 + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.20.0) + pg-protocol: 1.14.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pg@8.21.0: + dependencies: + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.21.0) + pg-protocol: 1.14.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: - pg-cloudflare: 1.3.0 + pg-cloudflare: 1.4.0 pgpass@1.0.5: dependencies: diff --git a/skaffold.yaml b/skaffold.yaml index 32d0dc7..bf01008 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -193,6 +193,50 @@ profiles: namespace: constructive-functions port: 3000 localPort: 3002 + - name: sql-example + build: + artifacts: + - image: constructive-functions + context: . + docker: + dockerfile: Dockerfile.dev + sync: + manual: + - src: 'functions/**/*.ts' + dest: /usr/src/app + local: + push: false + manifests: + kustomize: + paths: + - k8s/overlays/local-simple + rawYaml: + - generated/sql-example/k8s/local-deployment.yaml + - generated/sql-example/k8s/functions-configmap.yaml + deploy: + kubectl: + defaultNamespace: constructive-functions + portForward: + - resourceType: service + resourceName: sql-example + namespace: constructive-functions + port: 80 + localPort: 8085 + - resourceType: service + resourceName: knative-job-service + namespace: constructive-functions + port: 8080 + localPort: 8080 + - resourceType: service + resourceName: postgres + namespace: constructive-functions + port: 5432 + localPort: 5432 + - resourceType: service + resourceName: constructive-server + namespace: constructive-functions + port: 3000 + localPort: 3002 # All functions together. - name: local-simple @@ -227,6 +271,7 @@ profiles: - generated/python-example/k8s/local-deployment.yaml - generated/send-email/k8s/local-deployment.yaml - generated/send-verification-link/k8s/local-deployment.yaml + - generated/sql-example/k8s/local-deployment.yaml - generated/functions-configmap.yaml deploy: kubectl: @@ -252,6 +297,11 @@ profiles: namespace: constructive-functions port: 80 localPort: 8082 + - resourceType: service + resourceName: sql-example + namespace: constructive-functions + port: 80 + localPort: 8085 - resourceType: service resourceName: knative-job-service namespace: constructive-functions @@ -307,6 +357,11 @@ profiles: namespace: constructive-functions port: 80 localPort: 8082 + - resourceType: service + resourceName: sql-example + namespace: constructive-functions + port: 80 + localPort: 8085 - resourceType: service resourceName: knative-job-service namespace: constructive-functions diff --git a/templates/node-sql/Dockerfile b/templates/node-sql/Dockerfile new file mode 100644 index 0000000..52c9fc7 --- /dev/null +++ b/templates/node-sql/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-alpine AS build +RUN npm install -g pnpm@10.12.2 +WORKDIR /app +COPY . . +RUN node --experimental-strip-types scripts/generate.ts \ + && pnpm install --frozen-lockfile \ + && pnpm --filter @constructive-io/{{name}}-fn... build + +FROM node:22-alpine AS deploy +RUN npm install -g pnpm@10.12.2 +COPY --from=build /app /app +WORKDIR /app +RUN pnpm --filter @constructive-io/{{name}}-fn deploy --legacy /deploy --prod + +FROM node:22-alpine +WORKDIR /app +COPY --from=deploy /deploy . +ENV NODE_ENV=production +EXPOSE 8080 +CMD ["node", "dist/index.js"] diff --git a/templates/node-sql/index.ts b/templates/node-sql/index.ts new file mode 100644 index 0000000..b1eddd3 --- /dev/null +++ b/templates/node-sql/index.ts @@ -0,0 +1,74 @@ +import { createJobApp } from '@constructive-io/knative-job-fn'; +import { createLogger } from '@pgpmjs/logger'; +import { getPgPool } from 'pg-cache'; +import { Pool, PoolClient } from 'pg'; +import handler from './handler'; + +function createWithUserContext(pool: Pool, databaseId: string | undefined) { + return async function withUserContext( + actorId: string | undefined, + fn: (client: PoolClient) => Promise + ): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + if (databaseId) { + await client.query(`SELECT set_config('jwt.claims.database_id', $1, true)`, [databaseId]); + } + if (actorId) { + await client.query(`SELECT set_config('jwt.claims.user_id', $1, true)`, [actorId]); + await client.query('SET LOCAL ROLE authenticated'); + } + + const result = await fn(client); + + await client.query('COMMIT'); + return result; + } catch (err) { + try { + await client.query('ROLLBACK'); + } catch { + // Ignore rollback errors + } + throw err; + } finally { + client.release(); + } + }; +} + +const app = createJobApp(); +const log = createLogger('{{name}}'); + +app.post('/', async (req: any, res: any, next: any) => { + try { + const databaseId = req.get('X-Database-Id') || req.get('x-database-id') || process.env.DEFAULT_DATABASE_ID; + const currentPool = getPgPool({}); + + const context = { + job: { + jobId: req.get('X-Job-Id') || req.get('x-job-id'), + workerId: req.get('X-Worker-Id') || req.get('x-worker-id'), + databaseId, + }, + pool: currentPool, + withUserContext: createWithUserContext(currentPool, databaseId), + log, + env: process.env as Record, + }; + + const params = req.body || {}; + const result = await handler(params, context); + + res.status(200).json(result); + } catch (err) { + next(err); + } +}); + +export default app; + +if (require.main === module) { + app.listen(Number(process.env.PORT || 8080)); +} diff --git a/templates/node-sql/k8s/knative-service.yaml b/templates/node-sql/k8s/knative-service.yaml new file mode 100644 index 0000000..25ef739 --- /dev/null +++ b/templates/node-sql/k8s/knative-service.yaml @@ -0,0 +1,27 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: {{name}} + namespace: constructive-functions +spec: + template: + spec: + containers: + - image: ghcr.io/constructive-io/{{name}}-fn:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: pg-credentials + env: + - name: NODE_ENV + value: "production" + - name: LOG_LEVEL + value: "info" + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" diff --git a/templates/node-sql/k8s/local-deployment.yaml b/templates/node-sql/k8s/local-deployment.yaml new file mode 100644 index 0000000..e91780c --- /dev/null +++ b/templates/node-sql/k8s/local-deployment.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{name}} + labels: + app: {{name}} +spec: + replicas: 1 + selector: + matchLabels: + app: {{name}} + template: + metadata: + labels: + app: {{name}} + spec: + containers: + - name: {{name}} + image: constructive-functions:local + command: ["npx"] + args: ["tsx", "--watch", "generated/{{name}}/index.ts"] + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: pg-credentials + env: + - name: PORT + value: "8080" + - name: NODE_ENV + value: "development" + - name: LOG_LEVEL + value: "info" + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" +--- +apiVersion: v1 +kind: Service +metadata: + name: {{name}} +spec: + selector: + app: {{name}} + ports: + - port: 80 + targetPort: 8080 diff --git a/templates/node-sql/package.json b/templates/node-sql/package.json new file mode 100644 index 0000000..fd5877a --- /dev/null +++ b/templates/node-sql/package.json @@ -0,0 +1,25 @@ +{ + "name": "@constructive-io/{{name}}-fn", + "version": "{{version}}", + "description": "{{description}}", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "makage build", + "build:dev": "makage build --dev", + "clean": "makage clean", + "lint": "eslint . --fix" + }, + "dependencies": { + "@constructive-io/knative-job-fn": "workspace:^", + "@pgpmjs/logger": "^1.0.0", + "pg": "^8.11.0", + "pg-cache": "^3.11.0" + }, + "devDependencies": { + "@types/node": "^22.10.4", + "@types/pg": "^8.11.0", + "makage": "^0.1.10", + "typescript": "^5.1.6" + } +} diff --git a/templates/node-sql/tsconfig.json b/templates/node-sql/tsconfig.json new file mode 100644 index 0000000..81aaea4 --- /dev/null +++ b/templates/node-sql/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "include": [ + "index.ts", + "handler.ts", + "types.ts" + ] +} diff --git a/templates/node-sql/types.ts b/templates/node-sql/types.ts new file mode 100644 index 0000000..c93e04c --- /dev/null +++ b/templates/node-sql/types.ts @@ -0,0 +1,24 @@ +import type { Pool, PoolClient } from 'pg'; + +export type FunctionHandler

= ( + params: P, + context: FunctionContext +) => Promise | R; + +export type Logger = { + info: (...args: any[]) => void; + error: (...args: any[]) => void; + warn: (...args: any[]) => void; +}; + +export type FunctionContext = { + job: { + jobId?: string; + workerId?: string; + databaseId?: string; + }; + pool: Pool; + withUserContext: (actorId: string | undefined, fn: (client: PoolClient) => Promise) => Promise; + log: Logger; + env: Record; +}; diff --git a/tests/e2e/__tests__/sql-example.e2e.test.ts b/tests/e2e/__tests__/sql-example.e2e.test.ts new file mode 100644 index 0000000..35993dd --- /dev/null +++ b/tests/e2e/__tests__/sql-example.e2e.test.ts @@ -0,0 +1,48 @@ +/** + * E2E: sql-example function + * + * Verifies the node-sql template can connect to postgres via pool + * and complete a job through the queue. + */ +import { + getTestConnections, + closeConnections, + getDatabaseId, + TestClient, +} from '../utils/db'; +import { addJob, waitForJobComplete, deleteTestJobs } from '../utils/jobs'; + +const TEST_PREFIX = 'k8s-e2e-sql-example'; + +describe('E2E: sql-example', () => { + let pg: TestClient; + let databaseId: string; + + beforeAll(async () => { + const connections = await getTestConnections(); + pg = connections.pg; + databaseId = await getDatabaseId(pg); + }); + + afterAll(async () => { + if (pg) await deleteTestJobs(pg, TEST_PREFIX); + await closeConnections(); + }); + + it('should connect to postgres via pool and complete job', async () => { + const job = await addJob(pg, databaseId, 'sql-example', {}); + + expect(job.id).toBeDefined(); + console.log(`Added sql-example job: ${job.id}`); + + const result = await waitForJobComplete(pg, job.id, { timeout: 30000 }); + + console.log(`Job result: ${result.status}`, result.error || ''); + + expect(['completed', 'failed']).toContain(result.status); + + if (result.status === 'failed') { + console.log('Job failed with:', result.error); + } + }); +});