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..0ae2475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,77 @@ importers: specifier: ^5.1.6 version: 5.9.3 + generated/send-email-link: + dependencies: + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + '@constructive-io/postmaster': + specifier: ^1.5.2 + version: 1.6.2 + '@launchql/mjml': + specifier: 0.1.1 + version: 0.1.1(@babel/core@7.28.5)(react-dom@16.14.0(react@16.14.0))(react-is@18.3.1)(react@16.14.0) + '@launchql/styled-email': + specifier: 0.1.0 + version: 0.1.0(@babel/core@7.28.5)(react-dom@16.14.0(react@16.14.0))(react-is@18.3.1)(react@16.14.0) + '@pgpmjs/env': + specifier: ^2.15.3 + version: 2.17.0 + '@pgpmjs/logger': + specifier: ^2.4.3 + version: 2.5.2 + graphql-request: + specifier: ^7.1.2 + version: 7.4.0(graphql@16.12.0) + graphql-tag: + specifier: ^2.12.6 + version: 2.12.6(graphql@16.12.0) + simple-smtp-server: + specifier: ^0.7.3 + version: 0.7.3 + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + makage: + specifier: ^0.1.10 + version: 0.1.12 + typescript: + specifier: ^5.1.6 + version: 5.9.3 + + generated/send-sms: + dependencies: + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + '@pgpmjs/env': + specifier: ^2.15.3 + version: 2.17.0 + '@pgpmjs/logger': + specifier: ^2.4.3 + version: 2.5.2 + graphql-request: + specifier: ^7.1.2 + version: 7.4.0(graphql@16.12.0) + graphql-tag: + specifier: ^2.12.6 + version: 2.12.6(graphql@16.12.0) + twilio: + specifier: ^5.5.0 + version: 5.13.1 + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + makage: + specifier: ^0.1.10 + version: 0.1.12 + typescript: + specifier: ^5.1.6 + version: 5.9.3 + generated/send-verification-link: dependencies: '@constructive-io/fn-runtime': @@ -141,6 +212,78 @@ importers: specifier: ^5.1.6 version: 5.9.3 + generated/simple-email: + dependencies: + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + '@constructive-io/postmaster': + specifier: ^1.5.2 + version: 1.6.2 + '@pgpmjs/env': + specifier: ^2.15.3 + version: 2.17.0 + '@pgpmjs/logger': + specifier: ^2.4.3 + version: 2.5.2 + simple-smtp-server: + specifier: ^0.7.3 + version: 0.7.3 + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + makage: + specifier: ^0.1.10 + version: 0.1.12 + typescript: + 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 + 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 + + generated/text-embedding: + dependencies: + '@agentic-kit/ollama': + specifier: ^1.0.3 + version: 1.2.0 + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + 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': @@ -413,6 +556,9 @@ packages: 12factor-env@1.6.2: resolution: {integrity: sha512-U4EO6sy9Cc6h1ST3hhLD2rc2s4LERxProove3XZ52rMq2rTo5uTKWNKwD2OYDUwqNij+p5SgjmpPO6L/Gqtizw==} + '@agentic-kit/ollama@1.2.0': + resolution: {integrity: sha512-MWVawKqphgs6Dq2FdWGvILw/72Eqg1EEUQAaACxX+CvQJTF4ArtNdTbQCLfPM8kk0l3JESI2DBHSccckj6pmag==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1047,6 +1193,9 @@ 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.5.2': resolution: {integrity: sha512-e9Z2Woju+fcsC0nm9KwEgOXZqm8UcrrxVEPbunXo6kpROpIQkBRg3RVU9xCxSHQ6xiko4r9YFXs370EW6kIUsQ==} @@ -1381,6 +1530,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1530,6 +1683,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1708,6 +1864,9 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1804,6 +1963,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + editorconfig@1.0.4: resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} engines: {node: '>=14'} @@ -2211,6 +2373,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2627,11 +2793,21 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + juice@7.0.0: resolution: {integrity: sha512-AjKQX31KKN+uJs+zaf+GW8mBO/f/0NqSh2moTMyvwBY+4/lXIYTU8D8I2h6BAV3Xnz6GGsbalUyFqbYMe+Vh+Q==} engines: {node: '>=10.0.0'} hasBin: true + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2658,12 +2834,33 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -3064,9 +3261,6 @@ packages: peerDependencies: pg: '>=8.0' - pg-protocol@1.11.0: - resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} - pg-protocol@1.13.0: resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} @@ -3262,6 +3456,10 @@ packages: scheduler@0.19.1: resolution: {integrity: sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==} + scmp@2.1.0: + resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + deprecated: Just use Node.js's crypto.timingSafeEqual() + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3486,6 +3684,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + twilio@5.13.1: + resolution: {integrity: sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==} + engines: {node: '>=14.0'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3630,6 +3832,10 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + xmlbuilder@13.0.2: + resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==} + engines: {node: '>=6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -3678,6 +3884,8 @@ snapshots: dependencies: envalid: 8.1.1 + '@agentic-kit/ollama@1.2.0': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4503,6 +4711,10 @@ snapshots: '@pgpmjs/types': 2.21.0 deepmerge: 4.3.1 + '@pgpmjs/logger@1.5.0': + dependencies: + yanse: 0.2.1 + '@pgpmjs/logger@2.5.2': dependencies: yanse: 0.2.1 @@ -4674,7 +4886,7 @@ snapshots: '@types/pg@8.16.0': dependencies: '@types/node': 22.19.3 - pg-protocol: 1.11.0 + pg-protocol: 1.13.0 pg-types: 2.2.0 '@types/qs@6.14.0': {} @@ -4865,6 +5077,12 @@ snapshots: acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -5075,6 +5293,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bytes@3.1.2: {} @@ -5272,6 +5492,8 @@ snapshots: css-what@6.2.2: {} + dayjs@1.11.20: {} + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -5366,6 +5588,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + editorconfig@1.0.4: dependencies: '@one-ini/wasm': 0.1.1 @@ -5850,6 +6076,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.6.3: @@ -6633,6 +6866,19 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + juice@7.0.0: dependencies: cheerio: 1.1.2 @@ -6643,6 +6889,17 @@ snapshots: transitivePeerDependencies: - encoding + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6666,10 +6923,24 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} long-timeout@0.1.1: {} @@ -7227,8 +7498,6 @@ snapshots: dependencies: pg: 8.20.0 - pg-protocol@1.11.0: {} - pg-protocol@1.13.0: {} pg-types@2.2.0: @@ -7412,6 +7681,8 @@ snapshots: loose-envify: 1.4.0 object-assign: 4.1.1 + scmp@2.1.0: {} + semver@6.3.1: {} semver@7.7.3: {} @@ -7677,6 +7948,19 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + twilio@5.13.1: + dependencies: + axios: 1.13.5 + dayjs: 1.11.20 + https-proxy-agent: 5.0.1 + jsonwebtoken: 9.0.3 + qs: 6.14.1 + scmp: 2.1.0 + xmlbuilder: 13.0.2 + transitivePeerDependencies: + - debug + - supports-color + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -7839,6 +8123,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + xmlbuilder@13.0.2: {} + xtend@4.0.2: {} y18n@4.0.3: {} 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..75c8261 --- /dev/null +++ b/templates/node-sql/index.ts @@ -0,0 +1,91 @@ +import { createJobApp } from '@constructive-io/knative-job-fn'; +import { createLogger } from '@pgpmjs/logger'; +import { Pool, PoolClient } from 'pg'; +import handler from './handler'; + +let pool: Pool | null = null; + +function getPool(): Pool { + if (!pool) { + pool = new Pool({ + host: process.env.PGHOST, + port: Number(process.env.PGPORT || 5432), + database: process.env.PGDATABASE, + user: process.env.PGUSER, + password: process.env.PGPASSWORD, + max: Number(process.env.PGPOOL_MAX || 10), + idleTimeoutMillis: Number(process.env.PGPOOL_IDLE_TIMEOUT || 30000), + connectionTimeoutMillis: Number(process.env.PGPOOL_CONNECTION_TIMEOUT || 5000), + }); + } + return pool; +} + +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 = getPool(); + + 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..0357d6f --- /dev/null +++ b/templates/node-sql/package.json @@ -0,0 +1,24 @@ +{ + "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" + }, + "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); + } + }); +});