@teleporthq/teleport-plugin-next-workflows 0.44.11 → 0.44.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/_helpers/fake-fetch.ts +75 -0
- package/__tests__/_helpers/gui-place-order-statements.ts +17 -0
- package/__tests__/_helpers/inserted-lines.ts +22 -0
- package/__tests__/_helpers/invoice-store.ts +10 -1
- package/__tests__/_helpers/load-security-runtime.ts +45 -0
- package/__tests__/_helpers/load-server-runtime.ts +26 -3
- package/__tests__/_helpers/run-account-delete-route.ts +3 -1
- package/__tests__/_helpers/run-generated-middleware.ts +7 -2
- package/__tests__/account-delete-owned-rows.test.ts +3 -1
- package/__tests__/adopted-element-trigger-code.test.ts +128 -0
- package/__tests__/analytics-track-outcome.test.ts +145 -0
- package/__tests__/auth-password-change-ends-sessions.test.ts +8 -1
- package/__tests__/auth-password-security.test.ts +2275 -0
- package/__tests__/auth-session-cost.test.ts +31 -0
- package/__tests__/auth-signup-route.test.ts +960 -0
- package/__tests__/authored-code-every-path.test.ts +247 -0
- package/__tests__/automations-bus.test.ts +3820 -0
- package/__tests__/automations-catalog-drift.test.ts +97 -0
- package/__tests__/booking-calendar-feed-route.test.ts +20 -3
- package/__tests__/cart-price-order.test.ts +336 -1
- package/__tests__/code-lookup-rate-limit-route.test.ts +189 -0
- package/__tests__/cost-bearing-node-types.test.ts +119 -0
- package/__tests__/credential-columns-reply.test.ts +9 -0
- package/__tests__/credential-result-reply.test.ts +19 -5
- package/__tests__/cron-route-loop-templateparams.test.ts +6 -3
- package/__tests__/custom-js-authored-code.test.ts +304 -0
- package/__tests__/custom-node-custom-code-gate.test.ts +136 -0
- package/__tests__/custom-node-step-handlers.test.ts +251 -0
- package/__tests__/custom-node-step-reach.test.ts +63 -0
- package/__tests__/data-api-anon-user-id-fallback.test.ts +7 -2
- package/__tests__/data-api-browser-reads.test.ts +7 -0
- package/__tests__/data-api-business-events.test.ts +403 -0
- package/__tests__/data-api-invalid-body.test.ts +7 -0
- package/__tests__/data-api-low-stock-autofire.test.ts +30 -4
- package/__tests__/data-api-optional-feature-columns.test.ts +44 -0
- package/__tests__/data-api-owner-scope-credentials.test.ts +1324 -0
- package/__tests__/data-api-protected-tables.test.ts +48 -15
- package/__tests__/data-api-retired-table-and-account-fields.test.ts +208 -0
- package/__tests__/email-locale-senders.test.ts +5 -3
- package/__tests__/expression-injection.test.ts +343 -0
- package/__tests__/fixtures/admin-file-workflows.json +242 -0
- package/__tests__/form-confirmation-email-template.test.ts +44 -0
- package/__tests__/internal-request-headers.test.ts +25 -0
- package/__tests__/invoice-pdf-service-project-header.test.ts +120 -0
- package/__tests__/invoice-self-call-origin.test.ts +57 -1
- package/__tests__/legacy-webhook-self-calls.test.ts +86 -1
- package/__tests__/middleware-home-route-protection.test.ts +3 -1
- package/__tests__/middleware-subscriber-access.test.ts +1 -1
- package/__tests__/newsletter-admin-route.test.ts +649 -0
- package/__tests__/newsletter-plugin.test.ts +1248 -0
- package/__tests__/outbound-nodes-security.test.ts +1929 -0
- package/__tests__/page-access-guard.test.ts +217 -0
- package/__tests__/page-access-route-table.test.ts +231 -0
- package/__tests__/payment-drivers-module.test.ts +23 -0
- package/__tests__/payment-legacy-webhook-routes.test.ts +100 -5
- package/__tests__/payment-webhook-route.test.ts +4 -1
- package/__tests__/paypal-webhook-signature-verification.test.ts +44 -0
- package/__tests__/place-order-guards.test.ts +506 -0
- package/__tests__/public-step-guards.test.ts +310 -0
- package/__tests__/publish-refusals.test.ts +505 -0
- package/__tests__/realtime-identity-routes.test.ts +492 -0
- package/__tests__/record-event-writer-server-only.test.ts +975 -0
- package/__tests__/resolve-config-template-params.test.ts +3 -3
- package/__tests__/runtime-storage-upload-access.test.ts +280 -17
- package/__tests__/security-integration-env-names.test.ts +74 -0
- package/__tests__/security-lists-parity.test.ts +245 -0
- package/__tests__/security-runtime-shadow.test.ts +224 -1
- package/__tests__/security-runtime.test.ts +893 -0
- package/__tests__/segment-rate-limit-error-branch.test.ts +284 -0
- package/__tests__/segment-route-abuse-gate.test.ts +309 -0
- package/__tests__/server-job-hops-real-data-api.test.ts +669 -0
- package/__tests__/server-session-identity.test.ts +522 -0
- package/__tests__/server-step-chain.test.ts +222 -0
- package/__tests__/server-step-signing.test.ts +107 -2
- package/__tests__/session-cookie-resolver.test.ts +62 -106
- package/__tests__/signup-event.test.ts +649 -0
- package/__tests__/signup-reply-identical.test.ts +144 -0
- package/__tests__/slack-message-escaping.test.ts +123 -0
- package/__tests__/visitor-step-limits.test.ts +318 -0
- package/__tests__/webhook-delivery-lease.test.ts +4 -1
- package/__tests__/webpack-safe-handlers.test.ts +57 -8
- package/__tests__/workflow-auth-guard.test.ts +9 -1
- package/dist/cjs/account-delete-route-generator.d.ts +1 -0
- package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
- package/dist/cjs/account-delete-route-generator.js +18 -3
- package/dist/cjs/account-delete-route-generator.js.map +1 -1
- package/dist/cjs/api-route-generator.d.ts +52 -3
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +600 -145
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts +126 -3
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +1353 -139
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/automations/activity-kicks.d.ts +12 -0
- package/dist/cjs/automations/activity-kicks.d.ts.map +1 -0
- package/dist/cjs/automations/activity-kicks.js +123 -0
- package/dist/cjs/automations/activity-kicks.js.map +1 -0
- package/dist/cjs/automations/app-events-source.d.ts +33 -0
- package/dist/cjs/automations/app-events-source.d.ts.map +1 -0
- package/dist/cjs/automations/app-events-source.js +109 -0
- package/dist/cjs/automations/app-events-source.js.map +1 -0
- package/dist/cjs/automations/automations-project-plugin.d.ts +21 -0
- package/dist/cjs/automations/automations-project-plugin.d.ts.map +1 -0
- package/dist/cjs/automations/automations-project-plugin.js +102 -0
- package/dist/cjs/automations/automations-project-plugin.js.map +1 -0
- package/dist/cjs/automations/automations-scope.d.ts +104 -0
- package/dist/cjs/automations/automations-scope.d.ts.map +1 -0
- package/dist/cjs/automations/automations-scope.js +176 -0
- package/dist/cjs/automations/automations-scope.js.map +1 -0
- package/dist/cjs/automations/dispatcher-source.d.ts +72 -0
- package/dist/cjs/automations/dispatcher-source.d.ts.map +1 -0
- package/dist/cjs/automations/dispatcher-source.js +1008 -0
- package/dist/cjs/automations/dispatcher-source.js.map +1 -0
- package/dist/cjs/automations/event-bus-source.d.ts +44 -0
- package/dist/cjs/automations/event-bus-source.d.ts.map +1 -0
- package/dist/cjs/automations/event-bus-source.js +427 -0
- package/dist/cjs/automations/event-bus-source.js.map +1 -0
- package/dist/cjs/automations/handler-module-generator.d.ts +25 -0
- package/dist/cjs/automations/handler-module-generator.d.ts.map +1 -0
- package/dist/cjs/automations/handler-module-generator.js +78 -0
- package/dist/cjs/automations/handler-module-generator.js.map +1 -0
- package/dist/cjs/automations/hydrators-source.d.ts +33 -0
- package/dist/cjs/automations/hydrators-source.d.ts.map +1 -0
- package/dist/cjs/automations/hydrators-source.js +571 -0
- package/dist/cjs/automations/hydrators-source.js.map +1 -0
- package/dist/cjs/booking-calendar-feed-route-generator.d.ts.map +1 -1
- package/dist/cjs/booking-calendar-feed-route-generator.js +8 -0
- package/dist/cjs/booking-calendar-feed-route-generator.js.map +1 -1
- package/dist/cjs/configured-nextauth-url.d.ts +17 -0
- package/dist/cjs/configured-nextauth-url.d.ts.map +1 -0
- package/dist/cjs/configured-nextauth-url.js +20 -0
- package/dist/cjs/configured-nextauth-url.js.map +1 -0
- package/dist/cjs/cron-request-verification.d.ts +22 -1
- package/dist/cjs/cron-request-verification.d.ts.map +1 -1
- package/dist/cjs/cron-request-verification.js +50 -6
- package/dist/cjs/cron-request-verification.js.map +1 -1
- package/dist/cjs/data-api-route-generator.d.ts +19 -0
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +711 -43
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts +3 -1
- package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts.map +1 -1
- package/dist/cjs/ecommerce/parameterized-stock-chain.js +45 -5
- package/dist/cjs/ecommerce/parameterized-stock-chain.js.map +1 -1
- package/dist/cjs/ecommerce/place-order-guards.d.ts +126 -0
- package/dist/cjs/ecommerce/place-order-guards.d.ts.map +1 -0
- package/dist/cjs/ecommerce/place-order-guards.js +670 -0
- package/dist/cjs/ecommerce/place-order-guards.js.map +1 -0
- package/dist/cjs/ecommerce-customhandler-rewriter.d.ts +2 -0
- package/dist/cjs/ecommerce-customhandler-rewriter.d.ts.map +1 -1
- package/dist/cjs/ecommerce-customhandler-rewriter.js +38 -2
- package/dist/cjs/ecommerce-customhandler-rewriter.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +368 -60
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/graph-utils.d.ts +9 -0
- package/dist/cjs/graph-utils.d.ts.map +1 -1
- package/dist/cjs/graph-utils.js +32 -1
- package/dist/cjs/graph-utils.js.map +1 -1
- package/dist/cjs/index.d.ts +20 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +72 -2
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/invoice/api-routes-code.d.ts +9 -3
- package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/cjs/invoice/api-routes-code.js +9 -7
- package/dist/cjs/invoice/api-routes-code.js.map +1 -1
- package/dist/cjs/invoice/index.d.ts.map +1 -1
- package/dist/cjs/invoice/index.js +12 -7
- package/dist/cjs/invoice/index.js.map +1 -1
- package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-assembly-code.js +8 -3
- package/dist/cjs/invoice/invoice-assembly-code.js.map +1 -1
- package/dist/cjs/invoice/pdf-generator-code.d.ts.map +1 -1
- package/dist/cjs/invoice/pdf-generator-code.js +2 -1
- package/dist/cjs/invoice/pdf-generator-code.js.map +1 -1
- package/dist/cjs/invoice/pdf-service-client-code.d.ts.map +1 -1
- package/dist/cjs/invoice/pdf-service-client-code.js +15 -7
- package/dist/cjs/invoice/pdf-service-client-code.js.map +1 -1
- package/dist/cjs/newsletter/admin-route-code.d.ts +60 -0
- package/dist/cjs/newsletter/admin-route-code.d.ts.map +1 -0
- package/dist/cjs/newsletter/admin-route-code.js +300 -0
- package/dist/cjs/newsletter/admin-route-code.js.map +1 -0
- package/dist/cjs/newsletter/helper-code.d.ts +109 -0
- package/dist/cjs/newsletter/helper-code.d.ts.map +1 -0
- package/dist/cjs/newsletter/helper-code.js +662 -0
- package/dist/cjs/newsletter/helper-code.js.map +1 -0
- package/dist/cjs/newsletter/newsletter-scope.d.ts +65 -0
- package/dist/cjs/newsletter/newsletter-scope.d.ts.map +1 -0
- package/dist/cjs/newsletter/newsletter-scope.js +103 -0
- package/dist/cjs/newsletter/newsletter-scope.js.map +1 -0
- package/dist/cjs/newsletter/project-plugin.d.ts +18 -0
- package/dist/cjs/newsletter/project-plugin.d.ts.map +1 -0
- package/dist/cjs/newsletter/project-plugin.js +97 -0
- package/dist/cjs/newsletter/project-plugin.js.map +1 -0
- package/dist/cjs/newsletter/routes-code.d.ts +6 -0
- package/dist/cjs/newsletter/routes-code.d.ts.map +1 -0
- package/dist/cjs/newsletter/routes-code.js +181 -0
- package/dist/cjs/newsletter/routes-code.js.map +1 -0
- package/dist/cjs/nodes/account/account-compare-passwords.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-compare-passwords.js +16 -6
- package/dist/cjs/nodes/account/account-compare-passwords.js.map +1 -1
- package/dist/cjs/nodes/account/account-delete-current.js +1 -1
- package/dist/cjs/nodes/account/account-get-current.js +1 -1
- package/dist/cjs/nodes/account/account-hash-password.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-hash-password.js +15 -3
- package/dist/cjs/nodes/account/account-hash-password.js.map +1 -1
- package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-login.js +23 -6
- package/dist/cjs/nodes/account/account-login.js.map +1 -1
- package/dist/cjs/nodes/account/account-logout.js +1 -1
- package/dist/cjs/nodes/account/account-refresh-session.js +1 -1
- package/dist/cjs/nodes/account/account-request-password-reset.d.ts +3 -0
- package/dist/cjs/nodes/account/account-request-password-reset.d.ts.map +1 -0
- package/dist/cjs/nodes/account/account-request-password-reset.js +46 -0
- package/dist/cjs/nodes/account/account-request-password-reset.js.map +1 -0
- package/dist/cjs/nodes/account/account-reset-password.d.ts +3 -0
- package/dist/cjs/nodes/account/account-reset-password.d.ts.map +1 -0
- package/dist/cjs/nodes/account/account-reset-password.js +44 -0
- package/dist/cjs/nodes/account/account-reset-password.js.map +1 -0
- package/dist/cjs/nodes/account/account-signup.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-signup.js +82 -19
- package/dist/cjs/nodes/account/account-signup.js.map +1 -1
- package/dist/cjs/nodes/account/account-social-login.js +1 -1
- package/dist/cjs/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-custom-prompt.js +20 -5
- package/dist/cjs/nodes/ai/ai-custom-prompt.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-detect-language.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-detect-language.js +20 -5
- package/dist/cjs/nodes/ai/ai-detect-language.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-generate-text-embedding.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-generate-text-embedding.js +10 -2
- package/dist/cjs/nodes/ai/ai-generate-text-embedding.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-provider-utils-sources.generated.js +2 -2
- package/dist/cjs/nodes/ai/ai-provider-utils-sources.generated.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-provider-utils.d.ts +31 -2
- package/dist/cjs/nodes/ai/ai-provider-utils.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-provider-utils.js +125 -11
- package/dist/cjs/nodes/ai/ai-provider-utils.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-select-database-data.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-select-database-data.js +6 -2
- package/dist/cjs/nodes/ai/ai-select-database-data.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-sentiment-analysis.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-sentiment-analysis.js +20 -5
- package/dist/cjs/nodes/ai/ai-sentiment-analysis.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-summarization.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-summarization.js +20 -5
- package/dist/cjs/nodes/ai/ai-summarization.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-text-classifier.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-text-classifier.js +20 -5
- package/dist/cjs/nodes/ai/ai-text-classifier.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-text-transform.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-text-transform.js +20 -5
- package/dist/cjs/nodes/ai/ai-text-transform.js.map +1 -1
- package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts +3 -0
- package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts.map +1 -0
- package/dist/cjs/nodes/analytics/analytics-track-outcome.js +110 -0
- package/dist/cjs/nodes/analytics/analytics-track-outcome.js.map +1 -0
- package/dist/cjs/nodes/cart/cart-price-order.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-price-order.js +161 -2
- package/dist/cjs/nodes/cart/cart-price-order.js.map +1 -1
- package/dist/cjs/nodes/cost-bearing-node-types.d.ts +4 -0
- package/dist/cjs/nodes/cost-bearing-node-types.d.ts.map +1 -0
- package/dist/cjs/nodes/cost-bearing-node-types.js +43 -0
- package/dist/cjs/nodes/cost-bearing-node-types.js.map +1 -0
- package/dist/cjs/nodes/data/data-select.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-select.js +12 -2
- package/dist/cjs/nodes/data/data-select.js.map +1 -1
- package/dist/cjs/nodes/email/email-mailersend.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-mailersend.js +15 -11
- package/dist/cjs/nodes/email/email-mailersend.js.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.js +15 -15
- package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.js +15 -15
- package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
- package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-resend.js +15 -15
- package/dist/cjs/nodes/email/email-resend.js.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.js +15 -15
- package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/cjs/nodes/email/html-escape-code.d.ts +3 -0
- package/dist/cjs/nodes/email/html-escape-code.d.ts.map +1 -0
- package/dist/cjs/nodes/email/html-escape-code.js +23 -0
- package/dist/cjs/nodes/email/html-escape-code.js.map +1 -0
- package/dist/cjs/nodes/email/message-trust.d.ts +40 -0
- package/dist/cjs/nodes/email/message-trust.d.ts.map +1 -0
- package/dist/cjs/nodes/email/message-trust.js +167 -0
- package/dist/cjs/nodes/email/message-trust.js.map +1 -0
- package/dist/cjs/nodes/email/outbound-message-guard.d.ts +113 -0
- package/dist/cjs/nodes/email/outbound-message-guard.d.ts.map +1 -0
- package/dist/cjs/nodes/email/outbound-message-guard.js +460 -0
- package/dist/cjs/nodes/email/outbound-message-guard.js.map +1 -0
- package/dist/cjs/nodes/email/trusted-html-code.d.ts +22 -0
- package/dist/cjs/nodes/email/trusted-html-code.d.ts.map +1 -0
- package/dist/cjs/nodes/email/trusted-html-code.js +52 -0
- package/dist/cjs/nodes/email/trusted-html-code.js.map +1 -0
- package/dist/cjs/nodes/general/general-custom-js.d.ts.map +1 -1
- package/dist/cjs/nodes/general/general-custom-js.js +44 -7
- package/dist/cjs/nodes/general/general-custom-js.js.map +1 -1
- package/dist/cjs/nodes/general/general-http-request.d.ts.map +1 -1
- package/dist/cjs/nodes/general/general-http-request.js +431 -45
- package/dist/cjs/nodes/general/general-http-request.js.map +1 -1
- package/dist/cjs/nodes/general/general-rate-limiter.d.ts.map +1 -1
- package/dist/cjs/nodes/general/general-rate-limiter.js +46 -78
- package/dist/cjs/nodes/general/general-rate-limiter.js.map +1 -1
- package/dist/cjs/nodes/general/general-wait.d.ts +3 -0
- package/dist/cjs/nodes/general/general-wait.d.ts.map +1 -0
- package/dist/cjs/nodes/general/general-wait.js +81 -0
- package/dist/cjs/nodes/general/general-wait.js.map +1 -0
- package/dist/cjs/nodes/general/visitor-request.d.ts +44 -0
- package/dist/cjs/nodes/general/visitor-request.d.ts.map +1 -0
- package/dist/cjs/nodes/general/visitor-request.js +72 -0
- package/dist/cjs/nodes/general/visitor-request.js.map +1 -0
- package/dist/cjs/nodes/index.d.ts.map +1 -1
- package/dist/cjs/nodes/index.js +7 -1
- package/dist/cjs/nodes/index.js.map +1 -1
- package/dist/cjs/nodes/integrations/integration-slack.d.ts.map +1 -1
- package/dist/cjs/nodes/integrations/integration-slack.js +17 -6
- package/dist/cjs/nodes/integrations/integration-slack.js.map +1 -1
- package/dist/cjs/nodes/newsletter/newsletter-confirm.d.ts +3 -0
- package/dist/cjs/nodes/newsletter/newsletter-confirm.d.ts.map +1 -0
- package/dist/cjs/nodes/newsletter/newsletter-confirm.js +41 -0
- package/dist/cjs/nodes/newsletter/newsletter-confirm.js.map +1 -0
- package/dist/cjs/nodes/newsletter/newsletter-subscribe.d.ts +7 -0
- package/dist/cjs/nodes/newsletter/newsletter-subscribe.d.ts.map +1 -0
- package/dist/cjs/nodes/newsletter/newsletter-subscribe.js +69 -0
- package/dist/cjs/nodes/newsletter/newsletter-subscribe.js.map +1 -0
- package/dist/cjs/nodes/newsletter/newsletter-unsubscribe.d.ts +3 -0
- package/dist/cjs/nodes/newsletter/newsletter-unsubscribe.d.ts.map +1 -0
- package/dist/cjs/nodes/newsletter/newsletter-unsubscribe.js +42 -0
- package/dist/cjs/nodes/newsletter/newsletter-unsubscribe.js.map +1 -0
- package/dist/cjs/nodes/realtime/realtime-join-channel.d.ts.map +1 -1
- package/dist/cjs/nodes/realtime/realtime-join-channel.js +5 -4
- package/dist/cjs/nodes/realtime/realtime-join-channel.js.map +1 -1
- package/dist/cjs/nodes/realtime/realtime-send-channel-event.d.ts.map +1 -1
- package/dist/cjs/nodes/realtime/realtime-send-channel-event.js +11 -2
- package/dist/cjs/nodes/realtime/realtime-send-channel-event.js.map +1 -1
- package/dist/cjs/nodes/realtime/realtime-send-channel-message.d.ts.map +1 -1
- package/dist/cjs/nodes/realtime/realtime-send-channel-message.js +11 -2
- package/dist/cjs/nodes/realtime/realtime-send-channel-message.js.map +1 -1
- package/dist/cjs/nodes/sms/sms-infobip.d.ts.map +1 -1
- package/dist/cjs/nodes/sms/sms-infobip.js +11 -8
- package/dist/cjs/nodes/sms/sms-infobip.js.map +1 -1
- package/dist/cjs/nodes/sms/sms-smsapi.d.ts.map +1 -1
- package/dist/cjs/nodes/sms/sms-smsapi.js +11 -8
- package/dist/cjs/nodes/sms/sms-smsapi.js.map +1 -1
- package/dist/cjs/nodes/sms/sms-textmagic.d.ts.map +1 -1
- package/dist/cjs/nodes/sms/sms-textmagic.js +11 -8
- package/dist/cjs/nodes/sms/sms-textmagic.js.map +1 -1
- package/dist/cjs/nodes/sms/sms-twilio.d.ts.map +1 -1
- package/dist/cjs/nodes/sms/sms-twilio.js +12 -10
- package/dist/cjs/nodes/sms/sms-twilio.js.map +1 -1
- package/dist/cjs/nodes/types.d.ts +8 -1
- package/dist/cjs/nodes/types.d.ts.map +1 -1
- package/dist/cjs/nodes/utility/utility-similarity-scoring.d.ts.map +1 -1
- package/dist/cjs/nodes/utility/utility-similarity-scoring.js +29 -10
- package/dist/cjs/nodes/utility/utility-similarity-scoring.js.map +1 -1
- package/dist/cjs/page-access-guard-generator.d.ts +23 -0
- package/dist/cjs/page-access-guard-generator.d.ts.map +1 -0
- package/dist/cjs/page-access-guard-generator.js +135 -0
- package/dist/cjs/page-access-guard-generator.js.map +1 -0
- package/dist/cjs/password-reset-route-generator.d.ts +119 -0
- package/dist/cjs/password-reset-route-generator.d.ts.map +1 -0
- package/dist/cjs/password-reset-route-generator.js +460 -0
- package/dist/cjs/password-reset-route-generator.js.map +1 -0
- package/dist/cjs/payments/payment-core-code.d.ts.map +1 -1
- package/dist/cjs/payments/payment-core-code.js +12 -7
- package/dist/cjs/payments/payment-core-code.js.map +1 -1
- package/dist/cjs/project-code-scanner.d.ts +11 -0
- package/dist/cjs/project-code-scanner.d.ts.map +1 -0
- package/dist/cjs/project-code-scanner.js +80 -0
- package/dist/cjs/project-code-scanner.js.map +1 -0
- package/dist/cjs/public-step-guards.d.ts +122 -0
- package/dist/cjs/public-step-guards.d.ts.map +1 -0
- package/dist/cjs/public-step-guards.js +288 -0
- package/dist/cjs/public-step-guards.js.map +1 -0
- package/dist/cjs/publish-refusals.d.ts +66 -0
- package/dist/cjs/publish-refusals.d.ts.map +1 -0
- package/dist/cjs/publish-refusals.js +278 -0
- package/dist/cjs/publish-refusals.js.map +1 -0
- package/dist/cjs/realtime-generator.d.ts +63 -1
- package/dist/cjs/realtime-generator.d.ts.map +1 -1
- package/dist/cjs/realtime-generator.js +404 -230
- package/dist/cjs/realtime-generator.js.map +1 -1
- package/dist/cjs/runtime-storage-generator.d.ts +32 -1
- package/dist/cjs/runtime-storage-generator.d.ts.map +1 -1
- package/dist/cjs/runtime-storage-generator.js +265 -12
- package/dist/cjs/runtime-storage-generator.js.map +1 -1
- package/dist/cjs/security-runtime/index.d.ts +14 -0
- package/dist/cjs/security-runtime/index.d.ts.map +1 -0
- package/dist/cjs/security-runtime/index.js +48 -0
- package/dist/cjs/security-runtime/index.js.map +1 -0
- package/dist/cjs/security-runtime/security-runtime-code.d.ts +53 -0
- package/dist/cjs/security-runtime/security-runtime-code.d.ts.map +1 -0
- package/dist/cjs/security-runtime/security-runtime-code.js +580 -0
- package/dist/cjs/security-runtime/security-runtime-code.js.map +1 -0
- package/dist/cjs/security-scanner.d.ts +72 -3
- package/dist/cjs/security-scanner.d.ts.map +1 -1
- package/dist/cjs/security-scanner.js +220 -8
- package/dist/cjs/security-scanner.js.map +1 -1
- package/dist/cjs/segment-splitter.d.ts.map +1 -1
- package/dist/cjs/segment-splitter.js +10 -2
- package/dist/cjs/segment-splitter.js.map +1 -1
- package/dist/cjs/segment-trust.d.ts +36 -0
- package/dist/cjs/segment-trust.d.ts.map +1 -1
- package/dist/cjs/segment-trust.js +147 -3
- package/dist/cjs/segment-trust.js.map +1 -1
- package/dist/cjs/self-call-headers.d.ts +31 -0
- package/dist/cjs/self-call-headers.d.ts.map +1 -0
- package/dist/cjs/self-call-headers.js +48 -0
- package/dist/cjs/self-call-headers.js.map +1 -0
- package/dist/cjs/sent-email-log/index.d.ts +7 -0
- package/dist/cjs/sent-email-log/index.d.ts.map +1 -1
- package/dist/cjs/sent-email-log/index.js +17 -1
- package/dist/cjs/sent-email-log/index.js.map +1 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.d.ts +7 -0
- package/dist/cjs/sent-email-log/sent-email-log-code.d.ts.map +1 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.js +17 -3
- package/dist/cjs/sent-email-log/sent-email-log-code.js.map +1 -1
- package/dist/cjs/server-custom-node-call.d.ts +79 -0
- package/dist/cjs/server-custom-node-call.d.ts.map +1 -0
- package/dist/cjs/server-custom-node-call.js +285 -0
- package/dist/cjs/server-custom-node-call.js.map +1 -0
- package/dist/cjs/server-runtime-code.d.ts +39 -12
- package/dist/cjs/server-runtime-code.d.ts.map +1 -1
- package/dist/cjs/server-runtime-code.js +484 -41
- package/dist/cjs/server-runtime-code.js.map +1 -1
- package/dist/cjs/session-cookie-resolver.d.ts +3 -1
- package/dist/cjs/session-cookie-resolver.d.ts.map +1 -1
- package/dist/cjs/session-revocation-generator.d.ts +39 -0
- package/dist/cjs/session-revocation-generator.d.ts.map +1 -0
- package/dist/cjs/session-revocation-generator.js +177 -0
- package/dist/cjs/session-revocation-generator.js.map +1 -0
- package/dist/cjs/slack-message-values.d.ts +6 -0
- package/dist/cjs/slack-message-values.d.ts.map +1 -0
- package/dist/cjs/slack-message-values.js +73 -0
- package/dist/cjs/slack-message-values.js.map +1 -0
- package/dist/cjs/transactional-email-code.d.ts +11 -0
- package/dist/cjs/transactional-email-code.d.ts.map +1 -1
- package/dist/cjs/transactional-email-code.js +29 -5
- package/dist/cjs/transactional-email-code.js.map +1 -1
- package/dist/cjs/trigger-generator.d.ts +2 -0
- package/dist/cjs/trigger-generator.d.ts.map +1 -1
- package/dist/cjs/trigger-generator.js +17 -4
- package/dist/cjs/trigger-generator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/visitor-step-limits.d.ts +66 -0
- package/dist/cjs/visitor-step-limits.d.ts.map +1 -0
- package/dist/cjs/visitor-step-limits.js +187 -0
- package/dist/cjs/visitor-step-limits.js.map +1 -0
- package/dist/cjs/webhook-generator.d.ts.map +1 -1
- package/dist/cjs/webhook-generator.js +93 -15
- package/dist/cjs/webhook-generator.js.map +1 -1
- package/dist/cjs/webhook-signature-verification.d.ts.map +1 -1
- package/dist/cjs/webhook-signature-verification.js +13 -3
- package/dist/cjs/webhook-signature-verification.js.map +1 -1
- package/dist/cjs/workflow-auth-generator.d.ts +4 -1
- package/dist/cjs/workflow-auth-generator.d.ts.map +1 -1
- package/dist/cjs/workflow-auth-generator.js +8 -3
- package/dist/cjs/workflow-auth-generator.js.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +11 -3
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +302 -56
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/cjs/workflow-scope.d.ts.map +1 -1
- package/dist/cjs/workflow-scope.js +5 -0
- package/dist/cjs/workflow-scope.js.map +1 -1
- package/dist/esm/account-delete-route-generator.d.ts +1 -0
- package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
- package/dist/esm/account-delete-route-generator.js +18 -3
- package/dist/esm/account-delete-route-generator.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts +52 -3
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +595 -142
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts +126 -3
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +1350 -140
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/automations/activity-kicks.d.ts +12 -0
- package/dist/esm/automations/activity-kicks.d.ts.map +1 -0
- package/dist/esm/automations/activity-kicks.js +118 -0
- package/dist/esm/automations/activity-kicks.js.map +1 -0
- package/dist/esm/automations/app-events-source.d.ts +33 -0
- package/dist/esm/automations/app-events-source.d.ts.map +1 -0
- package/dist/esm/automations/app-events-source.js +104 -0
- package/dist/esm/automations/app-events-source.js.map +1 -0
- package/dist/esm/automations/automations-project-plugin.d.ts +21 -0
- package/dist/esm/automations/automations-project-plugin.d.ts.map +1 -0
- package/dist/esm/automations/automations-project-plugin.js +98 -0
- package/dist/esm/automations/automations-project-plugin.js.map +1 -0
- package/dist/esm/automations/automations-scope.d.ts +104 -0
- package/dist/esm/automations/automations-scope.d.ts.map +1 -0
- package/dist/esm/automations/automations-scope.js +165 -0
- package/dist/esm/automations/automations-scope.js.map +1 -0
- package/dist/esm/automations/dispatcher-source.d.ts +72 -0
- package/dist/esm/automations/dispatcher-source.d.ts.map +1 -0
- package/dist/esm/automations/dispatcher-source.js +999 -0
- package/dist/esm/automations/dispatcher-source.js.map +1 -0
- package/dist/esm/automations/event-bus-source.d.ts +44 -0
- package/dist/esm/automations/event-bus-source.d.ts.map +1 -0
- package/dist/esm/automations/event-bus-source.js +421 -0
- package/dist/esm/automations/event-bus-source.js.map +1 -0
- package/dist/esm/automations/handler-module-generator.d.ts +25 -0
- package/dist/esm/automations/handler-module-generator.d.ts.map +1 -0
- package/dist/esm/automations/handler-module-generator.js +71 -0
- package/dist/esm/automations/handler-module-generator.js.map +1 -0
- package/dist/esm/automations/hydrators-source.d.ts +33 -0
- package/dist/esm/automations/hydrators-source.d.ts.map +1 -0
- package/dist/esm/automations/hydrators-source.js +567 -0
- package/dist/esm/automations/hydrators-source.js.map +1 -0
- package/dist/esm/booking-calendar-feed-route-generator.d.ts.map +1 -1
- package/dist/esm/booking-calendar-feed-route-generator.js +8 -0
- package/dist/esm/booking-calendar-feed-route-generator.js.map +1 -1
- package/dist/esm/configured-nextauth-url.d.ts +17 -0
- package/dist/esm/configured-nextauth-url.d.ts.map +1 -0
- package/dist/esm/configured-nextauth-url.js +17 -0
- package/dist/esm/configured-nextauth-url.js.map +1 -0
- package/dist/esm/cron-request-verification.d.ts +22 -1
- package/dist/esm/cron-request-verification.d.ts.map +1 -1
- package/dist/esm/cron-request-verification.js +48 -5
- package/dist/esm/cron-request-verification.js.map +1 -1
- package/dist/esm/data-api-route-generator.d.ts +19 -0
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +710 -43
- package/dist/esm/data-api-route-generator.js.map +1 -1
- package/dist/esm/ecommerce/parameterized-stock-chain.d.ts +3 -1
- package/dist/esm/ecommerce/parameterized-stock-chain.d.ts.map +1 -1
- package/dist/esm/ecommerce/parameterized-stock-chain.js +43 -4
- package/dist/esm/ecommerce/parameterized-stock-chain.js.map +1 -1
- package/dist/esm/ecommerce/place-order-guards.d.ts +126 -0
- package/dist/esm/ecommerce/place-order-guards.d.ts.map +1 -0
- package/dist/esm/ecommerce/place-order-guards.js +655 -0
- package/dist/esm/ecommerce/place-order-guards.js.map +1 -0
- package/dist/esm/ecommerce-customhandler-rewriter.d.ts +2 -0
- package/dist/esm/ecommerce-customhandler-rewriter.d.ts.map +1 -1
- package/dist/esm/ecommerce-customhandler-rewriter.js +38 -2
- package/dist/esm/ecommerce-customhandler-rewriter.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +368 -60
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/graph-utils.d.ts +9 -0
- package/dist/esm/graph-utils.d.ts.map +1 -1
- package/dist/esm/graph-utils.js +30 -0
- package/dist/esm/graph-utils.js.map +1 -1
- package/dist/esm/index.d.ts +20 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +27 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/invoice/api-routes-code.d.ts +9 -3
- package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/esm/invoice/api-routes-code.js +9 -7
- package/dist/esm/invoice/api-routes-code.js.map +1 -1
- package/dist/esm/invoice/index.d.ts.map +1 -1
- package/dist/esm/invoice/index.js +12 -7
- package/dist/esm/invoice/index.js.map +1 -1
- package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-assembly-code.js +8 -3
- package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
- package/dist/esm/invoice/pdf-generator-code.d.ts.map +1 -1
- package/dist/esm/invoice/pdf-generator-code.js +2 -1
- package/dist/esm/invoice/pdf-generator-code.js.map +1 -1
- package/dist/esm/invoice/pdf-service-client-code.d.ts.map +1 -1
- package/dist/esm/invoice/pdf-service-client-code.js +15 -7
- package/dist/esm/invoice/pdf-service-client-code.js.map +1 -1
- package/dist/esm/newsletter/admin-route-code.d.ts +60 -0
- package/dist/esm/newsletter/admin-route-code.d.ts.map +1 -0
- package/dist/esm/newsletter/admin-route-code.js +295 -0
- package/dist/esm/newsletter/admin-route-code.js.map +1 -0
- package/dist/esm/newsletter/helper-code.d.ts +109 -0
- package/dist/esm/newsletter/helper-code.d.ts.map +1 -0
- package/dist/esm/newsletter/helper-code.js +656 -0
- package/dist/esm/newsletter/helper-code.js.map +1 -0
- package/dist/esm/newsletter/newsletter-scope.d.ts +65 -0
- package/dist/esm/newsletter/newsletter-scope.d.ts.map +1 -0
- package/dist/esm/newsletter/newsletter-scope.js +97 -0
- package/dist/esm/newsletter/newsletter-scope.js.map +1 -0
- package/dist/esm/newsletter/project-plugin.d.ts +18 -0
- package/dist/esm/newsletter/project-plugin.d.ts.map +1 -0
- package/dist/esm/newsletter/project-plugin.js +93 -0
- package/dist/esm/newsletter/project-plugin.js.map +1 -0
- package/dist/esm/newsletter/routes-code.d.ts +6 -0
- package/dist/esm/newsletter/routes-code.d.ts.map +1 -0
- package/dist/esm/newsletter/routes-code.js +175 -0
- package/dist/esm/newsletter/routes-code.js.map +1 -0
- package/dist/esm/nodes/account/account-compare-passwords.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-compare-passwords.js +16 -6
- package/dist/esm/nodes/account/account-compare-passwords.js.map +1 -1
- package/dist/esm/nodes/account/account-delete-current.js +1 -1
- package/dist/esm/nodes/account/account-get-current.js +1 -1
- package/dist/esm/nodes/account/account-hash-password.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-hash-password.js +15 -3
- package/dist/esm/nodes/account/account-hash-password.js.map +1 -1
- package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-login.js +23 -6
- package/dist/esm/nodes/account/account-login.js.map +1 -1
- package/dist/esm/nodes/account/account-logout.js +1 -1
- package/dist/esm/nodes/account/account-refresh-session.js +1 -1
- package/dist/esm/nodes/account/account-request-password-reset.d.ts +3 -0
- package/dist/esm/nodes/account/account-request-password-reset.d.ts.map +1 -0
- package/dist/esm/nodes/account/account-request-password-reset.js +43 -0
- package/dist/esm/nodes/account/account-request-password-reset.js.map +1 -0
- package/dist/esm/nodes/account/account-reset-password.d.ts +3 -0
- package/dist/esm/nodes/account/account-reset-password.d.ts.map +1 -0
- package/dist/esm/nodes/account/account-reset-password.js +41 -0
- package/dist/esm/nodes/account/account-reset-password.js.map +1 -0
- package/dist/esm/nodes/account/account-signup.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-signup.js +82 -19
- package/dist/esm/nodes/account/account-signup.js.map +1 -1
- package/dist/esm/nodes/account/account-social-login.js +1 -1
- package/dist/esm/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-custom-prompt.js +20 -5
- package/dist/esm/nodes/ai/ai-custom-prompt.js.map +1 -1
- package/dist/esm/nodes/ai/ai-detect-language.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-detect-language.js +20 -5
- package/dist/esm/nodes/ai/ai-detect-language.js.map +1 -1
- package/dist/esm/nodes/ai/ai-generate-text-embedding.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-generate-text-embedding.js +10 -2
- package/dist/esm/nodes/ai/ai-generate-text-embedding.js.map +1 -1
- package/dist/esm/nodes/ai/ai-provider-utils-sources.generated.js +2 -2
- package/dist/esm/nodes/ai/ai-provider-utils-sources.generated.js.map +1 -1
- package/dist/esm/nodes/ai/ai-provider-utils.d.ts +31 -2
- package/dist/esm/nodes/ai/ai-provider-utils.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-provider-utils.js +124 -10
- package/dist/esm/nodes/ai/ai-provider-utils.js.map +1 -1
- package/dist/esm/nodes/ai/ai-select-database-data.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-select-database-data.js +6 -2
- package/dist/esm/nodes/ai/ai-select-database-data.js.map +1 -1
- package/dist/esm/nodes/ai/ai-sentiment-analysis.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-sentiment-analysis.js +20 -5
- package/dist/esm/nodes/ai/ai-sentiment-analysis.js.map +1 -1
- package/dist/esm/nodes/ai/ai-summarization.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-summarization.js +20 -5
- package/dist/esm/nodes/ai/ai-summarization.js.map +1 -1
- package/dist/esm/nodes/ai/ai-text-classifier.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-text-classifier.js +20 -5
- package/dist/esm/nodes/ai/ai-text-classifier.js.map +1 -1
- package/dist/esm/nodes/ai/ai-text-transform.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-text-transform.js +20 -5
- package/dist/esm/nodes/ai/ai-text-transform.js.map +1 -1
- package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts +3 -0
- package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts.map +1 -0
- package/dist/esm/nodes/analytics/analytics-track-outcome.js +107 -0
- package/dist/esm/nodes/analytics/analytics-track-outcome.js.map +1 -0
- package/dist/esm/nodes/cart/cart-price-order.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-price-order.js +161 -2
- package/dist/esm/nodes/cart/cart-price-order.js.map +1 -1
- package/dist/esm/nodes/cost-bearing-node-types.d.ts +4 -0
- package/dist/esm/nodes/cost-bearing-node-types.d.ts.map +1 -0
- package/dist/esm/nodes/cost-bearing-node-types.js +39 -0
- package/dist/esm/nodes/cost-bearing-node-types.js.map +1 -0
- package/dist/esm/nodes/data/data-select.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-select.js +12 -2
- package/dist/esm/nodes/data/data-select.js.map +1 -1
- package/dist/esm/nodes/email/email-mailersend.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-mailersend.js +15 -11
- package/dist/esm/nodes/email/email-mailersend.js.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.js +15 -15
- package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
- package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-postmark.js +15 -15
- package/dist/esm/nodes/email/email-postmark.js.map +1 -1
- package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-resend.js +15 -15
- package/dist/esm/nodes/email/email-resend.js.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.js +15 -15
- package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/esm/nodes/email/html-escape-code.d.ts +3 -0
- package/dist/esm/nodes/email/html-escape-code.d.ts.map +1 -0
- package/dist/esm/nodes/email/html-escape-code.js +18 -0
- package/dist/esm/nodes/email/html-escape-code.js.map +1 -0
- package/dist/esm/nodes/email/message-trust.d.ts +40 -0
- package/dist/esm/nodes/email/message-trust.d.ts.map +1 -0
- package/dist/esm/nodes/email/message-trust.js +161 -0
- package/dist/esm/nodes/email/message-trust.js.map +1 -0
- package/dist/esm/nodes/email/outbound-message-guard.d.ts +113 -0
- package/dist/esm/nodes/email/outbound-message-guard.d.ts.map +1 -0
- package/dist/esm/nodes/email/outbound-message-guard.js +452 -0
- package/dist/esm/nodes/email/outbound-message-guard.js.map +1 -0
- package/dist/esm/nodes/email/trusted-html-code.d.ts +22 -0
- package/dist/esm/nodes/email/trusted-html-code.d.ts.map +1 -0
- package/dist/esm/nodes/email/trusted-html-code.js +49 -0
- package/dist/esm/nodes/email/trusted-html-code.js.map +1 -0
- package/dist/esm/nodes/general/general-custom-js.d.ts.map +1 -1
- package/dist/esm/nodes/general/general-custom-js.js +44 -7
- package/dist/esm/nodes/general/general-custom-js.js.map +1 -1
- package/dist/esm/nodes/general/general-http-request.d.ts.map +1 -1
- package/dist/esm/nodes/general/general-http-request.js +431 -45
- package/dist/esm/nodes/general/general-http-request.js.map +1 -1
- package/dist/esm/nodes/general/general-rate-limiter.d.ts.map +1 -1
- package/dist/esm/nodes/general/general-rate-limiter.js +46 -78
- package/dist/esm/nodes/general/general-rate-limiter.js.map +1 -1
- package/dist/esm/nodes/general/general-wait.d.ts +3 -0
- package/dist/esm/nodes/general/general-wait.d.ts.map +1 -0
- package/dist/esm/nodes/general/general-wait.js +78 -0
- package/dist/esm/nodes/general/general-wait.js.map +1 -0
- package/dist/esm/nodes/general/visitor-request.d.ts +44 -0
- package/dist/esm/nodes/general/visitor-request.d.ts.map +1 -0
- package/dist/esm/nodes/general/visitor-request.js +65 -0
- package/dist/esm/nodes/general/visitor-request.js.map +1 -0
- package/dist/esm/nodes/index.d.ts.map +1 -1
- package/dist/esm/nodes/index.js +7 -1
- package/dist/esm/nodes/index.js.map +1 -1
- package/dist/esm/nodes/integrations/integration-slack.d.ts.map +1 -1
- package/dist/esm/nodes/integrations/integration-slack.js +17 -6
- package/dist/esm/nodes/integrations/integration-slack.js.map +1 -1
- package/dist/esm/nodes/newsletter/newsletter-confirm.d.ts +3 -0
- package/dist/esm/nodes/newsletter/newsletter-confirm.d.ts.map +1 -0
- package/dist/esm/nodes/newsletter/newsletter-confirm.js +38 -0
- package/dist/esm/nodes/newsletter/newsletter-confirm.js.map +1 -0
- package/dist/esm/nodes/newsletter/newsletter-subscribe.d.ts +7 -0
- package/dist/esm/nodes/newsletter/newsletter-subscribe.d.ts.map +1 -0
- package/dist/esm/nodes/newsletter/newsletter-subscribe.js +66 -0
- package/dist/esm/nodes/newsletter/newsletter-subscribe.js.map +1 -0
- package/dist/esm/nodes/newsletter/newsletter-unsubscribe.d.ts +3 -0
- package/dist/esm/nodes/newsletter/newsletter-unsubscribe.d.ts.map +1 -0
- package/dist/esm/nodes/newsletter/newsletter-unsubscribe.js +39 -0
- package/dist/esm/nodes/newsletter/newsletter-unsubscribe.js.map +1 -0
- package/dist/esm/nodes/realtime/realtime-join-channel.d.ts.map +1 -1
- package/dist/esm/nodes/realtime/realtime-join-channel.js +5 -4
- package/dist/esm/nodes/realtime/realtime-join-channel.js.map +1 -1
- package/dist/esm/nodes/realtime/realtime-send-channel-event.d.ts.map +1 -1
- package/dist/esm/nodes/realtime/realtime-send-channel-event.js +11 -2
- package/dist/esm/nodes/realtime/realtime-send-channel-event.js.map +1 -1
- package/dist/esm/nodes/realtime/realtime-send-channel-message.d.ts.map +1 -1
- package/dist/esm/nodes/realtime/realtime-send-channel-message.js +11 -2
- package/dist/esm/nodes/realtime/realtime-send-channel-message.js.map +1 -1
- package/dist/esm/nodes/sms/sms-infobip.d.ts.map +1 -1
- package/dist/esm/nodes/sms/sms-infobip.js +11 -8
- package/dist/esm/nodes/sms/sms-infobip.js.map +1 -1
- package/dist/esm/nodes/sms/sms-smsapi.d.ts.map +1 -1
- package/dist/esm/nodes/sms/sms-smsapi.js +11 -8
- package/dist/esm/nodes/sms/sms-smsapi.js.map +1 -1
- package/dist/esm/nodes/sms/sms-textmagic.d.ts.map +1 -1
- package/dist/esm/nodes/sms/sms-textmagic.js +11 -8
- package/dist/esm/nodes/sms/sms-textmagic.js.map +1 -1
- package/dist/esm/nodes/sms/sms-twilio.d.ts.map +1 -1
- package/dist/esm/nodes/sms/sms-twilio.js +12 -10
- package/dist/esm/nodes/sms/sms-twilio.js.map +1 -1
- package/dist/esm/nodes/types.d.ts +8 -1
- package/dist/esm/nodes/types.d.ts.map +1 -1
- package/dist/esm/nodes/utility/utility-similarity-scoring.d.ts.map +1 -1
- package/dist/esm/nodes/utility/utility-similarity-scoring.js +29 -10
- package/dist/esm/nodes/utility/utility-similarity-scoring.js.map +1 -1
- package/dist/esm/page-access-guard-generator.d.ts +23 -0
- package/dist/esm/page-access-guard-generator.d.ts.map +1 -0
- package/dist/esm/page-access-guard-generator.js +131 -0
- package/dist/esm/page-access-guard-generator.js.map +1 -0
- package/dist/esm/password-reset-route-generator.d.ts +119 -0
- package/dist/esm/password-reset-route-generator.d.ts.map +1 -0
- package/dist/esm/password-reset-route-generator.js +448 -0
- package/dist/esm/password-reset-route-generator.js.map +1 -0
- package/dist/esm/payments/payment-core-code.d.ts.map +1 -1
- package/dist/esm/payments/payment-core-code.js +12 -7
- package/dist/esm/payments/payment-core-code.js.map +1 -1
- package/dist/esm/project-code-scanner.d.ts +11 -0
- package/dist/esm/project-code-scanner.d.ts.map +1 -0
- package/dist/esm/project-code-scanner.js +75 -0
- package/dist/esm/project-code-scanner.js.map +1 -0
- package/dist/esm/public-step-guards.d.ts +122 -0
- package/dist/esm/public-step-guards.d.ts.map +1 -0
- package/dist/esm/public-step-guards.js +280 -0
- package/dist/esm/public-step-guards.js.map +1 -0
- package/dist/esm/publish-refusals.d.ts +66 -0
- package/dist/esm/publish-refusals.d.ts.map +1 -0
- package/dist/esm/publish-refusals.js +271 -0
- package/dist/esm/publish-refusals.js.map +1 -0
- package/dist/esm/realtime-generator.d.ts +63 -1
- package/dist/esm/realtime-generator.d.ts.map +1 -1
- package/dist/esm/realtime-generator.js +401 -229
- package/dist/esm/realtime-generator.js.map +1 -1
- package/dist/esm/runtime-storage-generator.d.ts +32 -1
- package/dist/esm/runtime-storage-generator.d.ts.map +1 -1
- package/dist/esm/runtime-storage-generator.js +264 -11
- package/dist/esm/runtime-storage-generator.js.map +1 -1
- package/dist/esm/security-runtime/index.d.ts +14 -0
- package/dist/esm/security-runtime/index.d.ts.map +1 -0
- package/dist/esm/security-runtime/index.js +37 -0
- package/dist/esm/security-runtime/index.js.map +1 -0
- package/dist/esm/security-runtime/security-runtime-code.d.ts +53 -0
- package/dist/esm/security-runtime/security-runtime-code.d.ts.map +1 -0
- package/dist/esm/security-runtime/security-runtime-code.js +575 -0
- package/dist/esm/security-runtime/security-runtime-code.js.map +1 -0
- package/dist/esm/security-scanner.d.ts +72 -3
- package/dist/esm/security-scanner.d.ts.map +1 -1
- package/dist/esm/security-scanner.js +217 -8
- package/dist/esm/security-scanner.js.map +1 -1
- package/dist/esm/segment-splitter.d.ts.map +1 -1
- package/dist/esm/segment-splitter.js +10 -2
- package/dist/esm/segment-splitter.js.map +1 -1
- package/dist/esm/segment-trust.d.ts +36 -0
- package/dist/esm/segment-trust.d.ts.map +1 -1
- package/dist/esm/segment-trust.js +146 -2
- package/dist/esm/segment-trust.js.map +1 -1
- package/dist/esm/self-call-headers.d.ts +31 -0
- package/dist/esm/self-call-headers.d.ts.map +1 -0
- package/dist/esm/self-call-headers.js +43 -0
- package/dist/esm/self-call-headers.js.map +1 -0
- package/dist/esm/sent-email-log/index.d.ts +7 -0
- package/dist/esm/sent-email-log/index.d.ts.map +1 -1
- package/dist/esm/sent-email-log/index.js +15 -0
- package/dist/esm/sent-email-log/index.js.map +1 -1
- package/dist/esm/sent-email-log/sent-email-log-code.d.ts +7 -0
- package/dist/esm/sent-email-log/sent-email-log-code.d.ts.map +1 -1
- package/dist/esm/sent-email-log/sent-email-log-code.js +17 -3
- package/dist/esm/sent-email-log/sent-email-log-code.js.map +1 -1
- package/dist/esm/server-custom-node-call.d.ts +79 -0
- package/dist/esm/server-custom-node-call.d.ts.map +1 -0
- package/dist/esm/server-custom-node-call.js +278 -0
- package/dist/esm/server-custom-node-call.js.map +1 -0
- package/dist/esm/server-runtime-code.d.ts +39 -12
- package/dist/esm/server-runtime-code.d.ts.map +1 -1
- package/dist/esm/server-runtime-code.js +483 -40
- package/dist/esm/server-runtime-code.js.map +1 -1
- package/dist/esm/session-cookie-resolver.d.ts +3 -1
- package/dist/esm/session-cookie-resolver.d.ts.map +1 -1
- package/dist/esm/session-revocation-generator.d.ts +39 -0
- package/dist/esm/session-revocation-generator.d.ts.map +1 -0
- package/dist/esm/session-revocation-generator.js +172 -0
- package/dist/esm/session-revocation-generator.js.map +1 -0
- package/dist/esm/slack-message-values.d.ts +6 -0
- package/dist/esm/slack-message-values.d.ts.map +1 -0
- package/dist/esm/slack-message-values.js +68 -0
- package/dist/esm/slack-message-values.js.map +1 -0
- package/dist/esm/transactional-email-code.d.ts +11 -0
- package/dist/esm/transactional-email-code.d.ts.map +1 -1
- package/dist/esm/transactional-email-code.js +28 -4
- package/dist/esm/transactional-email-code.js.map +1 -1
- package/dist/esm/trigger-generator.d.ts +2 -0
- package/dist/esm/trigger-generator.d.ts.map +1 -1
- package/dist/esm/trigger-generator.js +15 -3
- package/dist/esm/trigger-generator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/visitor-step-limits.d.ts +66 -0
- package/dist/esm/visitor-step-limits.d.ts.map +1 -0
- package/dist/esm/visitor-step-limits.js +181 -0
- package/dist/esm/visitor-step-limits.js.map +1 -0
- package/dist/esm/webhook-generator.d.ts.map +1 -1
- package/dist/esm/webhook-generator.js +93 -15
- package/dist/esm/webhook-generator.js.map +1 -1
- package/dist/esm/webhook-signature-verification.d.ts.map +1 -1
- package/dist/esm/webhook-signature-verification.js +13 -3
- package/dist/esm/webhook-signature-verification.js.map +1 -1
- package/dist/esm/workflow-auth-generator.d.ts +4 -1
- package/dist/esm/workflow-auth-generator.d.ts.map +1 -1
- package/dist/esm/workflow-auth-generator.js +8 -3
- package/dist/esm/workflow-auth-generator.js.map +1 -1
- package/dist/esm/workflow-component-plugin.js +12 -4
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +306 -60
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/dist/esm/workflow-scope.d.ts.map +1 -1
- package/dist/esm/workflow-scope.js +5 -0
- package/dist/esm/workflow-scope.js.map +1 -1
- package/package.json +4 -4
- package/src/account-delete-route-generator.ts +28 -4
- package/src/api-route-generator.ts +712 -134
- package/src/auth-generator.ts +1471 -142
- package/src/automations/activity-kicks.ts +139 -0
- package/src/automations/app-events-source.ts +117 -0
- package/src/automations/automations-project-plugin.ts +202 -0
- package/src/automations/automations-scope.ts +196 -0
- package/src/automations/dispatcher-source.ts +1072 -0
- package/src/automations/event-bus-source.ts +428 -0
- package/src/automations/handler-module-generator.ts +107 -0
- package/src/automations/hydrators-source.ts +647 -0
- package/src/booking-calendar-feed-route-generator.ts +8 -0
- package/src/configured-nextauth-url.ts +17 -0
- package/src/cron-request-verification.ts +55 -5
- package/src/data-api-route-generator.ts +780 -43
- package/src/ecommerce/parameterized-stock-chain.ts +52 -5
- package/src/ecommerce/place-order-guards.ts +785 -0
- package/src/ecommerce-customhandler-rewriter.ts +55 -2
- package/src/executor-generator.ts +372 -60
- package/src/graph-utils.ts +33 -0
- package/src/index.ts +70 -0
- package/src/invoice/api-routes-code.ts +20 -7
- package/src/invoice/index.ts +11 -6
- package/src/invoice/invoice-assembly-code.ts +8 -3
- package/src/invoice/pdf-generator-code.ts +2 -1
- package/src/invoice/pdf-service-client-code.ts +15 -7
- package/src/newsletter/admin-route-code.ts +319 -0
- package/src/newsletter/helper-code.ts +748 -0
- package/src/newsletter/newsletter-scope.ts +116 -0
- package/src/newsletter/project-plugin.ts +119 -0
- package/src/newsletter/routes-code.ts +186 -0
- package/src/nodes/account/account-compare-passwords.ts +17 -6
- package/src/nodes/account/account-delete-current.ts +1 -1
- package/src/nodes/account/account-get-current.ts +1 -1
- package/src/nodes/account/account-hash-password.ts +19 -3
- package/src/nodes/account/account-login.ts +24 -7
- package/src/nodes/account/account-logout.ts +1 -1
- package/src/nodes/account/account-refresh-session.ts +1 -1
- package/src/nodes/account/account-request-password-reset.ts +47 -0
- package/src/nodes/account/account-reset-password.ts +45 -0
- package/src/nodes/account/account-signup.ts +86 -20
- package/src/nodes/account/account-social-login.ts +1 -1
- package/src/nodes/ai/ai-custom-prompt.ts +25 -6
- package/src/nodes/ai/ai-detect-language.ts +25 -6
- package/src/nodes/ai/ai-generate-text-embedding.ts +16 -3
- package/src/nodes/ai/ai-provider-utils-sources.generated.ts +2 -2
- package/src/nodes/ai/ai-provider-utils.ts +135 -10
- package/src/nodes/ai/ai-select-database-data.ts +8 -3
- package/src/nodes/ai/ai-sentiment-analysis.ts +25 -6
- package/src/nodes/ai/ai-summarization.ts +25 -6
- package/src/nodes/ai/ai-text-classifier.ts +25 -6
- package/src/nodes/ai/ai-text-transform.ts +25 -6
- package/src/nodes/analytics/analytics-track-outcome.ts +111 -0
- package/src/nodes/cart/cart-price-order.ts +200 -2
- package/src/nodes/cost-bearing-node-types.ts +45 -0
- package/src/nodes/data/data-select.ts +11 -2
- package/src/nodes/email/email-mailersend.ts +16 -8
- package/src/nodes/email/email-mailgun.ts +17 -12
- package/src/nodes/email/email-postmark.ts +17 -12
- package/src/nodes/email/email-resend.ts +17 -12
- package/src/nodes/email/email-sendgrid.ts +17 -12
- package/src/nodes/email/html-escape-code.ts +21 -0
- package/src/nodes/email/message-trust.ts +198 -0
- package/src/nodes/email/outbound-message-guard.ts +572 -0
- package/src/nodes/email/trusted-html-code.ts +49 -0
- package/src/nodes/general/general-custom-js.ts +46 -7
- package/src/nodes/general/general-http-request.ts +449 -46
- package/src/nodes/general/general-rate-limiter.ts +54 -81
- package/src/nodes/general/general-wait.ts +80 -0
- package/src/nodes/general/visitor-request.ts +79 -0
- package/src/nodes/index.ts +12 -0
- package/src/nodes/integrations/integration-slack.ts +21 -6
- package/src/nodes/newsletter/newsletter-confirm.ts +44 -0
- package/src/nodes/newsletter/newsletter-subscribe.ts +78 -0
- package/src/nodes/newsletter/newsletter-unsubscribe.ts +43 -0
- package/src/nodes/realtime/realtime-join-channel.ts +5 -4
- package/src/nodes/realtime/realtime-send-channel-event.ts +13 -2
- package/src/nodes/realtime/realtime-send-channel-message.ts +13 -2
- package/src/nodes/sms/sms-infobip.ts +14 -4
- package/src/nodes/sms/sms-smsapi.ts +14 -4
- package/src/nodes/sms/sms-textmagic.ts +14 -4
- package/src/nodes/sms/sms-twilio.ts +14 -4
- package/src/nodes/types.ts +9 -1
- package/src/nodes/utility/utility-similarity-scoring.ts +33 -12
- package/src/page-access-guard-generator.ts +154 -0
- package/src/password-reset-route-generator.ts +531 -0
- package/src/payments/payment-core-code.ts +12 -7
- package/src/project-code-scanner.ts +98 -0
- package/src/public-step-guards.ts +448 -0
- package/src/publish-refusals.ts +459 -0
- package/src/realtime-generator.ts +457 -234
- package/src/runtime-storage-generator.ts +270 -11
- package/src/security-runtime/index.ts +53 -0
- package/src/security-runtime/security-runtime-code.ts +579 -0
- package/src/security-scanner.ts +253 -10
- package/src/segment-splitter.ts +10 -2
- package/src/segment-trust.ts +205 -2
- package/src/self-call-headers.ts +46 -0
- package/src/sent-email-log/index.ts +16 -0
- package/src/sent-email-log/sent-email-log-code.ts +30 -4
- package/src/server-custom-node-call.ts +331 -0
- package/src/server-runtime-code.ts +488 -40
- package/src/session-revocation-generator.ts +177 -0
- package/src/slack-message-values.ts +84 -0
- package/src/transactional-email-code.ts +30 -4
- package/src/trigger-generator.ts +19 -3
- package/src/visitor-step-limits.ts +228 -0
- package/src/webhook-generator.ts +94 -14
- package/src/webhook-signature-verification.ts +13 -3
- package/src/workflow-auth-generator.ts +9 -4
- package/src/workflow-component-plugin.ts +14 -4
- package/src/workflow-project-plugin.ts +374 -61
- package/src/workflow-scope.ts +6 -0
|
@@ -0,0 +1,3820 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'crypto'
|
|
2
|
+
import * as nodePath from 'path'
|
|
3
|
+
import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'
|
|
4
|
+
import { PROTECTED_TABLES } from '@teleporthq/teleport-shared/src/utils/table-access'
|
|
5
|
+
import { generateDataSourceFetcherWithCore } from '../../teleport-plugin-next-data-source/src/data-source-fetchers'
|
|
6
|
+
import { FETCH_BACKED_NETWORK, FetchCall, withEnv, withFetch } from './_helpers/fake-fetch'
|
|
7
|
+
import { insertedLines } from './_helpers/inserted-lines'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The in-app event bus (spec §5.2): a project with an automation (a workflow
|
|
11
|
+
* started by `event-business-event`) gets the bus, the queue worker, one
|
|
12
|
+
* handler module per automation and the dispatch route; a project without one
|
|
13
|
+
* gets none of it, and nothing else in its output changes. The generated
|
|
14
|
+
* modules are EXECUTED against an in-memory Postgres that answers the
|
|
15
|
+
* statements they send, so the claim, the delivery ledger and the Wait are
|
|
16
|
+
* proven by what lands in the tables.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type Row = Record<string, unknown>
|
|
20
|
+
|
|
21
|
+
const DATA_API_ROUTE = 'pages/api/data/[...params].js'
|
|
22
|
+
|
|
23
|
+
interface GeneratedFile {
|
|
24
|
+
name: string
|
|
25
|
+
fileType?: string
|
|
26
|
+
content: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface GeneratedEntry {
|
|
30
|
+
path: string[]
|
|
31
|
+
files: GeneratedFile[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Structure {
|
|
35
|
+
uidl: Row & { globals: { env: Record<string, string> } }
|
|
36
|
+
files: Map<string, GeneratedEntry>
|
|
37
|
+
dependencies: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const NOW = Date.now()
|
|
41
|
+
const MINUTE_MS = 60 * 1000
|
|
42
|
+
const DAY_MS = 24 * 60 * MINUTE_MS
|
|
43
|
+
const ECHO = 'https://echo.test'
|
|
44
|
+
const trigger = (eventType: string) => ({
|
|
45
|
+
type: 'event-business-event',
|
|
46
|
+
nodeId: 't',
|
|
47
|
+
scope: 'global',
|
|
48
|
+
config: { eventType },
|
|
49
|
+
})
|
|
50
|
+
const ref = (nodeId: string, path: string[]) => ({ type: 'workflowContext', nodeId, path })
|
|
51
|
+
const post = (id: string, path: string, stepNumber: number, body: Row = {}) => ({
|
|
52
|
+
id,
|
|
53
|
+
type: 'general-http-request',
|
|
54
|
+
config: { url: ECHO + path, method: 'POST', body },
|
|
55
|
+
stepNumber,
|
|
56
|
+
label: 'Post ' + path,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const AUTOMATIONS: Record<string, Row> = {
|
|
60
|
+
'wf-paid-ok': {
|
|
61
|
+
id: 'wf-paid-ok',
|
|
62
|
+
name: 'Thank the customer',
|
|
63
|
+
trigger: trigger('order-paid'),
|
|
64
|
+
nodes: [
|
|
65
|
+
post('thanks', '/thanks', 1, {
|
|
66
|
+
email: ref('t', ['order', 'customer', 'email']),
|
|
67
|
+
number: ref('t', ['order', 'number']),
|
|
68
|
+
test: ref('t', ['event', 'test']),
|
|
69
|
+
// Recorded by the writer, read by no hydrator: kept.
|
|
70
|
+
giftMessage: ref('t', ['order', 'giftMessage']),
|
|
71
|
+
// Not a block of order-paid: a test that sends it never passes it on.
|
|
72
|
+
extra: ref('t', ['extra']),
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
75
|
+
edges: [{ id: 'e1', source: 't', target: 'thanks' }],
|
|
76
|
+
},
|
|
77
|
+
'wf-paid-fail': {
|
|
78
|
+
id: 'wf-paid-fail',
|
|
79
|
+
name: 'Tell the warehouse',
|
|
80
|
+
trigger: trigger('order-paid'),
|
|
81
|
+
nodes: [
|
|
82
|
+
post('warehouse', '/fails', 1, {
|
|
83
|
+
actor: ref('t', ['actor', 'email']),
|
|
84
|
+
email: ref('t', ['order', 'customer', 'email']),
|
|
85
|
+
}),
|
|
86
|
+
],
|
|
87
|
+
edges: [{ id: 'e1', source: 't', target: 'warehouse' }],
|
|
88
|
+
},
|
|
89
|
+
'wf-paid-off': {
|
|
90
|
+
id: 'wf-paid-off',
|
|
91
|
+
name: 'Switched off',
|
|
92
|
+
trigger: trigger('order-paid'),
|
|
93
|
+
nodes: [post('off', '/off', 1)],
|
|
94
|
+
edges: [{ id: 'e1', source: 't', target: 'off' }],
|
|
95
|
+
},
|
|
96
|
+
'wf-signup-wait': {
|
|
97
|
+
id: 'wf-signup-wait',
|
|
98
|
+
name: 'Welcome two days later',
|
|
99
|
+
trigger: trigger('user-signed-up'),
|
|
100
|
+
nodes: [
|
|
101
|
+
post('before', '/before', 1, { email: ref('t', ['user', 'email']) }),
|
|
102
|
+
{
|
|
103
|
+
id: 'wait',
|
|
104
|
+
type: 'general-wait',
|
|
105
|
+
config: { amount: 2, unit: 'days' },
|
|
106
|
+
stepNumber: 2,
|
|
107
|
+
label: 'Wait 2 days',
|
|
108
|
+
},
|
|
109
|
+
post('after', '/after', 3, {
|
|
110
|
+
email: ref('t', ['user', 'email']),
|
|
111
|
+
waitedUntil: ref('wait', ['until']),
|
|
112
|
+
}),
|
|
113
|
+
],
|
|
114
|
+
edges: [
|
|
115
|
+
{ id: 'e1', source: 't', target: 'before' },
|
|
116
|
+
{ id: 'e2', source: 'before', target: 'wait' },
|
|
117
|
+
{ id: 'e3', source: 'wait', target: 'after' },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
'wf-booking-slow': {
|
|
121
|
+
id: 'wf-booking-slow',
|
|
122
|
+
name: 'Slow booking notice',
|
|
123
|
+
trigger: trigger('booking-created'),
|
|
124
|
+
nodes: [post('hold', '/hold', 1)],
|
|
125
|
+
edges: [{ id: 'e1', source: 't', target: 'hold' }],
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The rest of the project: a page workflow with a server data read and a link
|
|
130
|
+
// to the order details page, a global browser workflow (so
|
|
131
|
+
// `global-workflows.js` exists) and a webhook.
|
|
132
|
+
const BASE_WORKFLOWS: Record<string, Row> = {
|
|
133
|
+
'wf-page': {
|
|
134
|
+
id: 'wf-page',
|
|
135
|
+
name: 'Load orders',
|
|
136
|
+
trigger: { type: 'event-page-loaded', nodeId: 't', scope: 'page', config: { allPages: true } },
|
|
137
|
+
nodes: [
|
|
138
|
+
{
|
|
139
|
+
id: 'select',
|
|
140
|
+
type: 'data-select',
|
|
141
|
+
config: { dataSourceId: 'ds-1', tableName: 'teleport_orders', filters: [] },
|
|
142
|
+
stepNumber: 1,
|
|
143
|
+
label: 'Orders',
|
|
144
|
+
},
|
|
145
|
+
{ id: 'set', type: 'state-update-local-state', config: {}, stepNumber: 2, label: 'Set' },
|
|
146
|
+
{
|
|
147
|
+
id: 'open',
|
|
148
|
+
type: 'navigation-go-to-page',
|
|
149
|
+
config: {
|
|
150
|
+
pageId: '/orders/[order_number]',
|
|
151
|
+
targetPage: {
|
|
152
|
+
isDetailsPage: true,
|
|
153
|
+
rowOwnerTable: 'teleport_orders',
|
|
154
|
+
staticUrl: '/orders',
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
stepNumber: 3,
|
|
158
|
+
label: 'Open the order',
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
edges: [
|
|
162
|
+
{ id: 'e1', source: 't', target: 'select' },
|
|
163
|
+
{ id: 'e2', source: 'select', target: 'set' },
|
|
164
|
+
{ id: 'e3', source: 'set', target: 'open' },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
'wf-logout': {
|
|
168
|
+
id: 'wf-logout',
|
|
169
|
+
name: 'Forget the cart',
|
|
170
|
+
trigger: { type: 'event-user-logged-out', nodeId: 't', scope: 'global', config: {} },
|
|
171
|
+
nodes: [
|
|
172
|
+
{
|
|
173
|
+
id: 'forget',
|
|
174
|
+
type: 'storage-local-remove',
|
|
175
|
+
config: { key: 'cart' },
|
|
176
|
+
stepNumber: 1,
|
|
177
|
+
label: 'Forget',
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
edges: [{ id: 'e1', source: 't', target: 'forget' }],
|
|
181
|
+
},
|
|
182
|
+
'wf-hook': {
|
|
183
|
+
id: 'wf-hook',
|
|
184
|
+
name: 'Orders hook',
|
|
185
|
+
trigger: { type: 'event-webhook-received', nodeId: 't', scope: 'global', config: {} },
|
|
186
|
+
nodes: [post('relay', '/relay', 1)],
|
|
187
|
+
edges: [{ id: 'e1', source: 't', target: 'relay' }],
|
|
188
|
+
webhookConfig: { httpMethod: 'POST', urlPath: 'webhooks/orders' },
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The data source the editor keeps the automation tables in (the GUI mapper's
|
|
193
|
+
// `automations`): the bus opens that database.
|
|
194
|
+
const TELEPORT_AUTOMATIONS = { dataSourceId: 'ds-1', dataSourceType: 'teleport' }
|
|
195
|
+
|
|
196
|
+
// The data-source modules the page plugins write before this plugin runs: a
|
|
197
|
+
// page's server read of the TeleportHQ database, an API route reading it, an
|
|
198
|
+
// API route that only re-exports a module, and another database's module.
|
|
199
|
+
const TELEPORT_SOURCE = { id: 'ds-1', type: 'teleport', name: 'Store', config: {} }
|
|
200
|
+
const OWN_POSTGRES = {
|
|
201
|
+
id: 'ds-2',
|
|
202
|
+
type: 'postgresql',
|
|
203
|
+
name: 'Own',
|
|
204
|
+
config: { connectionString: 'postgresql://u:p@db.test:5432/d' },
|
|
205
|
+
}
|
|
206
|
+
const TELEPORT_PRODUCTS_MODULE = 'utils/data-sources/teleport-products-ds-1.js'
|
|
207
|
+
const TELEPORT_ORDERS_ROUTE = 'pages/api/teleport-orders-ds-1.js'
|
|
208
|
+
const REEXPORT_ROUTE = 'pages/api/teleport-products-ds-1.js'
|
|
209
|
+
const OWN_ITEMS_MODULE = 'utils/data-sources/postgresql-items-ds-2.js'
|
|
210
|
+
const FETCHERS = (): Map<string, GeneratedEntry> => {
|
|
211
|
+
const entry = (path: string, content: string): [string, GeneratedEntry] => {
|
|
212
|
+
const parts = path.replace(/\.js$/, '').split('/')
|
|
213
|
+
const name = parts.pop() as string
|
|
214
|
+
return ['resource-' + path, { path: parts, files: [{ name, fileType: 'js', content }] }]
|
|
215
|
+
}
|
|
216
|
+
return new Map([
|
|
217
|
+
entry(
|
|
218
|
+
TELEPORT_PRODUCTS_MODULE,
|
|
219
|
+
generateDataSourceFetcherWithCore(TELEPORT_SOURCE as never, 'teleport_products', false)
|
|
220
|
+
),
|
|
221
|
+
entry(
|
|
222
|
+
TELEPORT_ORDERS_ROUTE,
|
|
223
|
+
generateDataSourceFetcherWithCore(TELEPORT_SOURCE as never, 'teleport_orders', true)
|
|
224
|
+
),
|
|
225
|
+
entry(
|
|
226
|
+
REEXPORT_ROUTE,
|
|
227
|
+
"import dataSourceModule from '../../utils/data-sources/teleport-products-ds-1'\n\nexport default dataSourceModule.handler\n"
|
|
228
|
+
),
|
|
229
|
+
entry(
|
|
230
|
+
OWN_ITEMS_MODULE,
|
|
231
|
+
generateDataSourceFetcherWithCore(OWN_POSTGRES as never, 'items', false)
|
|
232
|
+
),
|
|
233
|
+
])
|
|
234
|
+
}
|
|
235
|
+
const TELEPORT_READS = [TELEPORT_PRODUCTS_MODULE, TELEPORT_ORDERS_ROUTE]
|
|
236
|
+
|
|
237
|
+
const generate = async (
|
|
238
|
+
workflows: Record<string, Row>,
|
|
239
|
+
automations: Row | null = TELEPORT_AUTOMATIONS,
|
|
240
|
+
// What the other generators wrote before this plugin runs, and more of the UIDL.
|
|
241
|
+
extra: { files?: () => Map<string, GeneratedEntry>; uidl?: Row } = {}
|
|
242
|
+
): Promise<Structure> => {
|
|
243
|
+
const structure = {
|
|
244
|
+
uidl: {
|
|
245
|
+
name: 'Store',
|
|
246
|
+
globals: { settings: { title: 'Store', language: 'en' }, env: { NEXTAUTH_SECRET: 'k' } },
|
|
247
|
+
root: {
|
|
248
|
+
name: 'Root',
|
|
249
|
+
stateDefinitions: {
|
|
250
|
+
route: { type: 'string', defaultValue: 'Home', values: [{ value: 'Home' }] },
|
|
251
|
+
},
|
|
252
|
+
node: { type: 'element', content: { elementType: 'container', children: [] } },
|
|
253
|
+
},
|
|
254
|
+
components: {},
|
|
255
|
+
dataSources: { 'ds-1': { id: 'ds-1', type: 'teleport', name: 'Store', config: {} } },
|
|
256
|
+
...(automations && { automations }),
|
|
257
|
+
workflows: { workflows: JSON.parse(JSON.stringify(workflows)) },
|
|
258
|
+
...extra.uidl,
|
|
259
|
+
},
|
|
260
|
+
strategy: { pages: { options: {} } },
|
|
261
|
+
files: extra.files ? extra.files() : new Map<string, GeneratedEntry>(),
|
|
262
|
+
dependencies: {} as Record<string, string>,
|
|
263
|
+
devDependencies: {},
|
|
264
|
+
}
|
|
265
|
+
await new NextWorkflowProjectPlugin().runAfter(structure as never)
|
|
266
|
+
return structure as unknown as Structure
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const filePath = (entry: GeneratedEntry, file: GeneratedFile): string =>
|
|
270
|
+
[...entry.path, `${file.name}.${file.fileType || 'js'}`].join('/')
|
|
271
|
+
|
|
272
|
+
const sources = (structure: Structure): Map<string, string> => {
|
|
273
|
+
const files = new Map<string, string>()
|
|
274
|
+
for (const entry of structure.files.values()) {
|
|
275
|
+
for (const file of entry.files) {
|
|
276
|
+
files.set(filePath(entry, file), String(file.content))
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return files
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// An in-memory Postgres for the statements the bus sends
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
interface EventRow {
|
|
287
|
+
id: string
|
|
288
|
+
event_type: string
|
|
289
|
+
dedupe_key: string
|
|
290
|
+
order_id: string | null
|
|
291
|
+
entity_type: string | null
|
|
292
|
+
entity_id: string | null
|
|
293
|
+
payload: string | null
|
|
294
|
+
actor: string | null
|
|
295
|
+
actor_type: string
|
|
296
|
+
status: string
|
|
297
|
+
attempts: number
|
|
298
|
+
claimed_at?: Date | null
|
|
299
|
+
created_at: Date
|
|
300
|
+
/** Held by another dispatcher's open claim: SKIP LOCKED passes it over. */
|
|
301
|
+
locked?: boolean
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
interface AutomationRow {
|
|
305
|
+
workflow_id: string
|
|
306
|
+
event_type: string
|
|
307
|
+
enabled: boolean
|
|
308
|
+
enabled_at: Date | null
|
|
309
|
+
created_at: Date
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface DeliveryRow {
|
|
313
|
+
id: number
|
|
314
|
+
event_id: string
|
|
315
|
+
workflow_id: string
|
|
316
|
+
status: string
|
|
317
|
+
error: string | null
|
|
318
|
+
attempts: number
|
|
319
|
+
started_at: Date | null
|
|
320
|
+
finished_at: Date | null
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface WaitRow {
|
|
324
|
+
id: number
|
|
325
|
+
event_id: string
|
|
326
|
+
workflow_id: string
|
|
327
|
+
resume_node_id: string
|
|
328
|
+
context: string | null
|
|
329
|
+
run_at: Date
|
|
330
|
+
status: string
|
|
331
|
+
created_at: Date
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const STALE_MS = 15 * MINUTE_MS
|
|
335
|
+
|
|
336
|
+
class FakeDatabase {
|
|
337
|
+
events: EventRow[] = []
|
|
338
|
+
automations: AutomationRow[] = []
|
|
339
|
+
deliveries: DeliveryRow[] = []
|
|
340
|
+
waits: WaitRow[] = []
|
|
341
|
+
orders: Row[] = []
|
|
342
|
+
orderItems: Row[] = []
|
|
343
|
+
users: Row[] = []
|
|
344
|
+
bookings: Row[] = []
|
|
345
|
+
sentEmails: Row[] = []
|
|
346
|
+
mailSent: Row[] = []
|
|
347
|
+
reviews: Row[] = []
|
|
348
|
+
waitlistEntries: Row[] = []
|
|
349
|
+
waitlists: Row[] = []
|
|
350
|
+
statements: string[] = []
|
|
351
|
+
/** What every database client was opened with. */
|
|
352
|
+
clientOptions: Row[] = []
|
|
353
|
+
/** Answers a read of teleport_orders with this error instead. */
|
|
354
|
+
ordersError: (Error & { code?: string }) | null = null
|
|
355
|
+
products: Row[] = []
|
|
356
|
+
/** Runs right after a claim is answered (an owner switching automations off meanwhile). */
|
|
357
|
+
afterClaim: (() => void) | null = null
|
|
358
|
+
/** The signed requests the dispatch route took: key -> expires_at (unix seconds). */
|
|
359
|
+
signedRequests = new Map<string, number>()
|
|
360
|
+
/** Fails every statement on the signed-request table. */
|
|
361
|
+
signedRequestsDown = false
|
|
362
|
+
private serial = 0
|
|
363
|
+
|
|
364
|
+
query(sql: string, values: unknown[] = []): { rows: Row[]; rowCount?: number } {
|
|
365
|
+
const text = sql.trim()
|
|
366
|
+
this.statements.push(text)
|
|
367
|
+
const event = (id: unknown) => this.events.find((candidate) => candidate.id === id)
|
|
368
|
+
if (text === 'BEGIN' || text === 'COMMIT' || text === 'ROLLBACK') {
|
|
369
|
+
return { rows: [] }
|
|
370
|
+
}
|
|
371
|
+
if (text.includes('teleport_automation_requests') || text.includes('pg_advisory_xact_lock')) {
|
|
372
|
+
if (this.signedRequestsDown) {
|
|
373
|
+
throw new Error('connection terminated')
|
|
374
|
+
}
|
|
375
|
+
if (text === 'SELECT pg_advisory_xact_lock(hashtext($1))') {
|
|
376
|
+
return { rows: [] }
|
|
377
|
+
}
|
|
378
|
+
if (
|
|
379
|
+
text ===
|
|
380
|
+
'CREATE TABLE IF NOT EXISTS teleport_automation_requests (request_key TEXT PRIMARY KEY, expires_at BIGINT NOT NULL)' ||
|
|
381
|
+
text ===
|
|
382
|
+
'CREATE INDEX IF NOT EXISTS teleport_automation_requests_expires_at ON teleport_automation_requests (expires_at)'
|
|
383
|
+
) {
|
|
384
|
+
return { rows: [] }
|
|
385
|
+
}
|
|
386
|
+
if (
|
|
387
|
+
text ===
|
|
388
|
+
'DELETE FROM teleport_automation_requests WHERE request_key IN (SELECT request_key FROM teleport_automation_requests WHERE expires_at < $1 LIMIT 1000)'
|
|
389
|
+
) {
|
|
390
|
+
this.signedRequests.forEach((expiresAt, key) => {
|
|
391
|
+
if (expiresAt < Number(values[0])) {
|
|
392
|
+
this.signedRequests.delete(key)
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
return { rows: [] }
|
|
396
|
+
}
|
|
397
|
+
if (
|
|
398
|
+
text ===
|
|
399
|
+
'INSERT INTO teleport_automation_requests (request_key, expires_at) VALUES ($1, $2) ON CONFLICT (request_key) DO NOTHING RETURNING request_key'
|
|
400
|
+
) {
|
|
401
|
+
const [key, expiresAt] = values as [string, number]
|
|
402
|
+
if (this.signedRequests.has(key)) {
|
|
403
|
+
return { rows: [] }
|
|
404
|
+
}
|
|
405
|
+
this.signedRequests.set(key, expiresAt)
|
|
406
|
+
return { rows: [{ request_key: key }] }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (text.startsWith('INSERT INTO teleport_events (event_type, dedupe_key, order_id')) {
|
|
410
|
+
const [type, dedupeKey, orderId, entityType, entityId, payload, actor, actorType] = values
|
|
411
|
+
if (this.events.some((candidate) => candidate.dedupe_key === dedupeKey)) {
|
|
412
|
+
return { rows: [] }
|
|
413
|
+
}
|
|
414
|
+
const id = this.newEvent({
|
|
415
|
+
event_type: String(type),
|
|
416
|
+
dedupe_key: String(dedupeKey),
|
|
417
|
+
order_id: (orderId as string) || null,
|
|
418
|
+
entity_type: (entityType as string) || null,
|
|
419
|
+
entity_id: (entityId as string) || null,
|
|
420
|
+
payload: (payload as string) || null,
|
|
421
|
+
actor: (actor as string) || null,
|
|
422
|
+
actor_type: String(actorType || 'system'),
|
|
423
|
+
})
|
|
424
|
+
return { rows: [{ id }] }
|
|
425
|
+
}
|
|
426
|
+
if (text.startsWith('INSERT INTO teleport_events (event_type, dedupe_key, payload')) {
|
|
427
|
+
const [type, dedupeKey, payload, actor, actorType] = values
|
|
428
|
+
const id = this.newEvent({
|
|
429
|
+
event_type: String(type),
|
|
430
|
+
dedupe_key: String(dedupeKey),
|
|
431
|
+
payload: (payload as string) || null,
|
|
432
|
+
actor: (actor as string) || null,
|
|
433
|
+
actor_type: String(actorType || 'system'),
|
|
434
|
+
status: 'test',
|
|
435
|
+
})
|
|
436
|
+
return { rows: [{ id, created_at: event(id)!.created_at }] }
|
|
437
|
+
}
|
|
438
|
+
if (text.includes('FROM teleport_events e') && text.includes('FOR UPDATE SKIP LOCKED')) {
|
|
439
|
+
const [types, workflowIds] = values as string[][]
|
|
440
|
+
const now = Date.now()
|
|
441
|
+
// Answered as the statement asks: the clauses below are read off its text.
|
|
442
|
+
const staleCountsDeliveries = text.includes(
|
|
443
|
+
'GREATEST(COALESCE(e.claimed_at, e.created_at), COALESCE((SELECT max(d.started_at)'
|
|
444
|
+
)
|
|
445
|
+
const settlesExhausted = text.includes("OR (e.status = 'pending' AND e.attempts >= 5")
|
|
446
|
+
const sparesWaiting = text.includes("d.status = 'waiting'")
|
|
447
|
+
// Only a run parked by an automation of this build that is on holds the event.
|
|
448
|
+
const sparesOnlyLiveWaits = text.includes(
|
|
449
|
+
"d.status = 'waiting' AND d.workflow_id = ANY($2::text[]) AND EXISTS (SELECT 1 FROM teleport_automations a WHERE a.workflow_id = d.workflow_id AND a.enabled)"
|
|
450
|
+
)
|
|
451
|
+
const holdsEvent = (d: DeliveryRow) =>
|
|
452
|
+
!sparesOnlyLiveWaits ||
|
|
453
|
+
(workflowIds.includes(d.workflow_id) &&
|
|
454
|
+
this.automations.some((a) => a.workflow_id === d.workflow_id && a.enabled))
|
|
455
|
+
// Untouched for the stale window: its claim, and (as the claim asks) the
|
|
456
|
+
// latest start of one of its deliveries.
|
|
457
|
+
const stale = (candidate: EventRow) =>
|
|
458
|
+
!(
|
|
459
|
+
sparesWaiting &&
|
|
460
|
+
this.deliveries.some(
|
|
461
|
+
(d) => d.event_id === candidate.id && d.status === 'waiting' && holdsEvent(d)
|
|
462
|
+
)
|
|
463
|
+
) &&
|
|
464
|
+
Math.max(
|
|
465
|
+
(candidate.claimed_at || candidate.created_at).getTime(),
|
|
466
|
+
...(staleCountsDeliveries
|
|
467
|
+
? this.deliveries
|
|
468
|
+
.filter((d) => d.event_id === candidate.id && d.started_at)
|
|
469
|
+
.map((d) => d.started_at!.getTime())
|
|
470
|
+
: [])
|
|
471
|
+
) <
|
|
472
|
+
now - STALE_MS
|
|
473
|
+
const due = this.events.filter(
|
|
474
|
+
(candidate) =>
|
|
475
|
+
!candidate.locked &&
|
|
476
|
+
types.includes(candidate.event_type) &&
|
|
477
|
+
((candidate.attempts < 5 &&
|
|
478
|
+
((candidate.status === 'recorded' &&
|
|
479
|
+
candidate.created_at.getTime() > now - 3 * DAY_MS) ||
|
|
480
|
+
candidate.status === 'failed' ||
|
|
481
|
+
(candidate.status === 'pending' && stale(candidate))) &&
|
|
482
|
+
this.enabledFor(candidate, workflowIds).length > 0) ||
|
|
483
|
+
(settlesExhausted &&
|
|
484
|
+
candidate.status === 'pending' &&
|
|
485
|
+
candidate.attempts >= 5 &&
|
|
486
|
+
stale(candidate)))
|
|
487
|
+
)
|
|
488
|
+
due.sort(
|
|
489
|
+
(a, b) =>
|
|
490
|
+
Number(a.status !== 'recorded') - Number(b.status !== 'recorded') ||
|
|
491
|
+
a.created_at.getTime() - b.created_at.getTime()
|
|
492
|
+
)
|
|
493
|
+
const claimed = due.slice(0, 20).map((candidate) => ({ ...candidate }))
|
|
494
|
+
if (this.afterClaim) {
|
|
495
|
+
this.afterClaim()
|
|
496
|
+
}
|
|
497
|
+
return { rows: claimed }
|
|
498
|
+
}
|
|
499
|
+
// The claim's later writes hold only while it is still the claim.
|
|
500
|
+
const holdsClaim = (target: EventRow | undefined, claimedAt: unknown): target is EventRow =>
|
|
501
|
+
!!target &&
|
|
502
|
+
target.status === 'pending' &&
|
|
503
|
+
!!target.claimed_at &&
|
|
504
|
+
claimedAt instanceof Date &&
|
|
505
|
+
target.claimed_at.getTime() === claimedAt.getTime()
|
|
506
|
+
if (text.startsWith("UPDATE teleport_events SET status = 'pending', claimed_at = date_trunc")) {
|
|
507
|
+
const marked: Row[] = []
|
|
508
|
+
for (const candidate of this.events) {
|
|
509
|
+
if ((values[0] as string[]).includes(candidate.id)) {
|
|
510
|
+
candidate.status = 'pending'
|
|
511
|
+
candidate.claimed_at = new Date()
|
|
512
|
+
// An attempt counted by the claim itself (it must not be: see the time budget).
|
|
513
|
+
if (text.includes('attempts = attempts + 1')) {
|
|
514
|
+
candidate.attempts += 1
|
|
515
|
+
}
|
|
516
|
+
marked.push({ id: candidate.id, claimed_at: candidate.claimed_at })
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { rows: marked }
|
|
520
|
+
}
|
|
521
|
+
if (text.startsWith('UPDATE teleport_events SET attempts = attempts + 1')) {
|
|
522
|
+
const target = event(values[0])
|
|
523
|
+
if (holdsClaim(target, values[1])) {
|
|
524
|
+
target.attempts += 1
|
|
525
|
+
}
|
|
526
|
+
return { rows: [] }
|
|
527
|
+
}
|
|
528
|
+
if (text.startsWith("UPDATE teleport_events SET status = 'failed', attempts = attempts + 1")) {
|
|
529
|
+
const target = event(values[0])
|
|
530
|
+
if (holdsClaim(target, values[1])) {
|
|
531
|
+
target.status = 'failed'
|
|
532
|
+
target.attempts += 1
|
|
533
|
+
}
|
|
534
|
+
return { rows: [] }
|
|
535
|
+
}
|
|
536
|
+
if (text.startsWith('UPDATE teleport_events SET status = $2')) {
|
|
537
|
+
const target = event(values[0])
|
|
538
|
+
// Guarded by the claim when the statement names it.
|
|
539
|
+
const guarded = text.includes("AND status = 'pending' AND claimed_at = $3")
|
|
540
|
+
if (target && (guarded ? holdsClaim(target, values[2]) : true)) {
|
|
541
|
+
target.status = String(values[1])
|
|
542
|
+
}
|
|
543
|
+
return { rows: [] }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (text.includes('FROM teleport_automations a JOIN teleport_events e')) {
|
|
547
|
+
const target = event(values[0])
|
|
548
|
+
return {
|
|
549
|
+
rows: target
|
|
550
|
+
? this.enabledFor(target, values[1] as string[]).map((a) => ({
|
|
551
|
+
workflow_id: a.workflow_id,
|
|
552
|
+
}))
|
|
553
|
+
: [],
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (
|
|
557
|
+
text.startsWith('INSERT INTO teleport_event_deliveries AS d') &&
|
|
558
|
+
text.includes("VALUES ($1, $2, 'failed', 0, $3")
|
|
559
|
+
) {
|
|
560
|
+
// A refusal: the automation never ran; its delivery says why.
|
|
561
|
+
const [eventId, workflowId, error] = values as string[]
|
|
562
|
+
const existing = this.deliveries.find(
|
|
563
|
+
(d) => d.event_id === eventId && d.workflow_id === workflowId
|
|
564
|
+
)
|
|
565
|
+
if (!existing) {
|
|
566
|
+
this.deliveries.push({
|
|
567
|
+
id: ++this.serial,
|
|
568
|
+
event_id: eventId,
|
|
569
|
+
workflow_id: workflowId,
|
|
570
|
+
status: 'failed',
|
|
571
|
+
error,
|
|
572
|
+
attempts: 0,
|
|
573
|
+
started_at: new Date(),
|
|
574
|
+
finished_at: new Date(),
|
|
575
|
+
})
|
|
576
|
+
} else if (existing.status === 'failed') {
|
|
577
|
+
existing.error = error
|
|
578
|
+
}
|
|
579
|
+
return { rows: [] }
|
|
580
|
+
}
|
|
581
|
+
if (text.startsWith('INSERT INTO teleport_event_deliveries AS d')) {
|
|
582
|
+
const [eventId, workflowId] = values as string[]
|
|
583
|
+
const existing = this.deliveries.find(
|
|
584
|
+
(d) => d.event_id === eventId && d.workflow_id === workflowId
|
|
585
|
+
)
|
|
586
|
+
if (!existing) {
|
|
587
|
+
return { rows: [this.newDelivery(eventId, workflowId)] }
|
|
588
|
+
}
|
|
589
|
+
const stale =
|
|
590
|
+
existing.status === 'running' &&
|
|
591
|
+
!!existing.started_at &&
|
|
592
|
+
existing.started_at.getTime() < Date.now() - STALE_MS
|
|
593
|
+
if (existing.status !== 'failed' && !stale) {
|
|
594
|
+
return { rows: [] }
|
|
595
|
+
}
|
|
596
|
+
existing.status = 'running'
|
|
597
|
+
existing.attempts += 1
|
|
598
|
+
existing.error = null
|
|
599
|
+
existing.started_at = new Date()
|
|
600
|
+
existing.finished_at = null
|
|
601
|
+
return { rows: [{ id: existing.id, attempts: existing.attempts }] }
|
|
602
|
+
}
|
|
603
|
+
if (text.startsWith('INSERT INTO teleport_event_deliveries (event_id')) {
|
|
604
|
+
const [eventId, workflowId] = values as string[]
|
|
605
|
+
return { rows: [this.newDelivery(eventId, workflowId)] }
|
|
606
|
+
}
|
|
607
|
+
if (text.startsWith('WITH w AS (UPDATE teleport_automation_waits SET status = $1')) {
|
|
608
|
+
// A resume's outcome, as one statement: the Wait under its claim token,
|
|
609
|
+
// and its delivery only when that matched.
|
|
610
|
+
const [waitStatus, waitId, runAt, status, error, startedAt, eventId, workflowId] = values
|
|
611
|
+
const wait = this.waits.find((w) => w.id === waitId)
|
|
612
|
+
const held =
|
|
613
|
+
!!wait && wait.status === 'resuming' && wait.run_at.getTime() === (runAt as Date).getTime()
|
|
614
|
+
if (!held) {
|
|
615
|
+
return { rows: [], rowCount: 0 }
|
|
616
|
+
}
|
|
617
|
+
wait!.status = String(waitStatus)
|
|
618
|
+
let rowCount = 0
|
|
619
|
+
for (const d of this.deliveries) {
|
|
620
|
+
if (d.event_id === eventId && d.workflow_id === workflowId && d.status === 'waiting') {
|
|
621
|
+
d.status = String(status)
|
|
622
|
+
d.error = (error as string) || null
|
|
623
|
+
d.started_at = startedAt as Date
|
|
624
|
+
d.finished_at = new Date()
|
|
625
|
+
rowCount += 1
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return { rows: [], rowCount }
|
|
629
|
+
}
|
|
630
|
+
if (text.startsWith('INSERT INTO "teleport_sent_emails"')) {
|
|
631
|
+
const columns = (text.match(/\(([^)]*)\) VALUES/) || ['', ''])[1]
|
|
632
|
+
.split(',')
|
|
633
|
+
.map((column) => column.trim().replace(/"/g, ''))
|
|
634
|
+
this.sentEmails.push(Object.fromEntries(columns.map((column, i) => [column, values[i]])))
|
|
635
|
+
return { rows: [] }
|
|
636
|
+
}
|
|
637
|
+
if (
|
|
638
|
+
text.startsWith("UPDATE teleport_event_deliveries SET status = 'failed', error = 'Timed out'")
|
|
639
|
+
) {
|
|
640
|
+
for (const d of this.deliveries) {
|
|
641
|
+
if (
|
|
642
|
+
d.event_id === values[0] &&
|
|
643
|
+
d.status === 'running' &&
|
|
644
|
+
d.started_at!.getTime() < Date.now() - STALE_MS
|
|
645
|
+
) {
|
|
646
|
+
d.status = 'failed'
|
|
647
|
+
d.error = 'Timed out'
|
|
648
|
+
d.finished_at = new Date()
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return { rows: [] }
|
|
652
|
+
}
|
|
653
|
+
if (text.startsWith('UPDATE teleport_event_deliveries')) {
|
|
654
|
+
const delivery = this.deliveries.find((d) => d.id === values[0])
|
|
655
|
+
// Only while the same claim (its attempt number) holds the row.
|
|
656
|
+
const guarded = text.includes("AND status = 'running' AND attempts = $4")
|
|
657
|
+
if (delivery && guarded && delivery.status === 'running' && delivery.attempts === values[3]) {
|
|
658
|
+
delivery.status = String(values[1])
|
|
659
|
+
delivery.error = (values[2] as string) || null
|
|
660
|
+
delivery.finished_at = new Date()
|
|
661
|
+
return { rows: [], rowCount: 1 }
|
|
662
|
+
}
|
|
663
|
+
return { rows: [], rowCount: 0 }
|
|
664
|
+
}
|
|
665
|
+
if (text.startsWith('SELECT workflow_id, status FROM teleport_event_deliveries')) {
|
|
666
|
+
return {
|
|
667
|
+
rows: this.deliveries
|
|
668
|
+
.filter((d) => d.event_id === values[0])
|
|
669
|
+
.map((d) => ({ workflow_id: d.workflow_id, status: d.status })),
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (text.startsWith('INSERT INTO teleport_automation_waits')) {
|
|
673
|
+
const [eventId, workflowId, resumeNodeId, context, runAt] = values
|
|
674
|
+
this.waits.push({
|
|
675
|
+
id: ++this.serial,
|
|
676
|
+
event_id: String(eventId),
|
|
677
|
+
workflow_id: String(workflowId),
|
|
678
|
+
resume_node_id: String(resumeNodeId),
|
|
679
|
+
context: (context as string) || null,
|
|
680
|
+
run_at: new Date(String(runAt)),
|
|
681
|
+
status: 'waiting',
|
|
682
|
+
created_at: new Date(),
|
|
683
|
+
})
|
|
684
|
+
return { rows: [] }
|
|
685
|
+
}
|
|
686
|
+
if (text.includes('FROM teleport_automation_waits w') && text.includes('SKIP LOCKED')) {
|
|
687
|
+
const workflowIds = values[0] as string[]
|
|
688
|
+
const now = Date.now()
|
|
689
|
+
return {
|
|
690
|
+
rows: this.waits
|
|
691
|
+
.filter(
|
|
692
|
+
(w) =>
|
|
693
|
+
workflowIds.includes(w.workflow_id) &&
|
|
694
|
+
((w.status === 'waiting' && w.run_at.getTime() <= now) ||
|
|
695
|
+
(w.status === 'resuming' && w.run_at.getTime() < now - STALE_MS)) &&
|
|
696
|
+
this.automations.some((a) => a.workflow_id === w.workflow_id && a.enabled)
|
|
697
|
+
)
|
|
698
|
+
.map((w) => ({ ...w })),
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (text.startsWith("UPDATE teleport_automation_waits SET status = 'resuming'")) {
|
|
702
|
+
const marked: Row[] = []
|
|
703
|
+
for (const wait of this.waits) {
|
|
704
|
+
if ((values[0] as number[]).includes(wait.id)) {
|
|
705
|
+
wait.status = 'resuming'
|
|
706
|
+
wait.run_at = new Date()
|
|
707
|
+
marked.push({ id: wait.id, run_at: wait.run_at })
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return { rows: marked }
|
|
711
|
+
}
|
|
712
|
+
if (
|
|
713
|
+
text.includes(
|
|
714
|
+
"FROM teleport_automation_waits WHERE event_id = $1 AND workflow_id = $2 AND status = 'failed' ORDER BY id DESC LIMIT 1"
|
|
715
|
+
)
|
|
716
|
+
) {
|
|
717
|
+
const failed = this.waits
|
|
718
|
+
.filter(
|
|
719
|
+
(w) => w.event_id === values[0] && w.workflow_id === values[1] && w.status === 'failed'
|
|
720
|
+
)
|
|
721
|
+
.sort((a, b) => b.id - a.id)
|
|
722
|
+
return { rows: failed.slice(0, 1).map((w) => ({ ...w })) }
|
|
723
|
+
}
|
|
724
|
+
if (
|
|
725
|
+
text.startsWith(
|
|
726
|
+
"UPDATE teleport_automation_waits SET status = 'done' WHERE id = $1 AND status = 'failed'"
|
|
727
|
+
)
|
|
728
|
+
) {
|
|
729
|
+
const wait = this.waits.find((w) => w.id === values[0])
|
|
730
|
+
if (wait && wait.status === 'failed') {
|
|
731
|
+
wait.status = 'done'
|
|
732
|
+
}
|
|
733
|
+
return { rows: [] }
|
|
734
|
+
}
|
|
735
|
+
if (text.startsWith('SELECT (EXISTS (SELECT 1 FROM teleport_automation_waits w')) {
|
|
736
|
+
// The activity kick's due check, read the way the dispatcher's claims are.
|
|
737
|
+
const [workflowIds, types] = values as string[][]
|
|
738
|
+
const now = Date.now()
|
|
739
|
+
const on = (workflowId: string) =>
|
|
740
|
+
workflowIds.includes(workflowId) &&
|
|
741
|
+
this.automations.some((a) => a.workflow_id === workflowId && a.enabled)
|
|
742
|
+
const dueWait = this.waits.some(
|
|
743
|
+
(w) =>
|
|
744
|
+
on(w.workflow_id) &&
|
|
745
|
+
((w.status === 'waiting' && w.run_at.getTime() <= now) ||
|
|
746
|
+
(w.status === 'resuming' && w.run_at.getTime() < now - STALE_MS))
|
|
747
|
+
)
|
|
748
|
+
const dueRetry = this.events.some(
|
|
749
|
+
(e) =>
|
|
750
|
+
types.includes(e.event_type) &&
|
|
751
|
+
((e.attempts < 5 &&
|
|
752
|
+
(e.status === 'failed' || this.stalePending(e, workflowIds)) &&
|
|
753
|
+
this.enabledFor(e, workflowIds).length > 0) ||
|
|
754
|
+
(e.attempts >= 5 && this.stalePending(e, workflowIds)))
|
|
755
|
+
)
|
|
756
|
+
return { rows: [{ due: dueWait || dueRetry }] }
|
|
757
|
+
}
|
|
758
|
+
if (text.includes('FROM teleport_products WHERE id = $1')) {
|
|
759
|
+
return { rows: this.products.filter((product) => product.id === values[0]) }
|
|
760
|
+
}
|
|
761
|
+
if (text.startsWith("UPDATE teleport_automation_waits SET status = 'waiting'")) {
|
|
762
|
+
const wait = this.waits.find((w) => w.id === values[0])
|
|
763
|
+
// Guarded by the claim's run_at token when the statement names it.
|
|
764
|
+
const guarded = text.includes("AND status = 'resuming' AND run_at = $2")
|
|
765
|
+
if (
|
|
766
|
+
wait &&
|
|
767
|
+
wait.status === 'resuming' &&
|
|
768
|
+
(!guarded || wait.run_at.getTime() === (values[1] as Date).getTime())
|
|
769
|
+
) {
|
|
770
|
+
wait.status = 'waiting'
|
|
771
|
+
}
|
|
772
|
+
return { rows: [] }
|
|
773
|
+
}
|
|
774
|
+
if (text.includes('FROM teleport_events e WHERE e.id = $1')) {
|
|
775
|
+
return {
|
|
776
|
+
rows: this.events
|
|
777
|
+
.filter((candidate) => candidate.id === values[0])
|
|
778
|
+
.map((candidate) => ({ ...candidate })),
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (text.includes('FROM teleport_orders WHERE id = $1')) {
|
|
782
|
+
if (this.ordersError) {
|
|
783
|
+
throw this.ordersError
|
|
784
|
+
}
|
|
785
|
+
return { rows: this.orders.filter((order) => order.id === values[0]) }
|
|
786
|
+
}
|
|
787
|
+
if (text.includes('FROM teleport_order_items WHERE order_id = $1')) {
|
|
788
|
+
return { rows: this.orderItems.filter((item) => item.order_id === values[0]) }
|
|
789
|
+
}
|
|
790
|
+
if (text.includes('FROM "users" WHERE LOWER(email) = LOWER($1)')) {
|
|
791
|
+
return {
|
|
792
|
+
rows: this.users.filter(
|
|
793
|
+
(user) => String(user.email).toLowerCase() === String(values[0]).toLowerCase()
|
|
794
|
+
),
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (text.includes('FROM teleport_product_reviews WHERE id = $1')) {
|
|
798
|
+
return { rows: this.reviews.filter((review) => review.id === values[0]) }
|
|
799
|
+
}
|
|
800
|
+
if (text.includes('FROM teleport_waitlist_entries WHERE id = $1')) {
|
|
801
|
+
return { rows: this.waitlistEntries.filter((entry) => entry.id === values[0]) }
|
|
802
|
+
}
|
|
803
|
+
if (text.includes('FROM teleport_waitlists WHERE id = $1')) {
|
|
804
|
+
return { rows: this.waitlists.filter((list) => list.id === values[0]) }
|
|
805
|
+
}
|
|
806
|
+
if (text.includes('FROM "users" WHERE id = $1')) {
|
|
807
|
+
return { rows: this.users.filter((user) => user.id === values[0]) }
|
|
808
|
+
}
|
|
809
|
+
if (text.includes('FROM teleport_bookings WHERE id = $1')) {
|
|
810
|
+
return { rows: this.bookings.filter((booking) => booking.id === values[0]) }
|
|
811
|
+
}
|
|
812
|
+
throw new Error('Unexpected statement: ' + text)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
claimStatement(): string {
|
|
816
|
+
const claim = this.statements.find(
|
|
817
|
+
(text) => text.includes('FROM teleport_events e') && text.includes('SKIP LOCKED')
|
|
818
|
+
)
|
|
819
|
+
if (!claim) {
|
|
820
|
+
throw new Error('No claim was sent')
|
|
821
|
+
}
|
|
822
|
+
return claim
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
delivery(workflowId: string): DeliveryRow | undefined {
|
|
826
|
+
return this.deliveries.find((d) => d.workflow_id === workflowId)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** A pending event nothing touched for the stale window, as the dispatcher reads it. */
|
|
830
|
+
private stalePending(event: EventRow, workflowIds: string[]): boolean {
|
|
831
|
+
const heldByLiveWait = this.deliveries.some(
|
|
832
|
+
(d) =>
|
|
833
|
+
d.event_id === event.id &&
|
|
834
|
+
d.status === 'waiting' &&
|
|
835
|
+
workflowIds.includes(d.workflow_id) &&
|
|
836
|
+
this.automations.some((a) => a.workflow_id === d.workflow_id && a.enabled)
|
|
837
|
+
)
|
|
838
|
+
const touched = Math.max(
|
|
839
|
+
(event.claimed_at || event.created_at).getTime(),
|
|
840
|
+
...this.deliveries
|
|
841
|
+
.filter((d) => d.event_id === event.id && d.started_at)
|
|
842
|
+
.map((d) => d.started_at!.getTime())
|
|
843
|
+
)
|
|
844
|
+
return event.status === 'pending' && !heldByLiveWait && touched < Date.now() - STALE_MS
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private enabledFor(event: EventRow, workflowIds: string[]): AutomationRow[] {
|
|
848
|
+
return this.automations.filter(
|
|
849
|
+
(a) =>
|
|
850
|
+
a.event_type === event.event_type &&
|
|
851
|
+
workflowIds.includes(a.workflow_id) &&
|
|
852
|
+
a.enabled &&
|
|
853
|
+
(a.enabled_at || a.created_at).getTime() <= event.created_at.getTime()
|
|
854
|
+
)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
private newEvent(values: Partial<EventRow>): string {
|
|
858
|
+
const id = `00000000-0000-4000-8000-${String(++this.serial).padStart(12, '0')}`
|
|
859
|
+
this.events.push({
|
|
860
|
+
id,
|
|
861
|
+
event_type: '',
|
|
862
|
+
dedupe_key: '',
|
|
863
|
+
order_id: null,
|
|
864
|
+
entity_type: null,
|
|
865
|
+
entity_id: null,
|
|
866
|
+
payload: null,
|
|
867
|
+
actor: null,
|
|
868
|
+
actor_type: 'system',
|
|
869
|
+
status: 'recorded',
|
|
870
|
+
attempts: 0,
|
|
871
|
+
created_at: new Date(),
|
|
872
|
+
...values,
|
|
873
|
+
})
|
|
874
|
+
return id
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private newDelivery(eventId: string, workflowId: string): Row {
|
|
878
|
+
const id = ++this.serial
|
|
879
|
+
this.deliveries.push({
|
|
880
|
+
id,
|
|
881
|
+
event_id: eventId,
|
|
882
|
+
workflow_id: workflowId,
|
|
883
|
+
status: 'running',
|
|
884
|
+
error: null,
|
|
885
|
+
attempts: 1,
|
|
886
|
+
started_at: new Date(),
|
|
887
|
+
finished_at: null,
|
|
888
|
+
})
|
|
889
|
+
return { id, attempts: 1 }
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
// Running the generated modules
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
const resolveRelative = (fromDir: string, id: string): string => {
|
|
898
|
+
const joined = nodePath.posix.normalize(nodePath.posix.join(fromDir, id))
|
|
899
|
+
return joined.endsWith('.js') ? joined : `${joined}.js`
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** A CommonJS loader over the generated files, with `pg` answered by `db`. */
|
|
903
|
+
const createApp = (files: Map<string, string>, db: FakeDatabase) => {
|
|
904
|
+
const cache = new Map<string, { exports: unknown }>()
|
|
905
|
+
class Client {
|
|
906
|
+
constructor(options: Row) {
|
|
907
|
+
db.clientOptions.push(options)
|
|
908
|
+
}
|
|
909
|
+
async connect(): Promise<void> {
|
|
910
|
+
return undefined
|
|
911
|
+
}
|
|
912
|
+
async query(sql: string, values?: unknown[]) {
|
|
913
|
+
return db.query(sql, values)
|
|
914
|
+
}
|
|
915
|
+
async end(): Promise<void> {
|
|
916
|
+
return undefined
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const load = (path: string): unknown => {
|
|
920
|
+
const cached = cache.get(path)
|
|
921
|
+
if (cached) {
|
|
922
|
+
return cached.exports
|
|
923
|
+
}
|
|
924
|
+
const source = files.get(path)
|
|
925
|
+
if (source === undefined) {
|
|
926
|
+
throw new Error(`Cannot find module '${path}'`)
|
|
927
|
+
}
|
|
928
|
+
const module = { exports: {} as unknown }
|
|
929
|
+
cache.set(path, module)
|
|
930
|
+
const dir = nodePath.posix.dirname(path)
|
|
931
|
+
const localRequire = (id: string): unknown => {
|
|
932
|
+
if (id === 'pg') {
|
|
933
|
+
return { Client }
|
|
934
|
+
}
|
|
935
|
+
// The mail provider's SDK: every send succeeds, and is kept.
|
|
936
|
+
if (id === 'resend') {
|
|
937
|
+
return {
|
|
938
|
+
Resend: class {
|
|
939
|
+
emails = {
|
|
940
|
+
send: async (payload: Row) => {
|
|
941
|
+
db.mailSent.push(payload)
|
|
942
|
+
return { data: { id: 'msg-' + db.mailSent.length } }
|
|
943
|
+
},
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (id === '@vercel/functions' || id === 'node-fetch') {
|
|
949
|
+
throw new Error(`Cannot find module '${id}'`)
|
|
950
|
+
}
|
|
951
|
+
if (id.startsWith('.')) {
|
|
952
|
+
return load(resolveRelative(dir, id))
|
|
953
|
+
}
|
|
954
|
+
if (id in FETCH_BACKED_NETWORK) {
|
|
955
|
+
return FETCH_BACKED_NETWORK[id]
|
|
956
|
+
}
|
|
957
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
958
|
+
return require(id)
|
|
959
|
+
}
|
|
960
|
+
// eslint-disable-next-line no-new-func
|
|
961
|
+
new Function('module', 'exports', 'require', source)(module, module.exports, localRequire)
|
|
962
|
+
return module.exports
|
|
963
|
+
}
|
|
964
|
+
return load
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
interface Totals {
|
|
968
|
+
claimed: number
|
|
969
|
+
delivered: number
|
|
970
|
+
failed: number
|
|
971
|
+
resumed: number
|
|
972
|
+
wouldWait?: Row
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
interface Bus {
|
|
976
|
+
emitBusinessEvent(input: Row, req?: Row): Promise<{ recorded: boolean; id: string | null }>
|
|
977
|
+
scheduleDispatch(req?: Row): string
|
|
978
|
+
dispatch(options?: Row): Promise<Totals>
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
type RouteHandler = (req: unknown, res: unknown) => Promise<void>
|
|
982
|
+
|
|
983
|
+
const ENV = {
|
|
984
|
+
TELEPORT_DB_CONNECTION_STRING: 'postgresql://u:p@localhost:5432/db',
|
|
985
|
+
NEXTAUTH_SECRET: 'app-secret',
|
|
986
|
+
NEXTAUTH_URL: 'https://shop.test',
|
|
987
|
+
VERCEL: undefined,
|
|
988
|
+
TELEPORT_CRON_ALLOW_UNSIGNED: undefined,
|
|
989
|
+
TELEPORT_CRON_SECRET: undefined,
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const silenced = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
993
|
+
const sink = console as unknown as { warn: typeof console.warn; error: typeof console.warn }
|
|
994
|
+
const saved = { warn: sink.warn, error: sink.error }
|
|
995
|
+
sink.warn = () => undefined
|
|
996
|
+
sink.error = () => undefined
|
|
997
|
+
try {
|
|
998
|
+
return await run()
|
|
999
|
+
} finally {
|
|
1000
|
+
sink.warn = saved.warn
|
|
1001
|
+
sink.error = saved.error
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Runs `run` with every fetch answered: echo.test/fails is down, the rest answer 200. */
|
|
1006
|
+
const withEcho = <T>(run: (calls: FetchCall[]) => Promise<T>, env: Row = {}) =>
|
|
1007
|
+
withEnv({ ...ENV, ...env } as Record<string, string | undefined>, () =>
|
|
1008
|
+
withFetch((call) => {
|
|
1009
|
+
if (call.url === ECHO + '/fails') {
|
|
1010
|
+
throw new Error('network down')
|
|
1011
|
+
}
|
|
1012
|
+
return { status: 200, body: { ok: true } }
|
|
1013
|
+
}, run)
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
const ago = (ms: number) => new Date(Date.now() - ms)
|
|
1017
|
+
|
|
1018
|
+
const automationRow = (workflowId: string, eventType: string, enabled = true): AutomationRow => ({
|
|
1019
|
+
workflow_id: workflowId,
|
|
1020
|
+
event_type: eventType,
|
|
1021
|
+
enabled,
|
|
1022
|
+
enabled_at: ago(DAY_MS),
|
|
1023
|
+
created_at: ago(DAY_MS),
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
const eventRow = (values: Partial<EventRow> & { id: string; event_type: string }): EventRow => ({
|
|
1027
|
+
dedupe_key: values.id,
|
|
1028
|
+
order_id: null,
|
|
1029
|
+
entity_type: null,
|
|
1030
|
+
entity_id: null,
|
|
1031
|
+
payload: null,
|
|
1032
|
+
actor: null,
|
|
1033
|
+
actor_type: 'system',
|
|
1034
|
+
status: 'recorded',
|
|
1035
|
+
attempts: 0,
|
|
1036
|
+
created_at: ago(1000),
|
|
1037
|
+
...values,
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
const seedOrderPaid = (db: FakeDatabase): EventRow => {
|
|
1041
|
+
db.automations.push(
|
|
1042
|
+
automationRow('wf-paid-ok', 'order-paid'),
|
|
1043
|
+
automationRow('wf-paid-fail', 'order-paid'),
|
|
1044
|
+
automationRow('wf-paid-off', 'order-paid', false)
|
|
1045
|
+
)
|
|
1046
|
+
db.orders.push({
|
|
1047
|
+
id: 'ord-1',
|
|
1048
|
+
order_number: 'ORD-1042',
|
|
1049
|
+
status: 'paid',
|
|
1050
|
+
payment_status: 'paid',
|
|
1051
|
+
total_amount: '53.20',
|
|
1052
|
+
tax_amount: '8',
|
|
1053
|
+
shipping_amount: '10',
|
|
1054
|
+
discount_amount: '4.8',
|
|
1055
|
+
currency: 'USD',
|
|
1056
|
+
billing_email: 'ava@example.com',
|
|
1057
|
+
billing_name: 'Ava Thompson',
|
|
1058
|
+
fulfillment_method: 'delivery',
|
|
1059
|
+
payment_method: 'online',
|
|
1060
|
+
payment_provider: 'stripe',
|
|
1061
|
+
created_at: ago(MINUTE_MS),
|
|
1062
|
+
})
|
|
1063
|
+
db.orderItems.push({
|
|
1064
|
+
id: 'item-1',
|
|
1065
|
+
order_id: 'ord-1',
|
|
1066
|
+
product_id: 'p-1',
|
|
1067
|
+
product_name: 'Linen Throw Pillow',
|
|
1068
|
+
quantity: 2,
|
|
1069
|
+
unit_price: '20',
|
|
1070
|
+
total_price: '40',
|
|
1071
|
+
unit_price_paid: '18',
|
|
1072
|
+
total_price_paid: '36',
|
|
1073
|
+
})
|
|
1074
|
+
const event = eventRow({
|
|
1075
|
+
id: 'evt-paid',
|
|
1076
|
+
event_type: 'order-paid',
|
|
1077
|
+
dedupe_key: 'order-paid:ord-1',
|
|
1078
|
+
order_id: 'ord-1',
|
|
1079
|
+
entity_type: 'order',
|
|
1080
|
+
entity_id: 'ord-1',
|
|
1081
|
+
payload: JSON.stringify({ amount: 53.2, provider: 'stripe', discountDetails: [] }),
|
|
1082
|
+
actor: 'stripe',
|
|
1083
|
+
})
|
|
1084
|
+
db.events.push(event)
|
|
1085
|
+
return event
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const seedSignup = (db: FakeDatabase): EventRow => {
|
|
1089
|
+
db.automations.push(automationRow('wf-signup-wait', 'user-signed-up'))
|
|
1090
|
+
db.users.push({
|
|
1091
|
+
id: 'user-1',
|
|
1092
|
+
email: 'noah@example.com',
|
|
1093
|
+
name: 'Noah',
|
|
1094
|
+
role: 'user',
|
|
1095
|
+
password: 'hash',
|
|
1096
|
+
created_at: ago(1000),
|
|
1097
|
+
})
|
|
1098
|
+
const event = eventRow({
|
|
1099
|
+
id: 'evt-signup',
|
|
1100
|
+
event_type: 'user-signed-up',
|
|
1101
|
+
entity_type: 'user',
|
|
1102
|
+
entity_id: 'user-1',
|
|
1103
|
+
actor: 'noah@example.com',
|
|
1104
|
+
actor_type: 'customer',
|
|
1105
|
+
})
|
|
1106
|
+
db.events.push(event)
|
|
1107
|
+
return event
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// The auth route (and the bus, reading it first) keeps the configured
|
|
1111
|
+
// NEXTAUTH_URL on globalThis once per process: each test is its own process.
|
|
1112
|
+
afterEach(() => {
|
|
1113
|
+
delete (globalThis as Row).__tqConfiguredNextAuthUrl
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
const AUTOMATION_KEYS = (structure: Structure) =>
|
|
1117
|
+
Array.from(structure.files.keys()).filter((key) => key.startsWith('automations-'))
|
|
1118
|
+
|
|
1119
|
+
describe('in-app event bus — generation', () => {
|
|
1120
|
+
it('emits the bus, the queue worker, the registry, one handler per automation and the dispatch route', async () => {
|
|
1121
|
+
const structure = await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS })
|
|
1122
|
+
const files = sources(structure)
|
|
1123
|
+
|
|
1124
|
+
for (const path of [
|
|
1125
|
+
'lib/automations/event-bus.js',
|
|
1126
|
+
'lib/automations/dispatcher.js',
|
|
1127
|
+
'lib/automations/registry.js',
|
|
1128
|
+
'lib/automations/store.js',
|
|
1129
|
+
'lib/automations/wait-until.js',
|
|
1130
|
+
'lib/automations/due-check.js',
|
|
1131
|
+
'lib/automations/hydrate/order.js',
|
|
1132
|
+
'lib/automations/hydrate/user.js',
|
|
1133
|
+
'lib/automations/hydrate/booking.js',
|
|
1134
|
+
'pages/api/automations/dispatch.js',
|
|
1135
|
+
'lib/automations/handlers/thank-the-customer-wf-paid.js',
|
|
1136
|
+
'lib/automations/handlers/welcome-two-days-later-wf-signu.js',
|
|
1137
|
+
]) {
|
|
1138
|
+
expect(files.has(path)).toBe(true)
|
|
1139
|
+
}
|
|
1140
|
+
const handlers = Array.from(files.keys()).filter((path) =>
|
|
1141
|
+
path.startsWith('lib/automations/handlers/')
|
|
1142
|
+
)
|
|
1143
|
+
expect(handlers).toHaveLength(5)
|
|
1144
|
+
|
|
1145
|
+
const registry = files.get('lib/automations/registry.js')!
|
|
1146
|
+
expect(registry).toContain('"order-paid": [')
|
|
1147
|
+
expect(registry).toContain('"user-signed-up": [')
|
|
1148
|
+
expect(registry).toContain("require('./handlers/thank-the-customer-wf-paid.js').run")
|
|
1149
|
+
|
|
1150
|
+
// Never in the browser: not in the global hook, which still runs the
|
|
1151
|
+
// project's own global workflow.
|
|
1152
|
+
const globalHook = files.get('utils/workflows/global-workflows.js')!
|
|
1153
|
+
expect(globalHook).toContain("'storage-local-remove'")
|
|
1154
|
+
expect(globalHook).not.toContain('echo.test')
|
|
1155
|
+
expect(globalHook).not.toContain('general-wait')
|
|
1156
|
+
// The queue lives in Postgres, and Vercel keeps a dispatch alive with waitUntil.
|
|
1157
|
+
expect(structure.dependencies.pg).toBeDefined()
|
|
1158
|
+
expect(structure.dependencies['@vercel/functions']).toBe('^2.2.13')
|
|
1159
|
+
// The dispatch route reads its body raw (to check the signed digest) and
|
|
1160
|
+
// asks for a time limit its 50 s budget fits in.
|
|
1161
|
+
expect(files.get('pages/api/automations/dispatch.js')).toContain(
|
|
1162
|
+
'module.exports.config = { api: { bodyParser: false }, maxDuration: 60 };'
|
|
1163
|
+
)
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
it('generates nothing for a project without an automation, and adding one changes nothing else', async () => {
|
|
1167
|
+
const without = await generate(BASE_WORKFLOWS, TELEPORT_AUTOMATIONS, { files: FETCHERS })
|
|
1168
|
+
const withAutomations = await generate(
|
|
1169
|
+
{ ...BASE_WORKFLOWS, ...AUTOMATIONS },
|
|
1170
|
+
TELEPORT_AUTOMATIONS,
|
|
1171
|
+
{ files: FETCHERS }
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
expect(AUTOMATION_KEYS(without)).toEqual([])
|
|
1175
|
+
const withoutFiles = sources(without)
|
|
1176
|
+
expect(Array.from(withoutFiles.keys()).some((path) => path.includes('automations'))).toBe(false)
|
|
1177
|
+
// The data-source modules are left exactly as their plugin wrote them.
|
|
1178
|
+
const seeded = sources({ files: FETCHERS() } as Structure)
|
|
1179
|
+
for (const [path, content] of seeded) {
|
|
1180
|
+
expect({ path, content: withoutFiles.get(path) }).toEqual({ path, content })
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const withFiles = sources(withAutomations)
|
|
1184
|
+
for (const [path, content] of withoutFiles) {
|
|
1185
|
+
// The data API only gains the wake-up after a write to the event ledger
|
|
1186
|
+
// (data-api-business-events.test.ts), and the activity kick.
|
|
1187
|
+
if (path === DATA_API_ROUTE) {
|
|
1188
|
+
const diff = insertedLines(content, withFiles.get(path) as string)
|
|
1189
|
+
expect(diff.kept).toBe(true)
|
|
1190
|
+
expect(diff.inserted.join('\n')).toContain('wakeAutomations(req);')
|
|
1191
|
+
expect(diff.inserted.join('\n')).toContain('kickDueAutomations(req);')
|
|
1192
|
+
continue
|
|
1193
|
+
}
|
|
1194
|
+
// A server read of the TeleportHQ database only gains the activity kick.
|
|
1195
|
+
if (TELEPORT_READS.indexOf(path) !== -1) {
|
|
1196
|
+
const diff = insertedLines(content, withFiles.get(path) as string)
|
|
1197
|
+
expect({ path, kept: diff.kept }).toEqual({ path, kept: true })
|
|
1198
|
+
expect(diff.inserted).toEqual([
|
|
1199
|
+
"import { kickWhenDue as __tqKickDueAutomations } from '../../lib/automations/due-check'",
|
|
1200
|
+
' // Activity on the site: due automation work (a Wait, a retry) starts off this request.',
|
|
1201
|
+
' __tqKickDueAutomations(req)',
|
|
1202
|
+
])
|
|
1203
|
+
continue
|
|
1204
|
+
}
|
|
1205
|
+
expect({ path, content: withFiles.get(path) }).toEqual({ path, content })
|
|
1206
|
+
}
|
|
1207
|
+
const added = Array.from(withFiles.keys()).filter((path) => !withoutFiles.has(path))
|
|
1208
|
+
expect(added.every((path) => /^(lib|pages\/api)\/automations\//.test(path))).toBe(true)
|
|
1209
|
+
// The dispatch route verifies signed requests with the cron signing keys.
|
|
1210
|
+
expect(withAutomations.uidl.globals.env).toEqual({
|
|
1211
|
+
...without.uidl.globals.env,
|
|
1212
|
+
TELEPORT_CRON_SECRET: '',
|
|
1213
|
+
TELEPORT_CRON_SECRET_PREVIOUS: '',
|
|
1214
|
+
})
|
|
1215
|
+
expect(withAutomations.dependencies).toEqual({
|
|
1216
|
+
...without.dependencies,
|
|
1217
|
+
pg: expect.any(String),
|
|
1218
|
+
'@vercel/functions': expect.any(String),
|
|
1219
|
+
})
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
it('records the low-stock events through utils/app-events.js, which wakes the queue only with the bus', async () => {
|
|
1223
|
+
const LOW_STOCK = {
|
|
1224
|
+
ecommerceSettings: {
|
|
1225
|
+
stockManagement: true,
|
|
1226
|
+
stockManagementConfig: {
|
|
1227
|
+
lowStockAlerts: true,
|
|
1228
|
+
lowStockThreshold: 3,
|
|
1229
|
+
lowStockAlertConfig: { provider: 'email-resend' },
|
|
1230
|
+
},
|
|
1231
|
+
},
|
|
1232
|
+
}
|
|
1233
|
+
const withBus = sources(
|
|
1234
|
+
await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }, TELEPORT_AUTOMATIONS, {
|
|
1235
|
+
uidl: LOW_STOCK,
|
|
1236
|
+
})
|
|
1237
|
+
)
|
|
1238
|
+
expect(withBus.get(DATA_API_ROUTE)).toContain(
|
|
1239
|
+
"var appEvents = require('../../../utils/app-events');"
|
|
1240
|
+
)
|
|
1241
|
+
expect(withBus.get(DATA_API_ROUTE)).toContain(
|
|
1242
|
+
'recordLowStockEvents(req, rows, extractThresholdFromQuery(query));'
|
|
1243
|
+
)
|
|
1244
|
+
expect(withBus.get('utils/app-events.js')).toContain(
|
|
1245
|
+
"require('../lib/automations/event-bus').scheduleDispatch(req);"
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
// Without the bus the data API records nothing and has no app-events module.
|
|
1249
|
+
const withoutBus = sources(
|
|
1250
|
+
await generate(BASE_WORKFLOWS, TELEPORT_AUTOMATIONS, { uidl: LOW_STOCK })
|
|
1251
|
+
)
|
|
1252
|
+
expect(withoutBus.has('utils/app-events.js')).toBe(false)
|
|
1253
|
+
expect(withoutBus.get(DATA_API_ROUTE)).not.toContain('app-events')
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
it('declares the cron signing keys for a project whose only signed route is the dispatch route', async () => {
|
|
1257
|
+
const automationsOnly = await generate(AUTOMATIONS)
|
|
1258
|
+
expect(automationsOnly.uidl.globals.env).toMatchObject({
|
|
1259
|
+
TELEPORT_CRON_SECRET: '',
|
|
1260
|
+
TELEPORT_CRON_SECRET_PREVIOUS: '',
|
|
1261
|
+
})
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
describe('runs on the TeleportHQ database only', () => {
|
|
1265
|
+
const quietly = async <T>(
|
|
1266
|
+
run: () => Promise<T>
|
|
1267
|
+
): Promise<{ result: T; warnings: string[] }> => {
|
|
1268
|
+
const warnings: string[] = []
|
|
1269
|
+
const saved = console.warn
|
|
1270
|
+
console.warn = (...args: unknown[]) => {
|
|
1271
|
+
warnings.push(args.map(String).join(' '))
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
return { result: await run(), warnings }
|
|
1275
|
+
} finally {
|
|
1276
|
+
console.warn = saved
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
const ROUTE = 'pages/api/automations/dispatch.js'
|
|
1280
|
+
const REASON =
|
|
1281
|
+
'Automations are not set up on this site: no TeleportHQ database holds the automation tables'
|
|
1282
|
+
|
|
1283
|
+
it('the TeleportHQ database: the bus on TELEPORT_DB_*, the real dispatch route', async () => {
|
|
1284
|
+
const structure = await generate(AUTOMATIONS, TELEPORT_AUTOMATIONS)
|
|
1285
|
+
const files = sources(structure)
|
|
1286
|
+
expect(files.get('lib/automations/store.js')).toContain(
|
|
1287
|
+
'process.env.TELEPORT_DB_CONNECTION_STRING'
|
|
1288
|
+
)
|
|
1289
|
+
expect(files.get('lib/automations/store.js')).not.toContain('DATABASE_URL')
|
|
1290
|
+
expect(files.get(ROUTE)).toContain('verifyCronRequest(req, rawBody)')
|
|
1291
|
+
expect(Object.keys(structure.uidl.globals.env).sort()).toEqual([
|
|
1292
|
+
'NEXTAUTH_SECRET',
|
|
1293
|
+
'TELEPORT_CRON_SECRET',
|
|
1294
|
+
'TELEPORT_CRON_SECRET_PREVIOUS',
|
|
1295
|
+
])
|
|
1296
|
+
})
|
|
1297
|
+
|
|
1298
|
+
it('anything else: no bus, one build warning, a dispatch route that says why, nothing else changes', async () => {
|
|
1299
|
+
const without = await generate(BASE_WORKFLOWS, null, { files: FETCHERS })
|
|
1300
|
+
const withoutFiles = sources(without)
|
|
1301
|
+
for (const automations of [null, { dataSourceId: 'ds-own', dataSourceType: 'postgresql' }]) {
|
|
1302
|
+
const { result: refused, warnings } = await quietly(() =>
|
|
1303
|
+
generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }, automations, { files: FETCHERS })
|
|
1304
|
+
)
|
|
1305
|
+
expect(AUTOMATION_KEYS(refused)).toEqual(['automations-dispatch-route'])
|
|
1306
|
+
expect(warnings.filter((line) => line.includes('no TeleportHQ database'))).toHaveLength(1)
|
|
1307
|
+
const refusedFiles = sources(refused)
|
|
1308
|
+
expect(
|
|
1309
|
+
Array.from(refusedFiles.keys()).filter((path) => path.includes('automations'))
|
|
1310
|
+
).toEqual([ROUTE])
|
|
1311
|
+
expect(Array.from(refusedFiles.keys()).sort()).toEqual(
|
|
1312
|
+
[...Array.from(withoutFiles.keys()), ROUTE].sort()
|
|
1313
|
+
)
|
|
1314
|
+
for (const [path, content] of withoutFiles) {
|
|
1315
|
+
expect({ path, content: refusedFiles.get(path) }).toEqual({ path, content })
|
|
1316
|
+
}
|
|
1317
|
+
expect(refused.uidl.globals.env).toEqual(without.uidl.globals.env)
|
|
1318
|
+
expect(refused.dependencies).toEqual(without.dependencies)
|
|
1319
|
+
|
|
1320
|
+
// Whatever is sent, the route answers the reason (the editor's test
|
|
1321
|
+
// proxy shows it), never a missing page.
|
|
1322
|
+
const handler = createApp(refusedFiles, new FakeDatabase())(ROUTE) as RouteHandler
|
|
1323
|
+
for (const method of ['GET', 'POST']) {
|
|
1324
|
+
const reply = { status: 0, body: null as unknown }
|
|
1325
|
+
await handler(
|
|
1326
|
+
{ method, url: '/api/automations/dispatch', headers: {} },
|
|
1327
|
+
{
|
|
1328
|
+
status(code: number) {
|
|
1329
|
+
reply.status = code
|
|
1330
|
+
return {
|
|
1331
|
+
json(payload: unknown) {
|
|
1332
|
+
reply.body = payload
|
|
1333
|
+
},
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
}
|
|
1337
|
+
)
|
|
1338
|
+
expect(reply).toEqual({ status: 503, body: { error: REASON } })
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
})
|
|
1342
|
+
})
|
|
1343
|
+
|
|
1344
|
+
it('keeps the queue tables out of every browser read', () => {
|
|
1345
|
+
for (const table of [
|
|
1346
|
+
'teleport_automations',
|
|
1347
|
+
'teleport_event_deliveries',
|
|
1348
|
+
'teleport_automation_waits',
|
|
1349
|
+
]) {
|
|
1350
|
+
expect(PROTECTED_TABLES).toContain(table)
|
|
1351
|
+
}
|
|
1352
|
+
})
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
describe('in-app event bus — dispatcher', () => {
|
|
1356
|
+
let files: Map<string, string>
|
|
1357
|
+
|
|
1358
|
+
beforeAll(async () => {
|
|
1359
|
+
files = sources(await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }))
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
const bus = (db: FakeDatabase) => createApp(files, db)('lib/automations/event-bus.js') as Bus
|
|
1363
|
+
const dispatchOnce = (db: FakeDatabase, options: Row = {}) =>
|
|
1364
|
+
withEcho(async (calls) => ({
|
|
1365
|
+
result: await silenced(() => bus(db).dispatch(options)),
|
|
1366
|
+
calls,
|
|
1367
|
+
}))
|
|
1368
|
+
|
|
1369
|
+
it('claims due events with SKIP LOCKED, runs every enabled automation and records each outcome', async () => {
|
|
1370
|
+
const db = new FakeDatabase()
|
|
1371
|
+
const event = seedOrderPaid(db)
|
|
1372
|
+
|
|
1373
|
+
const { result, calls } = await dispatchOnce(db)
|
|
1374
|
+
|
|
1375
|
+
const claim = db.claimStatement()
|
|
1376
|
+
expect(claim).toContain('FOR UPDATE SKIP LOCKED')
|
|
1377
|
+
expect(claim).toContain('LIMIT 20')
|
|
1378
|
+
expect(claim).toContain('e.attempts < 5')
|
|
1379
|
+
expect(claim).toContain("(e.status = 'recorded' AND e.created_at > now() - interval '3 days')")
|
|
1380
|
+
expect(claim).toContain("OR e.status = 'failed'")
|
|
1381
|
+
expect(claim).toContain(
|
|
1382
|
+
'GREATEST(COALESCE(e.claimed_at, e.created_at), COALESCE((SELECT max(d.started_at) FROM teleport_event_deliveries d WHERE d.event_id = e.id), e.created_at)) ' +
|
|
1383
|
+
"< now() - interval '15 minutes'"
|
|
1384
|
+
)
|
|
1385
|
+
expect(claim).toContain('a.workflow_id = ANY($2::text[]) AND a.enabled')
|
|
1386
|
+
expect(claim).toContain("ORDER BY CASE WHEN e.status = 'recorded' THEN 0 ELSE 1 END")
|
|
1387
|
+
expect(result).toEqual({ claimed: 1, delivered: 1, failed: 1, resumed: 0 })
|
|
1388
|
+
|
|
1389
|
+
// The handler read the hydrated order: the customer and the number come
|
|
1390
|
+
// from teleport_orders, not from the payload, whose other keys are not
|
|
1391
|
+
// blocks of the event and are dropped.
|
|
1392
|
+
const thanks = calls.filter((call) => call.url === ECHO + '/thanks')
|
|
1393
|
+
expect(thanks.map((call) => call.json)).toEqual([
|
|
1394
|
+
{ email: 'ava@example.com', number: 'ORD-1042', test: false },
|
|
1395
|
+
])
|
|
1396
|
+
// Switched off: never run, no delivery row.
|
|
1397
|
+
expect(calls.some((call) => call.url === ECHO + '/off')).toBe(false)
|
|
1398
|
+
|
|
1399
|
+
expect(db.delivery('wf-paid-ok')).toMatchObject({ status: 'delivered', error: null })
|
|
1400
|
+
expect(db.delivery('wf-paid-fail')).toMatchObject({ status: 'failed' })
|
|
1401
|
+
expect(db.delivery('wf-paid-fail')!.error).toContain('network down')
|
|
1402
|
+
expect(db.delivery('wf-paid-off')).toBeUndefined()
|
|
1403
|
+
// Settled from its delivery rows; one attempt, counted when a handler started.
|
|
1404
|
+
expect(db.events.find((e) => e.id === event.id)).toMatchObject({
|
|
1405
|
+
status: 'failed',
|
|
1406
|
+
attempts: 1,
|
|
1407
|
+
})
|
|
1408
|
+
})
|
|
1409
|
+
|
|
1410
|
+
it('reads the order block with its v1 meanings', async () => {
|
|
1411
|
+
const db = new FakeDatabase()
|
|
1412
|
+
seedOrderPaid(db)
|
|
1413
|
+
const app = createApp(files, db)
|
|
1414
|
+
const hydrateOrder = (
|
|
1415
|
+
app('lib/automations/hydrate/order.js') as {
|
|
1416
|
+
hydrate: (client: unknown, row: Row, payload: Row, context: Row) => Promise<Row>
|
|
1417
|
+
}
|
|
1418
|
+
).hydrate
|
|
1419
|
+
const client = { query: async (sql: string, values: unknown[]) => db.query(sql, values) }
|
|
1420
|
+
|
|
1421
|
+
const blocks = await withEnv(ENV, () =>
|
|
1422
|
+
hydrateOrder(client, db.events[0] as never, {}, { baseUrl: 'https://shop.test' })
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
expect(blocks.order).toMatchObject({
|
|
1426
|
+
number: 'ORD-1042',
|
|
1427
|
+
// Checkout records a provider payment as 'online': the provider is the method.
|
|
1428
|
+
paymentMethod: 'stripe',
|
|
1429
|
+
// The lines at their shop prices (total_price), not what was paid.
|
|
1430
|
+
subtotal: 40,
|
|
1431
|
+
itemCount: 2,
|
|
1432
|
+
total: 53.2,
|
|
1433
|
+
pageUrl: 'https://shop.test/orders/ORD-1042',
|
|
1434
|
+
})
|
|
1435
|
+
expect((blocks.order as Row).items).toEqual([
|
|
1436
|
+
expect.objectContaining({ unitPrice: 18, total: 36 }),
|
|
1437
|
+
])
|
|
1438
|
+
})
|
|
1439
|
+
|
|
1440
|
+
it('never runs an (event, automation) pair twice: a delivered or running pair is a no-op', async () => {
|
|
1441
|
+
const db = new FakeDatabase()
|
|
1442
|
+
seedOrderPaid(db)
|
|
1443
|
+
|
|
1444
|
+
const first = await dispatchOnce(db)
|
|
1445
|
+
// The retry: the event failed, so it is claimed again.
|
|
1446
|
+
const second = await dispatchOnce(db)
|
|
1447
|
+
expect(second.result).toEqual({ claimed: 1, delivered: 0, failed: 1, resumed: 0 })
|
|
1448
|
+
|
|
1449
|
+
const calls = [...first.calls, ...second.calls]
|
|
1450
|
+
expect(calls.filter((call) => call.url === ECHO + '/thanks')).toHaveLength(1)
|
|
1451
|
+
expect(calls.filter((call) => call.url === ECHO + '/fails')).toHaveLength(2)
|
|
1452
|
+
expect(db.delivery('wf-paid-fail')!.attempts).toBe(2)
|
|
1453
|
+
expect(db.events[0]).toMatchObject({ status: 'failed', attempts: 2 })
|
|
1454
|
+
|
|
1455
|
+
// Another dispatcher is running the pair right now: this one leaves it.
|
|
1456
|
+
const busy = new FakeDatabase()
|
|
1457
|
+
seedOrderPaid(busy)
|
|
1458
|
+
busy.deliveries.push({
|
|
1459
|
+
id: 900,
|
|
1460
|
+
event_id: 'evt-paid',
|
|
1461
|
+
workflow_id: 'wf-paid-ok',
|
|
1462
|
+
status: 'running',
|
|
1463
|
+
error: null,
|
|
1464
|
+
attempts: 1,
|
|
1465
|
+
started_at: new Date(),
|
|
1466
|
+
finished_at: null,
|
|
1467
|
+
})
|
|
1468
|
+
const { calls: busyCalls } = await dispatchOnce(busy)
|
|
1469
|
+
expect(busyCalls.some((call) => call.url === ECHO + '/thanks')).toBe(false)
|
|
1470
|
+
// Still running elsewhere: the event stays claimed, not settled.
|
|
1471
|
+
expect(busy.events[0].status).toBe('pending')
|
|
1472
|
+
})
|
|
1473
|
+
|
|
1474
|
+
it('leaves a handler that outlives its timeout running, and never starts it again until its claim is stale', async () => {
|
|
1475
|
+
const db = new FakeDatabase()
|
|
1476
|
+
db.automations.push(automationRow('wf-booking-slow', 'booking-created'))
|
|
1477
|
+
db.bookings.push({ id: 'b-1', reference: 'BK-1', customer_email: 'liam@example.com' })
|
|
1478
|
+
db.events.push(
|
|
1479
|
+
eventRow({
|
|
1480
|
+
id: 'evt-booking',
|
|
1481
|
+
event_type: 'booking-created',
|
|
1482
|
+
entity_type: 'booking',
|
|
1483
|
+
entity_id: 'b-1',
|
|
1484
|
+
})
|
|
1485
|
+
)
|
|
1486
|
+
const held: Array<() => void> = []
|
|
1487
|
+
const scope = globalThis as Record<string, unknown>
|
|
1488
|
+
const previousFetch = scope.fetch
|
|
1489
|
+
scope.fetch = () =>
|
|
1490
|
+
new Promise((resolve) => {
|
|
1491
|
+
held.push(() =>
|
|
1492
|
+
resolve({
|
|
1493
|
+
ok: true,
|
|
1494
|
+
status: 200,
|
|
1495
|
+
headers: new Map([['content-type', 'application/json']]),
|
|
1496
|
+
json: async () => ({}),
|
|
1497
|
+
text: async () => '{}',
|
|
1498
|
+
})
|
|
1499
|
+
)
|
|
1500
|
+
})
|
|
1501
|
+
const app = createApp(files, db)
|
|
1502
|
+
const run = (options: Row) =>
|
|
1503
|
+
(app('lib/automations/event-bus.js') as Bus).dispatch({ handlerTimeoutMs: 20, ...options })
|
|
1504
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 10))
|
|
1505
|
+
try {
|
|
1506
|
+
await withEnv(ENV, () =>
|
|
1507
|
+
silenced(async () => {
|
|
1508
|
+
expect(await run({})).toEqual({ claimed: 1, delivered: 0, failed: 0, resumed: 0 })
|
|
1509
|
+
expect(held).toHaveLength(1)
|
|
1510
|
+
expect(db.delivery('wf-booking-slow')).toMatchObject({ status: 'running', error: null })
|
|
1511
|
+
expect(db.events[0]).toMatchObject({ status: 'pending', attempts: 1 })
|
|
1512
|
+
|
|
1513
|
+
// The next kick: the claim is fresh, so nothing starts the handler again.
|
|
1514
|
+
expect(await run({})).toEqual({ claimed: 0, delivered: 0, failed: 0, resumed: 0 })
|
|
1515
|
+
expect(held).toHaveLength(1)
|
|
1516
|
+
|
|
1517
|
+
// Fifteen minutes on, the run never reported back: it is taken over.
|
|
1518
|
+
db.events[0].claimed_at = ago(STALE_MS + MINUTE_MS)
|
|
1519
|
+
db.delivery('wf-booking-slow')!.started_at = ago(STALE_MS + MINUTE_MS)
|
|
1520
|
+
await run({})
|
|
1521
|
+
expect(held).toHaveLength(2)
|
|
1522
|
+
expect(db.delivery('wf-booking-slow')!.attempts).toBe(2)
|
|
1523
|
+
|
|
1524
|
+
// The first run ends at last: its claim is gone, so it writes nothing.
|
|
1525
|
+
held[0]()
|
|
1526
|
+
await settle()
|
|
1527
|
+
expect(db.delivery('wf-booking-slow')!.status).toBe('running')
|
|
1528
|
+
// The second one ends: recorded as delivered.
|
|
1529
|
+
held[1]()
|
|
1530
|
+
await settle()
|
|
1531
|
+
expect(db.delivery('wf-booking-slow')!.status).toBe('delivered')
|
|
1532
|
+
})
|
|
1533
|
+
)
|
|
1534
|
+
} finally {
|
|
1535
|
+
scope.fetch = previousFetch
|
|
1536
|
+
}
|
|
1537
|
+
})
|
|
1538
|
+
|
|
1539
|
+
it('starts nothing once its time budget is spent, and hands the claims back untouched', async () => {
|
|
1540
|
+
const db = new FakeDatabase()
|
|
1541
|
+
seedOrderPaid(db)
|
|
1542
|
+
const { result, calls } = await dispatchOnce(db, { deadlineMs: -1 })
|
|
1543
|
+
expect(result).toEqual({ claimed: 0, delivered: 0, failed: 0, resumed: 0 })
|
|
1544
|
+
expect(calls).toEqual([])
|
|
1545
|
+
expect(db.deliveries).toEqual([])
|
|
1546
|
+
expect(db.events[0]).toMatchObject({ status: 'recorded', attempts: 0 })
|
|
1547
|
+
})
|
|
1548
|
+
|
|
1549
|
+
it('retries a failed event whatever its age, and a new one only within the window', async () => {
|
|
1550
|
+
const db = new FakeDatabase()
|
|
1551
|
+
seedOrderPaid(db)
|
|
1552
|
+
db.events[0].status = 'failed'
|
|
1553
|
+
db.events[0].attempts = 1
|
|
1554
|
+
db.events[0].created_at = ago(5 * DAY_MS)
|
|
1555
|
+
db.automations.forEach((a) => {
|
|
1556
|
+
a.enabled_at = ago(10 * DAY_MS)
|
|
1557
|
+
})
|
|
1558
|
+
db.events.push(
|
|
1559
|
+
eventRow({ id: 'evt-stale', event_type: 'order-paid', created_at: ago(4 * DAY_MS) })
|
|
1560
|
+
)
|
|
1561
|
+
const { result } = await dispatchOnce(db)
|
|
1562
|
+
expect(result.claimed).toBe(1)
|
|
1563
|
+
expect(db.events.find((e) => e.id === 'evt-stale')!.status).toBe('recorded')
|
|
1564
|
+
expect(db.events[0].attempts).toBe(2)
|
|
1565
|
+
})
|
|
1566
|
+
|
|
1567
|
+
it('fails the event, and counts the attempt, when its record cannot be read — never delivers it without', async () => {
|
|
1568
|
+
const db = new FakeDatabase()
|
|
1569
|
+
seedOrderPaid(db)
|
|
1570
|
+
db.ordersError = Object.assign(new Error('connection terminated'), { code: '08006' })
|
|
1571
|
+
const { result, calls } = await dispatchOnce(db)
|
|
1572
|
+
expect(result.delivered).toBe(0)
|
|
1573
|
+
expect(calls).toEqual([])
|
|
1574
|
+
expect(db.events[0]).toMatchObject({ status: 'failed', attempts: 1 })
|
|
1575
|
+
|
|
1576
|
+
// A table the project does not have reads as no record: the event is
|
|
1577
|
+
// refused, never delivered from what the row recorded.
|
|
1578
|
+
const missing = new FakeDatabase()
|
|
1579
|
+
seedOrderPaid(missing)
|
|
1580
|
+
missing.ordersError = Object.assign(new Error('relation "teleport_orders" does not exist'), {
|
|
1581
|
+
code: '42P01',
|
|
1582
|
+
})
|
|
1583
|
+
const refused = await dispatchOnce(missing)
|
|
1584
|
+
expect(refused.result.delivered).toBe(0)
|
|
1585
|
+
expect(refused.calls).toEqual([])
|
|
1586
|
+
expect(missing.delivery('wf-paid-ok')).toMatchObject({
|
|
1587
|
+
status: 'failed',
|
|
1588
|
+
error: 'No such order',
|
|
1589
|
+
})
|
|
1590
|
+
})
|
|
1591
|
+
|
|
1592
|
+
it('passes over an event another dispatcher holds, one recorded before the automation was switched on, and automations this build does not have', async () => {
|
|
1593
|
+
const db = new FakeDatabase()
|
|
1594
|
+
const event = seedOrderPaid(db)
|
|
1595
|
+
event.locked = true
|
|
1596
|
+
db.events.push({
|
|
1597
|
+
...event,
|
|
1598
|
+
id: 'evt-old',
|
|
1599
|
+
dedupe_key: 'old',
|
|
1600
|
+
locked: false,
|
|
1601
|
+
created_at: ago(2 * DAY_MS),
|
|
1602
|
+
})
|
|
1603
|
+
// On in the table, but not in this build's registry.
|
|
1604
|
+
db.automations.push(automationRow('wf-ghost', 'user-signed-up'))
|
|
1605
|
+
db.events.push(eventRow({ id: 'evt-ghost', event_type: 'user-signed-up' }))
|
|
1606
|
+
|
|
1607
|
+
const { result } = await dispatchOnce(db)
|
|
1608
|
+
|
|
1609
|
+
expect(result.claimed).toBe(0)
|
|
1610
|
+
expect(db.deliveries).toEqual([])
|
|
1611
|
+
// Recorded before the automation was switched on: left as it was.
|
|
1612
|
+
expect(db.events.find((e) => e.id === 'evt-old')!.status).toBe('recorded')
|
|
1613
|
+
})
|
|
1614
|
+
|
|
1615
|
+
it('leaves an event recorded when every automation of it was switched off after the claim', async () => {
|
|
1616
|
+
const db = new FakeDatabase()
|
|
1617
|
+
seedOrderPaid(db)
|
|
1618
|
+
db.afterClaim = () => {
|
|
1619
|
+
db.automations.forEach((a) => {
|
|
1620
|
+
a.enabled = false
|
|
1621
|
+
})
|
|
1622
|
+
}
|
|
1623
|
+
const { result, calls } = await dispatchOnce(db)
|
|
1624
|
+
expect(result).toEqual({ claimed: 1, delivered: 0, failed: 0, resumed: 0 })
|
|
1625
|
+
expect(calls).toEqual([])
|
|
1626
|
+
expect(db.deliveries).toEqual([])
|
|
1627
|
+
// Not delivered: nothing ran for it.
|
|
1628
|
+
expect(db.events[0]).toMatchObject({ status: 'recorded', attempts: 0 })
|
|
1629
|
+
|
|
1630
|
+
// Nothing is read for it either: a record missing meanwhile neither
|
|
1631
|
+
// fails the event nor counts an attempt.
|
|
1632
|
+
const gone = new FakeDatabase()
|
|
1633
|
+
seedOrderPaid(gone)
|
|
1634
|
+
gone.orders = []
|
|
1635
|
+
gone.afterClaim = () => {
|
|
1636
|
+
gone.automations.forEach((a) => {
|
|
1637
|
+
a.enabled = false
|
|
1638
|
+
})
|
|
1639
|
+
}
|
|
1640
|
+
await dispatchOnce(gone)
|
|
1641
|
+
expect(gone.deliveries).toEqual([])
|
|
1642
|
+
expect(gone.events[0]).toMatchObject({ status: 'recorded', attempts: 0 })
|
|
1643
|
+
expect(gone.statements.some((text) => text.includes('FROM teleport_orders'))).toBe(false)
|
|
1644
|
+
})
|
|
1645
|
+
|
|
1646
|
+
it('parks a run at a Wait, and resumes it at the next node once it is due', async () => {
|
|
1647
|
+
const db = new FakeDatabase()
|
|
1648
|
+
seedSignup(db)
|
|
1649
|
+
|
|
1650
|
+
const first = await dispatchOnce(db)
|
|
1651
|
+
expect(first.result).toEqual({ claimed: 1, delivered: 1, failed: 0, resumed: 0 })
|
|
1652
|
+
expect(first.calls.map((call) => call.url)).toEqual([ECHO + '/before'])
|
|
1653
|
+
expect(first.calls[0].json).toEqual({ email: 'noah@example.com' })
|
|
1654
|
+
// Not finished: the delivery waits with it, and the event stays open.
|
|
1655
|
+
expect(db.delivery('wf-signup-wait')).toMatchObject({ status: 'waiting', attempts: 1 })
|
|
1656
|
+
expect(db.events[0]).toMatchObject({ status: 'pending', attempts: 1 })
|
|
1657
|
+
|
|
1658
|
+
expect(db.waits).toHaveLength(1)
|
|
1659
|
+
const wait = db.waits[0]
|
|
1660
|
+
expect(wait).toMatchObject({
|
|
1661
|
+
event_id: 'evt-signup',
|
|
1662
|
+
workflow_id: 'wf-signup-wait',
|
|
1663
|
+
resume_node_id: 'after',
|
|
1664
|
+
status: 'waiting',
|
|
1665
|
+
})
|
|
1666
|
+
const due = wait.run_at.getTime() - NOW
|
|
1667
|
+
expect(due).toBeGreaterThan(2 * DAY_MS - MINUTE_MS)
|
|
1668
|
+
expect(due).toBeLessThan(2 * DAY_MS + MINUTE_MS)
|
|
1669
|
+
const saved = JSON.parse(wait.context!)
|
|
1670
|
+
expect(saved.wait).toEqual({ until: wait.run_at.toISOString() })
|
|
1671
|
+
expect(saved.before).toBeDefined()
|
|
1672
|
+
// Nothing of the run's credentials is stored with it.
|
|
1673
|
+
expect(saved.__internalHeaders).toBeUndefined()
|
|
1674
|
+
expect(saved.__baseUrl).toBeUndefined()
|
|
1675
|
+
// The account's password never reaches the event.
|
|
1676
|
+
expect(JSON.stringify(saved)).not.toContain('hash')
|
|
1677
|
+
|
|
1678
|
+
// Not due yet: nothing resumes.
|
|
1679
|
+
expect((await dispatchOnce(db)).result.resumed).toBe(0)
|
|
1680
|
+
|
|
1681
|
+
wait.run_at = ago(1000)
|
|
1682
|
+
const second = await dispatchOnce(db)
|
|
1683
|
+
expect(second.result).toEqual({ claimed: 0, delivered: 0, failed: 0, resumed: 1 })
|
|
1684
|
+
expect(second.calls.map((call) => call.url)).toEqual([ECHO + '/after'])
|
|
1685
|
+
expect(second.calls[0].json).toEqual({
|
|
1686
|
+
email: 'noah@example.com',
|
|
1687
|
+
waitedUntil: saved.wait.until,
|
|
1688
|
+
})
|
|
1689
|
+
expect(wait.status).toBe('done')
|
|
1690
|
+
// The delivery takes the resumed run's outcome; the event settles.
|
|
1691
|
+
expect(db.delivery('wf-signup-wait')).toMatchObject({ status: 'delivered', attempts: 1 })
|
|
1692
|
+
// The Wait and its delivery in one statement, never two a crash could split.
|
|
1693
|
+
const resumeWrites = db.statements.filter(
|
|
1694
|
+
(text) =>
|
|
1695
|
+
text.startsWith('WITH w AS (UPDATE teleport_automation_waits') ||
|
|
1696
|
+
/^UPDATE teleport_automation_waits SET status = \$|^UPDATE teleport_event_deliveries SET status = \$3/.test(
|
|
1697
|
+
text
|
|
1698
|
+
)
|
|
1699
|
+
)
|
|
1700
|
+
expect(resumeWrites).toHaveLength(1)
|
|
1701
|
+
expect(resumeWrites[0]).toContain(
|
|
1702
|
+
'AND run_at = $3 RETURNING id) UPDATE teleport_event_deliveries SET status = $4'
|
|
1703
|
+
)
|
|
1704
|
+
expect(resumeWrites[0]).toContain("AND status = 'waiting' AND EXISTS (SELECT 1 FROM w)")
|
|
1705
|
+
expect(db.events[0]).toMatchObject({ status: 'delivered', attempts: 1 })
|
|
1706
|
+
})
|
|
1707
|
+
|
|
1708
|
+
it('records a failure after the Wait on the delivery, so Run again and the retries see it', async () => {
|
|
1709
|
+
const db = new FakeDatabase()
|
|
1710
|
+
seedSignup(db)
|
|
1711
|
+
await dispatchOnce(db)
|
|
1712
|
+
db.waits[0].run_at = ago(1000)
|
|
1713
|
+
const resumed = await withEnv(ENV, () =>
|
|
1714
|
+
withFetch(
|
|
1715
|
+
(call) => {
|
|
1716
|
+
if (call.url === ECHO + '/after') {
|
|
1717
|
+
throw new Error('mail provider down')
|
|
1718
|
+
}
|
|
1719
|
+
return { status: 200, body: {} }
|
|
1720
|
+
},
|
|
1721
|
+
() => silenced(() => bus(db).dispatch({}))
|
|
1722
|
+
)
|
|
1723
|
+
)
|
|
1724
|
+
expect(resumed.resumed).toBe(0)
|
|
1725
|
+
expect(db.waits[0].status).toBe('failed')
|
|
1726
|
+
const delivery = db.delivery('wf-signup-wait')!
|
|
1727
|
+
expect(delivery).toMatchObject({ status: 'failed', attempts: 1 })
|
|
1728
|
+
expect(delivery.error).toContain('mail provider down')
|
|
1729
|
+
// The resumed part only: the History's duration starts at the resume.
|
|
1730
|
+
expect(delivery.started_at!.getTime()).toBeGreaterThan(Date.now() - 60 * 1000)
|
|
1731
|
+
expect(db.events[0]).toMatchObject({ status: 'failed', attempts: 1 })
|
|
1732
|
+
})
|
|
1733
|
+
|
|
1734
|
+
it('never takes over an event whose run is parked at a Wait, however long it waits', async () => {
|
|
1735
|
+
const db = new FakeDatabase()
|
|
1736
|
+
seedSignup(db)
|
|
1737
|
+
await dispatchOnce(db)
|
|
1738
|
+
// Days later: the claim and the run's start are long stale.
|
|
1739
|
+
db.events[0].claimed_at = ago(2 * DAY_MS)
|
|
1740
|
+
db.delivery('wf-signup-wait')!.started_at = ago(2 * DAY_MS)
|
|
1741
|
+
const { result, calls } = await dispatchOnce(db)
|
|
1742
|
+
expect(result.claimed).toBe(0)
|
|
1743
|
+
expect(calls).toEqual([])
|
|
1744
|
+
expect(db.delivery('wf-signup-wait')!.status).toBe('waiting')
|
|
1745
|
+
expect(db.events[0].status).toBe('pending')
|
|
1746
|
+
})
|
|
1747
|
+
|
|
1748
|
+
it('takes over a resume left running past the stale window', async () => {
|
|
1749
|
+
const db = new FakeDatabase()
|
|
1750
|
+
seedSignup(db)
|
|
1751
|
+
await dispatchOnce(db)
|
|
1752
|
+
const wait = db.waits[0]
|
|
1753
|
+
wait.status = 'resuming'
|
|
1754
|
+
wait.run_at = ago(5 * MINUTE_MS)
|
|
1755
|
+
expect((await dispatchOnce(db)).result.resumed).toBe(0)
|
|
1756
|
+
wait.run_at = ago(STALE_MS + MINUTE_MS)
|
|
1757
|
+
expect((await dispatchOnce(db)).result.resumed).toBe(1)
|
|
1758
|
+
expect(wait.status).toBe('done')
|
|
1759
|
+
})
|
|
1760
|
+
})
|
|
1761
|
+
|
|
1762
|
+
describe('in-app event bus — Waits', () => {
|
|
1763
|
+
const wait = (id: string, stepNumber: number) => ({
|
|
1764
|
+
id,
|
|
1765
|
+
type: 'general-wait',
|
|
1766
|
+
config: { amount: 1, unit: 'days' },
|
|
1767
|
+
stepNumber,
|
|
1768
|
+
label: 'Wait a day',
|
|
1769
|
+
})
|
|
1770
|
+
const chain = (nodes: Row[]) =>
|
|
1771
|
+
nodes.map((node, i) => ({
|
|
1772
|
+
id: 'e' + i,
|
|
1773
|
+
source: i === 0 ? 't' : String(nodes[i - 1].id),
|
|
1774
|
+
target: String(node.id),
|
|
1775
|
+
}))
|
|
1776
|
+
const welcomeMail = {
|
|
1777
|
+
id: 'mail',
|
|
1778
|
+
type: 'email-resend',
|
|
1779
|
+
config: {
|
|
1780
|
+
apiKey: 'k',
|
|
1781
|
+
from: 'shop@example.com',
|
|
1782
|
+
to: ref('t', ['user', 'email']),
|
|
1783
|
+
subject: 'Welcome',
|
|
1784
|
+
body: '<p>Welcome</p>',
|
|
1785
|
+
},
|
|
1786
|
+
stepNumber: 1,
|
|
1787
|
+
label: 'Welcome',
|
|
1788
|
+
}
|
|
1789
|
+
const afterWait = post('after', '/after', 3, {
|
|
1790
|
+
email: ref('t', ['user', 'email']),
|
|
1791
|
+
waitedUntil: ref('wait', ['until']),
|
|
1792
|
+
})
|
|
1793
|
+
const WAITS: Record<string, Row> = {
|
|
1794
|
+
'wf-mail-wait': {
|
|
1795
|
+
id: 'wf-mail-wait',
|
|
1796
|
+
name: 'Welcome, then follow up',
|
|
1797
|
+
trigger: trigger('user-signed-up'),
|
|
1798
|
+
nodes: [welcomeMail, wait('wait', 2), afterWait],
|
|
1799
|
+
edges: chain([welcomeMail, wait('wait', 2), afterWait]),
|
|
1800
|
+
},
|
|
1801
|
+
'wf-trailing': {
|
|
1802
|
+
id: 'wf-trailing',
|
|
1803
|
+
name: 'Welcome, then wait',
|
|
1804
|
+
trigger: trigger('booking-created'),
|
|
1805
|
+
nodes: [post('hello', '/hello', 1), wait('last', 2)],
|
|
1806
|
+
edges: chain([post('hello', '/hello', 1), wait('last', 2)]),
|
|
1807
|
+
},
|
|
1808
|
+
'wf-note': {
|
|
1809
|
+
id: 'wf-note',
|
|
1810
|
+
name: 'Note the signup',
|
|
1811
|
+
trigger: trigger('user-signed-up'),
|
|
1812
|
+
nodes: [post('note', '/note', 1)],
|
|
1813
|
+
edges: chain([post('note', '/note', 1)]),
|
|
1814
|
+
},
|
|
1815
|
+
}
|
|
1816
|
+
let files: Map<string, string>
|
|
1817
|
+
|
|
1818
|
+
beforeAll(async () => {
|
|
1819
|
+
files = sources(await generate(WAITS))
|
|
1820
|
+
})
|
|
1821
|
+
|
|
1822
|
+
/** One dispatch, every fetch answered by `answer` (200 when it answers nothing). */
|
|
1823
|
+
const dispatchWith = (db: FakeDatabase, answer: (url: string) => boolean = () => true) =>
|
|
1824
|
+
withEnv(ENV, () =>
|
|
1825
|
+
withFetch(
|
|
1826
|
+
(call) => {
|
|
1827
|
+
if (!answer(call.url)) {
|
|
1828
|
+
throw new Error('provider down')
|
|
1829
|
+
}
|
|
1830
|
+
return { status: 200, body: { ok: true } }
|
|
1831
|
+
},
|
|
1832
|
+
async (calls) => ({
|
|
1833
|
+
result: await silenced(() =>
|
|
1834
|
+
(createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({})
|
|
1835
|
+
),
|
|
1836
|
+
calls,
|
|
1837
|
+
})
|
|
1838
|
+
)
|
|
1839
|
+
)
|
|
1840
|
+
|
|
1841
|
+
const seedSignupFor = (db: FakeDatabase, workflowIds: string[]) => {
|
|
1842
|
+
workflowIds.forEach((id) => db.automations.push(automationRow(id, 'user-signed-up')))
|
|
1843
|
+
db.users.push({ id: 'user-1', email: 'noah@example.com', name: 'Noah', created_at: ago(1000) })
|
|
1844
|
+
db.events.push(
|
|
1845
|
+
eventRow({
|
|
1846
|
+
id: 'evt-signup',
|
|
1847
|
+
event_type: 'user-signed-up',
|
|
1848
|
+
entity_type: 'user',
|
|
1849
|
+
entity_id: 'user-1',
|
|
1850
|
+
actor: 'noah@example.com',
|
|
1851
|
+
actor_type: 'customer',
|
|
1852
|
+
})
|
|
1853
|
+
)
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
it('retries a run that failed after its Wait from the step after it: what ran before is never repeated', async () => {
|
|
1857
|
+
const db = new FakeDatabase()
|
|
1858
|
+
seedSignupFor(db, ['wf-mail-wait'])
|
|
1859
|
+
|
|
1860
|
+
await dispatchWith(db)
|
|
1861
|
+
expect(db.mailSent).toHaveLength(1)
|
|
1862
|
+
expect(db.waits).toHaveLength(1)
|
|
1863
|
+
const parked = db.waits[0]
|
|
1864
|
+
const { wait: waited } = JSON.parse(parked.context!)
|
|
1865
|
+
|
|
1866
|
+
// Due, and the step after the Wait fails.
|
|
1867
|
+
parked.run_at = ago(1000)
|
|
1868
|
+
await dispatchWith(db, (url) => url !== ECHO + '/after')
|
|
1869
|
+
expect(parked.status).toBe('failed')
|
|
1870
|
+
expect(db.delivery('wf-mail-wait')).toMatchObject({ status: 'failed', attempts: 1 })
|
|
1871
|
+
expect(db.events[0]).toMatchObject({ status: 'failed', attempts: 1 })
|
|
1872
|
+
|
|
1873
|
+
// The retry resumes after the Wait, with what the run had stored.
|
|
1874
|
+
const retry = await dispatchWith(db)
|
|
1875
|
+
expect(retry.result).toEqual({ claimed: 1, delivered: 1, failed: 0, resumed: 0 })
|
|
1876
|
+
expect(retry.calls.map((call) => [call.url, call.json])).toEqual([
|
|
1877
|
+
[ECHO + '/after', { email: 'noah@example.com', waitedUntil: waited.until }],
|
|
1878
|
+
])
|
|
1879
|
+
// The welcome email before the Wait went out once.
|
|
1880
|
+
expect(db.mailSent).toHaveLength(1)
|
|
1881
|
+
expect(db.waits).toHaveLength(1)
|
|
1882
|
+
expect(parked.status).toBe('done')
|
|
1883
|
+
expect(db.delivery('wf-mail-wait')).toMatchObject({ status: 'delivered', attempts: 2 })
|
|
1884
|
+
expect(db.events[0]).toMatchObject({ status: 'delivered', attempts: 2 })
|
|
1885
|
+
})
|
|
1886
|
+
|
|
1887
|
+
it('keeps resuming a retry from the same Wait while it fails, and starts a first failure over', async () => {
|
|
1888
|
+
const db = new FakeDatabase()
|
|
1889
|
+
seedSignupFor(db, ['wf-mail-wait'])
|
|
1890
|
+
await dispatchWith(db)
|
|
1891
|
+
db.waits[0].run_at = ago(1000)
|
|
1892
|
+
const down = (url: string) => url !== ECHO + '/after'
|
|
1893
|
+
await dispatchWith(db, down)
|
|
1894
|
+
const again = await dispatchWith(db, down)
|
|
1895
|
+
expect(again.calls.map((call) => call.url)).toEqual([ECHO + '/after'])
|
|
1896
|
+
expect(db.waits[0].status).toBe('failed')
|
|
1897
|
+
expect(db.mailSent).toHaveLength(1)
|
|
1898
|
+
expect(db.delivery('wf-mail-wait')).toMatchObject({ status: 'failed', attempts: 2 })
|
|
1899
|
+
|
|
1900
|
+
// A run that failed before reaching any Wait is run again from its start.
|
|
1901
|
+
const early = new FakeDatabase()
|
|
1902
|
+
seedSignupFor(early, ['wf-note'])
|
|
1903
|
+
await dispatchWith(early, (url) => url !== ECHO + '/note')
|
|
1904
|
+
expect(early.events[0].status).toBe('failed')
|
|
1905
|
+
const rerun = await dispatchWith(early)
|
|
1906
|
+
expect(rerun.calls.map((call) => call.url)).toEqual([ECHO + '/note'])
|
|
1907
|
+
expect(early.delivery('wf-note')).toMatchObject({ status: 'delivered', attempts: 2 })
|
|
1908
|
+
})
|
|
1909
|
+
|
|
1910
|
+
/** Every fetch answered at once, except /after's, which waits for its release. */
|
|
1911
|
+
const holdAfter = () => {
|
|
1912
|
+
const held: Array<() => void> = []
|
|
1913
|
+
const answer = {
|
|
1914
|
+
ok: true,
|
|
1915
|
+
status: 200,
|
|
1916
|
+
headers: new Map([['content-type', 'application/json']]),
|
|
1917
|
+
json: async () => ({}),
|
|
1918
|
+
text: async () => '{}',
|
|
1919
|
+
}
|
|
1920
|
+
const fetch = (url: string) =>
|
|
1921
|
+
url === ECHO + '/after'
|
|
1922
|
+
? new Promise((resolve) => held.push(() => resolve(answer)))
|
|
1923
|
+
: Promise.resolve(answer)
|
|
1924
|
+
return { held, fetch }
|
|
1925
|
+
}
|
|
1926
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 10))
|
|
1927
|
+
|
|
1928
|
+
/**
|
|
1929
|
+
* A dispatch whose handlers are waited for 20 ms, every fetch answered by
|
|
1930
|
+
* `fetch`, then `afterwards` (in the same environment: a late run writes
|
|
1931
|
+
* its outcome on the database the app is configured with).
|
|
1932
|
+
*/
|
|
1933
|
+
const slowDispatch = async (
|
|
1934
|
+
db: FakeDatabase,
|
|
1935
|
+
fetch: unknown,
|
|
1936
|
+
afterwards: () => Promise<void>
|
|
1937
|
+
) => {
|
|
1938
|
+
const scope = globalThis as Record<string, unknown>
|
|
1939
|
+
const previous = scope.fetch
|
|
1940
|
+
scope.fetch = fetch
|
|
1941
|
+
try {
|
|
1942
|
+
await withEnv(ENV, async () => {
|
|
1943
|
+
await silenced(() =>
|
|
1944
|
+
(createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({
|
|
1945
|
+
handlerTimeoutMs: 20,
|
|
1946
|
+
})
|
|
1947
|
+
)
|
|
1948
|
+
await silenced(afterwards)
|
|
1949
|
+
})
|
|
1950
|
+
} finally {
|
|
1951
|
+
scope.fetch = previous
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/** A run of wf-mail-wait whose part after the Wait failed once. */
|
|
1956
|
+
const failedAfterWait = async () => {
|
|
1957
|
+
const db = new FakeDatabase()
|
|
1958
|
+
seedSignupFor(db, ['wf-mail-wait'])
|
|
1959
|
+
await dispatchWith(db)
|
|
1960
|
+
db.waits[0].run_at = ago(1000)
|
|
1961
|
+
await dispatchWith(db, (url) => url !== ECHO + '/after')
|
|
1962
|
+
expect(db.waits[0].status).toBe('failed')
|
|
1963
|
+
return db
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
it('settles the Wait of a late retry only when that retry still owns its delivery', async () => {
|
|
1967
|
+
for (const takenOver of [true, false]) {
|
|
1968
|
+
const db = await failedAfterWait()
|
|
1969
|
+
const slow = holdAfter()
|
|
1970
|
+
const delivery = () => db.delivery('wf-mail-wait')!
|
|
1971
|
+
// The retry resumes after the Wait and outlives the dispatcher's patience.
|
|
1972
|
+
await slowDispatch(db, slow.fetch, async () => {
|
|
1973
|
+
expect(slow.held).toHaveLength(1)
|
|
1974
|
+
expect(delivery()).toMatchObject({ status: 'running', attempts: 2 })
|
|
1975
|
+
if (takenOver) {
|
|
1976
|
+
// Another dispatcher took the stale run over: the delivery is its.
|
|
1977
|
+
delivery().attempts = 3
|
|
1978
|
+
}
|
|
1979
|
+
slow.held[0]()
|
|
1980
|
+
await settle()
|
|
1981
|
+
})
|
|
1982
|
+
expect({ takenOver, wait: db.waits[0].status, delivery: delivery().status }).toEqual(
|
|
1983
|
+
takenOver
|
|
1984
|
+
? { takenOver, wait: 'failed', delivery: 'running' }
|
|
1985
|
+
: { takenOver, wait: 'done', delivery: 'delivered' }
|
|
1986
|
+
)
|
|
1987
|
+
}
|
|
1988
|
+
})
|
|
1989
|
+
|
|
1990
|
+
it("records a late resume's outcome only while its claim still holds the Wait", async () => {
|
|
1991
|
+
for (const takenOver of [true, false]) {
|
|
1992
|
+
const db = new FakeDatabase()
|
|
1993
|
+
seedSignupFor(db, ['wf-mail-wait'])
|
|
1994
|
+
await dispatchWith(db)
|
|
1995
|
+
const parked = db.waits[0]
|
|
1996
|
+
parked.run_at = ago(1000)
|
|
1997
|
+
const slow = holdAfter()
|
|
1998
|
+
await slowDispatch(db, slow.fetch, async () => {
|
|
1999
|
+
expect(slow.held).toHaveLength(1)
|
|
2000
|
+
expect(parked.status).toBe('resuming')
|
|
2001
|
+
if (takenOver) {
|
|
2002
|
+
// Stale, and claimed again by another dispatcher: a new run_at token.
|
|
2003
|
+
parked.run_at = new Date(parked.run_at.getTime() + 1000)
|
|
2004
|
+
}
|
|
2005
|
+
slow.held[0]()
|
|
2006
|
+
await settle()
|
|
2007
|
+
})
|
|
2008
|
+
const delivery = db.delivery('wf-mail-wait')!
|
|
2009
|
+
expect({ takenOver, wait: parked.status, delivery: delivery.status }).toEqual(
|
|
2010
|
+
takenOver
|
|
2011
|
+
? { takenOver, wait: 'resuming', delivery: 'waiting' }
|
|
2012
|
+
: { takenOver, wait: 'done', delivery: 'delivered' }
|
|
2013
|
+
)
|
|
2014
|
+
}
|
|
2015
|
+
})
|
|
2016
|
+
|
|
2017
|
+
it('ends a run whose last step is a Wait: nothing is parked, the event is delivered', async () => {
|
|
2018
|
+
const db = new FakeDatabase()
|
|
2019
|
+
db.automations.push(automationRow('wf-trailing', 'booking-created'))
|
|
2020
|
+
db.bookings.push({ id: 'bk-1', reference: 'BK-1', status: 'pending', customer_email: 'a@b.c' })
|
|
2021
|
+
db.events.push(
|
|
2022
|
+
eventRow({
|
|
2023
|
+
id: 'evt-booking',
|
|
2024
|
+
event_type: 'booking-created',
|
|
2025
|
+
entity_type: 'booking',
|
|
2026
|
+
entity_id: 'bk-1',
|
|
2027
|
+
})
|
|
2028
|
+
)
|
|
2029
|
+
const { result, calls } = await dispatchWith(db)
|
|
2030
|
+
expect(result).toEqual({ claimed: 1, delivered: 1, failed: 0, resumed: 0 })
|
|
2031
|
+
expect(calls.map((call) => call.url)).toEqual([ECHO + '/hello'])
|
|
2032
|
+
expect(db.waits).toEqual([])
|
|
2033
|
+
expect(db.delivery('wf-trailing')).toMatchObject({ status: 'delivered' })
|
|
2034
|
+
expect(db.events[0]).toMatchObject({ status: 'delivered' })
|
|
2035
|
+
})
|
|
2036
|
+
|
|
2037
|
+
it('lets a run parked by an automation that is now off no longer hold back the retry of another', async () => {
|
|
2038
|
+
const db = new FakeDatabase()
|
|
2039
|
+
seedSignupFor(db, ['wf-mail-wait', 'wf-note'])
|
|
2040
|
+
// The note fails; the welcome parks at its Wait.
|
|
2041
|
+
await dispatchWith(db, (url) => url !== ECHO + '/note')
|
|
2042
|
+
expect(db.delivery('wf-mail-wait')!.status).toBe('waiting')
|
|
2043
|
+
expect(db.delivery('wf-note')!.status).toBe('failed')
|
|
2044
|
+
expect(db.events[0].status).toBe('pending')
|
|
2045
|
+
|
|
2046
|
+
// Long after, with the welcome automation still on, its parked run holds the event.
|
|
2047
|
+
const longAgo = ago(STALE_MS + MINUTE_MS)
|
|
2048
|
+
db.events[0].claimed_at = longAgo
|
|
2049
|
+
db.deliveries.forEach((d) => {
|
|
2050
|
+
d.started_at = longAgo
|
|
2051
|
+
})
|
|
2052
|
+
expect((await dispatchWith(db)).result.claimed).toBe(0)
|
|
2053
|
+
|
|
2054
|
+
// Switched off: its run stays parked, and the note is retried.
|
|
2055
|
+
db.automations.find((a) => a.workflow_id === 'wf-mail-wait')!.enabled = false
|
|
2056
|
+
const retried = await dispatchWith(db)
|
|
2057
|
+
expect(db.claimStatement()).toContain(
|
|
2058
|
+
"d.status = 'waiting' AND d.workflow_id = ANY($2::text[]) AND EXISTS (SELECT 1 FROM teleport_automations a WHERE a.workflow_id = d.workflow_id AND a.enabled)"
|
|
2059
|
+
)
|
|
2060
|
+
expect(retried.result).toEqual({ claimed: 1, delivered: 1, failed: 0, resumed: 0 })
|
|
2061
|
+
expect(retried.calls.map((call) => call.url)).toEqual([ECHO + '/note'])
|
|
2062
|
+
expect(db.delivery('wf-note')).toMatchObject({ status: 'delivered', attempts: 2 })
|
|
2063
|
+
expect(db.delivery('wf-mail-wait')!.status).toBe('waiting')
|
|
2064
|
+
expect(db.waits[0].status).toBe('waiting')
|
|
2065
|
+
expect(db.events[0].status).toBe('delivered')
|
|
2066
|
+
})
|
|
2067
|
+
})
|
|
2068
|
+
|
|
2069
|
+
describe('in-app event bus — claims that outlive their run', () => {
|
|
2070
|
+
let files: Map<string, string>
|
|
2071
|
+
|
|
2072
|
+
beforeAll(async () => {
|
|
2073
|
+
files = sources(await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }))
|
|
2074
|
+
})
|
|
2075
|
+
|
|
2076
|
+
const okResponse = () => ({
|
|
2077
|
+
ok: true,
|
|
2078
|
+
status: 200,
|
|
2079
|
+
headers: new Map([['content-type', 'application/json']]),
|
|
2080
|
+
json: async () => ({}),
|
|
2081
|
+
text: async () => '{}',
|
|
2082
|
+
})
|
|
2083
|
+
|
|
2084
|
+
/** Every fetch waits until released, then answers 200. */
|
|
2085
|
+
const holdFetches = () => {
|
|
2086
|
+
const held: Array<() => void> = []
|
|
2087
|
+
const urls: string[] = []
|
|
2088
|
+
const scope = globalThis as Record<string, unknown>
|
|
2089
|
+
const previous = scope.fetch
|
|
2090
|
+
scope.fetch = (url: string) => {
|
|
2091
|
+
urls.push(url)
|
|
2092
|
+
return new Promise((resolve) => {
|
|
2093
|
+
held.push(() => resolve(okResponse()))
|
|
2094
|
+
})
|
|
2095
|
+
}
|
|
2096
|
+
return { held, urls, restore: () => (scope.fetch = previous) }
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 10))
|
|
2100
|
+
|
|
2101
|
+
const seedBooking = (db: FakeDatabase): EventRow => {
|
|
2102
|
+
db.automations.push(automationRow('wf-booking-slow', 'booking-created'))
|
|
2103
|
+
db.bookings.push({
|
|
2104
|
+
id: 'b-1',
|
|
2105
|
+
reference: 'BK-1',
|
|
2106
|
+
status: 'confirmed',
|
|
2107
|
+
customer_email: 'liam@example.com',
|
|
2108
|
+
starts_at: ago(-DAY_MS),
|
|
2109
|
+
ends_at: ago(-DAY_MS - 60 * MINUTE_MS),
|
|
2110
|
+
})
|
|
2111
|
+
const event = eventRow({
|
|
2112
|
+
id: 'evt-booking',
|
|
2113
|
+
event_type: 'booking-created',
|
|
2114
|
+
entity_type: 'booking',
|
|
2115
|
+
entity_id: 'b-1',
|
|
2116
|
+
})
|
|
2117
|
+
db.events.push(event)
|
|
2118
|
+
return event
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
it('settles an event whose last attempt never reported back, starting no handler', async () => {
|
|
2122
|
+
const db = new FakeDatabase()
|
|
2123
|
+
const event = seedBooking(db)
|
|
2124
|
+
event.status = 'pending'
|
|
2125
|
+
event.attempts = 5
|
|
2126
|
+
event.claimed_at = ago(5 * MINUTE_MS)
|
|
2127
|
+
db.deliveries.push({
|
|
2128
|
+
id: 700,
|
|
2129
|
+
event_id: event.id,
|
|
2130
|
+
workflow_id: 'wf-booking-slow',
|
|
2131
|
+
status: 'running',
|
|
2132
|
+
error: null,
|
|
2133
|
+
attempts: 5,
|
|
2134
|
+
started_at: ago(5 * MINUTE_MS),
|
|
2135
|
+
finished_at: null,
|
|
2136
|
+
})
|
|
2137
|
+
const dispatch = () =>
|
|
2138
|
+
withEcho(async (recorded) => ({
|
|
2139
|
+
result: await silenced(() =>
|
|
2140
|
+
(createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({})
|
|
2141
|
+
),
|
|
2142
|
+
calls: recorded,
|
|
2143
|
+
}))
|
|
2144
|
+
|
|
2145
|
+
// Inside the stale window: left alone.
|
|
2146
|
+
await dispatch()
|
|
2147
|
+
expect(event.status).toBe('pending')
|
|
2148
|
+
expect(db.delivery('wf-booking-slow')!.status).toBe('running')
|
|
2149
|
+
|
|
2150
|
+
event.claimed_at = ago(STALE_MS + MINUTE_MS)
|
|
2151
|
+
db.delivery('wf-booking-slow')!.started_at = ago(STALE_MS + MINUTE_MS)
|
|
2152
|
+
const { result, calls } = await dispatch()
|
|
2153
|
+
expect(calls).toEqual([])
|
|
2154
|
+
expect(result).toEqual({ claimed: 0, delivered: 0, failed: 0, resumed: 0 })
|
|
2155
|
+
expect(db.delivery('wf-booking-slow')).toMatchObject({ status: 'failed', error: 'Timed out' })
|
|
2156
|
+
expect(event).toMatchObject({ status: 'failed', attempts: 5 })
|
|
2157
|
+
})
|
|
2158
|
+
|
|
2159
|
+
const exhausted = (db: FakeDatabase, deliveryStatus: string) => {
|
|
2160
|
+
const event = seedBooking(db)
|
|
2161
|
+
event.status = 'pending'
|
|
2162
|
+
event.attempts = 5
|
|
2163
|
+
event.claimed_at = ago(STALE_MS + MINUTE_MS)
|
|
2164
|
+
db.deliveries.push({
|
|
2165
|
+
id: 701,
|
|
2166
|
+
event_id: event.id,
|
|
2167
|
+
workflow_id: 'wf-booking-slow',
|
|
2168
|
+
status: deliveryStatus,
|
|
2169
|
+
error: null,
|
|
2170
|
+
attempts: 5,
|
|
2171
|
+
started_at: ago(STALE_MS + MINUTE_MS),
|
|
2172
|
+
finished_at: deliveryStatus === 'running' ? null : ago(STALE_MS),
|
|
2173
|
+
})
|
|
2174
|
+
return event
|
|
2175
|
+
}
|
|
2176
|
+
const dispatchWith = (db: FakeDatabase, options: Row = {}) =>
|
|
2177
|
+
withEcho(async (calls) => ({
|
|
2178
|
+
result: await silenced(() =>
|
|
2179
|
+
(createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch(options)
|
|
2180
|
+
),
|
|
2181
|
+
calls,
|
|
2182
|
+
}))
|
|
2183
|
+
|
|
2184
|
+
it('settles an exhausted event from its delivery rows: a 5th attempt that finished late ends delivered', async () => {
|
|
2185
|
+
const db = new FakeDatabase()
|
|
2186
|
+
const event = exhausted(db, 'delivered')
|
|
2187
|
+
const { calls } = await dispatchWith(db)
|
|
2188
|
+
expect(calls).toEqual([])
|
|
2189
|
+
expect(db.delivery('wf-booking-slow')!.status).toBe('delivered')
|
|
2190
|
+
expect(event).toMatchObject({ status: 'delivered', attempts: 5 })
|
|
2191
|
+
})
|
|
2192
|
+
|
|
2193
|
+
it('times out the runs of an exhausted event even out of time, so no final event keeps a running row', async () => {
|
|
2194
|
+
const db = new FakeDatabase()
|
|
2195
|
+
const event = exhausted(db, 'running')
|
|
2196
|
+
const { calls } = await dispatchWith(db, { deadlineMs: -1 })
|
|
2197
|
+
expect(calls).toEqual([])
|
|
2198
|
+
expect(db.delivery('wf-booking-slow')).toMatchObject({ status: 'failed', error: 'Timed out' })
|
|
2199
|
+
expect(event.status).toBe('failed')
|
|
2200
|
+
expect(db.deliveries.filter((d) => d.status === 'running')).toEqual([])
|
|
2201
|
+
})
|
|
2202
|
+
|
|
2203
|
+
it('lets a dispatcher that ran out of time hand back only the Waits it still holds', async () => {
|
|
2204
|
+
const db = new FakeDatabase()
|
|
2205
|
+
seedSignup(db)
|
|
2206
|
+
db.events[0].status = 'delivered'
|
|
2207
|
+
for (const id of [1, 2]) {
|
|
2208
|
+
db.waits.push({
|
|
2209
|
+
id,
|
|
2210
|
+
event_id: 'evt-signup',
|
|
2211
|
+
workflow_id: 'wf-signup-wait',
|
|
2212
|
+
resume_node_id: 'after',
|
|
2213
|
+
context: JSON.stringify({ wait: { until: ago(MINUTE_MS).toISOString() } }),
|
|
2214
|
+
run_at: ago(MINUTE_MS),
|
|
2215
|
+
status: 'waiting',
|
|
2216
|
+
created_at: ago(DAY_MS),
|
|
2217
|
+
})
|
|
2218
|
+
}
|
|
2219
|
+
const fetches = holdFetches()
|
|
2220
|
+
const first = createApp(files, db)('lib/automations/event-bus.js') as Bus
|
|
2221
|
+
const second = createApp(files, db)('lib/automations/event-bus.js') as Bus
|
|
2222
|
+
try {
|
|
2223
|
+
await withEnv(ENV, () =>
|
|
2224
|
+
silenced(async () => {
|
|
2225
|
+
// The first dispatcher claims both Waits and freezes on the first resume.
|
|
2226
|
+
const frozen = first.dispatch({ deadlineMs: 30, handlerTimeoutMs: 60000 })
|
|
2227
|
+
await settle()
|
|
2228
|
+
expect(db.waits.map((w) => w.status)).toEqual(['resuming', 'resuming'])
|
|
2229
|
+
expect(fetches.urls).toHaveLength(1)
|
|
2230
|
+
|
|
2231
|
+
// Long enough later, a second dispatcher takes the other Wait over.
|
|
2232
|
+
const other = db.waits[1]
|
|
2233
|
+
other.run_at = ago(STALE_MS + MINUTE_MS)
|
|
2234
|
+
await second.dispatch({ handlerTimeoutMs: 20 })
|
|
2235
|
+
expect(fetches.urls).toHaveLength(2)
|
|
2236
|
+
const takeover = other.run_at
|
|
2237
|
+
|
|
2238
|
+
// The first wakes past its budget and hands back what it did not
|
|
2239
|
+
// start: that Wait is no longer its own, so it writes nothing.
|
|
2240
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
2241
|
+
fetches.held[0]()
|
|
2242
|
+
await frozen
|
|
2243
|
+
expect(db.waits[0].status).toBe('done')
|
|
2244
|
+
expect(other).toMatchObject({ status: 'resuming', run_at: takeover })
|
|
2245
|
+
|
|
2246
|
+
fetches.held[1]()
|
|
2247
|
+
await settle()
|
|
2248
|
+
expect(other.status).toBe('done')
|
|
2249
|
+
})
|
|
2250
|
+
)
|
|
2251
|
+
} finally {
|
|
2252
|
+
fetches.restore()
|
|
2253
|
+
}
|
|
2254
|
+
})
|
|
2255
|
+
|
|
2256
|
+
it('takes an event over within one stale window: not while one of its deliveries started recently', async () => {
|
|
2257
|
+
const db = new FakeDatabase()
|
|
2258
|
+
const event = seedOrderPaid(db)
|
|
2259
|
+
event.status = 'pending'
|
|
2260
|
+
event.attempts = 1
|
|
2261
|
+
event.claimed_at = ago(STALE_MS + 5 * MINUTE_MS)
|
|
2262
|
+
db.deliveries.push({
|
|
2263
|
+
id: 800,
|
|
2264
|
+
event_id: event.id,
|
|
2265
|
+
workflow_id: 'wf-paid-ok',
|
|
2266
|
+
status: 'running',
|
|
2267
|
+
error: null,
|
|
2268
|
+
attempts: 1,
|
|
2269
|
+
started_at: ago(5 * MINUTE_MS),
|
|
2270
|
+
finished_at: null,
|
|
2271
|
+
})
|
|
2272
|
+
const dispatch = () =>
|
|
2273
|
+
withEcho(async (recorded) => ({
|
|
2274
|
+
result: await silenced(() =>
|
|
2275
|
+
(createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({})
|
|
2276
|
+
),
|
|
2277
|
+
calls: recorded,
|
|
2278
|
+
}))
|
|
2279
|
+
|
|
2280
|
+
// Its claim is stale, but a delivery started five minutes ago.
|
|
2281
|
+
expect((await dispatch()).result.claimed).toBe(0)
|
|
2282
|
+
|
|
2283
|
+
// Once that start is stale too, one takeover runs everything again.
|
|
2284
|
+
db.delivery('wf-paid-ok')!.started_at = ago(STALE_MS + MINUTE_MS)
|
|
2285
|
+
const { result, calls } = await dispatch()
|
|
2286
|
+
expect(result.claimed).toBe(1)
|
|
2287
|
+
expect(calls.filter((call) => call.url === ECHO + '/thanks')).toHaveLength(1)
|
|
2288
|
+
expect(db.delivery('wf-paid-ok')).toMatchObject({ status: 'delivered', attempts: 2 })
|
|
2289
|
+
})
|
|
2290
|
+
|
|
2291
|
+
it('lets a run that froze and woke after a takeover write nothing over it', async () => {
|
|
2292
|
+
const db = new FakeDatabase()
|
|
2293
|
+
const event = seedBooking(db)
|
|
2294
|
+
event.created_at = ago(2000)
|
|
2295
|
+
// A second event the first dispatcher claimed and never reached.
|
|
2296
|
+
const later = seedSignup(db)
|
|
2297
|
+
const fetches = holdFetches()
|
|
2298
|
+
// Two processes sharing one database.
|
|
2299
|
+
const first = createApp(files, db)('lib/automations/event-bus.js') as Bus
|
|
2300
|
+
const second = createApp(files, db)('lib/automations/event-bus.js') as Bus
|
|
2301
|
+
try {
|
|
2302
|
+
await withEnv(ENV, () =>
|
|
2303
|
+
silenced(async () => {
|
|
2304
|
+
// The first dispatcher claims both and waits for its first handler (it froze there).
|
|
2305
|
+
const frozen = first.dispatch({ deadlineMs: 30, handlerTimeoutMs: 60000 })
|
|
2306
|
+
await settle()
|
|
2307
|
+
expect(fetches.urls).toHaveLength(1)
|
|
2308
|
+
expect(later.status).toBe('pending')
|
|
2309
|
+
const firstClaim = event.claimed_at
|
|
2310
|
+
|
|
2311
|
+
// Long enough later, a second dispatcher takes both over.
|
|
2312
|
+
for (const stale of [event, later]) {
|
|
2313
|
+
stale.claimed_at = ago(STALE_MS + MINUTE_MS)
|
|
2314
|
+
}
|
|
2315
|
+
db.delivery('wf-booking-slow')!.started_at = ago(STALE_MS + MINUTE_MS)
|
|
2316
|
+
await second.dispatch({ handlerTimeoutMs: 20 })
|
|
2317
|
+
expect(fetches.urls).toHaveLength(3)
|
|
2318
|
+
expect(event.claimed_at).not.toBe(firstClaim)
|
|
2319
|
+
expect(db.delivery('wf-booking-slow')).toMatchObject({ status: 'running', attempts: 2 })
|
|
2320
|
+
const takeover = { event: event.claimed_at, later: later.claimed_at }
|
|
2321
|
+
|
|
2322
|
+
// The first run wakes: its claims are gone, it writes nothing — not
|
|
2323
|
+
// the delivery, not the event, not the hand-back of the event it
|
|
2324
|
+
// never reached.
|
|
2325
|
+
fetches.held[0]()
|
|
2326
|
+
await frozen
|
|
2327
|
+
expect(db.delivery('wf-booking-slow')).toMatchObject({ status: 'running', attempts: 2 })
|
|
2328
|
+
expect(event).toMatchObject({ status: 'pending', claimed_at: takeover.event })
|
|
2329
|
+
expect(later).toMatchObject({ status: 'pending', claimed_at: takeover.later })
|
|
2330
|
+
|
|
2331
|
+
// The runs of the takeover end: recorded.
|
|
2332
|
+
fetches.held.slice(1).forEach((release) => release())
|
|
2333
|
+
await settle()
|
|
2334
|
+
expect(db.delivery('wf-booking-slow')!.status).toBe('delivered')
|
|
2335
|
+
})
|
|
2336
|
+
)
|
|
2337
|
+
} finally {
|
|
2338
|
+
fetches.restore()
|
|
2339
|
+
}
|
|
2340
|
+
})
|
|
2341
|
+
|
|
2342
|
+
it('starts nothing for a call that joins once the time budget is spent: the next kick takes it', async () => {
|
|
2343
|
+
const db = new FakeDatabase()
|
|
2344
|
+
seedBooking(db)
|
|
2345
|
+
const fetches = holdFetches()
|
|
2346
|
+
const app = createApp(files, db)('lib/automations/event-bus.js') as Bus
|
|
2347
|
+
try {
|
|
2348
|
+
await withEnv(ENV, () =>
|
|
2349
|
+
silenced(async () => {
|
|
2350
|
+
const running = app.dispatch({ deadlineMs: 30, handlerTimeoutMs: 60000 })
|
|
2351
|
+
await settle()
|
|
2352
|
+
// Joins the dispatch already running: the same promise, no new claim yet.
|
|
2353
|
+
const joined = app.dispatch({})
|
|
2354
|
+
expect(joined).toBe(running)
|
|
2355
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
2356
|
+
fetches.held[0]()
|
|
2357
|
+
await running
|
|
2358
|
+
await settle()
|
|
2359
|
+
const claims = db.statements.filter(
|
|
2360
|
+
(text) => text.includes('FROM teleport_events e') && text.includes('SKIP LOCKED')
|
|
2361
|
+
)
|
|
2362
|
+
expect(claims).toHaveLength(1)
|
|
2363
|
+
})
|
|
2364
|
+
)
|
|
2365
|
+
} finally {
|
|
2366
|
+
fetches.restore()
|
|
2367
|
+
}
|
|
2368
|
+
})
|
|
2369
|
+
|
|
2370
|
+
it('fails a Wait inside a loop the same way in a test as in a live run', async () => {
|
|
2371
|
+
const loopFiles = sources(
|
|
2372
|
+
await generate({
|
|
2373
|
+
'wf-loop-wait': {
|
|
2374
|
+
id: 'wf-loop-wait',
|
|
2375
|
+
name: 'Wait in a loop',
|
|
2376
|
+
trigger: trigger('form-submitted'),
|
|
2377
|
+
nodes: [
|
|
2378
|
+
{
|
|
2379
|
+
id: 'loop',
|
|
2380
|
+
type: 'general-loop',
|
|
2381
|
+
config: { collection: [1, 2] },
|
|
2382
|
+
stepNumber: 1,
|
|
2383
|
+
label: 'Each',
|
|
2384
|
+
},
|
|
2385
|
+
{
|
|
2386
|
+
id: 'wait',
|
|
2387
|
+
type: 'general-wait',
|
|
2388
|
+
config: { amount: 1, unit: 'hours' },
|
|
2389
|
+
stepNumber: 2,
|
|
2390
|
+
label: 'Wait',
|
|
2391
|
+
},
|
|
2392
|
+
],
|
|
2393
|
+
edges: [
|
|
2394
|
+
{ id: 'e1', source: 't', target: 'loop' },
|
|
2395
|
+
{ id: 'e2', source: 'loop', target: 'wait', sourceHandle: 'loop' },
|
|
2396
|
+
],
|
|
2397
|
+
},
|
|
2398
|
+
})
|
|
2399
|
+
)
|
|
2400
|
+
const db = new FakeDatabase()
|
|
2401
|
+
db.automations.push(automationRow('wf-loop-wait', 'form-submitted'))
|
|
2402
|
+
db.events.push(eventRow({ id: 'evt-form', event_type: 'form-submitted' }))
|
|
2403
|
+
const bus = () =>
|
|
2404
|
+
createApp(loopFiles, db)('lib/automations/event-bus.js') as Bus & {
|
|
2405
|
+
runTestEvent(input: Row, req?: Row): Promise<Totals>
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
await withEcho(() => silenced(() => bus().dispatch({})))
|
|
2409
|
+
const live = db.delivery('wf-loop-wait')!
|
|
2410
|
+
expect(live.status).toBe('failed')
|
|
2411
|
+
expect(live.error).toBe('A Wait cannot run inside a loop')
|
|
2412
|
+
|
|
2413
|
+
const tested = await withEcho(() =>
|
|
2414
|
+
silenced(() =>
|
|
2415
|
+
bus().runTestEvent({
|
|
2416
|
+
workflowId: 'wf-loop-wait',
|
|
2417
|
+
event: { event: { type: 'form-submitted' }, form: { id: 'f-1', name: 'Contact' } },
|
|
2418
|
+
})
|
|
2419
|
+
)
|
|
2420
|
+
)
|
|
2421
|
+
expect(tested).toEqual({ claimed: 1, delivered: 0, failed: 1, resumed: 0 })
|
|
2422
|
+
const testRun = db.deliveries.find((d) => d.status === 'test')!
|
|
2423
|
+
expect(testRun.error).toBe('A Wait cannot run inside a loop')
|
|
2424
|
+
expect(db.waits).toEqual([])
|
|
2425
|
+
})
|
|
2426
|
+
})
|
|
2427
|
+
|
|
2428
|
+
describe('in-app event bus — acts only on what the database stands behind', () => {
|
|
2429
|
+
// A row of the ledger can be written by a caller (the shared writer's
|
|
2430
|
+
// segment is reachable from a browser); the record it names cannot.
|
|
2431
|
+
const ATTACKER = 'attacker@evil.test'
|
|
2432
|
+
const greet = (id: string, eventType: string, emailPath: string[]): Row => ({
|
|
2433
|
+
id,
|
|
2434
|
+
name: 'Greet ' + eventType,
|
|
2435
|
+
trigger: trigger(eventType),
|
|
2436
|
+
nodes: [
|
|
2437
|
+
post('greet', '/greet/' + eventType, 1, {
|
|
2438
|
+
email: ref('t', emailPath),
|
|
2439
|
+
actor: ref('t', ['actor', 'email']),
|
|
2440
|
+
}),
|
|
2441
|
+
],
|
|
2442
|
+
edges: [{ id: 'e1', source: 't', target: 'greet' }],
|
|
2443
|
+
})
|
|
2444
|
+
const GREETERS: Record<string, Row> = {
|
|
2445
|
+
'wf-review': greet('wf-review', 'review-submitted', ['review', 'authorEmail']),
|
|
2446
|
+
'wf-news': greet('wf-news', 'newsletter-subscribed', ['subscriber', 'email']),
|
|
2447
|
+
'wf-wl': greet('wf-wl', 'waitlist-joined', ['waitlist', 'entry', 'email']),
|
|
2448
|
+
'wf-form': greet('wf-form', 'form-submitted', ['form', 'fields', 'email']),
|
|
2449
|
+
'wf-bye': {
|
|
2450
|
+
id: 'wf-bye',
|
|
2451
|
+
name: 'Say goodbye',
|
|
2452
|
+
trigger: trigger('user-deleted'),
|
|
2453
|
+
nodes: [
|
|
2454
|
+
post('bye', '/bye', 1, {
|
|
2455
|
+
id: ref('t', ['user', 'id']),
|
|
2456
|
+
email: ref('t', ['user', 'email']),
|
|
2457
|
+
name: ref('t', ['user', 'name']),
|
|
2458
|
+
actor: ref('t', ['actor', 'email']),
|
|
2459
|
+
}),
|
|
2460
|
+
],
|
|
2461
|
+
edges: [{ id: 'e1', source: 't', target: 'bye' }],
|
|
2462
|
+
},
|
|
2463
|
+
'wf-low': {
|
|
2464
|
+
id: 'wf-low',
|
|
2465
|
+
name: 'Restock',
|
|
2466
|
+
trigger: trigger('product-low-stock'),
|
|
2467
|
+
nodes: [
|
|
2468
|
+
post('low', '/low', 1, {
|
|
2469
|
+
name: ref('t', ['product', 'name']),
|
|
2470
|
+
stock: ref('t', ['product', 'stock']),
|
|
2471
|
+
threshold: ref('t', ['product', 'threshold']),
|
|
2472
|
+
sku: ref('t', ['product', 'sku']),
|
|
2473
|
+
}),
|
|
2474
|
+
],
|
|
2475
|
+
edges: [{ id: 'e1', source: 't', target: 'low' }],
|
|
2476
|
+
},
|
|
2477
|
+
'wf-source': {
|
|
2478
|
+
id: 'wf-source',
|
|
2479
|
+
name: 'Where they came from',
|
|
2480
|
+
trigger: trigger('user-signed-up'),
|
|
2481
|
+
nodes: [post('source', '/source', 1, { source: ref('t', ['user', 'source']) })],
|
|
2482
|
+
edges: [{ id: 'e1', source: 't', target: 'source' }],
|
|
2483
|
+
},
|
|
2484
|
+
}
|
|
2485
|
+
let files: Map<string, string>
|
|
2486
|
+
let orderFiles: Map<string, string>
|
|
2487
|
+
|
|
2488
|
+
beforeAll(async () => {
|
|
2489
|
+
files = sources(await generate(GREETERS))
|
|
2490
|
+
orderFiles = sources(await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }))
|
|
2491
|
+
})
|
|
2492
|
+
|
|
2493
|
+
const dispatchIn = (target: Map<string, string>, db: FakeDatabase) =>
|
|
2494
|
+
withEcho(async (calls) => ({
|
|
2495
|
+
result: await silenced(() =>
|
|
2496
|
+
(createApp(target, db)('lib/automations/event-bus.js') as Bus).dispatch({})
|
|
2497
|
+
),
|
|
2498
|
+
calls,
|
|
2499
|
+
}))
|
|
2500
|
+
|
|
2501
|
+
it('lays the record over what the row recorded, and takes the actor from the record', async () => {
|
|
2502
|
+
const db = new FakeDatabase()
|
|
2503
|
+
const event = seedOrderPaid(db)
|
|
2504
|
+
event.actor = ATTACKER
|
|
2505
|
+
event.actor_type = 'customer'
|
|
2506
|
+
event.payload = JSON.stringify({
|
|
2507
|
+
order: { customer: { email: ATTACKER, name: 'Mallory' }, giftMessage: 'kept' },
|
|
2508
|
+
})
|
|
2509
|
+
|
|
2510
|
+
const { calls } = await dispatchIn(orderFiles, db)
|
|
2511
|
+
|
|
2512
|
+
const thanks = calls.find((call) => call.url === ECHO + '/thanks')!
|
|
2513
|
+
expect(thanks.json).toEqual({
|
|
2514
|
+
email: 'ava@example.com',
|
|
2515
|
+
number: 'ORD-1042',
|
|
2516
|
+
test: false,
|
|
2517
|
+
giftMessage: 'kept',
|
|
2518
|
+
})
|
|
2519
|
+
const warehouse = calls.find((call) => call.url === ECHO + '/fails')!
|
|
2520
|
+
expect(warehouse.json).toEqual({ actor: 'ava@example.com', email: 'ava@example.com' })
|
|
2521
|
+
expect(JSON.stringify(calls.map((call) => call.json))).not.toContain(ATTACKER)
|
|
2522
|
+
})
|
|
2523
|
+
|
|
2524
|
+
it('refuses an order event whose order is not there: no automation runs, each delivery says why', async () => {
|
|
2525
|
+
const db = new FakeDatabase()
|
|
2526
|
+
const event = seedOrderPaid(db)
|
|
2527
|
+
event.order_id = 'ord-forged'
|
|
2528
|
+
event.entity_id = 'ord-forged'
|
|
2529
|
+
event.payload = JSON.stringify({ order: { number: 'ORD-9', customer: { email: ATTACKER } } })
|
|
2530
|
+
|
|
2531
|
+
const { result, calls } = await dispatchIn(orderFiles, db)
|
|
2532
|
+
|
|
2533
|
+
expect(calls).toEqual([])
|
|
2534
|
+
expect(result).toEqual({ claimed: 1, delivered: 0, failed: 2, resumed: 0 })
|
|
2535
|
+
expect(db.deliveries.map((d) => [d.workflow_id, d.status, d.error, d.attempts])).toEqual([
|
|
2536
|
+
['wf-paid-ok', 'failed', 'No such order', 0],
|
|
2537
|
+
['wf-paid-fail', 'failed', 'No such order', 0],
|
|
2538
|
+
])
|
|
2539
|
+
// Failed and counted, so a forged row is given up after five tries.
|
|
2540
|
+
expect(event).toMatchObject({ status: 'failed', attempts: 1 })
|
|
2541
|
+
})
|
|
2542
|
+
|
|
2543
|
+
const seedGreeters = (db: FakeDatabase) => {
|
|
2544
|
+
for (const [workflowId, eventType] of [
|
|
2545
|
+
['wf-review', 'review-submitted'],
|
|
2546
|
+
['wf-news', 'newsletter-subscribed'],
|
|
2547
|
+
['wf-wl', 'waitlist-joined'],
|
|
2548
|
+
['wf-form', 'form-submitted'],
|
|
2549
|
+
]) {
|
|
2550
|
+
db.automations.push(automationRow(workflowId, eventType))
|
|
2551
|
+
}
|
|
2552
|
+
db.reviews.push({
|
|
2553
|
+
id: 'rev-1',
|
|
2554
|
+
product_id: 'p-1',
|
|
2555
|
+
rating: 5,
|
|
2556
|
+
reviewer_email: 'mia@example.com',
|
|
2557
|
+
})
|
|
2558
|
+
db.users.push({
|
|
2559
|
+
id: 'user-9',
|
|
2560
|
+
email: 'zoe@example.com',
|
|
2561
|
+
source: 'newsletter',
|
|
2562
|
+
marketing_consent: true,
|
|
2563
|
+
})
|
|
2564
|
+
db.waitlists.push({ id: 'wl-1', kind: 'stock', target_id: 'p-1', target_url: '/products/p-1' })
|
|
2565
|
+
db.waitlistEntries.push({
|
|
2566
|
+
id: 'we-1',
|
|
2567
|
+
waitlist_id: 'wl-1',
|
|
2568
|
+
email: 'noor@example.com',
|
|
2569
|
+
status: 'confirmed',
|
|
2570
|
+
position: 3,
|
|
2571
|
+
})
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
const forgedPayloads: Record<string, Row> = {
|
|
2575
|
+
review: { review: { authorEmail: ATTACKER } },
|
|
2576
|
+
subscriber: { subscriber: { email: ATTACKER } },
|
|
2577
|
+
waitlist: { waitlist: { entry: { email: ATTACKER } } },
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
it('checks every kind of record before an automation runs: a review, a subscriber, a waitlist entry', async () => {
|
|
2581
|
+
const real = new FakeDatabase()
|
|
2582
|
+
seedGreeters(real)
|
|
2583
|
+
real.events.push(
|
|
2584
|
+
eventRow({
|
|
2585
|
+
id: 'e-rev',
|
|
2586
|
+
event_type: 'review-submitted',
|
|
2587
|
+
entity_type: 'review',
|
|
2588
|
+
entity_id: 'rev-1',
|
|
2589
|
+
payload: JSON.stringify(forgedPayloads.review),
|
|
2590
|
+
actor: ATTACKER,
|
|
2591
|
+
}),
|
|
2592
|
+
eventRow({
|
|
2593
|
+
id: 'e-news',
|
|
2594
|
+
event_type: 'newsletter-subscribed',
|
|
2595
|
+
entity_type: 'subscriber',
|
|
2596
|
+
entity_id: 'user-9',
|
|
2597
|
+
payload: JSON.stringify(forgedPayloads.subscriber),
|
|
2598
|
+
actor: ATTACKER,
|
|
2599
|
+
}),
|
|
2600
|
+
eventRow({
|
|
2601
|
+
id: 'e-wl',
|
|
2602
|
+
event_type: 'waitlist-joined',
|
|
2603
|
+
entity_type: 'waitlist',
|
|
2604
|
+
entity_id: 'we-1',
|
|
2605
|
+
payload: JSON.stringify(forgedPayloads.waitlist),
|
|
2606
|
+
actor: ATTACKER,
|
|
2607
|
+
})
|
|
2608
|
+
)
|
|
2609
|
+
const ran = await dispatchIn(files, real)
|
|
2610
|
+
expect(ran.result.delivered).toBe(3)
|
|
2611
|
+
const sent = Object.fromEntries(
|
|
2612
|
+
ran.calls.map((call) => [call.url.replace(ECHO + '/greet/', ''), call.json])
|
|
2613
|
+
)
|
|
2614
|
+
expect(sent).toEqual({
|
|
2615
|
+
'review-submitted': { email: 'mia@example.com', actor: 'mia@example.com' },
|
|
2616
|
+
'newsletter-subscribed': { email: 'zoe@example.com', actor: 'zoe@example.com' },
|
|
2617
|
+
'waitlist-joined': { email: 'noor@example.com', actor: 'noor@example.com' },
|
|
2618
|
+
})
|
|
2619
|
+
|
|
2620
|
+
// The same events naming records that are not there.
|
|
2621
|
+
const forged = new FakeDatabase()
|
|
2622
|
+
seedGreeters(forged)
|
|
2623
|
+
forged.events.push(
|
|
2624
|
+
eventRow({
|
|
2625
|
+
id: 'f-rev',
|
|
2626
|
+
event_type: 'review-submitted',
|
|
2627
|
+
entity_type: 'review',
|
|
2628
|
+
entity_id: 'rev-x',
|
|
2629
|
+
payload: JSON.stringify(forgedPayloads.review),
|
|
2630
|
+
actor: ATTACKER,
|
|
2631
|
+
}),
|
|
2632
|
+
eventRow({
|
|
2633
|
+
id: 'f-news',
|
|
2634
|
+
event_type: 'newsletter-subscribed',
|
|
2635
|
+
entity_type: 'subscriber',
|
|
2636
|
+
entity_id: 'user-x',
|
|
2637
|
+
payload: JSON.stringify(forgedPayloads.subscriber),
|
|
2638
|
+
actor: ATTACKER,
|
|
2639
|
+
}),
|
|
2640
|
+
eventRow({
|
|
2641
|
+
id: 'f-wl',
|
|
2642
|
+
event_type: 'waitlist-joined',
|
|
2643
|
+
entity_type: 'waitlist',
|
|
2644
|
+
entity_id: 'we-x',
|
|
2645
|
+
payload: JSON.stringify(forgedPayloads.waitlist),
|
|
2646
|
+
actor: ATTACKER,
|
|
2647
|
+
})
|
|
2648
|
+
)
|
|
2649
|
+
const refused = await dispatchIn(files, forged)
|
|
2650
|
+
expect(refused.calls).toEqual([])
|
|
2651
|
+
expect(forged.deliveries.map((d) => [d.workflow_id, d.error]).sort()).toEqual([
|
|
2652
|
+
['wf-news', 'No such subscriber'],
|
|
2653
|
+
['wf-review', 'No such review'],
|
|
2654
|
+
['wf-wl', 'No such waitlist entry'],
|
|
2655
|
+
])
|
|
2656
|
+
expect(forged.events.map((e) => e.status)).toEqual(['failed', 'failed', 'failed'])
|
|
2657
|
+
})
|
|
2658
|
+
|
|
2659
|
+
it('finds a newsletter subscriber by the address when the row names no id', async () => {
|
|
2660
|
+
const db = new FakeDatabase()
|
|
2661
|
+
seedGreeters(db)
|
|
2662
|
+
db.events.push(
|
|
2663
|
+
eventRow({
|
|
2664
|
+
id: 'e-news',
|
|
2665
|
+
event_type: 'newsletter-subscribed',
|
|
2666
|
+
entity_type: 'subscriber',
|
|
2667
|
+
payload: JSON.stringify({ subscriber: { email: 'ZOE@example.com' } }),
|
|
2668
|
+
})
|
|
2669
|
+
)
|
|
2670
|
+
const { calls } = await dispatchIn(files, db)
|
|
2671
|
+
expect(calls.map((call) => call.json)).toEqual([
|
|
2672
|
+
{ email: 'zoe@example.com', actor: 'zoe@example.com' },
|
|
2673
|
+
])
|
|
2674
|
+
})
|
|
2675
|
+
|
|
2676
|
+
it('runs a form submission from what it recorded: a submission has no row of its own', async () => {
|
|
2677
|
+
const db = new FakeDatabase()
|
|
2678
|
+
seedGreeters(db)
|
|
2679
|
+
db.events.push(
|
|
2680
|
+
eventRow({
|
|
2681
|
+
id: 'e-form',
|
|
2682
|
+
event_type: 'form-submitted',
|
|
2683
|
+
entity_type: 'form',
|
|
2684
|
+
entity_id: 'form-node-1',
|
|
2685
|
+
payload: JSON.stringify({
|
|
2686
|
+
form: { id: 'form-node-1', name: 'Contact', fields: { email: 'sam@example.com' } },
|
|
2687
|
+
}),
|
|
2688
|
+
actor: 'sam@example.com',
|
|
2689
|
+
})
|
|
2690
|
+
)
|
|
2691
|
+
const { calls } = await dispatchIn(files, db)
|
|
2692
|
+
expect(calls.map((call) => call.json)).toEqual([
|
|
2693
|
+
{ email: 'sam@example.com', actor: 'sam@example.com' },
|
|
2694
|
+
])
|
|
2695
|
+
})
|
|
2696
|
+
|
|
2697
|
+
it('runs a deleted account from the snapshot its deletion recorded, and nothing else', async () => {
|
|
2698
|
+
const snapshot = {
|
|
2699
|
+
id: 'user-7',
|
|
2700
|
+
email: 'liam@example.com',
|
|
2701
|
+
name: 'Liam',
|
|
2702
|
+
role: 'user',
|
|
2703
|
+
source: null,
|
|
2704
|
+
createdAt: '2026-01-02T03:04:05.000Z',
|
|
2705
|
+
}
|
|
2706
|
+
const deleted = (id: string, user: Row) =>
|
|
2707
|
+
eventRow({
|
|
2708
|
+
id,
|
|
2709
|
+
event_type: 'user-deleted',
|
|
2710
|
+
dedupe_key: 'user-deleted:' + id,
|
|
2711
|
+
entity_type: 'user',
|
|
2712
|
+
entity_id: 'user-7',
|
|
2713
|
+
payload: JSON.stringify({ user }),
|
|
2714
|
+
actor: String(user.email),
|
|
2715
|
+
actor_type: 'customer',
|
|
2716
|
+
})
|
|
2717
|
+
const db = new FakeDatabase()
|
|
2718
|
+
db.automations.push(automationRow('wf-bye', 'user-deleted'))
|
|
2719
|
+
// The row is gone: the route deleted it before recording the event.
|
|
2720
|
+
db.events.push(deleted('evt-bye', snapshot))
|
|
2721
|
+
const { result, calls } = await dispatchIn(files, db)
|
|
2722
|
+
expect(result.delivered).toBe(1)
|
|
2723
|
+
expect(calls.map((call) => call.json)).toEqual([
|
|
2724
|
+
{ id: 'user-7', email: 'liam@example.com', name: 'Liam', actor: 'liam@example.com' },
|
|
2725
|
+
])
|
|
2726
|
+
|
|
2727
|
+
// A snapshot of another account than the event names is no record.
|
|
2728
|
+
const forged = new FakeDatabase()
|
|
2729
|
+
forged.automations.push(automationRow('wf-bye', 'user-deleted'))
|
|
2730
|
+
forged.events.push(deleted('evt-forged', { ...snapshot, id: 'user-8' }))
|
|
2731
|
+
const refused = await dispatchIn(files, forged)
|
|
2732
|
+
expect(refused.calls).toEqual([])
|
|
2733
|
+
expect(forged.delivery('wf-bye')).toMatchObject({ status: 'failed', error: 'No such user' })
|
|
2734
|
+
|
|
2735
|
+
// Any other account event still needs its row.
|
|
2736
|
+
const signup = new FakeDatabase()
|
|
2737
|
+
signup.automations.push(automationRow('wf-source', 'user-signed-up'))
|
|
2738
|
+
signup.events.push({ ...deleted('evt-signup', snapshot), event_type: 'user-signed-up' })
|
|
2739
|
+
expect((await dispatchIn(files, signup)).calls).toEqual([])
|
|
2740
|
+
expect(signup.delivery('wf-source')).toMatchObject({ status: 'failed', error: 'No such user' })
|
|
2741
|
+
})
|
|
2742
|
+
|
|
2743
|
+
it('keeps what the writer recorded where the record has nothing: the low-stock level, the signup source', async () => {
|
|
2744
|
+
const db = new FakeDatabase()
|
|
2745
|
+
db.automations.push(
|
|
2746
|
+
automationRow('wf-low', 'product-low-stock'),
|
|
2747
|
+
automationRow('wf-source', 'user-signed-up')
|
|
2748
|
+
)
|
|
2749
|
+
db.products.push({ id: 'p-1', name: 'Beeswax Candle', sku: null, quantity: 2 })
|
|
2750
|
+
db.users.push({ id: 'user-1', email: 'noah@example.com', name: 'Noah', created_at: ago(1000) })
|
|
2751
|
+
db.events.push(
|
|
2752
|
+
eventRow({
|
|
2753
|
+
id: 'evt-low',
|
|
2754
|
+
event_type: 'product-low-stock',
|
|
2755
|
+
entity_type: 'product',
|
|
2756
|
+
entity_id: 'p-1',
|
|
2757
|
+
// As the data API records it.
|
|
2758
|
+
payload: JSON.stringify({
|
|
2759
|
+
product: { id: 'p-1', name: 'Old name', stock: 3, threshold: 5, sku: 'BWC-01' },
|
|
2760
|
+
}),
|
|
2761
|
+
}),
|
|
2762
|
+
eventRow({
|
|
2763
|
+
id: 'evt-joined',
|
|
2764
|
+
event_type: 'user-signed-up',
|
|
2765
|
+
entity_type: 'user',
|
|
2766
|
+
entity_id: 'user-1',
|
|
2767
|
+
// As the signup route records it.
|
|
2768
|
+
payload: JSON.stringify({
|
|
2769
|
+
user: { id: 'user-1', email: 'noah@example.com', source: 'signup' },
|
|
2770
|
+
}),
|
|
2771
|
+
})
|
|
2772
|
+
)
|
|
2773
|
+
const { calls } = await dispatchIn(files, db)
|
|
2774
|
+
const sent = Object.fromEntries(
|
|
2775
|
+
calls.map((call) => [call.url.replace(ECHO + '/', ''), call.json])
|
|
2776
|
+
)
|
|
2777
|
+
expect(sent).toEqual({
|
|
2778
|
+
// The record's name and stock win; its empty sku does not erase the recorded one.
|
|
2779
|
+
low: { name: 'Beeswax Candle', stock: 2, threshold: 5, sku: 'BWC-01' },
|
|
2780
|
+
source: { source: 'signup' },
|
|
2781
|
+
})
|
|
2782
|
+
})
|
|
2783
|
+
|
|
2784
|
+
it('ends a parked run whose record is gone instead of resuming it', async () => {
|
|
2785
|
+
const db = new FakeDatabase()
|
|
2786
|
+
seedSignup(db)
|
|
2787
|
+
await dispatchIn(orderFiles, db)
|
|
2788
|
+
expect(db.waits).toHaveLength(1)
|
|
2789
|
+
db.users = []
|
|
2790
|
+
db.waits[0].run_at = ago(1000)
|
|
2791
|
+
const { result, calls } = await dispatchIn(orderFiles, db)
|
|
2792
|
+
expect(result.resumed).toBe(0)
|
|
2793
|
+
expect(calls).toEqual([])
|
|
2794
|
+
expect(db.waits[0].status).toBe('failed')
|
|
2795
|
+
})
|
|
2796
|
+
})
|
|
2797
|
+
|
|
2798
|
+
describe('in-app event bus — the mail automations send', () => {
|
|
2799
|
+
it("files it under the automation and the event's order, so the order's email trail lists it", async () => {
|
|
2800
|
+
const files = sources(
|
|
2801
|
+
await generate({
|
|
2802
|
+
'wf-mail': {
|
|
2803
|
+
id: 'wf-mail',
|
|
2804
|
+
name: 'Thank by email',
|
|
2805
|
+
trigger: trigger('order-paid'),
|
|
2806
|
+
nodes: [
|
|
2807
|
+
{
|
|
2808
|
+
id: 'mail',
|
|
2809
|
+
type: 'email-resend',
|
|
2810
|
+
config: {
|
|
2811
|
+
apiKey: 'k',
|
|
2812
|
+
from: 'shop@example.com',
|
|
2813
|
+
to: ref('t', ['order', 'customer', 'email']),
|
|
2814
|
+
subject: 'Thank you',
|
|
2815
|
+
body: '<p>Thank you</p>',
|
|
2816
|
+
},
|
|
2817
|
+
stepNumber: 1,
|
|
2818
|
+
label: 'Thank',
|
|
2819
|
+
},
|
|
2820
|
+
],
|
|
2821
|
+
edges: [{ id: 'e1', source: 't', target: 'mail' }],
|
|
2822
|
+
},
|
|
2823
|
+
})
|
|
2824
|
+
)
|
|
2825
|
+
expect(files.get('utils/email/sent-email-log.js')).toContain(
|
|
2826
|
+
'function __attribute(entry, context)'
|
|
2827
|
+
)
|
|
2828
|
+
|
|
2829
|
+
const ORDER_ID = '5f0c2b1e-8a3d-4c7b-9e2f-1a2b3c4d5e6f'
|
|
2830
|
+
const db = new FakeDatabase()
|
|
2831
|
+
seedOrderPaid(db)
|
|
2832
|
+
db.automations = [automationRow('wf-mail', 'order-paid')]
|
|
2833
|
+
db.orders[0].id = ORDER_ID
|
|
2834
|
+
db.orderItems[0].order_id = ORDER_ID
|
|
2835
|
+
db.events[0].order_id = ORDER_ID
|
|
2836
|
+
db.events[0].entity_id = ORDER_ID
|
|
2837
|
+
|
|
2838
|
+
const result = await withEcho(() =>
|
|
2839
|
+
silenced(() => (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({}))
|
|
2840
|
+
)
|
|
2841
|
+
|
|
2842
|
+
expect(result.delivered).toBe(1)
|
|
2843
|
+
expect(db.mailSent.map((mail) => mail.to)).toEqual([['ava@example.com']])
|
|
2844
|
+
expect(db.sentEmails).toHaveLength(1)
|
|
2845
|
+
expect(db.sentEmails[0]).toMatchObject({
|
|
2846
|
+
recipient_email: 'ava@example.com',
|
|
2847
|
+
order_id: ORDER_ID,
|
|
2848
|
+
source: 'automation',
|
|
2849
|
+
source_ref: 'wf-mail',
|
|
2850
|
+
status: 'sent',
|
|
2851
|
+
})
|
|
2852
|
+
})
|
|
2853
|
+
|
|
2854
|
+
it('leaves the sent-email module untouched in a project without automations', async () => {
|
|
2855
|
+
const mailer = (id: string, eventType: string) => ({
|
|
2856
|
+
id,
|
|
2857
|
+
name: 'Mail',
|
|
2858
|
+
trigger: { type: eventType, nodeId: 't', scope: 'page', config: { allPages: true } },
|
|
2859
|
+
nodes: [
|
|
2860
|
+
{
|
|
2861
|
+
id: 'mail',
|
|
2862
|
+
type: 'email-resend',
|
|
2863
|
+
config: { apiKey: 'k', from: 'a@b.c', to: 'c@d.e', subject: 's', body: 'b' },
|
|
2864
|
+
stepNumber: 1,
|
|
2865
|
+
label: 'Mail',
|
|
2866
|
+
},
|
|
2867
|
+
],
|
|
2868
|
+
edges: [{ id: 'e1', source: 't', target: 'mail' }],
|
|
2869
|
+
})
|
|
2870
|
+
const files = sources(
|
|
2871
|
+
await generate({ 'wf-page-mail': mailer('wf-page-mail', 'event-page-loaded') })
|
|
2872
|
+
)
|
|
2873
|
+
expect(files.get('utils/email/sent-email-log.js')).not.toContain('__attribute')
|
|
2874
|
+
})
|
|
2875
|
+
})
|
|
2876
|
+
|
|
2877
|
+
describe('in-app event bus — emitting and the dispatch route', () => {
|
|
2878
|
+
let files: Map<string, string>
|
|
2879
|
+
|
|
2880
|
+
beforeAll(async () => {
|
|
2881
|
+
files = sources(await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }))
|
|
2882
|
+
})
|
|
2883
|
+
|
|
2884
|
+
const signup = {
|
|
2885
|
+
type: 'user-signed-up',
|
|
2886
|
+
entityType: 'user',
|
|
2887
|
+
entityId: 'user-1',
|
|
2888
|
+
payload: { source: 'signup' },
|
|
2889
|
+
actor: 'noah@example.com',
|
|
2890
|
+
actorType: 'customer',
|
|
2891
|
+
dedupeKey: 'user-signed-up:user-1',
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
it('records an event once per dedupe key and kicks the dispatch route off the request path', async () => {
|
|
2895
|
+
const db = new FakeDatabase()
|
|
2896
|
+
const app = createApp(files, db)
|
|
2897
|
+
const emitted = await withEnv(ENV, () =>
|
|
2898
|
+
withFetch(
|
|
2899
|
+
() => ({ status: 200, body: {} }),
|
|
2900
|
+
async (calls) => {
|
|
2901
|
+
const busModule = app('lib/automations/event-bus.js') as Bus
|
|
2902
|
+
// The origin comes from the request being served, never from the caller.
|
|
2903
|
+
const req = { headers: { host: 'shop.test', 'x-forwarded-proto': 'https' } }
|
|
2904
|
+
const first = await busModule.emitBusinessEvent(
|
|
2905
|
+
{ ...signup, baseUrl: 'https://evil.test' },
|
|
2906
|
+
req
|
|
2907
|
+
)
|
|
2908
|
+
const again = await busModule.emitBusinessEvent(signup, req)
|
|
2909
|
+
return { first, again, calls }
|
|
2910
|
+
}
|
|
2911
|
+
)
|
|
2912
|
+
)
|
|
2913
|
+
|
|
2914
|
+
expect(emitted.first.recorded).toBe(true)
|
|
2915
|
+
expect(emitted.again.recorded).toBe(false)
|
|
2916
|
+
expect(db.events).toHaveLength(1)
|
|
2917
|
+
const insert = db.statements.find((text) => text.startsWith('INSERT INTO teleport_events'))!
|
|
2918
|
+
expect(insert).toContain('ON CONFLICT (dedupe_key) DO NOTHING RETURNING id')
|
|
2919
|
+
// One kick, for the row that was inserted.
|
|
2920
|
+
expect(emitted.calls).toHaveLength(1)
|
|
2921
|
+
expect(emitted.calls[0].url).toBe('https://shop.test/api/automations/dispatch')
|
|
2922
|
+
expect(emitted.calls[0].method).toBe('POST')
|
|
2923
|
+
expect(emitted.calls[0].headers['x-internal-data-secret']).toBe('app-secret')
|
|
2924
|
+
})
|
|
2925
|
+
|
|
2926
|
+
const scheduleWith = async (env: Row) => {
|
|
2927
|
+
const db = new FakeDatabase()
|
|
2928
|
+
const scope = globalThis as Record<string, unknown>
|
|
2929
|
+
const held: Array<Promise<unknown>> = []
|
|
2930
|
+
scope.__tqWaitUntil = (promise: Promise<unknown>) => {
|
|
2931
|
+
held.push(promise)
|
|
2932
|
+
}
|
|
2933
|
+
try {
|
|
2934
|
+
return await withEnv({ ...ENV, ...env } as Record<string, string | undefined>, () =>
|
|
2935
|
+
withFetch(
|
|
2936
|
+
() => ({ status: 200, body: {} }),
|
|
2937
|
+
async (calls) => {
|
|
2938
|
+
const busModule = createApp(files, db)('lib/automations/event-bus.js') as Bus
|
|
2939
|
+
const mode = busModule.scheduleDispatch()
|
|
2940
|
+
await Promise.all(held)
|
|
2941
|
+
return { mode, calls, held, db }
|
|
2942
|
+
}
|
|
2943
|
+
)
|
|
2944
|
+
)
|
|
2945
|
+
} finally {
|
|
2946
|
+
delete scope.__tqWaitUntil
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
it("dispatches in Vercel's waitUntil there, and calls its own route everywhere else", async () => {
|
|
2951
|
+
const onVercel = await scheduleWith({ VERCEL: '1' })
|
|
2952
|
+
expect(onVercel.mode).toBe('wait-until')
|
|
2953
|
+
expect(onVercel.held).toHaveLength(1)
|
|
2954
|
+
expect(onVercel.calls).toEqual([])
|
|
2955
|
+
expect(onVercel.db.claimStatement()).toContain('SKIP LOCKED')
|
|
2956
|
+
|
|
2957
|
+
// A waitUntil that is not the host's keeps nothing alive.
|
|
2958
|
+
const elsewhere = await scheduleWith({})
|
|
2959
|
+
expect(elsewhere.mode).toBe('fetch')
|
|
2960
|
+
expect(elsewhere.held).toEqual([])
|
|
2961
|
+
expect(elsewhere.calls.map((call) => call.url)).toEqual([
|
|
2962
|
+
'https://shop.test/api/automations/dispatch',
|
|
2963
|
+
])
|
|
2964
|
+
})
|
|
2965
|
+
|
|
2966
|
+
// Signed as the services-worker signs a test (the cron scheme, task 10a R3):
|
|
2967
|
+
// HMAC over v1:<ts>:<project>:<cron>:<execution>:<path>[:<body sha256>].
|
|
2968
|
+
const CRON_KEY = 'c'.repeat(64)
|
|
2969
|
+
// Every request the worker signs names an execution of its own.
|
|
2970
|
+
let executions = 0
|
|
2971
|
+
const signedHeaders = (
|
|
2972
|
+
rawBody: string,
|
|
2973
|
+
withDigest: boolean,
|
|
2974
|
+
executionId = `exec-${++executions}`
|
|
2975
|
+
): Record<string, string> => {
|
|
2976
|
+
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
2977
|
+
const digest = createHash('sha256').update(rawBody).digest('hex')
|
|
2978
|
+
const signed = [
|
|
2979
|
+
'v1',
|
|
2980
|
+
timestamp,
|
|
2981
|
+
'project-1',
|
|
2982
|
+
'automations-test',
|
|
2983
|
+
executionId,
|
|
2984
|
+
'/api/automations/dispatch',
|
|
2985
|
+
...(withDigest ? [digest] : []),
|
|
2986
|
+
].join(':')
|
|
2987
|
+
return {
|
|
2988
|
+
'x-teleport-cron-id': 'automations-test',
|
|
2989
|
+
'x-teleport-project-id': 'project-1',
|
|
2990
|
+
'x-teleport-cron-execution-id': executionId,
|
|
2991
|
+
'x-teleport-cron-timestamp': timestamp,
|
|
2992
|
+
...(withDigest ? { 'x-teleport-body-sha256': digest } : {}),
|
|
2993
|
+
'x-teleport-cron-signature':
|
|
2994
|
+
'v1=' + createHmac('sha256', CRON_KEY).update(signed).digest('hex'),
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
type Caller = 'app' | 'wrong-secret' | 'signed' | 'signed-without-digest' | 'tampered'
|
|
2999
|
+
|
|
3000
|
+
const callRoute = async (db: FakeDatabase, caller: Caller, body: unknown) => {
|
|
3001
|
+
const rawBody = JSON.stringify(body)
|
|
3002
|
+
const headers: Record<string, string> =
|
|
3003
|
+
caller === 'app'
|
|
3004
|
+
? { 'x-internal-data-secret': 'app-secret' }
|
|
3005
|
+
: caller === 'wrong-secret'
|
|
3006
|
+
? { 'x-internal-data-secret': 'wrong' }
|
|
3007
|
+
: signedHeaders(rawBody, caller !== 'signed-without-digest')
|
|
3008
|
+
// What arrives differs from what was signed.
|
|
3009
|
+
const sent = caller === 'tampered' ? rawBody.replace('wf-paid-ok', 'wf-paid-fail') : rawBody
|
|
3010
|
+
const reply = { status: 0, body: null as unknown }
|
|
3011
|
+
const res = {
|
|
3012
|
+
setHeader: () => undefined,
|
|
3013
|
+
status(code: number) {
|
|
3014
|
+
reply.status = code
|
|
3015
|
+
return {
|
|
3016
|
+
json(payload: unknown) {
|
|
3017
|
+
reply.body = payload
|
|
3018
|
+
},
|
|
3019
|
+
}
|
|
3020
|
+
},
|
|
3021
|
+
}
|
|
3022
|
+
// The route reads its body raw (bodyParser: false): a Buffer, as Next hands it.
|
|
3023
|
+
const req = {
|
|
3024
|
+
method: 'POST',
|
|
3025
|
+
url: '/api/automations/dispatch',
|
|
3026
|
+
headers: {
|
|
3027
|
+
host: 'shop.test',
|
|
3028
|
+
'x-forwarded-proto': 'https',
|
|
3029
|
+
'content-type': 'application/json',
|
|
3030
|
+
...headers,
|
|
3031
|
+
},
|
|
3032
|
+
body: Buffer.from(sent),
|
|
3033
|
+
}
|
|
3034
|
+
const calls = await withEcho(
|
|
3035
|
+
async (recorded) => {
|
|
3036
|
+
const handler = createApp(files, db)('pages/api/automations/dispatch.js') as RouteHandler
|
|
3037
|
+
await silenced(() => handler(req, res))
|
|
3038
|
+
return recorded
|
|
3039
|
+
},
|
|
3040
|
+
{ TELEPORT_CRON_SECRET: CRON_KEY }
|
|
3041
|
+
)
|
|
3042
|
+
return { reply, calls }
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
it("presents the deployment's protection bypass on its own calls when one is set", async () => {
|
|
3046
|
+
const walled = await scheduleWith({ VERCEL_AUTOMATION_BYPASS_SECRET: 'bypass-1' })
|
|
3047
|
+
expect(walled.calls).toHaveLength(1)
|
|
3048
|
+
expect(walled.calls[0].headers['x-vercel-protection-bypass']).toBe('bypass-1')
|
|
3049
|
+
expect(walled.calls[0].headers['x-vercel-set-bypass-cookie']).toBe('false')
|
|
3050
|
+
expect(walled.calls[0].headers['x-internal-data-secret']).toBe('app-secret')
|
|
3051
|
+
|
|
3052
|
+
const open = await scheduleWith({ VERCEL_AUTOMATION_BYPASS_SECRET: undefined })
|
|
3053
|
+
expect(open.calls).toHaveLength(1)
|
|
3054
|
+
expect(open.calls[0].headers).not.toHaveProperty('x-vercel-protection-bypass')
|
|
3055
|
+
})
|
|
3056
|
+
|
|
3057
|
+
// A request sent exactly as it was captured: the same headers, the same body.
|
|
3058
|
+
const resend = async (db: FakeDatabase, headers: Record<string, string>, rawBody: string) => {
|
|
3059
|
+
const reply = { status: 0, body: null as unknown }
|
|
3060
|
+
const res = {
|
|
3061
|
+
setHeader: () => undefined,
|
|
3062
|
+
status(code: number) {
|
|
3063
|
+
reply.status = code
|
|
3064
|
+
return {
|
|
3065
|
+
json(payload: unknown) {
|
|
3066
|
+
reply.body = payload
|
|
3067
|
+
},
|
|
3068
|
+
}
|
|
3069
|
+
},
|
|
3070
|
+
}
|
|
3071
|
+
const req = {
|
|
3072
|
+
method: 'POST',
|
|
3073
|
+
url: '/api/automations/dispatch',
|
|
3074
|
+
headers: {
|
|
3075
|
+
host: 'shop.test',
|
|
3076
|
+
'x-forwarded-proto': 'https',
|
|
3077
|
+
'content-type': 'application/json',
|
|
3078
|
+
...headers,
|
|
3079
|
+
},
|
|
3080
|
+
body: Buffer.from(rawBody),
|
|
3081
|
+
}
|
|
3082
|
+
const calls = await withEcho(
|
|
3083
|
+
async (recorded) => {
|
|
3084
|
+
const handler = createApp(files, db)('pages/api/automations/dispatch.js') as RouteHandler
|
|
3085
|
+
await silenced(() => handler(req, res))
|
|
3086
|
+
return recorded
|
|
3087
|
+
},
|
|
3088
|
+
{ TELEPORT_CRON_SECRET: CRON_KEY }
|
|
3089
|
+
)
|
|
3090
|
+
return { reply, calls }
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
it('takes a signed request once: the same request again is refused before anything runs', async () => {
|
|
3094
|
+
const db = new FakeDatabase()
|
|
3095
|
+
seedOrderPaid(db)
|
|
3096
|
+
db.events = []
|
|
3097
|
+
const rawBody = JSON.stringify({ test: true, workflowId: 'wf-paid-ok', event: ORDER_EXAMPLE })
|
|
3098
|
+
const captured = signedHeaders(rawBody, true)
|
|
3099
|
+
|
|
3100
|
+
const first = await resend(db, captured, rawBody)
|
|
3101
|
+
expect(first.reply.status).toBe(200)
|
|
3102
|
+
expect(db.deliveries.map((d) => d.workflow_id)).toEqual(['wf-paid-ok'])
|
|
3103
|
+
|
|
3104
|
+
const again = await resend(db, captured, rawBody)
|
|
3105
|
+
expect(again.reply).toEqual({
|
|
3106
|
+
status: 409,
|
|
3107
|
+
body: { error: 'This request was already received' },
|
|
3108
|
+
})
|
|
3109
|
+
expect(again.calls).toEqual([])
|
|
3110
|
+
expect(db.events).toHaveLength(1)
|
|
3111
|
+
expect(db.deliveries).toHaveLength(1)
|
|
3112
|
+
// Kept as a digest: nothing the request sent is stored as it was sent.
|
|
3113
|
+
expect(Array.from(db.signedRequests.keys())).toEqual([expect.stringMatching(/^[0-9a-f]{64}$/)])
|
|
3114
|
+
|
|
3115
|
+
// A signed run of the queue is taken once too.
|
|
3116
|
+
const kick = signedHeaders('{}', false)
|
|
3117
|
+
expect((await resend(db, kick, '{}')).reply.status).toBe(200)
|
|
3118
|
+
expect((await resend(db, kick, '{}')).reply.status).toBe(409)
|
|
3119
|
+
// The app's own calls name no execution: never remembered, never refused.
|
|
3120
|
+
for (let i = 0; i < 2; i++) {
|
|
3121
|
+
expect((await callRoute(db, 'app', {})).reply.status).toBe(200)
|
|
3122
|
+
}
|
|
3123
|
+
expect(db.signedRequests.size).toBe(2)
|
|
3124
|
+
})
|
|
3125
|
+
|
|
3126
|
+
it('remembers a signed request while its signature can verify, and refuses one it cannot check', async () => {
|
|
3127
|
+
const db = new FakeDatabase()
|
|
3128
|
+
const nowSeconds = Math.floor(Date.now() / 1000)
|
|
3129
|
+
db.signedRequests.set('over', nowSeconds - 1)
|
|
3130
|
+
expect((await resend(db, signedHeaders('{}', false), '{}')).reply.status).toBe(200)
|
|
3131
|
+
expect(db.signedRequests.has('over')).toBe(false)
|
|
3132
|
+
const [expiresAt] = Array.from(db.signedRequests.values())
|
|
3133
|
+
// The signature accepts a timestamp up to 300 s off either way.
|
|
3134
|
+
expect(expiresAt - nowSeconds).toBeGreaterThanOrEqual(600)
|
|
3135
|
+
|
|
3136
|
+
const down = new FakeDatabase()
|
|
3137
|
+
seedOrderPaid(down)
|
|
3138
|
+
down.signedRequestsDown = true
|
|
3139
|
+
const refused = await resend(down, signedHeaders('{}', false), '{}')
|
|
3140
|
+
expect(refused.reply).toEqual({
|
|
3141
|
+
status: 503,
|
|
3142
|
+
body: { error: 'The request could not be checked. Try again.' },
|
|
3143
|
+
})
|
|
3144
|
+
expect(refused.calls).toEqual([])
|
|
3145
|
+
expect(down.deliveries).toEqual([])
|
|
3146
|
+
})
|
|
3147
|
+
|
|
3148
|
+
it('refuses a caller without the app secret or a cron signature', async () => {
|
|
3149
|
+
const db = new FakeDatabase()
|
|
3150
|
+
seedOrderPaid(db)
|
|
3151
|
+
const { reply, calls } = await callRoute(db, 'wrong-secret', {})
|
|
3152
|
+
expect(reply).toEqual({ status: 401, body: { error: 'Unauthorized' } })
|
|
3153
|
+
expect(calls).toEqual([])
|
|
3154
|
+
expect(db.statements).toEqual([])
|
|
3155
|
+
})
|
|
3156
|
+
|
|
3157
|
+
// A request as Node hands it to the route: a stream, read only when listened to.
|
|
3158
|
+
const streamed = (headers: Record<string, string>, chunks: Buffer[]) => {
|
|
3159
|
+
const read = { started: false }
|
|
3160
|
+
const req = {
|
|
3161
|
+
method: 'POST',
|
|
3162
|
+
url: '/api/automations/dispatch',
|
|
3163
|
+
headers: { host: 'shop.test', ...headers },
|
|
3164
|
+
on(name: string, listener: (value?: unknown) => void) {
|
|
3165
|
+
read.started = true
|
|
3166
|
+
if (name === 'data') {
|
|
3167
|
+
setImmediate(() => chunks.forEach((chunk) => listener(chunk)))
|
|
3168
|
+
}
|
|
3169
|
+
if (name === 'end') {
|
|
3170
|
+
setImmediate(() => setImmediate(() => listener()))
|
|
3171
|
+
}
|
|
3172
|
+
return req
|
|
3173
|
+
},
|
|
3174
|
+
}
|
|
3175
|
+
return { req, read }
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
const answer = async (db: FakeDatabase, req: unknown, env: Row = {}) => {
|
|
3179
|
+
const reply = { status: 0, body: null as unknown }
|
|
3180
|
+
const res = {
|
|
3181
|
+
setHeader: () => undefined,
|
|
3182
|
+
status(code: number) {
|
|
3183
|
+
reply.status = code
|
|
3184
|
+
return {
|
|
3185
|
+
json(payload: unknown) {
|
|
3186
|
+
reply.body = payload
|
|
3187
|
+
},
|
|
3188
|
+
}
|
|
3189
|
+
},
|
|
3190
|
+
}
|
|
3191
|
+
await withEcho(async () => {
|
|
3192
|
+
const handler = createApp(files, db)('pages/api/automations/dispatch.js') as RouteHandler
|
|
3193
|
+
await silenced(() => handler(req, res))
|
|
3194
|
+
}, env)
|
|
3195
|
+
return reply
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
it('refuses a caller that presents no credentials before reading anything it sent', async () => {
|
|
3199
|
+
const db = new FakeDatabase()
|
|
3200
|
+
seedOrderPaid(db)
|
|
3201
|
+
const { req, read } = streamed({}, [Buffer.from('{}')])
|
|
3202
|
+
expect(await answer(db, req)).toEqual({ status: 401, body: { error: 'Unauthorized' } })
|
|
3203
|
+
expect(read.started).toBe(false)
|
|
3204
|
+
expect(db.statements).toEqual([])
|
|
3205
|
+
|
|
3206
|
+
// A local run that allows unsigned calls still runs.
|
|
3207
|
+
const local = streamed({}, [Buffer.from('{}')])
|
|
3208
|
+
const ran = await answer(db, local.req, { TELEPORT_CRON_ALLOW_UNSIGNED: 'true' })
|
|
3209
|
+
expect(ran.status).toBe(200)
|
|
3210
|
+
})
|
|
3211
|
+
|
|
3212
|
+
it('refuses a body larger than 64 KB, without reading past it', async () => {
|
|
3213
|
+
const app = { 'x-internal-data-secret': 'app-secret' }
|
|
3214
|
+
const db = new FakeDatabase()
|
|
3215
|
+
seedOrderPaid(db)
|
|
3216
|
+
const tooLarge = { status: 413, body: { error: 'The request body is too large' } }
|
|
3217
|
+
|
|
3218
|
+
// Announced: refused before a byte is read.
|
|
3219
|
+
const announced = streamed({ ...app, 'content-length': String(70 * 1024) }, [])
|
|
3220
|
+
expect(await answer(db, announced.req)).toEqual(tooLarge)
|
|
3221
|
+
expect(announced.read.started).toBe(false)
|
|
3222
|
+
|
|
3223
|
+
// Streamed in pieces.
|
|
3224
|
+
const pieces = streamed(app, [Buffer.alloc(40 * 1024, 32), Buffer.alloc(40 * 1024, 32)])
|
|
3225
|
+
expect(await answer(db, pieces.req)).toEqual(tooLarge)
|
|
3226
|
+
|
|
3227
|
+
// Handed over whole.
|
|
3228
|
+
const whole = { ...streamed(app, []).req, body: Buffer.alloc(65 * 1024, 32) }
|
|
3229
|
+
expect(await answer(db, whole)).toEqual(tooLarge)
|
|
3230
|
+
expect(db.statements).toEqual([])
|
|
3231
|
+
|
|
3232
|
+
// 64 KB is still a body.
|
|
3233
|
+
const fits = { ...streamed(app, []).req, body: Buffer.alloc(64 * 1024, 32) }
|
|
3234
|
+
expect((await answer(db, fits)).status).toBe(200)
|
|
3235
|
+
})
|
|
3236
|
+
|
|
3237
|
+
it("calls the app's own routes on its own address, never on a host a caller picked", async () => {
|
|
3238
|
+
const store = createApp(files, new FakeDatabase())('lib/automations/store.js') as {
|
|
3239
|
+
appBaseUrl(req?: Row): string
|
|
3240
|
+
}
|
|
3241
|
+
const from = (host: string) => ({ headers: { host } })
|
|
3242
|
+
const scope = globalThis as Row
|
|
3243
|
+
// Every generation writes NEXTAUTH_URL=http://localhost:3000, and the
|
|
3244
|
+
// deploy keeps it: that is no address.
|
|
3245
|
+
const GENERATED = 'http://localhost:3000'
|
|
3246
|
+
const VERCEL = { VERCEL: '1', VERCEL_URL: 'shop-abc123.vercel.app' }
|
|
3247
|
+
// [env, request, the auth route's snapshot of NEXTAUTH_URL, origin]
|
|
3248
|
+
const cases: Array<[Row, Row | undefined, string | undefined, string]> = [
|
|
3249
|
+
// On Vercel: the origin the request reached.
|
|
3250
|
+
[{ ...VERCEL, NEXTAUTH_URL: GENERATED }, from('shop.com'), undefined, 'https://shop.com'],
|
|
3251
|
+
[VERCEL, from('shop.com'), undefined, 'https://shop.com'],
|
|
3252
|
+
// No request (a page render, a handler): the production domain, else the deployment.
|
|
3253
|
+
[
|
|
3254
|
+
{ ...VERCEL, NEXTAUTH_URL: GENERATED, VERCEL_PROJECT_PRODUCTION_URL: 'shop.com' },
|
|
3255
|
+
undefined,
|
|
3256
|
+
undefined,
|
|
3257
|
+
'https://shop.com',
|
|
3258
|
+
],
|
|
3259
|
+
[
|
|
3260
|
+
{ ...VERCEL, NEXTAUTH_URL: GENERATED },
|
|
3261
|
+
{ headers: {} },
|
|
3262
|
+
undefined,
|
|
3263
|
+
'https://shop-abc123.vercel.app',
|
|
3264
|
+
],
|
|
3265
|
+
// A real NEXTAUTH_URL wins over any header, on Vercel or not.
|
|
3266
|
+
[
|
|
3267
|
+
{ ...VERCEL, NEXTAUTH_URL: 'https://shop.test' },
|
|
3268
|
+
from('evil.test'),
|
|
3269
|
+
undefined,
|
|
3270
|
+
'https://shop.test',
|
|
3271
|
+
],
|
|
3272
|
+
[{ NEXTAUTH_URL: 'https://shop.test' }, from('evil.test'), undefined, 'https://shop.test'],
|
|
3273
|
+
[
|
|
3274
|
+
{ NEXTAUTH_URL: 'https://shop.test' },
|
|
3275
|
+
from('localhost:4000'),
|
|
3276
|
+
undefined,
|
|
3277
|
+
'https://shop.test',
|
|
3278
|
+
],
|
|
3279
|
+
// Anywhere else a public Host is the caller's word: never used.
|
|
3280
|
+
[{}, from('shop.com'), undefined, 'http://localhost:3000'],
|
|
3281
|
+
[{ PORT: '8080' }, from('shop.com'), undefined, 'http://localhost:8080'],
|
|
3282
|
+
[
|
|
3283
|
+
{ NEXTAUTH_URL: 'teleporthq.secrets.NEXTAUTH_URL' },
|
|
3284
|
+
from('shop.com'),
|
|
3285
|
+
undefined,
|
|
3286
|
+
'http://localhost:3000',
|
|
3287
|
+
],
|
|
3288
|
+
[{}, undefined, undefined, 'http://localhost:3000'],
|
|
3289
|
+
// The auth route rewrote NEXTAUTH_URL from X-Forwarded-Host: its snapshot is read.
|
|
3290
|
+
[
|
|
3291
|
+
{ NEXTAUTH_URL: 'https://evil.example' },
|
|
3292
|
+
from('shop.com'),
|
|
3293
|
+
GENERATED,
|
|
3294
|
+
'http://localhost:3000',
|
|
3295
|
+
],
|
|
3296
|
+
[{ NEXTAUTH_URL: 'https://evil.example' }, from('shop.com'), '', 'http://localhost:3000'],
|
|
3297
|
+
[
|
|
3298
|
+
{ NEXTAUTH_URL: 'https://evil.example' },
|
|
3299
|
+
from('evil.example'),
|
|
3300
|
+
'https://shop.test',
|
|
3301
|
+
'https://shop.test',
|
|
3302
|
+
],
|
|
3303
|
+
// A local server keeps its own loopback address.
|
|
3304
|
+
[{}, from('localhost:4000'), undefined, 'http://localhost:4000'],
|
|
3305
|
+
[{ NEXTAUTH_URL: GENERATED }, from('127.0.0.1:4000'), undefined, 'http://127.0.0.1:4000'],
|
|
3306
|
+
]
|
|
3307
|
+
for (const [env, req, snapshot, expected] of cases) {
|
|
3308
|
+
delete scope.__tqConfiguredNextAuthUrl
|
|
3309
|
+
if (snapshot !== undefined) {
|
|
3310
|
+
scope.__tqConfiguredNextAuthUrl = snapshot
|
|
3311
|
+
}
|
|
3312
|
+
const origin = await withEnv(
|
|
3313
|
+
{
|
|
3314
|
+
NEXTAUTH_URL: undefined,
|
|
3315
|
+
VERCEL_URL: undefined,
|
|
3316
|
+
VERCEL_PROJECT_PRODUCTION_URL: undefined,
|
|
3317
|
+
VERCEL: undefined,
|
|
3318
|
+
PORT: undefined,
|
|
3319
|
+
...env,
|
|
3320
|
+
} as Record<string, string | undefined>,
|
|
3321
|
+
async () => store.appBaseUrl(req)
|
|
3322
|
+
)
|
|
3323
|
+
const host = req && (req.headers as Row).host
|
|
3324
|
+
expect({ env, host, snapshot, origin }).toEqual({ env, host, snapshot, origin: expected })
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
// Read first by the bus, the configured value is kept the way the auth
|
|
3328
|
+
// route keeps it: a later rewrite of process.env changes nothing.
|
|
3329
|
+
delete scope.__tqConfiguredNextAuthUrl
|
|
3330
|
+
const kept = await withEnv(
|
|
3331
|
+
{ NEXTAUTH_URL: 'https://shop.test', VERCEL: undefined },
|
|
3332
|
+
async () => {
|
|
3333
|
+
const first = store.appBaseUrl(from('shop.com'))
|
|
3334
|
+
process.env.NEXTAUTH_URL = 'https://evil.example'
|
|
3335
|
+
return [first, store.appBaseUrl(from('evil.example'))]
|
|
3336
|
+
}
|
|
3337
|
+
)
|
|
3338
|
+
expect(kept).toEqual(['https://shop.test', 'https://shop.test'])
|
|
3339
|
+
})
|
|
3340
|
+
|
|
3341
|
+
it('never posts the app secret to a host the auth route took from a request', async () => {
|
|
3342
|
+
const scope = globalThis as Row
|
|
3343
|
+
// What /api/auth leaves behind off Vercel: the configured value kept, the
|
|
3344
|
+
// live one rewritten from X-Forwarded-Host.
|
|
3345
|
+
scope.__tqConfiguredNextAuthUrl = 'http://localhost:3000'
|
|
3346
|
+
const db = new FakeDatabase()
|
|
3347
|
+
const calls = await withEnv({ ...ENV, NEXTAUTH_URL: 'https://evil.example' }, () =>
|
|
3348
|
+
withFetch(
|
|
3349
|
+
() => ({ status: 200, body: {} }),
|
|
3350
|
+
async (recorded) => {
|
|
3351
|
+
const busModule = createApp(files, db)('lib/automations/event-bus.js') as Bus
|
|
3352
|
+
await busModule.emitBusinessEvent(signup, {
|
|
3353
|
+
headers: { host: 'evil.example', 'x-forwarded-host': 'evil.example' },
|
|
3354
|
+
})
|
|
3355
|
+
return recorded
|
|
3356
|
+
}
|
|
3357
|
+
)
|
|
3358
|
+
)
|
|
3359
|
+
expect(calls.map((call) => [call.url, call.headers['x-internal-data-secret']])).toEqual([
|
|
3360
|
+
['http://localhost:3000/api/automations/dispatch', 'app-secret'],
|
|
3361
|
+
])
|
|
3362
|
+
})
|
|
3363
|
+
|
|
3364
|
+
it('runs the queue for the app itself, and for a signed request without a body digest', async () => {
|
|
3365
|
+
const db = new FakeDatabase()
|
|
3366
|
+
seedOrderPaid(db)
|
|
3367
|
+
const { reply } = await callRoute(db, 'app', {})
|
|
3368
|
+
expect(reply).toEqual({
|
|
3369
|
+
status: 200,
|
|
3370
|
+
body: { claimed: 1, delivered: 1, failed: 1, resumed: 0 },
|
|
3371
|
+
})
|
|
3372
|
+
// A plain scheduled kick keeps today's signed string.
|
|
3373
|
+
const kicked = new FakeDatabase()
|
|
3374
|
+
seedOrderPaid(kicked)
|
|
3375
|
+
expect((await callRoute(kicked, 'signed-without-digest', {})).reply.status).toBe(200)
|
|
3376
|
+
})
|
|
3377
|
+
|
|
3378
|
+
const ORDER_EXAMPLE = {
|
|
3379
|
+
event: { id: 'placeholder', type: 'order-paid', version: 1, occurredAt: '', test: true },
|
|
3380
|
+
actor: { type: 'system', email: null },
|
|
3381
|
+
order: { id: 'ord-x', number: 'ORD-TEST', customer: { email: 'test@example.com' } },
|
|
3382
|
+
extra: { not: 'a block of order-paid' },
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
it('runs a test of ONE automation once, with the catalog blocks only, and never queues it', async () => {
|
|
3386
|
+
const db = new FakeDatabase()
|
|
3387
|
+
seedOrderPaid(db)
|
|
3388
|
+
db.events = []
|
|
3389
|
+
// Switched off: a test still runs it.
|
|
3390
|
+
db.automations.find((a) => a.workflow_id === 'wf-paid-ok')!.enabled = false
|
|
3391
|
+
|
|
3392
|
+
const { reply, calls } = await callRoute(db, 'signed', {
|
|
3393
|
+
test: true,
|
|
3394
|
+
workflowId: 'wf-paid-ok',
|
|
3395
|
+
event: ORDER_EXAMPLE,
|
|
3396
|
+
})
|
|
3397
|
+
|
|
3398
|
+
expect(reply).toEqual({
|
|
3399
|
+
status: 200,
|
|
3400
|
+
body: { claimed: 1, delivered: 1, failed: 0, resumed: 0 },
|
|
3401
|
+
})
|
|
3402
|
+
expect(calls.map((call) => [call.url, call.json])).toEqual([
|
|
3403
|
+
[ECHO + '/thanks', { email: 'test@example.com', number: 'ORD-TEST', test: true }],
|
|
3404
|
+
])
|
|
3405
|
+
expect(db.events).toHaveLength(1)
|
|
3406
|
+
expect(db.events[0].status).toBe('test')
|
|
3407
|
+
expect(db.events[0].id).not.toBe('placeholder')
|
|
3408
|
+
expect(db.deliveries.map((d) => [d.workflow_id, d.status, d.error])).toEqual([
|
|
3409
|
+
['wf-paid-ok', 'test', null],
|
|
3410
|
+
])
|
|
3411
|
+
|
|
3412
|
+
// Nothing is left to claim.
|
|
3413
|
+
const again = await withEcho(() =>
|
|
3414
|
+
silenced(() => (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({}))
|
|
3415
|
+
)
|
|
3416
|
+
expect(again.claimed).toBe(0)
|
|
3417
|
+
})
|
|
3418
|
+
|
|
3419
|
+
it('takes a test only from a request whose signature covers its body', async () => {
|
|
3420
|
+
const body = { test: true, workflowId: 'wf-paid-ok', event: ORDER_EXAMPLE }
|
|
3421
|
+
|
|
3422
|
+
// Changed on the way: the signature no longer holds.
|
|
3423
|
+
const tampered = new FakeDatabase()
|
|
3424
|
+
seedOrderPaid(tampered)
|
|
3425
|
+
const refused = await callRoute(tampered, 'tampered', body)
|
|
3426
|
+
expect(refused.reply).toEqual({ status: 401, body: { error: 'Unauthorized' } })
|
|
3427
|
+
expect(refused.calls).toEqual([])
|
|
3428
|
+
expect(tampered.statements).toEqual([])
|
|
3429
|
+
|
|
3430
|
+
// Authenticated, but nothing vouches for the body.
|
|
3431
|
+
for (const caller of ['app', 'signed-without-digest'] as const) {
|
|
3432
|
+
const unsignedDb = new FakeDatabase()
|
|
3433
|
+
seedOrderPaid(unsignedDb)
|
|
3434
|
+
const unsigned = await callRoute(unsignedDb, caller, body)
|
|
3435
|
+
expect(unsigned.reply).toEqual({ status: 400, body: { error: 'Unsigned body' } })
|
|
3436
|
+
expect(unsigned.calls).toEqual([])
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
// The body as signed: it runs.
|
|
3440
|
+
const db = new FakeDatabase()
|
|
3441
|
+
seedOrderPaid(db)
|
|
3442
|
+
const ran = await callRoute(db, 'signed', body)
|
|
3443
|
+
expect(ran.reply.status).toBe(200)
|
|
3444
|
+
expect(ran.calls.map((call) => call.url)).toEqual([ECHO + '/thanks'])
|
|
3445
|
+
})
|
|
3446
|
+
|
|
3447
|
+
it('names what a test body lacks, so a sender without workflowId cannot fail silently', async () => {
|
|
3448
|
+
const db = new FakeDatabase()
|
|
3449
|
+
seedOrderPaid(db)
|
|
3450
|
+
const { reply, calls } = await callRoute(db, 'signed', { test: true, event: ORDER_EXAMPLE })
|
|
3451
|
+
expect(reply).toEqual({
|
|
3452
|
+
status: 400,
|
|
3453
|
+
body: { error: 'A test names the automation to run: workflowId is missing' },
|
|
3454
|
+
})
|
|
3455
|
+
expect(calls).toEqual([])
|
|
3456
|
+
const noEvent = await callRoute(db, 'signed', { test: true, workflowId: 'wf-paid-ok' })
|
|
3457
|
+
expect(noEvent.reply).toEqual({
|
|
3458
|
+
status: 400,
|
|
3459
|
+
body: { error: 'A test carries the event to send: event is missing' },
|
|
3460
|
+
})
|
|
3461
|
+
})
|
|
3462
|
+
|
|
3463
|
+
it('stops a test at a Wait and reports how long it would have waited, parking nothing', async () => {
|
|
3464
|
+
const db = new FakeDatabase()
|
|
3465
|
+
seedSignup(db)
|
|
3466
|
+
db.events = []
|
|
3467
|
+
const { reply, calls } = await callRoute(db, 'signed', {
|
|
3468
|
+
test: true,
|
|
3469
|
+
workflowId: 'wf-signup-wait',
|
|
3470
|
+
event: {
|
|
3471
|
+
event: { id: 'x', type: 'user-signed-up', version: 1, occurredAt: '', test: true },
|
|
3472
|
+
actor: { type: 'customer', email: 'test@example.com' },
|
|
3473
|
+
user: { id: 'u-x', email: 'test@example.com' },
|
|
3474
|
+
},
|
|
3475
|
+
})
|
|
3476
|
+
expect(reply).toEqual({
|
|
3477
|
+
status: 200,
|
|
3478
|
+
body: {
|
|
3479
|
+
claimed: 1,
|
|
3480
|
+
delivered: 1,
|
|
3481
|
+
failed: 0,
|
|
3482
|
+
resumed: 0,
|
|
3483
|
+
wouldWait: { amount: 2, unit: 'days' },
|
|
3484
|
+
},
|
|
3485
|
+
})
|
|
3486
|
+
expect(calls.map((call) => call.url)).toEqual([ECHO + '/before'])
|
|
3487
|
+
expect(db.waits).toEqual([])
|
|
3488
|
+
expect(JSON.parse(db.deliveries[0].error!)).toEqual({ wouldWait: { amount: 2, unit: 'days' } })
|
|
3489
|
+
})
|
|
3490
|
+
|
|
3491
|
+
it('refuses a test of an automation the site does not have, or of another event', async () => {
|
|
3492
|
+
const db = new FakeDatabase()
|
|
3493
|
+
const unknown = await callRoute(db, 'signed', {
|
|
3494
|
+
test: true,
|
|
3495
|
+
workflowId: 'wf-nope',
|
|
3496
|
+
event: ORDER_EXAMPLE,
|
|
3497
|
+
})
|
|
3498
|
+
expect(unknown.reply.status).toBe(404)
|
|
3499
|
+
const other = await callRoute(db, 'signed', {
|
|
3500
|
+
test: true,
|
|
3501
|
+
workflowId: 'wf-signup-wait',
|
|
3502
|
+
event: ORDER_EXAMPLE,
|
|
3503
|
+
})
|
|
3504
|
+
expect(other.reply.status).toBe(400)
|
|
3505
|
+
expect(db.events).toEqual([])
|
|
3506
|
+
})
|
|
3507
|
+
})
|
|
3508
|
+
|
|
3509
|
+
describe('in-app event bus — Slack messages', () => {
|
|
3510
|
+
// A Slack message is mrkdwn: `<…>` there is a link or a mention. What a
|
|
3511
|
+
// customer typed reaches it as a value of the event.
|
|
3512
|
+
const chip = (path: string[], label: string) =>
|
|
3513
|
+
`<span class="context-value-inline" data-ctx-node-id="t" data-ctx-path='${JSON.stringify([
|
|
3514
|
+
't',
|
|
3515
|
+
...path,
|
|
3516
|
+
])}' data-ctx-label="${label}" contenteditable="false" draggable="false">${label}</span>`
|
|
3517
|
+
const slackStep = (id: string, text: unknown, stepNumber: number) => ({
|
|
3518
|
+
id,
|
|
3519
|
+
type: 'integration-slack',
|
|
3520
|
+
config: { action: 'send-message', token: 'xoxb-test', channel: 'C1', text },
|
|
3521
|
+
stepNumber,
|
|
3522
|
+
label: 'Post to Slack',
|
|
3523
|
+
})
|
|
3524
|
+
const SLACK_AUTOMATION: Record<string, Row> = {
|
|
3525
|
+
'wf-slack': {
|
|
3526
|
+
id: 'wf-slack',
|
|
3527
|
+
name: 'Tell me on Slack',
|
|
3528
|
+
trigger: trigger('order-paid'),
|
|
3529
|
+
nodes: [
|
|
3530
|
+
// As the recipe writes it: the owner's own words and link, the values as chips.
|
|
3531
|
+
slackStep(
|
|
3532
|
+
'chips',
|
|
3533
|
+
`New order from ${chip(['order', 'customer', 'name'], 'Customer name')}: ${chip(
|
|
3534
|
+
['order', 'notes'],
|
|
3535
|
+
'Order notes'
|
|
3536
|
+
)} <https://shop.test/admin|Open the admin>`,
|
|
3537
|
+
1
|
|
3538
|
+
),
|
|
3539
|
+
// The whole message bound to one value.
|
|
3540
|
+
slackStep('whole', ref('t', ['order', 'notes']), 2),
|
|
3541
|
+
],
|
|
3542
|
+
edges: [
|
|
3543
|
+
{ id: 'e1', source: 't', target: 'chips' },
|
|
3544
|
+
{ id: 'e2', source: 'chips', target: 'whole' },
|
|
3545
|
+
],
|
|
3546
|
+
},
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
it('escapes every event value it posts, and keeps the owner’s own links', async () => {
|
|
3550
|
+
const files = sources(await generate({ ...BASE_WORKFLOWS, ...SLACK_AUTOMATION }))
|
|
3551
|
+
const db = new FakeDatabase()
|
|
3552
|
+
const posted = await withEnv(ENV, () =>
|
|
3553
|
+
withFetch(
|
|
3554
|
+
() => ({ status: 200, body: { ok: true, ts: '1.0' } }),
|
|
3555
|
+
async (calls) => {
|
|
3556
|
+
const registry = createApp(files, db)('lib/automations/registry.js') as Record<
|
|
3557
|
+
string,
|
|
3558
|
+
Array<{ workflowId: string; run: (event: Row, context: Row) => Promise<unknown> }>
|
|
3559
|
+
>
|
|
3560
|
+
const entry = registry['order-paid'].find((e) => e.workflowId === 'wf-slack')!
|
|
3561
|
+
await silenced(() =>
|
|
3562
|
+
entry.run(
|
|
3563
|
+
{
|
|
3564
|
+
event: { id: 'evt-1', type: 'order-paid', version: 1, occurredAt: '' },
|
|
3565
|
+
actor: { type: 'customer', email: 'ava@example.com' },
|
|
3566
|
+
order: {
|
|
3567
|
+
id: 'ord-1',
|
|
3568
|
+
customer: { name: `Ava "O'Brien" <!channel>`, email: 'ava@example.com' },
|
|
3569
|
+
notes: '<https://evil.test|Claim your refund> & more',
|
|
3570
|
+
},
|
|
3571
|
+
},
|
|
3572
|
+
{ __baseUrl: 'https://shop.test' }
|
|
3573
|
+
)
|
|
3574
|
+
)
|
|
3575
|
+
return calls
|
|
3576
|
+
.filter((call) => call.url === 'https://slack.com/api/chat.postMessage')
|
|
3577
|
+
.map((call) => call.json.text)
|
|
3578
|
+
}
|
|
3579
|
+
)
|
|
3580
|
+
)
|
|
3581
|
+
|
|
3582
|
+
expect(posted).toEqual([
|
|
3583
|
+
`New order from Ava "O'Brien" <!channel>: <https://evil.test|Claim your refund> & more <https://shop.test/admin|Open the admin>`,
|
|
3584
|
+
'<https://evil.test|Claim your refund> & more',
|
|
3585
|
+
])
|
|
3586
|
+
})
|
|
3587
|
+
|
|
3588
|
+
it('leaves the handler of an automation without a bound Slack message as it was', async () => {
|
|
3589
|
+
const { generateAutomationHandlerModule } = await import(
|
|
3590
|
+
'../src/automations/handler-module-generator'
|
|
3591
|
+
)
|
|
3592
|
+
const { generateHandlerFunction } = await import('../src/api-route-generator')
|
|
3593
|
+
const workflow = JSON.parse(JSON.stringify(AUTOMATIONS['wf-paid-ok']))
|
|
3594
|
+
expect(generateAutomationHandlerModule(workflow)).toBe(generateHandlerFunction(workflow))
|
|
3595
|
+
const chipsOnly = JSON.parse(JSON.stringify(SLACK_AUTOMATION['wf-slack']))
|
|
3596
|
+
chipsOnly.nodes = chipsOnly.nodes.slice(0, 1)
|
|
3597
|
+
expect(generateAutomationHandlerModule(chipsOnly)).toBe(generateHandlerFunction(chipsOnly))
|
|
3598
|
+
})
|
|
3599
|
+
})
|
|
3600
|
+
|
|
3601
|
+
describe('in-app event bus — the activity kick', () => {
|
|
3602
|
+
let files: Map<string, string>
|
|
3603
|
+
|
|
3604
|
+
beforeAll(async () => {
|
|
3605
|
+
files = sources(
|
|
3606
|
+
await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }, TELEPORT_AUTOMATIONS, {
|
|
3607
|
+
files: FETCHERS,
|
|
3608
|
+
})
|
|
3609
|
+
)
|
|
3610
|
+
})
|
|
3611
|
+
|
|
3612
|
+
interface DueCheck {
|
|
3613
|
+
kickWhenDue(req?: Row): Promise<boolean> | null
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
const seedDueWait = (db: FakeDatabase) => {
|
|
3617
|
+
seedSignup(db)
|
|
3618
|
+
db.waits.push({
|
|
3619
|
+
id: 900,
|
|
3620
|
+
event_id: 'evt-signup',
|
|
3621
|
+
workflow_id: 'wf-signup-wait',
|
|
3622
|
+
resume_node_id: 'after',
|
|
3623
|
+
context: JSON.stringify({ wait: { until: ago(MINUTE_MS).toISOString() } }),
|
|
3624
|
+
run_at: ago(MINUTE_MS),
|
|
3625
|
+
status: 'waiting',
|
|
3626
|
+
created_at: ago(DAY_MS),
|
|
3627
|
+
})
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
const DISPATCH = 'https://shop.test/api/automations/dispatch'
|
|
3631
|
+
|
|
3632
|
+
it('checks for due work at most once a minute per process, and wakes the queue only for some', async () => {
|
|
3633
|
+
const db = new FakeDatabase()
|
|
3634
|
+
seedDueWait(db)
|
|
3635
|
+
let now = Date.now()
|
|
3636
|
+
const clock = jest.spyOn(Date, 'now').mockImplementation(() => now)
|
|
3637
|
+
try {
|
|
3638
|
+
const run = await withEnv(ENV, () =>
|
|
3639
|
+
withFetch(
|
|
3640
|
+
() => ({ status: 200, body: {} }),
|
|
3641
|
+
async (calls) => {
|
|
3642
|
+
const check = createApp(files, db)('lib/automations/due-check.js') as DueCheck
|
|
3643
|
+
const req = { headers: { host: 'shop.test' } }
|
|
3644
|
+
const first = await check.kickWhenDue(req)
|
|
3645
|
+
const afterFirst = calls.length
|
|
3646
|
+
// Within the minute: no query, no kick.
|
|
3647
|
+
const statements = db.statements.length
|
|
3648
|
+
const second = check.kickWhenDue(req)
|
|
3649
|
+
const quiet = db.statements.length === statements
|
|
3650
|
+
now += 61 * 1000
|
|
3651
|
+
const third = await check.kickWhenDue(req)
|
|
3652
|
+
return { first, afterFirst, second, quiet, third, calls }
|
|
3653
|
+
}
|
|
3654
|
+
)
|
|
3655
|
+
)
|
|
3656
|
+
expect(run.first).toBe(true)
|
|
3657
|
+
expect(run.afterFirst).toBe(1)
|
|
3658
|
+
expect(run.second).toBeNull()
|
|
3659
|
+
expect(run.quiet).toBe(true)
|
|
3660
|
+
expect(run.third).toBe(true)
|
|
3661
|
+
expect(run.calls.map((call) => [call.url, call.headers['x-internal-data-secret']])).toEqual([
|
|
3662
|
+
[DISPATCH, 'app-secret'],
|
|
3663
|
+
[DISPATCH, 'app-secret'],
|
|
3664
|
+
])
|
|
3665
|
+
} finally {
|
|
3666
|
+
clock.mockRestore()
|
|
3667
|
+
}
|
|
3668
|
+
})
|
|
3669
|
+
|
|
3670
|
+
it('wakes nothing when nothing is due, and never runs while the site is being built', async () => {
|
|
3671
|
+
const idle = new FakeDatabase()
|
|
3672
|
+
seedSignup(idle)
|
|
3673
|
+
idle.events[0].status = 'delivered'
|
|
3674
|
+
const quiet = await withEnv(ENV, () =>
|
|
3675
|
+
withFetch(
|
|
3676
|
+
() => ({ status: 200, body: {} }),
|
|
3677
|
+
async (calls) => {
|
|
3678
|
+
const check = createApp(files, idle)('lib/automations/due-check.js') as DueCheck
|
|
3679
|
+
return { kicked: await check.kickWhenDue(), calls }
|
|
3680
|
+
}
|
|
3681
|
+
)
|
|
3682
|
+
)
|
|
3683
|
+
expect(quiet.kicked).toBe(false)
|
|
3684
|
+
expect(quiet.calls).toEqual([])
|
|
3685
|
+
const due = idle.statements.find((text) => text.startsWith('SELECT (EXISTS'))!
|
|
3686
|
+
expect(due).toContain("w.status = 'waiting' AND w.run_at <= now()")
|
|
3687
|
+
expect(due).toContain("(e.status = 'failed' OR (e.status = 'pending' AND (GREATEST(")
|
|
3688
|
+
|
|
3689
|
+
// A retry due counts as much as a Wait.
|
|
3690
|
+
const failed = new FakeDatabase()
|
|
3691
|
+
seedSignup(failed)
|
|
3692
|
+
failed.events[0].status = 'failed'
|
|
3693
|
+
failed.events[0].attempts = 1
|
|
3694
|
+
const retry = await withEnv(ENV, () =>
|
|
3695
|
+
withFetch(
|
|
3696
|
+
() => ({ status: 200, body: {} }),
|
|
3697
|
+
async (calls) => ({
|
|
3698
|
+
kicked: await (
|
|
3699
|
+
createApp(files, failed)('lib/automations/due-check.js') as DueCheck
|
|
3700
|
+
).kickWhenDue(),
|
|
3701
|
+
calls,
|
|
3702
|
+
})
|
|
3703
|
+
)
|
|
3704
|
+
)
|
|
3705
|
+
expect(retry.kicked).toBe(true)
|
|
3706
|
+
expect(retry.calls.map((call) => call.url)).toEqual([DISPATCH])
|
|
3707
|
+
|
|
3708
|
+
const building = new FakeDatabase()
|
|
3709
|
+
seedDueWait(building)
|
|
3710
|
+
const built = await withEnv({ ...ENV, NEXT_PHASE: 'phase-production-build' }, () =>
|
|
3711
|
+
withFetch(
|
|
3712
|
+
() => ({ status: 200, body: {} }),
|
|
3713
|
+
async () =>
|
|
3714
|
+
(createApp(files, building)('lib/automations/due-check.js') as DueCheck).kickWhenDue()
|
|
3715
|
+
)
|
|
3716
|
+
)
|
|
3717
|
+
expect(built).toBeNull()
|
|
3718
|
+
expect(building.statements).toEqual([])
|
|
3719
|
+
})
|
|
3720
|
+
|
|
3721
|
+
it('wakes the queue for what a stopped dispatcher left: a stale pending event, a stale resume', async () => {
|
|
3722
|
+
const kicked = async (database: FakeDatabase) =>
|
|
3723
|
+
withEnv(ENV, () =>
|
|
3724
|
+
withFetch(
|
|
3725
|
+
() => ({ status: 200, body: {} }),
|
|
3726
|
+
async (calls) => {
|
|
3727
|
+
const check = createApp(files, database)('lib/automations/due-check.js') as DueCheck
|
|
3728
|
+
return { kicked: await check.kickWhenDue(), calls: calls.length }
|
|
3729
|
+
}
|
|
3730
|
+
)
|
|
3731
|
+
)
|
|
3732
|
+
const pending = (claimedAgo: number) => {
|
|
3733
|
+
const pendingDb = new FakeDatabase()
|
|
3734
|
+
seedSignup(pendingDb)
|
|
3735
|
+
Object.assign(pendingDb.events[0], {
|
|
3736
|
+
status: 'pending',
|
|
3737
|
+
attempts: 1,
|
|
3738
|
+
created_at: ago(claimedAgo + MINUTE_MS),
|
|
3739
|
+
claimed_at: ago(claimedAgo),
|
|
3740
|
+
})
|
|
3741
|
+
return pendingDb
|
|
3742
|
+
}
|
|
3743
|
+
expect(await kicked(pending(STALE_MS + MINUTE_MS))).toEqual({ kicked: true, calls: 1 })
|
|
3744
|
+
// Claimed a minute ago: another dispatcher is still on it.
|
|
3745
|
+
expect(await kicked(pending(MINUTE_MS))).toEqual({ kicked: false, calls: 0 })
|
|
3746
|
+
|
|
3747
|
+
const resuming = (claimedAgo: number) => {
|
|
3748
|
+
const resumingDb = new FakeDatabase()
|
|
3749
|
+
seedDueWait(resumingDb)
|
|
3750
|
+
resumingDb.events[0].status = 'delivered'
|
|
3751
|
+
Object.assign(resumingDb.waits[0], { status: 'resuming', run_at: ago(claimedAgo) })
|
|
3752
|
+
return resumingDb
|
|
3753
|
+
}
|
|
3754
|
+
expect(await kicked(resuming(STALE_MS + MINUTE_MS))).toEqual({ kicked: true, calls: 1 })
|
|
3755
|
+
expect(await kicked(resuming(MINUTE_MS))).toEqual({ kicked: false, calls: 0 })
|
|
3756
|
+
|
|
3757
|
+
// The very fragments the dispatcher claims with.
|
|
3758
|
+
const db = resuming(MINUTE_MS)
|
|
3759
|
+
await kicked(db)
|
|
3760
|
+
const due = db.statements.find((text) => text.startsWith('SELECT (EXISTS'))!
|
|
3761
|
+
const claims = sources(await generate(AUTOMATIONS)).get('lib/automations/dispatcher.js')!
|
|
3762
|
+
expect(due).toContain(
|
|
3763
|
+
"OR (w.status = 'resuming' AND w.run_at < now() - interval '15 minutes'))"
|
|
3764
|
+
)
|
|
3765
|
+
const stale =
|
|
3766
|
+
"(GREATEST(COALESCE(e.claimed_at, e.created_at), COALESCE((SELECT max(d.started_at) FROM teleport_event_deliveries d WHERE d.event_id = e.id), e.created_at)) < now() - interval '15 minutes' AND NOT EXISTS (SELECT 1 FROM teleport_event_deliveries d WHERE d.event_id = e.id AND d.status = 'waiting' AND d.workflow_id = ANY("
|
|
3767
|
+
expect(due).toContain(stale)
|
|
3768
|
+
expect(claims).toContain(stale)
|
|
3769
|
+
})
|
|
3770
|
+
|
|
3771
|
+
it("is kept alive by Vercel's waitUntil there, off the request either way", async () => {
|
|
3772
|
+
const db = new FakeDatabase()
|
|
3773
|
+
seedDueWait(db)
|
|
3774
|
+
const scope = globalThis as Record<string, unknown>
|
|
3775
|
+
const held: Array<Promise<unknown>> = []
|
|
3776
|
+
scope.__tqWaitUntil = (promise: Promise<unknown>) => {
|
|
3777
|
+
held.push(promise)
|
|
3778
|
+
}
|
|
3779
|
+
try {
|
|
3780
|
+
await withEnv({ ...ENV, VERCEL: '1' }, () =>
|
|
3781
|
+
withFetch(
|
|
3782
|
+
() => ({ status: 200, body: {} }),
|
|
3783
|
+
async () => {
|
|
3784
|
+
const check = createApp(files, db)('lib/automations/due-check.js') as DueCheck
|
|
3785
|
+
const pending = check.kickWhenDue()
|
|
3786
|
+
// Handed to the platform before the request goes on.
|
|
3787
|
+
expect(held).toHaveLength(1)
|
|
3788
|
+
await pending
|
|
3789
|
+
await Promise.all(held)
|
|
3790
|
+
}
|
|
3791
|
+
)
|
|
3792
|
+
)
|
|
3793
|
+
} finally {
|
|
3794
|
+
delete scope.__tqWaitUntil
|
|
3795
|
+
}
|
|
3796
|
+
// The check, then the queue itself in the same waitUntil.
|
|
3797
|
+
expect(held.length).toBeGreaterThanOrEqual(2)
|
|
3798
|
+
expect(db.waits.find((w) => w.id === 900)!.status).toBe('done')
|
|
3799
|
+
})
|
|
3800
|
+
|
|
3801
|
+
it('starts from every server read of the TeleportHQ database and from the data API, nowhere else', () => {
|
|
3802
|
+
for (const path of TELEPORT_READS) {
|
|
3803
|
+
const source = files.get(path)!
|
|
3804
|
+
expect(
|
|
3805
|
+
source.startsWith(
|
|
3806
|
+
"import { kickWhenDue as __tqKickDueAutomations } from '../../lib/automations/due-check'\n"
|
|
3807
|
+
)
|
|
3808
|
+
).toBe(true)
|
|
3809
|
+
expect(source).toContain(
|
|
3810
|
+
'async function handler(req, res) {\n // Activity on the site: due automation work (a Wait, a retry) starts off this request.\n __tqKickDueAutomations(req)\n'
|
|
3811
|
+
)
|
|
3812
|
+
}
|
|
3813
|
+
for (const path of [REEXPORT_ROUTE, OWN_ITEMS_MODULE]) {
|
|
3814
|
+
expect(files.get(path)).not.toContain('due-check')
|
|
3815
|
+
}
|
|
3816
|
+
expect(files.get(DATA_API_ROUTE)).toContain(
|
|
3817
|
+
"require('../../../lib/automations/due-check').kickWhenDue(req);"
|
|
3818
|
+
)
|
|
3819
|
+
})
|
|
3820
|
+
})
|