From b5e6538e083f08738b17338dd893edd2ef5a2aa8 Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Wed, 10 Jun 2026 16:48:35 +0800 Subject: [PATCH 01/10] feat(functions): add storage-confirm-upload function Handles deferred upload confirmation via HeadObject check. Co-Authored-By: Claude Opus 4.5 --- functions/storage-confirm-upload/handler.json | 14 ++ functions/storage-confirm-upload/handler.ts | 166 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 functions/storage-confirm-upload/handler.json create mode 100644 functions/storage-confirm-upload/handler.ts diff --git a/functions/storage-confirm-upload/handler.json b/functions/storage-confirm-upload/handler.json new file mode 100644 index 000000000..1a0bd3529 --- /dev/null +++ b/functions/storage-confirm-upload/handler.json @@ -0,0 +1,14 @@ +{ + "name": "storage-confirm-upload", + "version": "1.0.0", + "type": "node-graphql", + "port": 8085, + "taskIdentifier": "storage:confirm_upload", + "description": "Confirms file upload by checking S3 HeadObject and transitioning status to uploaded", + "dependencies": { + "@aws-sdk/client-s3": "^3.1060.0", + "@pgpmjs/env": "^2.15.3", + "@pgpmjs/logger": "^2.4.3", + "pg": "^8.16.0" + } +} diff --git a/functions/storage-confirm-upload/handler.ts b/functions/storage-confirm-upload/handler.ts new file mode 100644 index 000000000..c1ea076cf --- /dev/null +++ b/functions/storage-confirm-upload/handler.ts @@ -0,0 +1,166 @@ +import { HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import type { FunctionHandler } from '@constructive-io/fn-runtime'; +import { createLogger } from '@pgpmjs/logger'; +import { Client as PgClient } from 'pg'; + +type StorageConfirmUploadPayload = { + file_id: string; + key: string; + bucket_id: string; + mime_type: string; + schema?: string; + table?: string; +}; + +const logger = createLogger('storage-confirm-upload'); + +const createPgClient = (): PgClient => { + return new PgClient({ + host: process.env.PGHOST || 'localhost', + port: Number(process.env.PGPORT) || 5432, + user: process.env.PGUSER || 'postgres', + password: process.env.PGPASSWORD || 'password', + database: process.env.PGDATABASE || 'constructive', + }); +}; + +const createS3Client = (): S3Client => { + const endpoint = process.env.S3_ENDPOINT || process.env.CDN_ENDPOINT; + return new S3Client({ + region: process.env.CDN_REGION || process.env.AWS_REGION || 'us-east-1', + endpoint: endpoint, + forcePathStyle: true, + credentials: { + accessKeyId: process.env.CDN_ACCESS_KEY || process.env.AWS_ACCESS_KEY_ID || '', + secretAccessKey: process.env.CDN_SECRET_KEY || process.env.AWS_SECRET_ACCESS_KEY || '', + }, + }); +}; + +const resolveS3BucketName = async ( + bucketId: string, + storageSchema: string, + databaseId: string +): Promise => { + const pg = createPgClient(); + try { + await pg.connect(); + // Get bucket type to construct S3 bucket name + // Format: test-bucket-{type}-{database_id} + const result = await pg.query( + `SELECT b.type + FROM "${storageSchema}".app_buckets b + WHERE b.id = $1`, + [bucketId] + ); + if (result.rows.length > 0 && result.rows[0].type) { + const bucketType = result.rows[0].type; + return `test-bucket-${bucketType}-${databaseId}`; + } + // Fallback to bucket_id if type not found + return bucketId; + } finally { + await pg.end(); + } +}; + +const checkFileExistsInS3 = async ( + s3: S3Client, + bucket: string, + key: string +): Promise => { + try { + await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); + return true; + } catch (err: unknown) { + const error = err as { name?: string }; + if (error.name === 'NotFound' || error.name === 'NoSuchKey') { + return false; + } + throw err; + } +}; + +const confirmFileUploaded = async ( + fileId: string, + schema: string, + table: string +): Promise => { + const pg = createPgClient(); + try { + await pg.connect(); + const privateSchema = schema.replace(/-public$/, '-private'); + const fnName = `${table}_confirm_uploaded`; + + await pg.query(`SELECT "${privateSchema}"."${fnName}"($1)`, [fileId]); + + logger.info('[storage-confirm-upload] File status confirmed', { fileId, schema, table }); + } finally { + await pg.end(); + } +}; + +const handler: FunctionHandler = async ( + params, + context +) => { + const { job, log } = context; + const { file_id, key, bucket_id, mime_type, schema, table } = params; + + if (!file_id || !key || !bucket_id) { + throw new Error('Missing required fields: file_id, key, or bucket_id'); + } + + log.info('[storage-confirm-upload] Processing', { + file_id, + key, + bucket_id, + schema, + table, + }); + + const s3 = createS3Client(); + + const storageSchema = schema + ? schema.replace(/-app-public$/, '-storage-public') + : 'storage_public'; + + let bucketName = bucket_id; + if (schema && job.databaseId) { + try { + bucketName = await resolveS3BucketName(bucket_id, storageSchema, job.databaseId); + log.info('[storage-confirm-upload] Resolved bucket name', { bucket_id, bucketName }); + } catch (err) { + log.warn('[storage-confirm-upload] Could not resolve bucket, using bucket_id', { + bucket_id, + error: (err as Error).message, + }); + } + } + + const exists = await checkFileExistsInS3(s3, bucketName, key); + + if (!exists) { + log.info('[storage-confirm-upload] File not found in S3, will retry', { + bucket: bucketName, + key, + }); + throw new Error(`File not found in S3: ${bucketName}/${key}`); + } + + log.info('[storage-confirm-upload] File exists in S3', { bucket: bucketName, key }); + + const targetSchema = schema || storageSchema; + const targetTable = table || 'app_files'; + + await confirmFileUploaded(file_id, targetSchema, targetTable); + + return { + success: true, + file_id, + bucket: bucketName, + key, + }; +}; + +export default handler; From d82d4f4b1231a96cd2eb9cb9ca234396c1137f08 Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Wed, 10 Jun 2026 16:48:45 +0800 Subject: [PATCH 02/10] chore(docker): add minio service and update config - Add minio service for local S3 testing - Fix db-setup image (was pointing to wrong image) - Add S3/MinIO environment variables - Update API configuration for tenant support Co-Authored-By: Claude Opus 4.5 --- docker-compose.yml | 59 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0717362c4..0bb6491e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: ports: - "5432:5432" volumes: - - pgdata:/var/lib/postgresql/data + - pgdata:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s @@ -26,7 +26,7 @@ services: retries: 5 db-setup: - image: ghcr.io/constructive-io/constructive:latest + image: ghcr.io/constructive-io/constructive-db-job:latest depends_on: postgres: condition: service_healthy @@ -36,10 +36,13 @@ services: PGUSER: postgres PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set (see .env.example)} PGDATABASE: constructive + CONSTRUCTIVE_DOMAIN: "localhost" entrypoint: ["bash", "-c"] command: - | + set -eo pipefail cd /app + echo "Creating database $$PGDATABASE" createdb -h "$$PGHOST" -U "$$PGUSER" "$$PGDATABASE" || true @@ -47,25 +50,31 @@ services: pgpm admin-users bootstrap --yes pgpm admin-users add --test --yes - echo "Deploying packages" - pgpm deploy --yes --database "$$PGDATABASE" --package constructive - pgpm deploy --yes --database "$$PGDATABASE" --package constructive-services - pgpm deploy --yes --database "$$PGDATABASE" --package constructive-prod + echo "Deploying constructive-local (services + database record)" + pgpm deploy --yes --database "$$PGDATABASE" --package constructive-local - echo "Deploying metaschema and jobs" + echo "Deploying metaschema" pgpm deploy --yes --database "$$PGDATABASE" --package metaschema + + echo "Deploying pgpm-database-jobs" pgpm deploy --yes --database "$$PGDATABASE" --package pgpm-database-jobs echo "Done" graphql-server: image: ghcr.io/constructive-io/constructive:latest + pull_policy: always depends_on: db-setup: condition: service_completed_successfully - entrypoint: ["constructive", "server", "--host", "0.0.0.0", "--port", "3000", "--origin", "*"] + extra_hosts: + - "host.docker.internal:host-gateway" + - "localhost:host-gateway" + entrypoint: ["cnc", "server", "--host", "0.0.0.0", "--port", "3000", "--origin", "*", "--strictAuth", "false"] environment: NODE_ENV: development + LOG_LEVEL: debug + DEBUG: "graphile*" PORT: "3000" SERVER_HOST: "0.0.0.0" SERVER_TRUST_PROXY: "true" @@ -78,12 +87,20 @@ services: PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set (see .env.example)} PGDATABASE: constructive # API configuration — header-based routing (X-Api-Name, X-Database-Id) + API_ENABLE_META: "true" API_ENABLE_SERVICES: "true" - API_EXPOSED_SCHEMAS: "metaschema_public,services_public,constructive_auth_public" - API_IS_PUBLIC: "false" - API_META_SCHEMAS: "metaschema_public,services_public,metaschema_modules_public,constructive_auth_public" + API_ENABLE_TENANT: "true" + API_EXPOSED_SCHEMAS: "collections_public,meta_public,storage_public,app_public" API_ANON_ROLE: "administrator" API_ROLE_NAME: "administrator" + API_DEFAULT_DATABASE_ID: "constructive" + # S3/MinIO configuration (extra_hosts makes localhost work from inside container) + CDN_ENDPOINT: "http://localhost:9000" + BUCKET_NAME: "test-bucket" + BUCKET_PROVIDER: "minio" + AWS_REGION: "us-east-1" + AWS_ACCESS_KEY: "minioadmin" + AWS_SECRET_KEY: "minioadmin" ports: - "3002:3000" @@ -93,5 +110,25 @@ services: - "1025:1025" # SMTP - "8025:8025" # Web UI + minio: + image: quay.io/minio/minio:latest + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + # Enable CORS for browser uploads + MINIO_API_CORS_ALLOW_ORIGIN: "*" + ports: + - "9000:9000" # API + - "9001:9001" # Console + volumes: + - minio-data:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + volumes: pgdata: + minio-data: From f2e3905770302763961baaccc4a5e116d1175a0c Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Wed, 10 Jun 2026 16:52:27 +0800 Subject: [PATCH 03/10] chore(k8s): add skaffold profile for storage-confirm-upload Co-Authored-By: Claude Opus 4.5 --- skaffold.yaml | 61 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/skaffold.yaml b/skaffold.yaml index bf010088d..9cb62fe9f 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -89,7 +89,7 @@ profiles: resourceName: python-example namespace: constructive-functions port: 80 - localPort: 8084 + localPort: 8086 - resourceType: service resourceName: knative-job-service namespace: constructive-functions @@ -221,6 +221,50 @@ profiles: resourceName: sql-example namespace: constructive-functions port: 80 + localPort: 8087 + - 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 + - name: storage-confirm-upload + 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/storage-confirm-upload/k8s/local-deployment.yaml + - generated/storage-confirm-upload/k8s/functions-configmap.yaml + deploy: + kubectl: + defaultNamespace: constructive-functions + portForward: + - resourceType: service + resourceName: storage-confirm-upload + namespace: constructive-functions + port: 80 localPort: 8085 - resourceType: service resourceName: knative-job-service @@ -272,6 +316,7 @@ profiles: - generated/send-email/k8s/local-deployment.yaml - generated/send-verification-link/k8s/local-deployment.yaml - generated/sql-example/k8s/local-deployment.yaml + - generated/storage-confirm-upload/k8s/local-deployment.yaml - generated/functions-configmap.yaml deploy: kubectl: @@ -286,7 +331,7 @@ profiles: resourceName: python-example namespace: constructive-functions port: 80 - localPort: 8084 + localPort: 8086 - resourceType: service resourceName: send-email namespace: constructive-functions @@ -301,6 +346,11 @@ profiles: resourceName: sql-example namespace: constructive-functions port: 80 + localPort: 8087 + - resourceType: service + resourceName: storage-confirm-upload + namespace: constructive-functions + port: 80 localPort: 8085 - resourceType: service resourceName: knative-job-service @@ -346,7 +396,7 @@ profiles: resourceName: python-example namespace: constructive-functions port: 80 - localPort: 8084 + localPort: 8086 - resourceType: service resourceName: send-email namespace: constructive-functions @@ -361,6 +411,11 @@ profiles: resourceName: sql-example namespace: constructive-functions port: 80 + localPort: 8087 + - resourceType: service + resourceName: storage-confirm-upload + namespace: constructive-functions + port: 80 localPort: 8085 - resourceType: service resourceName: knative-job-service From 87cd04ac5641d29f7d847bad8b22d431e6633d83 Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Wed, 10 Jun 2026 16:48:59 +0800 Subject: [PATCH 04/10] chore(deps): update dependencies Co-Authored-By: Claude Opus 4.5 --- pnpm-lock.yaml | 633 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 612 insertions(+), 21 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dcd03e50..299d501ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,7 +154,73 @@ importers: version: 8.20.0 pg-cache: specifier: ^3.11.0 - version: 3.11.0 + version: 3.12.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 + publishDirectory: dist + + generated/storage-confirm-upload: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1060.0 + version: 3.1060.0 + '@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.12.0 + pg: + specifier: ^8.16.0 + version: 8.20.0 + 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/stripe-webhook: + dependencies: + '@constructive-io/knative-job-fn': + specifier: workspace:^ + version: link:../../packages/fn-app + '@pgpmjs/env': + specifier: ^2.15.3 + version: 2.17.0 + '@pgpmjs/logger': + specifier: ^1.0.0 + version: 1.5.0 + '@pgsql/quotes': + specifier: ^17.1.0 + version: 17.1.0 + pg: + specifier: ^8.11.0 + version: 8.20.0 + pg-cache: + specifier: ^3.11.0 + version: 3.12.0 + stripe: + specifier: ^17.4.0 + version: 17.7.0 devDependencies: '@types/node': specifier: ^22.10.4 @@ -442,6 +508,125 @@ packages: 12factor-env@1.6.2: resolution: {integrity: sha512-U4EO6sy9Cc6h1ST3hhLD2rc2s4LERxProove3XZ52rMq2rTo5uTKWNKwD2OYDUwqNij+p5SgjmpPO6L/Gqtizw==} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.1060.0': + resolution: {integrity: sha512-lYdSUOE965Cz/kb3YVDMKz7C4icH0yJxkwB5M0KKAu1nGWT3L78Ty5g2wP3AhZEKH5VzNhPUo8AEcspWOfAGCw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.17': + resolution: {integrity: sha512-r8o4h2K7j6P9ngno+8ei0aK0U/4JwDb7A2fMMxGVoSqDN8AFlIzSDeZHME9LcVLR2codyhtr1WAAg+/nmkeeMA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/crc64-nvme@3.972.10': + resolution: {integrity: sha512-QsyJJlx+bSgApcd6kkloZ+nHg2nWJTwUA39/KiDcNRYjz9UOReQcNJRlJBImK+eF9EWl2LG5SW7LaVFuYUE8HQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.43': + resolution: {integrity: sha512-g0XVQKzaA/4cq1vz1IvCQwYM+1Pkv01J9yHDpCTXekVuGZRDEz0wqBQ1AuYTq7FM6uik4uBGH8Tb5d9YvgeA7g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.45': + resolution: {integrity: sha512-w9PuOoKCt6+xoESvY+zlV0u3PKQ0mVL259PcsVR6a3S/uYJJHnIi4r1NxdJHEcNldUVRIciltWnFMGBR4YEm3g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.48': + resolution: {integrity: sha512-+6BQ6Lrnc+EyAGElLRW6j+Sa+RirPHnIJsobvYO6nnyK+oGKmz1ne/ieclbLWyjyDKEU3/JVJWcWY3VLFPvGtQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.47': + resolution: {integrity: sha512-Iy2ebWVgrZBH05464uJiQYu6HSSiROnwVZptthEFXx2gWjo1ORCxEAFZB5Cr2MdfrSnZ+0QUPkZ1ZpCqpkUrLQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.50': + resolution: {integrity: sha512-b05Aelq5cqAvCCDQjCYacl0XmR8QhBNSqLbsdISkQmlQBa5oPS66zYPteWcSp5LswbpoIe552EUGjluKiadBig==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.43': + resolution: {integrity: sha512-GPokLNyvTfCmuaHk+v3GKVs4ZT3cMu5kgS2a+NPkOMt96cq6fSIK0g+mZHpGS6Cd4QGrPKesANEaLUKgOskTzg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.47': + resolution: {integrity: sha512-0AzvLrzlvJs0DzbeWGvNj+bX3Uzd7VNS6vDqCOdZzBlCGKGd78uxctJSW9iK/Rt/nxiJqpTvrYQlVJ4guVM2Dw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.47': + resolution: {integrity: sha512-eksfbUErOejUAGWBAcNqaP7IX21oUOEo73d9R56k9Ua4d57qS90NEYkWJsuSGzTXMFulCu17qXJI/qGmM7hvoA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.972.19': + resolution: {integrity: sha512-BkjsoevWdtXyfmItfvNW693XO/T2ooXAz3wx3fX1Y7YUHJB+Pvj7XM6Mu9n6lCQ32tF88NzguCOlL8G7e62SOA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.15': + resolution: {integrity: sha512-GFxHAXAO2iV4EIYZ97NcIiJiMATEjCm9sWS0VaRvHgxE9EDsL2tF0J08si74IT/YqFsdOgF/GWtoI2LkgbGj/Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.974.25': + resolution: {integrity: sha512-u4EmdygVkPTO0UjNcXqqXR5eG5WWzU2bGan1ZsujTqgC1PLDtgXqqK8LbySJ7i1gefAggHfUztZ5B67NLhmKJQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.12': + resolution: {integrity: sha512-9fXwH5lPa4M9lD6KhKOWZX2sXefuJX0PR/vxZ2u/ZYVrgr/tEUiFTdEBJ88y3/psuocWQ5IZmf5+T1hsa1qDUA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.46': + resolution: {integrity: sha512-ziGg3WIaAyRb8SO5fdoHBg+u6ikOhDN8QOagRKvZtDkfxFizHdDufCSoQkaOfvlpIxXRvTlFUaHpylMih4/KCw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.12': + resolution: {integrity: sha512-WiuUb6fqkMAAM3b1+2M2B74Zobh4JUsoS0s2gE792IRJCYaGp2BJzMK9rhfniNijxg1PhVppUeufpiEdDLNy+w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.15': + resolution: {integrity: sha512-Fpri1/PXKMKveORZ7E00VLTlWS5DkfZkW70PUE+bOnpWpAeHAQLoiDHhkzN3kNWbbSsGg64+IZYiq/EZgME3Mg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.31': + resolution: {integrity: sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1060.0': + resolution: {integrity: sha512-6NZaMKkFhpaNiwLpHi1sZaYjidL/lCJE6ME6NxwA8gv9vQna+Kr0j4OFwVoz6tANRWM3WbGz6jiPsGX/Vkjwow==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.10': + resolution: {integrity: sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.27': + resolution: {integrity: sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1070,6 +1255,9 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nodable/entities@2.1.1': + resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} + '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} @@ -1079,8 +1267,8 @@ packages: '@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.12.0': + resolution: {integrity: sha512-yPRZvc6VxpwDl7ZLuvPD2lrrBKYDMFBQP6KyURcvHWuisTwvrWrKOH0HWMPtMIbaBSnz6/KsrkxCz6QM9hC+Bw==} '@pgpmjs/logger@2.5.2': resolution: {integrity: sha512-e9Z2Woju+fcsC0nm9KwEgOXZqm8UcrrxVEPbunXo6kpROpIQkBRg3RVU9xCxSHQ6xiko4r9YFXs370EW6kIUsQ==} @@ -1088,8 +1276,11 @@ packages: '@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==} + '@pgpmjs/types@2.29.0': + resolution: {integrity: sha512-iUfNhBTi7PAJlOaptslOEL3uDiWyKyaWzRoFVfn6jxbBvshyUFPUonNJNf9n3eSFG7AYflMy7Z5n85ptceq8xA==} + + '@pgsql/quotes@17.1.0': + resolution: {integrity: sha512-J/H+LcrENBpYgL45WW6aTjb5Yk4tX4+AmB2/k8KZa+Zh3wiCtqmNIag+HZz5HmWaF6EZK9ZGC95NBD1fs+rUvg==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -1114,6 +1305,42 @@ packages: '@sinonjs/fake-timers@13.0.5': resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + '@smithy/core@3.24.6': + resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.7': + resolution: {integrity: sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.6': + resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.6': + resolution: {integrity: sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.6': + resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.3': + resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@styled-system/background@5.1.2': resolution: {integrity: sha512-jtwH2C/U6ssuGSvwTN3ri/IyjdHb8W9X/g8Y0JLcrH02G+BW3OS8kZdHphF1/YyRklnrKrBT2ngwGUK6aqqV3A==} @@ -1546,6 +1773,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2035,6 +2265,13 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -3059,6 +3296,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -3081,8 +3322,8 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - pg-cache@3.11.0: - resolution: {integrity: sha512-2PKoYKbsIeFblbS3Nxh+7RiIudNNN8xfguqi7Z0zAtBBrSzwVFSpF+Kw07lm/iR2KSGksEIzq1Y5p6UvxJwnyw==} + pg-cache@3.12.0: + resolution: {integrity: sha512-xlQdi1dQQYoKKBRR9L/KRfqOMutaVv7gTZcNt0bUvZVXGOVWegqtlAEEpPaQhxZjVxd0Z1b15lctqYhyX1wgew==} pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -3090,8 +3331,8 @@ packages: pg-connection-string@2.13.0: resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} - pg-env@1.15.0: - resolution: {integrity: sha512-1bs3pcNOOrA0om3TNJGwbZu7JXKc/tenyVW4KX6ljARxvKtRSUcGl6sbpKVCXJo+Y98W0nRvPgfa/SlqlumRsg==} + pg-env@1.16.0: + resolution: {integrity: sha512-Jexh/zTP7Tyr5BgP6rcY8RRl1PZu3lUssjyMYbjAhPermtGuHM/dMLlB2MVMyokJvkEDsJZOTrzheadCiJeIpA==} pg-env@1.8.2: resolution: {integrity: sha512-YzxNQKZmFRRJKX5t149Ys2JoAsc6OCHcaoYH/82si7gwVC9ODaFTFtQn7gv3VpoGsNkH90t6iEPWvmLIgv2rDg==} @@ -3439,6 +3680,13 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@17.7.0: + resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} + engines: {node: '>=12.*'} + + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + styled-components@5.3.11: resolution: {integrity: sha512-uuzIIfnVkagcVHv9nE0VPlHPSCmXIUGKfJ42LNjxCCTDTL5sgnJ8Z7GZBq0EnLYGln77tPpEpExt2+qa+cZqSw==} engines: {node: '>=10'} @@ -3677,6 +3925,10 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -3725,6 +3977,268 @@ snapshots: dependencies: envalid: 8.1.1 + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.10 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.10 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.10 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.10 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.10 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.10 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1060.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.17 + '@aws-sdk/credential-provider-node': 3.972.50 + '@aws-sdk/middleware-bucket-endpoint': 3.972.19 + '@aws-sdk/middleware-expect-continue': 3.972.15 + '@aws-sdk/middleware-flexible-checksums': 3.974.25 + '@aws-sdk/middleware-location-constraint': 3.972.12 + '@aws-sdk/middleware-sdk-s3': 3.972.46 + '@aws-sdk/middleware-ssec': 3.972.12 + '@aws-sdk/signature-v4-multi-region': 3.996.31 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.17': + dependencies: + '@aws-sdk/types': 3.973.10 + '@aws-sdk/xml-builder': 3.972.27 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.6 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.10': + dependencies: + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.45': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.48': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/credential-provider-env': 3.972.43 + '@aws-sdk/credential-provider-http': 3.972.45 + '@aws-sdk/credential-provider-login': 3.972.47 + '@aws-sdk/credential-provider-process': 3.972.43 + '@aws-sdk/credential-provider-sso': 3.972.47 + '@aws-sdk/credential-provider-web-identity': 3.972.47 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.47': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.50': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.43 + '@aws-sdk/credential-provider-http': 3.972.45 + '@aws-sdk/credential-provider-ini': 3.972.48 + '@aws-sdk/credential-provider-process': 3.972.43 + '@aws-sdk/credential-provider-sso': 3.972.47 + '@aws-sdk/credential-provider-web-identity': 3.972.47 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.47': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/token-providers': 3.1060.0 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.47': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/middleware-bucket-endpoint@3.972.19': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.15': + dependencies: + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.25': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.17 + '@aws-sdk/crc64-nvme': 3.972.10 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.12': + dependencies: + '@aws-sdk/types': 3.973.10 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.46': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/signature-v4-multi-region': 3.996.31 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.12': + dependencies: + '@aws-sdk/types': 3.973.10 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.15': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.17 + '@aws-sdk/signature-v4-multi-region': 3.996.31 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.31': + dependencies: + '@aws-sdk/types': 3.973.10 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1060.0': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.10': + dependencies: + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.27': + dependencies: + '@smithy/types': 4.14.3 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3925,7 +4439,7 @@ snapshots: '@constructive-io/job-pg@2.5.4': dependencies: '@constructive-io/job-utils': 2.5.4 - '@pgpmjs/logger': 2.11.0 + '@pgpmjs/logger': 2.12.0 pg: 8.20.0 transitivePeerDependencies: - pg-native @@ -3934,7 +4448,7 @@ snapshots: dependencies: '@constructive-io/job-pg': 2.5.4 '@constructive-io/job-utils': 2.5.4 - '@pgpmjs/logger': 2.11.0 + '@pgpmjs/logger': 2.12.0 node-schedule: 2.1.1 transitivePeerDependencies: - pg-native @@ -3942,9 +4456,9 @@ snapshots: '@constructive-io/job-utils@2.5.4': dependencies: '@pgpmjs/env': 2.17.0 - '@pgpmjs/logger': 2.11.0 + '@pgpmjs/logger': 2.12.0 '@pgpmjs/types': 2.21.0 - pg-cache: 3.11.0 + pg-cache: 3.12.0 pg-env: 1.8.2 transitivePeerDependencies: - pg-native @@ -4543,6 +5057,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nodable/entities@2.1.1': {} + '@one-ini/wasm@0.1.1': {} '@pgpmjs/env@2.17.0': @@ -4554,7 +5070,7 @@ snapshots: dependencies: yanse: 0.2.1 - '@pgpmjs/logger@2.11.0': + '@pgpmjs/logger@2.12.0': dependencies: yanse: 0.2.1 @@ -4566,9 +5082,11 @@ snapshots: dependencies: pg-env: 1.8.2 - '@pgpmjs/types@2.28.0': + '@pgpmjs/types@2.29.0': dependencies: - pg-env: 1.15.0 + pg-env: 1.16.0 + + '@pgsql/quotes@17.1.0': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -4591,6 +5109,54 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@smithy/core@3.24.6': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.7': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/types@4.14.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@styled-system/background@5.1.2': dependencies: '@styled-system/core': 5.1.2 @@ -5105,6 +5671,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.14.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -5675,6 +6243,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.1.1 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + fb-watchman@2.0.2: dependencies: bser: 2.1.1 @@ -7245,6 +7825,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -7263,13 +7845,13 @@ snapshots: path-to-regexp@8.3.0: {} - pg-cache@3.11.0: + pg-cache@3.12.0: dependencies: - '@pgpmjs/logger': 2.11.0 - '@pgpmjs/types': 2.28.0 + '@pgpmjs/logger': 2.12.0 + '@pgpmjs/types': 2.29.0 lru-cache: 11.3.0 pg: 8.21.0 - pg-env: 1.15.0 + pg-env: 1.16.0 transitivePeerDependencies: - pg-native @@ -7278,7 +7860,7 @@ snapshots: pg-connection-string@2.13.0: {} - pg-env@1.15.0: {} + pg-env@1.16.0: {} pg-env@1.8.2: {} @@ -7624,6 +8206,13 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@17.7.0: + dependencies: + '@types/node': 22.19.3 + qs: 6.14.1 + + strnum@2.3.0: {} + styled-components@5.3.11(@babel/core@7.28.5)(react-dom@16.14.0(react@16.14.0))(react-is@18.3.1)(react@16.14.0): dependencies: '@babel/helper-module-imports': 7.27.1(supports-color@5.5.0) @@ -7912,6 +8501,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + xml-naming@0.1.0: {} + xtend@4.0.2: {} y18n@4.0.3: {} From dbdb3d44de7572e57d0b841267932686a279247d Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Wed, 10 Jun 2026 16:58:32 +0800 Subject: [PATCH 05/10] test(functions): add tests for storage-confirm-upload Co-Authored-By: Claude Opus 4.5 --- .../__tests__/handler.test.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 functions/storage-confirm-upload/__tests__/handler.test.ts diff --git a/functions/storage-confirm-upload/__tests__/handler.test.ts b/functions/storage-confirm-upload/__tests__/handler.test.ts new file mode 100644 index 000000000..f76e90b1c --- /dev/null +++ b/functions/storage-confirm-upload/__tests__/handler.test.ts @@ -0,0 +1,202 @@ +import { createMockContext } from '../../../tests/helpers/mock-context'; + +const mockSend = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn().mockImplementation(() => ({ + send: mockSend + })), + HeadObjectCommand: jest.fn().mockImplementation((params) => params) +})); + +const mockPgQuery = jest.fn(); +const mockPgConnect = jest.fn(); +const mockPgEnd = jest.fn(); +jest.mock('pg', () => ({ + Client: jest.fn().mockImplementation(() => ({ + connect: mockPgConnect, + query: mockPgQuery, + end: mockPgEnd + })) +})); + +const loadHandler = () => { + const mod = require('../handler'); + return mod.default ?? mod; +}; + +describe('storage-confirm-upload handler', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + process.env.S3_ENDPOINT = 'http://localhost:9000'; + process.env.AWS_ACCESS_KEY_ID = 'minioadmin'; + process.env.AWS_SECRET_ACCESS_KEY = 'minioadmin'; + process.env.AWS_REGION = 'us-east-1'; + }); + + afterEach(() => { + delete process.env.S3_ENDPOINT; + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_REGION; + }); + + describe('validation', () => { + it('throws on missing file_id', async () => { + const handler = loadHandler(); + await expect( + handler( + { key: 'test.txt', bucket_id: 'bucket-123' }, + createMockContext() + ) + ).rejects.toThrow('Missing required fields'); + }); + + it('throws on missing key', async () => { + const handler = loadHandler(); + await expect( + handler( + { file_id: '123', bucket_id: 'bucket-123' }, + createMockContext() + ) + ).rejects.toThrow('Missing required fields'); + }); + + it('throws on missing bucket_id', async () => { + const handler = loadHandler(); + await expect( + handler( + { file_id: '123', key: 'test.txt' }, + createMockContext() + ) + ).rejects.toThrow('Missing required fields'); + }); + }); + + describe('S3 file check', () => { + it('throws when file not found in S3', async () => { + const handler = loadHandler(); + const notFoundError = new Error('Not Found'); + (notFoundError as any).name = 'NotFound'; + mockSend.mockRejectedValueOnce(notFoundError); + + await expect( + handler( + { file_id: '123', key: 'test.txt', bucket_id: 'bucket-123' }, + createMockContext() + ) + ).rejects.toThrow('File not found in S3'); + }); + + it('rethrows unexpected S3 errors', async () => { + const handler = loadHandler(); + mockSend.mockRejectedValueOnce(new Error('Network error')); + + await expect( + handler( + { file_id: '123', key: 'test.txt', bucket_id: 'bucket-123' }, + createMockContext() + ) + ).rejects.toThrow('Network error'); + }); + }); + + describe('confirm upload', () => { + it('calls confirm function when file exists in S3', async () => { + const handler = loadHandler(); + mockSend.mockResolvedValueOnce({}); // HeadObject success + mockPgQuery.mockResolvedValueOnce({ rows: [] }); // confirm_uploaded call + + const result = await handler( + { + file_id: '123', + key: 'test.txt', + bucket_id: 'bucket-123', + schema: 'test-db-app-public', + table: 'app_files' + }, + createMockContext() + ); + + expect(result.success).toBe(true); + expect(result.file_id).toBe('123'); + expect(mockPgQuery).toHaveBeenCalled(); + }); + + it('uses default schema and table when not provided', async () => { + const handler = loadHandler(); + mockSend.mockResolvedValueOnce({}); // HeadObject success + mockPgQuery.mockResolvedValueOnce({ rows: [] }); // confirm_uploaded call + + const result = await handler( + { + file_id: '123', + key: 'test.txt', + bucket_id: 'bucket-123' + }, + createMockContext() + ); + + expect(result.success).toBe(true); + // Should use default storage_public and app_files + expect(mockPgQuery).toHaveBeenCalledWith( + expect.stringContaining('app_files_confirm_uploaded'), + ['123'] + ); + }); + }); + + describe('bucket name resolution', () => { + it('resolves bucket name from database when schema and databaseId provided', async () => { + const handler = loadHandler(); + // First query: resolve bucket name + mockPgQuery.mockResolvedValueOnce({ rows: [{ type: 'public' }] }); + // HeadObject success + mockSend.mockResolvedValueOnce({}); + // Second query: confirm_uploaded call + mockPgQuery.mockResolvedValueOnce({ rows: [] }); + + const context = createMockContext(); + context.job.databaseId = 'db-123'; + + const result = await handler( + { + file_id: '123', + key: 'test.txt', + bucket_id: 'bucket-123', + schema: 'test-db-app-public' + }, + context + ); + + expect(result.success).toBe(true); + expect(result.bucket).toBe('test-bucket-public-db-123'); + }); + + it('falls back to bucket_id when resolution fails', async () => { + const handler = loadHandler(); + // First query fails + mockPgQuery.mockRejectedValueOnce(new Error('DB error')); + // HeadObject success + mockSend.mockResolvedValueOnce({}); + // Second query: confirm_uploaded call + mockPgQuery.mockResolvedValueOnce({ rows: [] }); + + const context = createMockContext(); + context.job.databaseId = 'db-123'; + + const result = await handler( + { + file_id: '123', + key: 'test.txt', + bucket_id: 'bucket-123', + schema: 'test-db-app-public' + }, + context + ); + + expect(result.success).toBe(true); + expect(result.bucket).toBe('bucket-123'); + }); + }); +}); From cdc2ba7acef4e318b123df3807dd75eb0caac63e Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Tue, 16 Jun 2026 15:31:55 +0800 Subject: [PATCH 06/10] chore(storage-confirm-upload): switch to node-sql template - Change template from node-graphql to node-sql for connection pooling - Add pool and withUserContext support to mock-context helper Co-Authored-By: Claude Opus 4.5 --- functions/storage-confirm-upload/handler.json | 6 ++---- tests/helpers/mock-context.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/functions/storage-confirm-upload/handler.json b/functions/storage-confirm-upload/handler.json index 1a0bd3529..b2dcc2588 100644 --- a/functions/storage-confirm-upload/handler.json +++ b/functions/storage-confirm-upload/handler.json @@ -1,14 +1,12 @@ { "name": "storage-confirm-upload", "version": "1.0.0", - "type": "node-graphql", + "type": "node-sql", "port": 8085, "taskIdentifier": "storage:confirm_upload", "description": "Confirms file upload by checking S3 HeadObject and transitioning status to uploaded", "dependencies": { "@aws-sdk/client-s3": "^3.1060.0", - "@pgpmjs/env": "^2.15.3", - "@pgpmjs/logger": "^2.4.3", - "pg": "^8.16.0" + "@pgsql/quotes": "^0.0.4" } } diff --git a/tests/helpers/mock-context.ts b/tests/helpers/mock-context.ts index 2ac5bd8ab..5ef4c264b 100644 --- a/tests/helpers/mock-context.ts +++ b/tests/helpers/mock-context.ts @@ -1,5 +1,6 @@ // Inline type to avoid requiring fn-runtime to be built for unit tests. -// Mirrors FunctionContext from packages/fn-runtime/src/types.ts +// Mirrors FunctionContext from packages/fn-runtime/src/types.ts (node-graphql) +// and generated/*/types.ts (node-sql) type FunctionContext = { job: { jobId?: string; @@ -14,6 +15,9 @@ type FunctionContext = { warn: (...args: any[]) => void; }; env: Record; + // node-sql template fields + pool?: any; + withUserContext?: any; }; type MockContextOptions = { @@ -21,6 +25,7 @@ type MockContextOptions = { databaseId?: string; clientResponse?: any; metaResponse?: any; + pool?: any; }; export const createMockContext = ( @@ -38,5 +43,7 @@ export const createMockContext = ( request: jest.fn().mockResolvedValue(options.metaResponse ?? {}) } as any, log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - env: options.env ?? {} + env: options.env ?? {}, + pool: options.pool, + withUserContext: jest.fn() }); From 95f6d4cb6b18aa08bf01e245b3e7f24148a9747e Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Tue, 16 Jun 2026 15:32:02 +0800 Subject: [PATCH 07/10] refactor(storage-confirm-upload): query storage_module metadata - Query metaschema_modules_public.storage_module for schema/table names - Use QuoteUtils.quoteQualifiedIdentifier for SQL identifier quoting - Remove string manipulation for schema derivation - Use connection pool from context instead of manual PgClient - Standardize env vars (CDN_ENDPOINT, AWS_ACCESS_KEY, AWS_REGION) Co-Authored-By: Claude Opus 4.5 --- functions/storage-confirm-upload/handler.ts | 207 +++++++++++--------- 1 file changed, 113 insertions(+), 94 deletions(-) diff --git a/functions/storage-confirm-upload/handler.ts b/functions/storage-confirm-upload/handler.ts index c1ea076cf..9088c5da8 100644 --- a/functions/storage-confirm-upload/handler.ts +++ b/functions/storage-confirm-upload/handler.ts @@ -1,69 +1,65 @@ import { HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import type { FunctionHandler } from '@constructive-io/fn-runtime'; -import { createLogger } from '@pgpmjs/logger'; -import { Client as PgClient } from 'pg'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { FunctionHandler } from './types'; type StorageConfirmUploadPayload = { file_id: string; key: string; bucket_id: string; - mime_type: string; - schema?: string; - table?: string; + mime_type?: string; + actor_id?: string; }; -const logger = createLogger('storage-confirm-upload'); +type Result = { + success: boolean; + file_id: string; + bucket: string; + key: string; +}; -const createPgClient = (): PgClient => { - return new PgClient({ - host: process.env.PGHOST || 'localhost', - port: Number(process.env.PGPORT) || 5432, - user: process.env.PGUSER || 'postgres', - password: process.env.PGPASSWORD || 'password', - database: process.env.PGDATABASE || 'constructive', - }); +type StorageModuleConfig = { + id: string; + bucketsSchema: string; + bucketsTable: string; + filesSchema: string; + filesTable: string; + endpoint: string | null; + provider: string | null; }; -const createS3Client = (): S3Client => { - const endpoint = process.env.S3_ENDPOINT || process.env.CDN_ENDPOINT; +const STORAGE_MODULE_QUERY = ` + SELECT + sm.id, + bs.schema_name AS buckets_schema, + bt.name AS buckets_table, + fs.schema_name AS files_schema, + ft.name AS files_table, + sm.endpoint, + sm.provider + FROM metaschema_modules_public.storage_module sm + JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id + JOIN metaschema_public.schema bs ON bs.id = bt.schema_id + JOIN metaschema_public.table ft ON ft.id = sm.files_table_id + JOIN metaschema_public.schema fs ON fs.id = ft.schema_id + WHERE sm.database_id = $1 + AND sm.scope = 'app' + LIMIT 1 +`; + +const getS3Client = (config?: StorageModuleConfig): S3Client => { + const endpoint = config?.endpoint || process.env.CDN_ENDPOINT; + const region = process.env.AWS_REGION || 'us-east-1'; + const accessKeyId = process.env.AWS_ACCESS_KEY || process.env.AWS_ACCESS_KEY_ID || ''; + const secretAccessKey = process.env.AWS_SECRET_KEY || process.env.AWS_SECRET_ACCESS_KEY || ''; + return new S3Client({ - region: process.env.CDN_REGION || process.env.AWS_REGION || 'us-east-1', - endpoint: endpoint, + region, + endpoint: endpoint || undefined, forcePathStyle: true, - credentials: { - accessKeyId: process.env.CDN_ACCESS_KEY || process.env.AWS_ACCESS_KEY_ID || '', - secretAccessKey: process.env.CDN_SECRET_KEY || process.env.AWS_SECRET_ACCESS_KEY || '', - }, + credentials: { accessKeyId, secretAccessKey }, }); }; -const resolveS3BucketName = async ( - bucketId: string, - storageSchema: string, - databaseId: string -): Promise => { - const pg = createPgClient(); - try { - await pg.connect(); - // Get bucket type to construct S3 bucket name - // Format: test-bucket-{type}-{database_id} - const result = await pg.query( - `SELECT b.type - FROM "${storageSchema}".app_buckets b - WHERE b.id = $1`, - [bucketId] - ); - if (result.rows.length > 0 && result.rows[0].type) { - const bucketType = result.rows[0].type; - return `test-bucket-${bucketType}-${databaseId}`; - } - // Fallback to bucket_id if type not found - return bucketId; - } finally { - await pg.end(); - } -}; - const checkFileExistsInS3 = async ( s3: S3Client, bucket: string, @@ -81,63 +77,74 @@ const checkFileExistsInS3 = async ( } }; -const confirmFileUploaded = async ( - fileId: string, - schema: string, - table: string -): Promise => { - const pg = createPgClient(); - try { - await pg.connect(); - const privateSchema = schema.replace(/-public$/, '-private'); - const fnName = `${table}_confirm_uploaded`; - - await pg.query(`SELECT "${privateSchema}"."${fnName}"($1)`, [fileId]); - - logger.info('[storage-confirm-upload] File status confirmed', { fileId, schema, table }); - } finally { - await pg.end(); - } -}; - -const handler: FunctionHandler = async ( +const handler: FunctionHandler = async ( params, context ) => { - const { job, log } = context; - const { file_id, key, bucket_id, mime_type, schema, table } = params; + const { job, log, pool } = context; + const { file_id, key, bucket_id } = params; if (!file_id || !key || !bucket_id) { throw new Error('Missing required fields: file_id, key, or bucket_id'); } - log.info('[storage-confirm-upload] Processing', { - file_id, - key, - bucket_id, - schema, - table, - }); + if (!job.databaseId) { + throw new Error('Missing databaseId in job context'); + } - const s3 = createS3Client(); + log.info('[storage-confirm-upload] Processing', { file_id, key, bucket_id }); - const storageSchema = schema - ? schema.replace(/-app-public$/, '-storage-public') - : 'storage_public'; + // Load storage module config from metaschema + const client = await pool.connect(); + let config: StorageModuleConfig; + try { + const result = await client.query(STORAGE_MODULE_QUERY, [job.databaseId]); + if (result.rows.length === 0) { + throw new Error(`STORAGE_MODULE_NOT_FOUND for database ${job.databaseId}`); + } + const row = result.rows[0]; + config = { + id: row.id, + bucketsSchema: row.buckets_schema, + bucketsTable: row.buckets_table, + filesSchema: row.files_schema, + filesTable: row.files_table, + endpoint: row.endpoint, + provider: row.provider, + }; + log.info('[storage-confirm-upload] Loaded storage config', { + bucketsSchema: config.bucketsSchema, + filesSchema: config.filesSchema, + }); + } finally { + client.release(); + } + // Resolve S3 bucket name from buckets table + const bucketsQualifiedName = QuoteUtils.quoteQualifiedIdentifier(config.bucketsSchema, config.bucketsTable); let bucketName = bucket_id; - if (schema && job.databaseId) { - try { - bucketName = await resolveS3BucketName(bucket_id, storageSchema, job.databaseId); + const bucketClient = await pool.connect(); + try { + const result = await bucketClient.query( + `SELECT type FROM ${bucketsQualifiedName} WHERE id = $1`, + [bucket_id] + ); + if (result.rows.length > 0 && result.rows[0].type) { + const bucketType = result.rows[0].type; + bucketName = `test-bucket-${bucketType}-${job.databaseId}`; log.info('[storage-confirm-upload] Resolved bucket name', { bucket_id, bucketName }); - } catch (err) { - log.warn('[storage-confirm-upload] Could not resolve bucket, using bucket_id', { - bucket_id, - error: (err as Error).message, - }); } + } catch (err) { + log.warn('[storage-confirm-upload] Could not resolve bucket, using bucket_id', { + bucket_id, + error: (err as Error).message, + }); + } finally { + bucketClient.release(); } + // Check file exists in S3 + const s3 = getS3Client(config); const exists = await checkFileExistsInS3(s3, bucketName, key); if (!exists) { @@ -150,10 +157,22 @@ const handler: FunctionHandler = async ( log.info('[storage-confirm-upload] File exists in S3', { bucket: bucketName, key }); - const targetSchema = schema || storageSchema; - const targetTable = table || 'app_files'; + // Confirm file uploaded - private schema is derived from files schema + const privateSchema = config.filesSchema.replace(/-public$/, '-private'); + const fnName = `${config.filesTable}_confirm_uploaded`; + const fnQualifiedName = QuoteUtils.quoteQualifiedIdentifier(privateSchema, fnName); - await confirmFileUploaded(file_id, targetSchema, targetTable); + const confirmClient = await pool.connect(); + try { + await confirmClient.query(`SELECT ${fnQualifiedName}($1)`, [file_id]); + log.info('[storage-confirm-upload] File status confirmed', { + file_id, + schema: privateSchema, + function: fnName, + }); + } finally { + confirmClient.release(); + } return { success: true, From 8d37364252848c365b7479ecb090a4cce2de92ae Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Tue, 16 Jun 2026 15:32:08 +0800 Subject: [PATCH 08/10] test(storage-confirm-upload): update tests for pool-based context - Mock storage_module query responses - Use pool mock instead of pg.Client mock - Add tests for storage_module lookup and schema validation - Test bucket resolution using storage_module schema names Co-Authored-By: Claude Opus 4.5 --- .../__tests__/handler.test.ts | 198 +++++++++++------- 1 file changed, 128 insertions(+), 70 deletions(-) diff --git a/functions/storage-confirm-upload/__tests__/handler.test.ts b/functions/storage-confirm-upload/__tests__/handler.test.ts index f76e90b1c..873e79bdb 100644 --- a/functions/storage-confirm-upload/__tests__/handler.test.ts +++ b/functions/storage-confirm-upload/__tests__/handler.test.ts @@ -8,16 +8,31 @@ jest.mock('@aws-sdk/client-s3', () => ({ HeadObjectCommand: jest.fn().mockImplementation((params) => params) })); -const mockPgQuery = jest.fn(); -const mockPgConnect = jest.fn(); -const mockPgEnd = jest.fn(); -jest.mock('pg', () => ({ - Client: jest.fn().mockImplementation(() => ({ - connect: mockPgConnect, - query: mockPgQuery, - end: mockPgEnd - })) -})); +const mockStorageModuleRow = { + id: 'sm-123', + buckets_schema: 'test-db-storage-public', + buckets_table: 'app_buckets', + files_schema: 'test-db-storage-public', + files_table: 'app_files', + endpoint: null, + provider: 'minio', +}; + +const createMockPool = () => { + const mockQuery = jest.fn(); + const mockRelease = jest.fn(); + const mockClient = { + query: mockQuery, + release: mockRelease + }; + const pool = { + connect: jest.fn().mockResolvedValue(mockClient), + query: mockQuery, + _mockClient: mockClient, + _mockQuery: mockQuery + }; + return pool; +}; const loadHandler = () => { const mod = require('../handler'); @@ -44,38 +59,73 @@ describe('storage-confirm-upload handler', () => { describe('validation', () => { it('throws on missing file_id', async () => { const handler = loadHandler(); + const pool = createMockPool(); await expect( handler( { key: 'test.txt', bucket_id: 'bucket-123' }, - createMockContext() + createMockContext({ pool, databaseId: 'db-123' }) ) ).rejects.toThrow('Missing required fields'); }); it('throws on missing key', async () => { const handler = loadHandler(); + const pool = createMockPool(); await expect( handler( { file_id: '123', bucket_id: 'bucket-123' }, - createMockContext() + createMockContext({ pool, databaseId: 'db-123' }) ) ).rejects.toThrow('Missing required fields'); }); it('throws on missing bucket_id', async () => { const handler = loadHandler(); + const pool = createMockPool(); await expect( handler( { file_id: '123', key: 'test.txt' }, - createMockContext() + createMockContext({ pool, databaseId: 'db-123' }) ) ).rejects.toThrow('Missing required fields'); }); + + it('throws on missing databaseId', async () => { + const handler = loadHandler(); + const pool = createMockPool(); + await expect( + handler( + { file_id: '123', key: 'test.txt', bucket_id: 'bucket-123' }, + createMockContext({ pool, databaseId: undefined }) + ) + ).rejects.toThrow('Missing databaseId'); + }); + }); + + describe('storage module lookup', () => { + it('throws when storage module not found', async () => { + const handler = loadHandler(); + const pool = createMockPool(); + pool._mockQuery.mockResolvedValueOnce({ rows: [] }); // No storage module + + await expect( + handler( + { file_id: '123', key: 'test.txt', bucket_id: 'bucket-123' }, + createMockContext({ pool, databaseId: 'db-123' }) + ) + ).rejects.toThrow('STORAGE_MODULE_NOT_FOUND'); + }); }); describe('S3 file check', () => { it('throws when file not found in S3', async () => { const handler = loadHandler(); + const pool = createMockPool(); + // Storage module query + pool._mockQuery.mockResolvedValueOnce({ rows: [mockStorageModuleRow] }); + // Bucket type query + pool._mockQuery.mockResolvedValueOnce({ rows: [{ type: 'public' }] }); + // S3 HeadObject fails const notFoundError = new Error('Not Found'); (notFoundError as any).name = 'NotFound'; mockSend.mockRejectedValueOnce(notFoundError); @@ -83,19 +133,22 @@ describe('storage-confirm-upload handler', () => { await expect( handler( { file_id: '123', key: 'test.txt', bucket_id: 'bucket-123' }, - createMockContext() + createMockContext({ pool, databaseId: 'db-123' }) ) ).rejects.toThrow('File not found in S3'); }); it('rethrows unexpected S3 errors', async () => { const handler = loadHandler(); + const pool = createMockPool(); + pool._mockQuery.mockResolvedValueOnce({ rows: [mockStorageModuleRow] }); + pool._mockQuery.mockResolvedValueOnce({ rows: [{ type: 'public' }] }); mockSend.mockRejectedValueOnce(new Error('Network error')); await expect( handler( { file_id: '123', key: 'test.txt', bucket_id: 'bucket-123' }, - createMockContext() + createMockContext({ pool, databaseId: 'db-123' }) ) ).rejects.toThrow('Network error'); }); @@ -104,95 +157,100 @@ describe('storage-confirm-upload handler', () => { describe('confirm upload', () => { it('calls confirm function when file exists in S3', async () => { const handler = loadHandler(); - mockSend.mockResolvedValueOnce({}); // HeadObject success - mockPgQuery.mockResolvedValueOnce({ rows: [] }); // confirm_uploaded call + const pool = createMockPool(); + // Storage module query + pool._mockQuery.mockResolvedValueOnce({ rows: [mockStorageModuleRow] }); + // Bucket type query + pool._mockQuery.mockResolvedValueOnce({ rows: [{ type: 'public' }] }); + // S3 HeadObject success + mockSend.mockResolvedValueOnce({}); + // Confirm uploaded call + pool._mockQuery.mockResolvedValueOnce({ rows: [] }); const result = await handler( - { - file_id: '123', - key: 'test.txt', - bucket_id: 'bucket-123', - schema: 'test-db-app-public', - table: 'app_files' - }, - createMockContext() + { file_id: '123', key: 'test.txt', bucket_id: 'bucket-123' }, + createMockContext({ pool, databaseId: 'db-123' }) ); expect(result.success).toBe(true); expect(result.file_id).toBe('123'); - expect(mockPgQuery).toHaveBeenCalled(); + // Verify confirm function was called with correct schema + expect(pool._mockQuery).toHaveBeenCalledWith( + expect.stringContaining('test-db-storage-private'), + ['123'] + ); + expect(pool._mockQuery).toHaveBeenCalledWith( + expect.stringContaining('app_files_confirm_uploaded'), + ['123'] + ); }); - it('uses default schema and table when not provided', async () => { + it('uses schema names from storage_module metadata', async () => { const handler = loadHandler(); - mockSend.mockResolvedValueOnce({}); // HeadObject success - mockPgQuery.mockResolvedValueOnce({ rows: [] }); // confirm_uploaded call + const pool = createMockPool(); + const customRow = { + ...mockStorageModuleRow, + buckets_schema: 'custom-db-storage-public', + files_schema: 'custom-db-storage-public', + files_table: 'custom_files', + }; + pool._mockQuery.mockResolvedValueOnce({ rows: [customRow] }); + pool._mockQuery.mockResolvedValueOnce({ rows: [{ type: 'private' }] }); + mockSend.mockResolvedValueOnce({}); + pool._mockQuery.mockResolvedValueOnce({ rows: [] }); const result = await handler( - { - file_id: '123', - key: 'test.txt', - bucket_id: 'bucket-123' - }, - createMockContext() + { file_id: '456', key: 'doc.pdf', bucket_id: 'bucket-456' }, + createMockContext({ pool, databaseId: 'custom-db' }) ); expect(result.success).toBe(true); - // Should use default storage_public and app_files - expect(mockPgQuery).toHaveBeenCalledWith( - expect.stringContaining('app_files_confirm_uploaded'), - ['123'] + // Verify it used the custom schema/table from storage_module + expect(pool._mockQuery).toHaveBeenCalledWith( + expect.stringContaining('custom-db-storage-private'), + ['456'] + ); + expect(pool._mockQuery).toHaveBeenCalledWith( + expect.stringContaining('custom_files_confirm_uploaded'), + ['456'] ); }); }); describe('bucket name resolution', () => { - it('resolves bucket name from database when schema and databaseId provided', async () => { + it('resolves bucket name from database using storage_module schema', async () => { const handler = loadHandler(); - // First query: resolve bucket name - mockPgQuery.mockResolvedValueOnce({ rows: [{ type: 'public' }] }); - // HeadObject success + const pool = createMockPool(); + pool._mockQuery.mockResolvedValueOnce({ rows: [mockStorageModuleRow] }); + pool._mockQuery.mockResolvedValueOnce({ rows: [{ type: 'public' }] }); mockSend.mockResolvedValueOnce({}); - // Second query: confirm_uploaded call - mockPgQuery.mockResolvedValueOnce({ rows: [] }); - - const context = createMockContext(); - context.job.databaseId = 'db-123'; + pool._mockQuery.mockResolvedValueOnce({ rows: [] }); const result = await handler( - { - file_id: '123', - key: 'test.txt', - bucket_id: 'bucket-123', - schema: 'test-db-app-public' - }, - context + { file_id: '123', key: 'test.txt', bucket_id: 'bucket-123' }, + createMockContext({ pool, databaseId: 'db-123' }) ); expect(result.success).toBe(true); expect(result.bucket).toBe('test-bucket-public-db-123'); + // Verify bucket query used correct schema from storage_module + expect(pool._mockQuery).toHaveBeenCalledWith( + expect.stringContaining('test-db-storage-public'), + ['bucket-123'] + ); }); it('falls back to bucket_id when resolution fails', async () => { const handler = loadHandler(); - // First query fails - mockPgQuery.mockRejectedValueOnce(new Error('DB error')); - // HeadObject success + const pool = createMockPool(); + pool._mockQuery.mockResolvedValueOnce({ rows: [mockStorageModuleRow] }); + pool._mockQuery.mockRejectedValueOnce(new Error('DB error')); mockSend.mockResolvedValueOnce({}); - // Second query: confirm_uploaded call - mockPgQuery.mockResolvedValueOnce({ rows: [] }); - - const context = createMockContext(); - context.job.databaseId = 'db-123'; + pool._mockQuery.mockResolvedValueOnce({ rows: [] }); const result = await handler( - { - file_id: '123', - key: 'test.txt', - bucket_id: 'bucket-123', - schema: 'test-db-app-public' - }, - context + { file_id: '123', key: 'test.txt', bucket_id: 'bucket-123' }, + createMockContext({ pool, databaseId: 'db-123' }) ); expect(result.success).toBe(true); From 8efca8698a67df2e58dc6cf1deb8bbee636057fa Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Tue, 16 Jun 2026 15:38:23 +0800 Subject: [PATCH 09/10] chore(deps): update lockfile for node-sql template dependencies - Fix @pgsql/quotes version to ^17.1.0 (was incorrectly ^0.0.4) - Update lockfile to match node-sql template dependencies Co-Authored-By: Claude Opus 4.5 --- functions/storage-confirm-upload/handler.json | 2 +- pnpm-lock.yaml | 40 ------------------- 2 files changed, 1 insertion(+), 41 deletions(-) diff --git a/functions/storage-confirm-upload/handler.json b/functions/storage-confirm-upload/handler.json index b2dcc2588..f5183ce92 100644 --- a/functions/storage-confirm-upload/handler.json +++ b/functions/storage-confirm-upload/handler.json @@ -7,6 +7,6 @@ "description": "Confirms file upload by checking S3 HeadObject and transitioning status to uploaded", "dependencies": { "@aws-sdk/client-s3": "^3.1060.0", - "@pgsql/quotes": "^0.0.4" + "@pgsql/quotes": "^17.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 299d501ba..381b0e705 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,37 +175,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.1060.0 version: 3.1060.0 - '@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.12.0 - pg: - specifier: ^8.16.0 - version: 8.20.0 - 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/stripe-webhook: - dependencies: '@constructive-io/knative-job-fn': specifier: workspace:^ version: link:../../packages/fn-app - '@pgpmjs/env': - specifier: ^2.15.3 - version: 2.17.0 '@pgpmjs/logger': specifier: ^1.0.0 version: 1.5.0 @@ -218,9 +190,6 @@ importers: pg-cache: specifier: ^3.11.0 version: 3.12.0 - stripe: - specifier: ^17.4.0 - version: 17.7.0 devDependencies: '@types/node': specifier: ^22.10.4 @@ -3680,10 +3649,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - stripe@17.7.0: - resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} - engines: {node: '>=12.*'} - strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} @@ -8206,11 +8171,6 @@ snapshots: strip-json-comments@3.1.1: {} - stripe@17.7.0: - dependencies: - '@types/node': 22.19.3 - qs: 6.14.1 - strnum@2.3.0: {} styled-components@5.3.11(@babel/core@7.28.5)(react-dom@16.14.0(react@16.14.0))(react-is@18.3.1)(react@16.14.0): From 61d16fc20f63dffc4b4e1dec7694d95ad66e784b Mon Sep 17 00:00:00 2001 From: Lucas Jiang Date: Tue, 16 Jun 2026 17:58:35 +0800 Subject: [PATCH 10/10] feat(dev): add S3/MinIO env vars to dev script Add CDN_ENDPOINT, S3_ENDPOINT, and AWS credentials to sharedEnv so storage-confirm-upload and other S3-dependent functions work in local dev mode (make dev-fn). Co-Authored-By: Claude Opus 4.5 --- scripts/dev.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/dev.ts b/scripts/dev.ts index f7f6177e5..f19c165cf 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -63,6 +63,14 @@ const sharedEnv: Record = { LOCAL_APP_PORT: '3000', SEND_VERIFICATION_LINK_DRY_RUN: 'true', SEND_EMAIL_DRY_RUN: 'true', + // S3/MinIO (matches docker-compose minio on port 9000) + CDN_ENDPOINT: 'http://localhost:9000', + S3_ENDPOINT: 'http://localhost:9000', + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY: 'minioadmin', + AWS_SECRET_KEY: 'minioadmin', + AWS_ACCESS_KEY_ID: 'minioadmin', + AWS_SECRET_ACCESS_KEY: 'minioadmin', }; // --- Process definitions (built from manifest) ---