webhookdb 0.1.0
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.
- checksums.yaml +7 -0
- data/data/messages/layouts/blank.email.liquid +10 -0
- data/data/messages/layouts/minimal.email.liquid +28 -0
- data/data/messages/layouts/standard.email.liquid +28 -0
- data/data/messages/partials/button.liquid +15 -0
- data/data/messages/partials/environment_banner.liquid +9 -0
- data/data/messages/partials/footer.liquid +22 -0
- data/data/messages/partials/greeting.liquid +3 -0
- data/data/messages/partials/logo_header.liquid +18 -0
- data/data/messages/partials/signoff.liquid +1 -0
- data/data/messages/styles/v1.liquid +346 -0
- data/data/messages/templates/errors/icalendar_fetch.email.liquid +29 -0
- data/data/messages/templates/invite.email.liquid +15 -0
- data/data/messages/templates/new_customer.email.liquid +24 -0
- data/data/messages/templates/org_database_migration_finished.email.liquid +7 -0
- data/data/messages/templates/org_database_migration_started.email.liquid +9 -0
- data/data/messages/templates/specs/_field_partial.liquid +1 -0
- data/data/messages/templates/specs/basic.email.liquid +2 -0
- data/data/messages/templates/specs/basic.fake.liquid +1 -0
- data/data/messages/templates/specs/with_field.email.liquid +2 -0
- data/data/messages/templates/specs/with_field.fake.liquid +1 -0
- data/data/messages/templates/specs/with_include.email.liquid +2 -0
- data/data/messages/templates/specs/with_partial.email.liquid +1 -0
- data/data/messages/templates/verification.email.liquid +14 -0
- data/data/messages/templates/verification.sms.liquid +1 -0
- data/data/messages/web/install-customer-login.liquid +48 -0
- data/data/messages/web/install-error.liquid +17 -0
- data/data/messages/web/install-success.liquid +35 -0
- data/data/messages/web/install.liquid +20 -0
- data/data/messages/web/partials/footer.liquid +4 -0
- data/data/messages/web/partials/form_error.liquid +1 -0
- data/data/messages/web/partials/header.liquid +3 -0
- data/data/messages/web/styles.liquid +134 -0
- data/data/windows_tz.txt +461 -0
- data/db/migrations/001_testing_pixies.rb +13 -0
- data/db/migrations/002_initial.rb +132 -0
- data/db/migrations/003_ux_overhaul.rb +20 -0
- data/db/migrations/004_incremental_backfill.rb +9 -0
- data/db/migrations/005_log_webhooks.rb +24 -0
- data/db/migrations/006_generalize_roles.rb +29 -0
- data/db/migrations/007_org_dns.rb +12 -0
- data/db/migrations/008_webhook_subscriptions.rb +19 -0
- data/db/migrations/009_nonunique_stripe_subscription_customer.rb +16 -0
- data/db/migrations/010_drop_integration_soft_delete.rb +14 -0
- data/db/migrations/011_webhook_subscriptions_created_at.rb +10 -0
- data/db/migrations/012_webhook_subscriptions_created_by.rb +9 -0
- data/db/migrations/013_default_org_membership.rb +30 -0
- data/db/migrations/014_webhook_subscription_deliveries.rb +26 -0
- data/db/migrations/015_dependent_integrations.rb +9 -0
- data/db/migrations/016_encrypted_columns.rb +9 -0
- data/db/migrations/017_skip_verification.rb +9 -0
- data/db/migrations/018_sync_targets.rb +25 -0
- data/db/migrations/019_org_schema.rb +9 -0
- data/db/migrations/020_org_database_migrations.rb +25 -0
- data/db/migrations/021_no_default_org_schema.rb +14 -0
- data/db/migrations/022_database_document.rb +15 -0
- data/db/migrations/023_sync_target_schema.rb +9 -0
- data/db/migrations/024_org_semaphore_jobs.rb +9 -0
- data/db/migrations/025_integration_backfill_cursor.rb +9 -0
- data/db/migrations/026_undo_integration_backfill_cursor.rb +9 -0
- data/db/migrations/027_sync_target_http_sync.rb +12 -0
- data/db/migrations/028_logged_webhook_path.rb +24 -0
- data/db/migrations/029_encrypt_columns.rb +97 -0
- data/db/migrations/030_org_sync_target_timeout.rb +9 -0
- data/db/migrations/031_org_max_query_rows.rb +9 -0
- data/db/migrations/032_remove_db_defaults.rb +12 -0
- data/db/migrations/033_backfill_jobs.rb +26 -0
- data/db/migrations/034_backfill_job_criteria.rb +9 -0
- data/db/migrations/035_synchronous_backfill.rb +9 -0
- data/db/migrations/036_oauth.rb +26 -0
- data/db/migrations/037_oauth_used.rb +9 -0
- data/lib/amigo/durable_job.rb +416 -0
- data/lib/pry/clipboard.rb +111 -0
- data/lib/sequel/advisory_lock.rb +65 -0
- data/lib/webhookdb/admin.rb +4 -0
- data/lib/webhookdb/admin_api/auth.rb +36 -0
- data/lib/webhookdb/admin_api/customers.rb +63 -0
- data/lib/webhookdb/admin_api/database_documents.rb +20 -0
- data/lib/webhookdb/admin_api/entities.rb +66 -0
- data/lib/webhookdb/admin_api/message_deliveries.rb +61 -0
- data/lib/webhookdb/admin_api/roles.rb +15 -0
- data/lib/webhookdb/admin_api.rb +34 -0
- data/lib/webhookdb/aggregate_result.rb +63 -0
- data/lib/webhookdb/api/auth.rb +122 -0
- data/lib/webhookdb/api/connstr_auth.rb +36 -0
- data/lib/webhookdb/api/db.rb +188 -0
- data/lib/webhookdb/api/demo.rb +14 -0
- data/lib/webhookdb/api/entities.rb +198 -0
- data/lib/webhookdb/api/helpers.rb +253 -0
- data/lib/webhookdb/api/install.rb +296 -0
- data/lib/webhookdb/api/me.rb +53 -0
- data/lib/webhookdb/api/organizations.rb +254 -0
- data/lib/webhookdb/api/replay.rb +64 -0
- data/lib/webhookdb/api/service_integrations.rb +402 -0
- data/lib/webhookdb/api/services.rb +27 -0
- data/lib/webhookdb/api/stripe.rb +22 -0
- data/lib/webhookdb/api/subscriptions.rb +67 -0
- data/lib/webhookdb/api/sync_targets.rb +232 -0
- data/lib/webhookdb/api/system.rb +37 -0
- data/lib/webhookdb/api/webhook_subscriptions.rb +96 -0
- data/lib/webhookdb/api.rb +92 -0
- data/lib/webhookdb/apps.rb +93 -0
- data/lib/webhookdb/async/audit_logger.rb +38 -0
- data/lib/webhookdb/async/autoscaler.rb +84 -0
- data/lib/webhookdb/async/job.rb +18 -0
- data/lib/webhookdb/async/job_logger.rb +45 -0
- data/lib/webhookdb/async/scheduled_job.rb +18 -0
- data/lib/webhookdb/async.rb +142 -0
- data/lib/webhookdb/aws.rb +98 -0
- data/lib/webhookdb/backfill_job.rb +107 -0
- data/lib/webhookdb/backfiller.rb +107 -0
- data/lib/webhookdb/cloudflare.rb +39 -0
- data/lib/webhookdb/connection_cache.rb +177 -0
- data/lib/webhookdb/console.rb +71 -0
- data/lib/webhookdb/convertkit.rb +14 -0
- data/lib/webhookdb/crypto.rb +66 -0
- data/lib/webhookdb/customer/reset_code.rb +94 -0
- data/lib/webhookdb/customer.rb +347 -0
- data/lib/webhookdb/database_document.rb +72 -0
- data/lib/webhookdb/db_adapter/column_types.rb +37 -0
- data/lib/webhookdb/db_adapter/default_sql.rb +187 -0
- data/lib/webhookdb/db_adapter/pg.rb +96 -0
- data/lib/webhookdb/db_adapter/snowflake.rb +137 -0
- data/lib/webhookdb/db_adapter.rb +208 -0
- data/lib/webhookdb/dbutil.rb +92 -0
- data/lib/webhookdb/demo_mode.rb +100 -0
- data/lib/webhookdb/developer_alert.rb +51 -0
- data/lib/webhookdb/email_octopus.rb +21 -0
- data/lib/webhookdb/enumerable.rb +18 -0
- data/lib/webhookdb/fixtures/backfill_jobs.rb +72 -0
- data/lib/webhookdb/fixtures/customers.rb +65 -0
- data/lib/webhookdb/fixtures/database_documents.rb +27 -0
- data/lib/webhookdb/fixtures/faker.rb +41 -0
- data/lib/webhookdb/fixtures/logged_webhooks.rb +56 -0
- data/lib/webhookdb/fixtures/message_deliveries.rb +59 -0
- data/lib/webhookdb/fixtures/oauth_sessions.rb +24 -0
- data/lib/webhookdb/fixtures/organization_database_migrations.rb +37 -0
- data/lib/webhookdb/fixtures/organization_memberships.rb +54 -0
- data/lib/webhookdb/fixtures/organizations.rb +32 -0
- data/lib/webhookdb/fixtures/reset_codes.rb +23 -0
- data/lib/webhookdb/fixtures/service_integrations.rb +42 -0
- data/lib/webhookdb/fixtures/subscriptions.rb +33 -0
- data/lib/webhookdb/fixtures/sync_targets.rb +32 -0
- data/lib/webhookdb/fixtures/webhook_subscriptions.rb +35 -0
- data/lib/webhookdb/fixtures.rb +15 -0
- data/lib/webhookdb/formatting.rb +56 -0
- data/lib/webhookdb/front.rb +49 -0
- data/lib/webhookdb/github.rb +22 -0
- data/lib/webhookdb/google_calendar.rb +29 -0
- data/lib/webhookdb/heroku.rb +21 -0
- data/lib/webhookdb/http.rb +114 -0
- data/lib/webhookdb/icalendar.rb +17 -0
- data/lib/webhookdb/id.rb +17 -0
- data/lib/webhookdb/idempotency.rb +90 -0
- data/lib/webhookdb/increase.rb +42 -0
- data/lib/webhookdb/intercom.rb +23 -0
- data/lib/webhookdb/jobs/amigo_test_jobs.rb +118 -0
- data/lib/webhookdb/jobs/backfill.rb +32 -0
- data/lib/webhookdb/jobs/create_mirror_table.rb +18 -0
- data/lib/webhookdb/jobs/create_stripe_customer.rb +17 -0
- data/lib/webhookdb/jobs/customer_created_notify_internal.rb +22 -0
- data/lib/webhookdb/jobs/demo_mode_sync_data.rb +19 -0
- data/lib/webhookdb/jobs/deprecated_jobs.rb +19 -0
- data/lib/webhookdb/jobs/developer_alert_handle.rb +14 -0
- data/lib/webhookdb/jobs/durable_job_recheck_poller.rb +17 -0
- data/lib/webhookdb/jobs/emailer.rb +15 -0
- data/lib/webhookdb/jobs/icalendar_enqueue_syncs.rb +25 -0
- data/lib/webhookdb/jobs/icalendar_sync.rb +23 -0
- data/lib/webhookdb/jobs/logged_webhook_replay.rb +17 -0
- data/lib/webhookdb/jobs/logged_webhook_resilient_replay.rb +15 -0
- data/lib/webhookdb/jobs/message_dispatched.rb +16 -0
- data/lib/webhookdb/jobs/organization_database_migration_notify_finished.rb +21 -0
- data/lib/webhookdb/jobs/organization_database_migration_notify_started.rb +21 -0
- data/lib/webhookdb/jobs/organization_database_migration_run.rb +24 -0
- data/lib/webhookdb/jobs/prepare_database_connections.rb +22 -0
- data/lib/webhookdb/jobs/process_webhook.rb +47 -0
- data/lib/webhookdb/jobs/renew_watch_channel.rb +24 -0
- data/lib/webhookdb/jobs/replication_migration.rb +24 -0
- data/lib/webhookdb/jobs/reset_code_create_dispatch.rb +23 -0
- data/lib/webhookdb/jobs/scheduled_backfills.rb +77 -0
- data/lib/webhookdb/jobs/send_invite.rb +15 -0
- data/lib/webhookdb/jobs/send_test_webhook.rb +25 -0
- data/lib/webhookdb/jobs/send_webhook.rb +20 -0
- data/lib/webhookdb/jobs/sync_target_enqueue_scheduled.rb +16 -0
- data/lib/webhookdb/jobs/sync_target_run_sync.rb +38 -0
- data/lib/webhookdb/jobs/trim_logged_webhooks.rb +15 -0
- data/lib/webhookdb/jobs/webhook_resource_notify_integrations.rb +30 -0
- data/lib/webhookdb/jobs/webhook_subscription_delivery_attempt.rb +29 -0
- data/lib/webhookdb/jobs.rb +4 -0
- data/lib/webhookdb/json.rb +113 -0
- data/lib/webhookdb/liquid/expose.rb +27 -0
- data/lib/webhookdb/liquid/filters.rb +16 -0
- data/lib/webhookdb/liquid/liquification.rb +26 -0
- data/lib/webhookdb/liquid/partial.rb +12 -0
- data/lib/webhookdb/logged_webhook/resilient.rb +95 -0
- data/lib/webhookdb/logged_webhook.rb +194 -0
- data/lib/webhookdb/message/body.rb +25 -0
- data/lib/webhookdb/message/delivery.rb +127 -0
- data/lib/webhookdb/message/email_transport.rb +133 -0
- data/lib/webhookdb/message/fake_transport.rb +54 -0
- data/lib/webhookdb/message/liquid_drops.rb +29 -0
- data/lib/webhookdb/message/template.rb +89 -0
- data/lib/webhookdb/message/transport.rb +43 -0
- data/lib/webhookdb/message.rb +150 -0
- data/lib/webhookdb/messages/error_icalendar_fetch.rb +42 -0
- data/lib/webhookdb/messages/invite.rb +23 -0
- data/lib/webhookdb/messages/new_customer.rb +14 -0
- data/lib/webhookdb/messages/org_database_migration_finished.rb +23 -0
- data/lib/webhookdb/messages/org_database_migration_started.rb +24 -0
- data/lib/webhookdb/messages/specs.rb +57 -0
- data/lib/webhookdb/messages/verification.rb +23 -0
- data/lib/webhookdb/method_utilities.rb +82 -0
- data/lib/webhookdb/microsoft_calendar.rb +36 -0
- data/lib/webhookdb/nextpax.rb +14 -0
- data/lib/webhookdb/oauth/front.rb +58 -0
- data/lib/webhookdb/oauth/intercom.rb +58 -0
- data/lib/webhookdb/oauth/session.rb +24 -0
- data/lib/webhookdb/oauth.rb +80 -0
- data/lib/webhookdb/organization/alerting.rb +35 -0
- data/lib/webhookdb/organization/database_migration.rb +151 -0
- data/lib/webhookdb/organization/db_builder.rb +429 -0
- data/lib/webhookdb/organization.rb +506 -0
- data/lib/webhookdb/organization_membership.rb +58 -0
- data/lib/webhookdb/phone_number.rb +38 -0
- data/lib/webhookdb/plaid.rb +23 -0
- data/lib/webhookdb/platform.rb +27 -0
- data/lib/webhookdb/plivo.rb +52 -0
- data/lib/webhookdb/postgres/maintenance.rb +166 -0
- data/lib/webhookdb/postgres/model.rb +82 -0
- data/lib/webhookdb/postgres/model_utilities.rb +382 -0
- data/lib/webhookdb/postgres/testing_pixie.rb +16 -0
- data/lib/webhookdb/postgres/validations.rb +46 -0
- data/lib/webhookdb/postgres.rb +176 -0
- data/lib/webhookdb/postmark.rb +20 -0
- data/lib/webhookdb/redis.rb +35 -0
- data/lib/webhookdb/replicator/atom_single_feed_v1.rb +116 -0
- data/lib/webhookdb/replicator/aws_pricing_v1.rb +488 -0
- data/lib/webhookdb/replicator/base.rb +1185 -0
- data/lib/webhookdb/replicator/column.rb +482 -0
- data/lib/webhookdb/replicator/convertkit_broadcast_v1.rb +69 -0
- data/lib/webhookdb/replicator/convertkit_subscriber_v1.rb +200 -0
- data/lib/webhookdb/replicator/convertkit_tag_v1.rb +66 -0
- data/lib/webhookdb/replicator/convertkit_v1_mixin.rb +65 -0
- data/lib/webhookdb/replicator/docgen.rb +167 -0
- data/lib/webhookdb/replicator/email_octopus_campaign_v1.rb +84 -0
- data/lib/webhookdb/replicator/email_octopus_contact_v1.rb +159 -0
- data/lib/webhookdb/replicator/email_octopus_event_v1.rb +244 -0
- data/lib/webhookdb/replicator/email_octopus_list_v1.rb +101 -0
- data/lib/webhookdb/replicator/fake.rb +453 -0
- data/lib/webhookdb/replicator/front_conversation_v1.rb +45 -0
- data/lib/webhookdb/replicator/front_marketplace_root_v1.rb +55 -0
- data/lib/webhookdb/replicator/front_message_v1.rb +45 -0
- data/lib/webhookdb/replicator/front_v1_mixin.rb +22 -0
- data/lib/webhookdb/replicator/github_issue_comment_v1.rb +58 -0
- data/lib/webhookdb/replicator/github_issue_v1.rb +83 -0
- data/lib/webhookdb/replicator/github_pull_v1.rb +84 -0
- data/lib/webhookdb/replicator/github_release_v1.rb +47 -0
- data/lib/webhookdb/replicator/github_repo_v1_mixin.rb +250 -0
- data/lib/webhookdb/replicator/github_repository_event_v1.rb +45 -0
- data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +465 -0
- data/lib/webhookdb/replicator/icalendar_event_v1.rb +334 -0
- data/lib/webhookdb/replicator/increase_account_number_v1.rb +77 -0
- data/lib/webhookdb/replicator/increase_account_transfer_v1.rb +61 -0
- data/lib/webhookdb/replicator/increase_account_v1.rb +63 -0
- data/lib/webhookdb/replicator/increase_ach_transfer_v1.rb +78 -0
- data/lib/webhookdb/replicator/increase_check_transfer_v1.rb +64 -0
- data/lib/webhookdb/replicator/increase_limit_v1.rb +78 -0
- data/lib/webhookdb/replicator/increase_transaction_v1.rb +74 -0
- data/lib/webhookdb/replicator/increase_v1_mixin.rb +121 -0
- data/lib/webhookdb/replicator/increase_wire_transfer_v1.rb +61 -0
- data/lib/webhookdb/replicator/intercom_contact_v1.rb +36 -0
- data/lib/webhookdb/replicator/intercom_conversation_v1.rb +38 -0
- data/lib/webhookdb/replicator/intercom_marketplace_root_v1.rb +69 -0
- data/lib/webhookdb/replicator/intercom_v1_mixin.rb +105 -0
- data/lib/webhookdb/replicator/oauth_refresh_access_token_mixin.rb +65 -0
- data/lib/webhookdb/replicator/plivo_sms_inbound_v1.rb +102 -0
- data/lib/webhookdb/replicator/postmark_inbound_message_v1.rb +94 -0
- data/lib/webhookdb/replicator/postmark_outbound_message_event_v1.rb +107 -0
- data/lib/webhookdb/replicator/schema_modification.rb +42 -0
- data/lib/webhookdb/replicator/shopify_customer_v1.rb +58 -0
- data/lib/webhookdb/replicator/shopify_order_v1.rb +64 -0
- data/lib/webhookdb/replicator/shopify_v1_mixin.rb +161 -0
- data/lib/webhookdb/replicator/signalwire_message_v1.rb +169 -0
- data/lib/webhookdb/replicator/sponsy_customer_v1.rb +54 -0
- data/lib/webhookdb/replicator/sponsy_placement_v1.rb +34 -0
- data/lib/webhookdb/replicator/sponsy_publication_v1.rb +125 -0
- data/lib/webhookdb/replicator/sponsy_slot_v1.rb +41 -0
- data/lib/webhookdb/replicator/sponsy_status_v1.rb +35 -0
- data/lib/webhookdb/replicator/sponsy_v1_mixin.rb +165 -0
- data/lib/webhookdb/replicator/state_machine_step.rb +69 -0
- data/lib/webhookdb/replicator/stripe_charge_v1.rb +77 -0
- data/lib/webhookdb/replicator/stripe_coupon_v1.rb +62 -0
- data/lib/webhookdb/replicator/stripe_customer_v1.rb +60 -0
- data/lib/webhookdb/replicator/stripe_dispute_v1.rb +77 -0
- data/lib/webhookdb/replicator/stripe_invoice_item_v1.rb +82 -0
- data/lib/webhookdb/replicator/stripe_invoice_v1.rb +116 -0
- data/lib/webhookdb/replicator/stripe_payout_v1.rb +67 -0
- data/lib/webhookdb/replicator/stripe_price_v1.rb +60 -0
- data/lib/webhookdb/replicator/stripe_product_v1.rb +60 -0
- data/lib/webhookdb/replicator/stripe_refund_v1.rb +101 -0
- data/lib/webhookdb/replicator/stripe_subscription_item_v1.rb +56 -0
- data/lib/webhookdb/replicator/stripe_subscription_v1.rb +75 -0
- data/lib/webhookdb/replicator/stripe_v1_mixin.rb +116 -0
- data/lib/webhookdb/replicator/transistor_episode_stats_v1.rb +141 -0
- data/lib/webhookdb/replicator/transistor_episode_v1.rb +169 -0
- data/lib/webhookdb/replicator/transistor_show_v1.rb +68 -0
- data/lib/webhookdb/replicator/transistor_v1_mixin.rb +65 -0
- data/lib/webhookdb/replicator/twilio_sms_v1.rb +156 -0
- data/lib/webhookdb/replicator/webhook_request.rb +5 -0
- data/lib/webhookdb/replicator/webhookdb_customer_v1.rb +74 -0
- data/lib/webhookdb/replicator.rb +224 -0
- data/lib/webhookdb/role.rb +42 -0
- data/lib/webhookdb/sentry.rb +35 -0
- data/lib/webhookdb/service/auth.rb +138 -0
- data/lib/webhookdb/service/collection.rb +91 -0
- data/lib/webhookdb/service/entities.rb +97 -0
- data/lib/webhookdb/service/helpers.rb +270 -0
- data/lib/webhookdb/service/middleware.rb +124 -0
- data/lib/webhookdb/service/types.rb +30 -0
- data/lib/webhookdb/service/validators.rb +32 -0
- data/lib/webhookdb/service/view_api.rb +63 -0
- data/lib/webhookdb/service.rb +219 -0
- data/lib/webhookdb/service_integration.rb +332 -0
- data/lib/webhookdb/shopify.rb +35 -0
- data/lib/webhookdb/signalwire.rb +13 -0
- data/lib/webhookdb/slack.rb +68 -0
- data/lib/webhookdb/snowflake.rb +90 -0
- data/lib/webhookdb/spec_helpers/async.rb +122 -0
- data/lib/webhookdb/spec_helpers/citest.rb +88 -0
- data/lib/webhookdb/spec_helpers/integration.rb +121 -0
- data/lib/webhookdb/spec_helpers/message.rb +41 -0
- data/lib/webhookdb/spec_helpers/postgres.rb +220 -0
- data/lib/webhookdb/spec_helpers/service.rb +432 -0
- data/lib/webhookdb/spec_helpers/shared_examples_for_columns.rb +56 -0
- data/lib/webhookdb/spec_helpers/shared_examples_for_replicators.rb +915 -0
- data/lib/webhookdb/spec_helpers/whdb.rb +139 -0
- data/lib/webhookdb/spec_helpers.rb +63 -0
- data/lib/webhookdb/sponsy.rb +14 -0
- data/lib/webhookdb/stripe.rb +37 -0
- data/lib/webhookdb/subscription.rb +203 -0
- data/lib/webhookdb/sync_target.rb +491 -0
- data/lib/webhookdb/tasks/admin.rb +49 -0
- data/lib/webhookdb/tasks/annotate.rb +36 -0
- data/lib/webhookdb/tasks/db.rb +82 -0
- data/lib/webhookdb/tasks/docs.rb +42 -0
- data/lib/webhookdb/tasks/fixture.rb +35 -0
- data/lib/webhookdb/tasks/message.rb +50 -0
- data/lib/webhookdb/tasks/regress.rb +87 -0
- data/lib/webhookdb/tasks/release.rb +27 -0
- data/lib/webhookdb/tasks/sidekiq.rb +23 -0
- data/lib/webhookdb/tasks/specs.rb +64 -0
- data/lib/webhookdb/theranest.rb +15 -0
- data/lib/webhookdb/transistor.rb +13 -0
- data/lib/webhookdb/twilio.rb +13 -0
- data/lib/webhookdb/typed_struct.rb +44 -0
- data/lib/webhookdb/version.rb +5 -0
- data/lib/webhookdb/webhook_response.rb +50 -0
- data/lib/webhookdb/webhook_subscription/delivery.rb +82 -0
- data/lib/webhookdb/webhook_subscription.rb +226 -0
- data/lib/webhookdb/windows_tz.rb +32 -0
- data/lib/webhookdb/xml.rb +92 -0
- data/lib/webhookdb.rb +224 -0
- data/lib/webterm/apps.rb +45 -0
- metadata +1129 -0
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "appydays/loggable"
|
|
4
|
+
require "concurrent-ruby"
|
|
5
|
+
|
|
6
|
+
require "webhookdb/backfiller"
|
|
7
|
+
require "webhookdb/db_adapter"
|
|
8
|
+
require "webhookdb/connection_cache"
|
|
9
|
+
require "webhookdb/replicator/column"
|
|
10
|
+
require "webhookdb/replicator/schema_modification"
|
|
11
|
+
require "webhookdb/replicator/webhook_request"
|
|
12
|
+
require "webhookdb/typed_struct"
|
|
13
|
+
|
|
14
|
+
require "webhookdb/jobs/send_webhook"
|
|
15
|
+
require "webhookdb/jobs/sync_target_run_sync"
|
|
16
|
+
|
|
17
|
+
class Webhookdb::Replicator::Base
|
|
18
|
+
include Appydays::Loggable
|
|
19
|
+
include Webhookdb::DBAdapter::ColumnTypes
|
|
20
|
+
|
|
21
|
+
# Return the descriptor for this service.
|
|
22
|
+
# @abstract
|
|
23
|
+
# @return [Webhookdb::Replicator::Descriptor]
|
|
24
|
+
def self.descriptor
|
|
25
|
+
raise NotImplementedError, "#{self.class}: must return a descriptor that is used for registration purposes"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Webhookdb::ServiceIntegration]
|
|
29
|
+
attr_reader :service_integration
|
|
30
|
+
|
|
31
|
+
def initialize(service_integration)
|
|
32
|
+
@service_integration = service_integration
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Webhookdb::Replicator::Descriptor]
|
|
36
|
+
def descriptor
|
|
37
|
+
return @descriptor ||= self.class.descriptor
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resource_name_singular
|
|
41
|
+
return @resource_name_singular ||= self.descriptor.resource_name_singular
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resource_name_plural
|
|
45
|
+
return @resource_name_plural ||= self.descriptor.resource_name_plural
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Return true if the service should process webhooks in the actual endpoint,
|
|
49
|
+
# rather than asynchronously through the job system.
|
|
50
|
+
# This should ONLY be used where we have important order-of-operations
|
|
51
|
+
# in webhook processing and/or need to return data to the webhook sender.
|
|
52
|
+
#
|
|
53
|
+
# NOTE: You MUST implement +synchronous_processing_response_body+ if this returns true.
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def process_webhooks_synchronously?
|
|
57
|
+
return false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Call with the value that was inserted by synchronous processing.
|
|
61
|
+
# Takes the row values being upserted (result upsert_webhook),
|
|
62
|
+
# and the arguments used to upsert it (arguments to upsert_webhook),
|
|
63
|
+
# and should return the body string to respond back with.
|
|
64
|
+
#
|
|
65
|
+
# @param [Hash] upserted
|
|
66
|
+
# @param [Webhookdb::Replicator::WebhookRequest] request
|
|
67
|
+
# @return [String]
|
|
68
|
+
def synchronous_processing_response_body(upserted:, request:)
|
|
69
|
+
return {message: "process synchronously"}.to_json if Webhookdb::Replicator.always_process_synchronously
|
|
70
|
+
raise NotImplementedError, "must be implemented if process_webhooks_synchronously? is true"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# In some cases, services may send us sensitive headers we do not want to log.
|
|
74
|
+
# This should be very rare but some services are designed really badly and send auth info in the webhook.
|
|
75
|
+
# Remove or obfuscate the passed header hash.
|
|
76
|
+
def preprocess_headers_for_logging(headers); end
|
|
77
|
+
|
|
78
|
+
# Return a tuple of (schema, table) based on the organization's replication schema,
|
|
79
|
+
# and the service integration's table name.
|
|
80
|
+
#
|
|
81
|
+
# @return [Array<Symbol>]
|
|
82
|
+
def schema_and_table_symbols
|
|
83
|
+
sch = self.service_integration.organization&.replication_schema&.to_sym || :public
|
|
84
|
+
tbl = self.service_integration.table_name.to_sym
|
|
85
|
+
return [sch, tbl]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Return a Sequel identifier using +schema_and_table_symbols+,
|
|
89
|
+
# or +schema+ or +table+ as overrides if given.
|
|
90
|
+
#
|
|
91
|
+
# @return [Sequel::SQL::QualifiedIdentifier]
|
|
92
|
+
def qualified_table_sequel_identifier(schema: nil, table: nil)
|
|
93
|
+
sch, tbl = self.schema_and_table_symbols
|
|
94
|
+
return Sequel[schema || sch][table || tbl]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Return a DBAdapter table based on the +schema_and_table_symbols+.
|
|
98
|
+
# @return [Webhookdb::DBAdapter::Table]
|
|
99
|
+
def dbadapter_table
|
|
100
|
+
sch, tbl = self.schema_and_table_symbols
|
|
101
|
+
schema = Webhookdb::DBAdapter::Schema.new(name: sch)
|
|
102
|
+
table = Webhookdb::DBAdapter::Table.new(name: tbl, schema:)
|
|
103
|
+
return table
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# +Time.at(t)+, but nil if t is nil.
|
|
107
|
+
# Use when we have 'nullable' integer timestamps.
|
|
108
|
+
# @return [Time]
|
|
109
|
+
protected def tsat(t)
|
|
110
|
+
return nil if t.nil?
|
|
111
|
+
return Time.at(t)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Given a Rack request, return the webhook response object.
|
|
115
|
+
# Usually this performs verification of the request based on the webhook secret
|
|
116
|
+
# configured on the service integration.
|
|
117
|
+
# Note that if +skip_webhook_verification+ is true on the service integration,
|
|
118
|
+
# this method always returns 201.
|
|
119
|
+
#
|
|
120
|
+
# @param [Rack::Request] request
|
|
121
|
+
# @return [Webhookdb::WebhookResponse]
|
|
122
|
+
def webhook_response(request)
|
|
123
|
+
return Webhookdb::WebhookResponse.ok(status: 201) if self.service_integration.skip_webhook_verification
|
|
124
|
+
return self._webhook_response(request)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Return a the response for the webhook.
|
|
128
|
+
# We must do this immediately in the endpoint itself,
|
|
129
|
+
# since verification may include info specific to the request content
|
|
130
|
+
# (like, it can be whitespace sensitive).
|
|
131
|
+
# @abstract
|
|
132
|
+
# @param [Rack::Request] request
|
|
133
|
+
# @return [Webhookdb::WebhookResponse]
|
|
134
|
+
def _webhook_response(request)
|
|
135
|
+
raise NotImplementedError
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# If we support webhooks, these fields correspond to the webhook state machine.
|
|
139
|
+
# Override them if some other fields are also needed for webhooks.
|
|
140
|
+
def _webhook_state_change_fields = ["webhook_secret"]
|
|
141
|
+
|
|
142
|
+
# If we support backfilling, these keys are used for them.
|
|
143
|
+
# Override if other fields are used instead.
|
|
144
|
+
# There cannot be overlap between these and the webhook state change fields.
|
|
145
|
+
def _backfill_state_change_fields = ["backfill_key", "backfill_secret", "api_url"]
|
|
146
|
+
|
|
147
|
+
# Set the new service integration field and
|
|
148
|
+
# return the newly calculated state machine.
|
|
149
|
+
#
|
|
150
|
+
# Subclasses can override this method and then super,
|
|
151
|
+
# to change the field or value.
|
|
152
|
+
#
|
|
153
|
+
# @param field [String] Like 'webhook_secret', 'backfill_key', etc.
|
|
154
|
+
# @param value [String] The value of the field.
|
|
155
|
+
# @param attr [String] Subclasses can pass in a custom field that does not correspond
|
|
156
|
+
# to a service integration column. When doing that, they must pass in attr,
|
|
157
|
+
# which is what will be set during the state change.
|
|
158
|
+
# @return [Webhookdb::Replicator::StateMachineStep]
|
|
159
|
+
def process_state_change(field, value, attr: nil)
|
|
160
|
+
attr ||= field
|
|
161
|
+
desc = self.descriptor
|
|
162
|
+
case field
|
|
163
|
+
when *self._webhook_state_change_fields
|
|
164
|
+
# If we don't support webhooks, then the backfill state machine may be using it.
|
|
165
|
+
meth = desc.supports_webhooks? ? :calculate_webhook_state_machine : :calculate_backfill_state_machine
|
|
166
|
+
when *self._backfill_state_change_fields
|
|
167
|
+
# If we don't support backfilling, then the create state machine may be using them.
|
|
168
|
+
meth = desc.supports_backfill? ? :calculate_backfill_state_machine : :calculate_webhook_state_machine
|
|
169
|
+
when "dependency_choice"
|
|
170
|
+
# Choose an upstream dependency for an integration.
|
|
171
|
+
# See where this is used for more details.
|
|
172
|
+
meth = self.preferred_create_state_machine_method
|
|
173
|
+
value = self._find_dependency_candidate(value)
|
|
174
|
+
attr = "depends_on"
|
|
175
|
+
when "noop_create"
|
|
176
|
+
# Use this to just recalculate the state machine,
|
|
177
|
+
# not make any changes to the data.
|
|
178
|
+
return self.calculate_preferred_create_state_machine
|
|
179
|
+
else
|
|
180
|
+
raise ArgumentError, "Field '#{field}' is not valid for a state change"
|
|
181
|
+
end
|
|
182
|
+
self.service_integration.db.transaction do
|
|
183
|
+
self.service_integration.send(:"#{attr}=", value)
|
|
184
|
+
self.service_integration.save_changes
|
|
185
|
+
step = self.send(meth)
|
|
186
|
+
if step.successful? && meth == :calculate_backfill_state_machine
|
|
187
|
+
# If we are processing the backfill state machine, and we finish successfully,
|
|
188
|
+
# we always want to start syncing.
|
|
189
|
+
self._enqueue_backfill_jobs(incremental: true)
|
|
190
|
+
end
|
|
191
|
+
return step
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# If the integration supports webhooks, then we want to do that on create.
|
|
196
|
+
# If it's backfill only, then we fall back to that instead.
|
|
197
|
+
# Things like choosing dependencies are webhook-vs-backfill agnostic,
|
|
198
|
+
# so which machine we choose isn't that important (but it does happen during creation).
|
|
199
|
+
# @return [Symbol]
|
|
200
|
+
def preferred_create_state_machine_method
|
|
201
|
+
return self.descriptor.supports_webhooks? ? :calculate_webhook_state_machine : :calculate_backfill_state_machine
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# See +preferred_create_state_machine_method+.
|
|
205
|
+
# If we prefer backfilling, and it's successful, we also want to enqueue jobs;
|
|
206
|
+
# that is, use +calculate_and_backfill_state_machine+, not just +calculate_backfill_state_machine+.
|
|
207
|
+
# @return [Webhookdb::Replicator::StateMachineStep]
|
|
208
|
+
def calculate_preferred_create_state_machine
|
|
209
|
+
m = self.preferred_create_state_machine_method
|
|
210
|
+
return self.calculate_and_backfill_state_machine(incremental: true)[0] if m == :calculate_backfill_state_machine
|
|
211
|
+
return self.calculate_webhook_state_machine
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def _enqueue_backfill_jobs(incremental:, criteria: nil, recursive: true, enqueue: true)
|
|
215
|
+
m = recursive ? :create_recursive : :create
|
|
216
|
+
j = Webhookdb::BackfillJob.send(
|
|
217
|
+
m,
|
|
218
|
+
service_integration:,
|
|
219
|
+
incremental:,
|
|
220
|
+
criteria: criteria || {},
|
|
221
|
+
created_by: Webhookdb.request_user_and_admin[0],
|
|
222
|
+
)
|
|
223
|
+
j.enqueue if enqueue
|
|
224
|
+
return j
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# @param value [String]
|
|
228
|
+
def _find_dependency_candidate(value)
|
|
229
|
+
int_val = value.strip.blank? ? 1 : value.to_i
|
|
230
|
+
idx = int_val - 1
|
|
231
|
+
dep_candidates = self.service_integration.dependency_candidates
|
|
232
|
+
raise Webhookdb::InvalidPrecondition, "no dependency candidates" if dep_candidates.empty?
|
|
233
|
+
raise Webhookdb::InvalidInput, "'#{value}' is not a valid dependency" if
|
|
234
|
+
idx.negative? || idx >= dep_candidates.length
|
|
235
|
+
return dep_candidates[idx]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Return the state machine that is used when setting up this integration.
|
|
239
|
+
# Usually this entails providing the user the webhook url,
|
|
240
|
+
# and providing or asking for a webhook secret. In some cases,
|
|
241
|
+
# this can be a lot more complex though.
|
|
242
|
+
#
|
|
243
|
+
# @abstract
|
|
244
|
+
# @return [Webhookdb::Replicator::StateMachineStep]
|
|
245
|
+
def calculate_webhook_state_machine
|
|
246
|
+
raise NotImplementedError
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Return the state machine that is used when adding backfill support to an integration.
|
|
250
|
+
# Usually this sets one or both of the backfill key and secret.
|
|
251
|
+
#
|
|
252
|
+
# @return [Webhookdb::Replicator::StateMachineStep]
|
|
253
|
+
def calculate_backfill_state_machine
|
|
254
|
+
# This is a pure function that can be tested on its own--the endpoints just need to return a state machine step
|
|
255
|
+
raise NotImplementedError
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Run calculate_backfill_state_machine.
|
|
259
|
+
# Then create and enqueue a new BackfillJob if it's successful.
|
|
260
|
+
# Returns a tuple of the StateMachineStep and BackfillJob.
|
|
261
|
+
# If the BackfillJob is returned, the StateMachineStep was successful;
|
|
262
|
+
# otherwise no job is created and the second item is nil.
|
|
263
|
+
# @return [Array<Webhookdb::StateMachineStep, Webhookdb::BackfillJob>]
|
|
264
|
+
def calculate_and_backfill_state_machine(incremental:, criteria: nil, recursive: true, enqueue: true)
|
|
265
|
+
step = self.calculate_backfill_state_machine
|
|
266
|
+
bfjob = nil
|
|
267
|
+
bfjob = self._enqueue_backfill_jobs(incremental:, criteria:, recursive:, enqueue:) if step.successful?
|
|
268
|
+
return step, bfjob
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# When backfilling is not supported, this message is used.
|
|
272
|
+
# It can be overridden for custom explanations,
|
|
273
|
+
# or descriptor#documentation_url can be provided,
|
|
274
|
+
# which will use a default message.
|
|
275
|
+
# If no documentation is available, a fallback message is used.
|
|
276
|
+
def backfill_not_supported_message
|
|
277
|
+
du = self.documentation_url
|
|
278
|
+
if du.blank?
|
|
279
|
+
msg = %(Sorry, you cannot backfill this integration. You may be looking for one of the following:
|
|
280
|
+
|
|
281
|
+
webhookdb integrations reset #{self.service_integration.table_name}
|
|
282
|
+
)
|
|
283
|
+
return msg
|
|
284
|
+
end
|
|
285
|
+
msg = %(Sorry, you cannot manually backfill this integration.
|
|
286
|
+
Please refer to the documentation at #{du}
|
|
287
|
+
for information on how to refresh data.)
|
|
288
|
+
return msg
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Remove all the information used in the initial creation of the integration so that it can be re-entered
|
|
292
|
+
def clear_webhook_information
|
|
293
|
+
self._clear_webook_information
|
|
294
|
+
# If we don't support both webhooks and backfilling, we are safe to clear ALL fields
|
|
295
|
+
# and get back into an initial state.
|
|
296
|
+
self._clear_backfill_information unless self.descriptor.supports_webhooks_and_backfill?
|
|
297
|
+
self.service_integration.save_changes
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def _clear_webook_information
|
|
301
|
+
self.service_integration.set(webhook_secret: "")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Remove all the information needed for backfilling from the integration so that it can be re-entered
|
|
305
|
+
def clear_backfill_information
|
|
306
|
+
self._clear_backfill_information
|
|
307
|
+
# If we don't support both webhooks and backfilling, we are safe to clear ALL fields
|
|
308
|
+
# and get back into an initial state.
|
|
309
|
+
self._clear_webook_information unless self.descriptor.supports_webhooks_and_backfill?
|
|
310
|
+
self.service_integration.save_changes
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def _clear_backfill_information
|
|
314
|
+
self.service_integration.set(api_url: "", backfill_key: "", backfill_secret: "")
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Find a dependent service integration with the given service name.
|
|
318
|
+
# If none are found, return nil. If multiple are found, raise,
|
|
319
|
+
# as this should only be used for automatically managed integrations.
|
|
320
|
+
# @return [Webhookdb::ServiceIntegration,nil]
|
|
321
|
+
def find_dependent(service_name)
|
|
322
|
+
sints = self.service_integration.dependents.filter { |si| si.service_name == service_name }
|
|
323
|
+
raise Webhookdb::InvalidPrecondition, "there are multiple #{service_name} integrations in dependents" if
|
|
324
|
+
sints.length > 1
|
|
325
|
+
return sints.first
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# @return [Webhookdb::ServiceIntegration]
|
|
329
|
+
def find_dependent!(service_name)
|
|
330
|
+
sint = self.find_dependent(service_name)
|
|
331
|
+
raise Webhookdb::InvalidPrecondition, "there is no #{service_name} integration in dependents" if sint.nil?
|
|
332
|
+
return sint
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Use this to determine whether we should add an enrichment column in
|
|
336
|
+
# the create table modification to store the enrichment body.
|
|
337
|
+
def _store_enrichment_body?
|
|
338
|
+
return false
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def create_table(if_not_exists: false)
|
|
342
|
+
cmd = self.create_table_modification(if_not_exists:)
|
|
343
|
+
self.admin_dataset(timeout: :fast) do |ds|
|
|
344
|
+
cmd.execute(ds.db)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Return the schema modification used to create the table where it does nto exist.
|
|
349
|
+
# @return [Webhookdb::Replicator::SchemaModification]
|
|
350
|
+
def create_table_modification(if_not_exists: false)
|
|
351
|
+
table = self.dbadapter_table
|
|
352
|
+
columns = [self.primary_key_column, self.remote_key_column]
|
|
353
|
+
columns.concat(self.storable_columns)
|
|
354
|
+
# 'data' column should be last, since it's very large, we want to see other columns in psql/pgcli first
|
|
355
|
+
columns << self.data_column
|
|
356
|
+
adapter = Webhookdb::DBAdapter::PG.new
|
|
357
|
+
result = Webhookdb::Replicator::SchemaModification.new
|
|
358
|
+
result.transaction_statements << adapter.create_table_sql(table, columns, if_not_exists:)
|
|
359
|
+
self.indices(table).each do |dbindex|
|
|
360
|
+
result.transaction_statements << adapter.create_index_sql(dbindex, concurrently: false)
|
|
361
|
+
end
|
|
362
|
+
result.application_database_statements << self.service_integration.ensure_sequence_sql if self.requires_sequence?
|
|
363
|
+
return result
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# We need to give indices a persistent name, unique across the schema,
|
|
367
|
+
# since multiple indices within a schema cannot share a name.
|
|
368
|
+
#
|
|
369
|
+
# Note that in certain RDBMS (Postgres) index names cannot exceed a certian length;
|
|
370
|
+
# Postgres will silently truncate them. This can result in an index not being created
|
|
371
|
+
# if it shares the same name as another index and we use 'CREATE INDEX IF NOT EXISTS.'
|
|
372
|
+
#
|
|
373
|
+
# To avoid this, if the generated name exceeds a certain size, an md5 hash of the column names is used.
|
|
374
|
+
#
|
|
375
|
+
# @param columns [Array<Webhookdb::DBAdapter::Column, Webhookdb::Replicator::Column>] Must respond to :name.
|
|
376
|
+
# @return [String]
|
|
377
|
+
protected def index_name(columns)
|
|
378
|
+
raise Webhookdb::InvalidPrecondition, "sint needs an opaque id" if self.service_integration.opaque_id.blank?
|
|
379
|
+
colnames = columns.map(&:name).join("_")
|
|
380
|
+
opaque_id = self.service_integration.opaque_id
|
|
381
|
+
# Handle old IDs without the leading 'svi_'.
|
|
382
|
+
opaque_id = "idx#{opaque_id}" if /\d/.match?(opaque_id[0])
|
|
383
|
+
name = "#{opaque_id}_#{colnames}_idx"
|
|
384
|
+
if name.size > MAX_INDEX_NAME_LENGTH
|
|
385
|
+
# We don't have the 32 extra chars for a full md5 hash.
|
|
386
|
+
# We can't convert to Base64 or whatever, since we don't want to depend on case sensitivity.
|
|
387
|
+
# So just lop off a few characters (normally 2) from the end of the md5.
|
|
388
|
+
# The collision space is so small (some combination of column names would need to have the
|
|
389
|
+
# same md5, which is unfathomable), we're not really worried about it.
|
|
390
|
+
colnames_md5 = Digest::MD5.hexdigest(colnames)
|
|
391
|
+
available_chars = MAX_INDEX_NAME_LENGTH - "#{opaque_id}__idx".size
|
|
392
|
+
name = "#{opaque_id}_#{colnames_md5[...available_chars]}_idx"
|
|
393
|
+
end
|
|
394
|
+
raise Webhookdb::InvariantViolation, "index names cannot exceed 63 chars, got #{name.size} in '#{name}'" if
|
|
395
|
+
name.size > 63
|
|
396
|
+
return name
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
MAX_INDEX_NAME_LENGTH = 63
|
|
400
|
+
|
|
401
|
+
# @return [Webhookdb::DBAdapter::Column]
|
|
402
|
+
def primary_key_column
|
|
403
|
+
return Webhookdb::DBAdapter::Column.new(name: :pk, type: BIGINT, pk: true)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# @return [Webhookdb::DBAdapter::Column]
|
|
407
|
+
def remote_key_column
|
|
408
|
+
return self._remote_key_column.to_dbadapter(unique: true, nullable: false)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# @return [Webhookdb::DBAdapter::Column]
|
|
412
|
+
def data_column
|
|
413
|
+
return Webhookdb::DBAdapter::Column.new(name: :data, type: OBJECT, nullable: false)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Column used to store enrichments. Return nil if the service does not use enrichments.
|
|
417
|
+
# @return [Webhookdb::DBAdapter::Column,nil]
|
|
418
|
+
def enrichment_column
|
|
419
|
+
return nil unless self._store_enrichment_body?
|
|
420
|
+
return Webhookdb::DBAdapter::Column.new(name: :enrichment, type: OBJECT, nullable: true)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# @return [Array<Webhookdb::DBAdapter::Column>]
|
|
424
|
+
def denormalized_columns
|
|
425
|
+
return self._denormalized_columns.map(&:to_dbadapter)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Names of columns for multi-column indices.
|
|
429
|
+
# Each one must be in +denormalized_columns+.
|
|
430
|
+
# @return [Array<Webhook::Replicator::IndexSpec>]
|
|
431
|
+
def _extra_index_specs
|
|
432
|
+
return []
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Denormalized columns, plus the enrichment column if supported.
|
|
436
|
+
# Does not include the data or external id columns, though perhaps it should.
|
|
437
|
+
# @return [Array<Webhookdb::DBAdapter::Column>]
|
|
438
|
+
def storable_columns
|
|
439
|
+
cols = self.denormalized_columns
|
|
440
|
+
if (enr = self.enrichment_column)
|
|
441
|
+
cols << enr
|
|
442
|
+
end
|
|
443
|
+
return cols
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Column to use as the 'timestamp' for the row.
|
|
447
|
+
# This is usually some created or updated at timestamp.
|
|
448
|
+
# @return [Webhookdb::DBAdapter::Column]
|
|
449
|
+
def timestamp_column
|
|
450
|
+
got = self._denormalized_columns.find { |c| c.name == self._timestamp_column_name }
|
|
451
|
+
raise NotImplementedError, "#{self.descriptor.name} has no timestamp column #{self._timestamp_column_name}" if
|
|
452
|
+
got.nil?
|
|
453
|
+
return got.to_dbadapter
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# The name of the timestamp column in the schema. This column is used primarily for conditional upserts
|
|
457
|
+
# (ie to know if a row has changed), but also as a general way of auditing changes.
|
|
458
|
+
# @abstract
|
|
459
|
+
# @return [Symbol]
|
|
460
|
+
def _timestamp_column_name
|
|
461
|
+
raise NotImplementedError
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Each integration needs a single remote key, like the Shopify order id for shopify orders,
|
|
465
|
+
# or sid for Twilio resources. This column must be unique for the table, like a primary key.
|
|
466
|
+
#
|
|
467
|
+
# @abstract
|
|
468
|
+
# @return [Webhookdb::Replicator::Column]
|
|
469
|
+
def _remote_key_column
|
|
470
|
+
raise NotImplementedError
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# When an integration needs denormalized columns, specify them here.
|
|
474
|
+
# Indices are created for each column.
|
|
475
|
+
# Modifiers can be used if columns should have a default or whatever.
|
|
476
|
+
# See +Webhookdb::Replicator::Column+ for more details about column fields.
|
|
477
|
+
#
|
|
478
|
+
# @return [Array<Webhookdb::Replicator::Column]
|
|
479
|
+
def _denormalized_columns
|
|
480
|
+
return []
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# @return [Array<Webhookdb::DBAdapter::Index>]
|
|
484
|
+
def indices(table)
|
|
485
|
+
dba_columns = [self.primary_key_column, self.remote_key_column]
|
|
486
|
+
dba_columns.concat(self.storable_columns)
|
|
487
|
+
dba_cols_by_name = dba_columns.index_by(&:name)
|
|
488
|
+
|
|
489
|
+
result = []
|
|
490
|
+
dba_columns.select(&:index?).each do |c|
|
|
491
|
+
targets = [c]
|
|
492
|
+
idx_name = self.index_name(targets)
|
|
493
|
+
result << Webhookdb::DBAdapter::Index.new(name: idx_name.to_sym, table:, targets:, where: c.index_where)
|
|
494
|
+
end
|
|
495
|
+
self._extra_index_specs.each do |spec|
|
|
496
|
+
targets = spec.columns.map { |n| dba_cols_by_name.fetch(n) }
|
|
497
|
+
idx_name = self.index_name(targets)
|
|
498
|
+
result << Webhookdb::DBAdapter::Index.new(name: idx_name.to_sym, table:, targets:, where: spec.where)
|
|
499
|
+
end
|
|
500
|
+
return result
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# We support adding columns to existing integrations without having to bump the version;
|
|
504
|
+
# changing types, or removing/renaming columns, is not supported and should bump the version
|
|
505
|
+
# or must be handled out-of-band (like deleting the integration then backfilling).
|
|
506
|
+
# To figure out what columns we need to add, we can check what are currently defined,
|
|
507
|
+
# check what exists, and add denormalized columns and indices for those that are missing.
|
|
508
|
+
def ensure_all_columns
|
|
509
|
+
modification = self.ensure_all_columns_modification
|
|
510
|
+
return if modification.noop?
|
|
511
|
+
self.admin_dataset(timeout: :slow_schema) do |ds|
|
|
512
|
+
modification.execute(ds.db)
|
|
513
|
+
# We need to clear cached columns on the data since we know we're adding more.
|
|
514
|
+
# It's probably not a huge deal but may as well keep it in sync.
|
|
515
|
+
ds.send(:clear_columns_cache)
|
|
516
|
+
end
|
|
517
|
+
self.readonly_dataset { |ds| ds.send(:clear_columns_cache) }
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# @return [Webhookdb::Replicator::SchemaModification]
|
|
521
|
+
def ensure_all_columns_modification
|
|
522
|
+
existing_cols, existing_indices = nil
|
|
523
|
+
max_pk = 0
|
|
524
|
+
sint = self.service_integration
|
|
525
|
+
self.admin_dataset do |ds|
|
|
526
|
+
return self.create_table_modification unless ds.db.table_exists?(self.qualified_table_sequel_identifier)
|
|
527
|
+
existing_cols = ds.columns.to_set
|
|
528
|
+
existing_indices = ds.db[:pg_indexes].where(
|
|
529
|
+
schemaname: sint.organization.replication_schema,
|
|
530
|
+
tablename: sint.table_name,
|
|
531
|
+
).select_map(:indexname).to_set
|
|
532
|
+
max_pk = ds.max(:pk) || 0
|
|
533
|
+
end
|
|
534
|
+
adapter = Webhookdb::DBAdapter::PG.new
|
|
535
|
+
table = self.dbadapter_table
|
|
536
|
+
result = Webhookdb::Replicator::SchemaModification.new
|
|
537
|
+
|
|
538
|
+
missing_columns = self._denormalized_columns.delete_if { |c| existing_cols.include?(c.name) }
|
|
539
|
+
# Add missing columns
|
|
540
|
+
missing_columns.each do |whcol|
|
|
541
|
+
# Don't bother bulking the ADDs into a single ALTER TABLE, it won't really matter.
|
|
542
|
+
result.transaction_statements << adapter.add_column_sql(table, whcol.to_dbadapter)
|
|
543
|
+
end
|
|
544
|
+
# Easier to handle this explicitly than use storage_columns, but it a duplicated concept so be careful.
|
|
545
|
+
if (enrich_col = self.enrichment_column) && !existing_cols.include?(enrich_col.name)
|
|
546
|
+
result.transaction_statements << adapter.add_column_sql(table, enrich_col)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Backfill values for new columns.
|
|
550
|
+
if missing_columns.any?
|
|
551
|
+
# We need to backfill values into the new column, but we don't want to lock the entire table
|
|
552
|
+
# as we update each row. So we need to update in chunks of rows.
|
|
553
|
+
# Chunk size should be large for speed (and sending over fewer queries), but small enough
|
|
554
|
+
# to induce a viable delay if another query is updating the same row.
|
|
555
|
+
# Note that the delay will only be for writes to those rows; reads will not block,
|
|
556
|
+
# so something a bit longer should be ok.
|
|
557
|
+
#
|
|
558
|
+
# Note that at the point these UPDATEs are running, we have the new column AND the new code inserting
|
|
559
|
+
# into that new column. We could in theory skip all the PKs that were added after this modification
|
|
560
|
+
# started to run. However considering the number of rows in this window will always be relatively low
|
|
561
|
+
# (though not absolutely low), and the SQL backfill operation should yield the same result
|
|
562
|
+
# as the Ruby operation, this doesn't seem too important.
|
|
563
|
+
result.nontransaction_statements.concat(missing_columns.filter_map(&:backfill_statement))
|
|
564
|
+
update_expr = missing_columns.to_h { |c| [c.name, c.backfill_expr || c.to_sql_expr] }
|
|
565
|
+
self.admin_dataset do |ds|
|
|
566
|
+
chunks = Webhookdb::Replicator::Base.chunked_row_update_bounds(max_pk)
|
|
567
|
+
chunks[...-1].each do |(lower, upper)|
|
|
568
|
+
update_query = ds.where { pk > lower }.where { pk <= upper }.update_sql(update_expr)
|
|
569
|
+
result.nontransaction_statements << update_query
|
|
570
|
+
end
|
|
571
|
+
final_update_query = ds.where { pk > chunks[-1][0] }.update_sql(update_expr)
|
|
572
|
+
result.nontransaction_statements << final_update_query
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Add missing indices. This should happen AFTER the UPDATE calls so the UPDATEs don't have to update indices.
|
|
577
|
+
self.indices(table).map do |index|
|
|
578
|
+
next if existing_indices.include?(index.name.to_s)
|
|
579
|
+
result.nontransaction_statements << adapter.create_index_sql(index, concurrently: true)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
result.application_database_statements << sint.ensure_sequence_sql if self.requires_sequence?
|
|
583
|
+
return result
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# Return an array of tuples used for splitting UPDATE queries so locks are not held on the entire table
|
|
587
|
+
# when backfilling values when adding new columns. See +ensure_all_columns_modification+.
|
|
588
|
+
#
|
|
589
|
+
# The returned chunks are like: [[0, 100], [100, 200], [200]],
|
|
590
|
+
# and meant to be used like `0 < pk <= 100`, `100 < pk <= 200`, `p, > 200`.
|
|
591
|
+
#
|
|
592
|
+
# Note that final value in the array is a single item, used like `pk > chunks[-1][0]`.
|
|
593
|
+
def self.chunked_row_update_bounds(max_pk, chunk_size: 1_000_000)
|
|
594
|
+
result = []
|
|
595
|
+
chunk_lower_pk = 0
|
|
596
|
+
chunk_upper_pk = chunk_size
|
|
597
|
+
while chunk_upper_pk <= max_pk
|
|
598
|
+
# Get chunks like 0 < pk <= 100, 100 < pk <= 200, etc
|
|
599
|
+
# Each loop we increment one row chunk size, until we find the chunk containing our max PK.
|
|
600
|
+
# Ie if row chunk size is 100, and max_pk is 450, the final chunk here is 400-500.
|
|
601
|
+
result << [chunk_lower_pk, chunk_upper_pk]
|
|
602
|
+
chunk_lower_pk += chunk_size
|
|
603
|
+
chunk_upper_pk += chunk_size
|
|
604
|
+
end
|
|
605
|
+
# Finally, one final chunk for all rows greater than our biggest chunk.
|
|
606
|
+
# For example, with a row chunk size of 100, and max_pk of 450, we got a final chunk of 400-500.
|
|
607
|
+
# But we could have gotten 100 writes (with a new max pk of 550), so this 'pk > 500' catches those.
|
|
608
|
+
result << [chunk_lower_pk]
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Some integrations require sequences, like when upserting rows with numerical unique ids
|
|
612
|
+
# (if they were random values like UUIDs we could generate them and not use a sequence).
|
|
613
|
+
# In those cases, the integrations can mark themselves as requiring a sequence.
|
|
614
|
+
#
|
|
615
|
+
# The sequence will be created in the *application database*,
|
|
616
|
+
# but it used primarily when inserting rows into the *organization/replication database*.
|
|
617
|
+
# This is necessary because things like sequences are not possible to migrate
|
|
618
|
+
# when moving replication databases.
|
|
619
|
+
def requires_sequence?
|
|
620
|
+
return false
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# A given HTTP request may not be handled by the service integration it was sent to,
|
|
624
|
+
# for example where the service integration is part of some 'root' hierarchy.
|
|
625
|
+
# This method is called in the webhook endpoint, and should return the replicator
|
|
626
|
+
# used to handle the webhook request. The request is validated by the returned instance,
|
|
627
|
+
# and it is enqueued for processing.
|
|
628
|
+
#
|
|
629
|
+
# By default, the service called by the webhook is the one we want to use,
|
|
630
|
+
# so return self.
|
|
631
|
+
#
|
|
632
|
+
# @param request [Rack::Request]
|
|
633
|
+
# @return [Webhookdb::Replicator::Base]
|
|
634
|
+
def dispatch_request_to(request)
|
|
635
|
+
return self
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Upsert webhook using only a body.
|
|
639
|
+
# This is not valid for the rare integration which does not rely on request info,
|
|
640
|
+
# like when we have to take different action based on a request method.
|
|
641
|
+
#
|
|
642
|
+
# @param body [Hash]
|
|
643
|
+
def upsert_webhook_body(body, **kw)
|
|
644
|
+
return self.upsert_webhook(Webhookdb::Replicator::WebhookRequest.new(body:), **kw)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Upsert a webhook request into the database. Note this is a WebhookRequest,
|
|
648
|
+
# NOT a Rack::Request.
|
|
649
|
+
#
|
|
650
|
+
# @param [Webhookdb::Replicator::WebhookRequest] request
|
|
651
|
+
def upsert_webhook(request, **kw)
|
|
652
|
+
return self._upsert_webhook(request, **kw)
|
|
653
|
+
rescue StandardError => e
|
|
654
|
+
self.logger.error("upsert_webhook_error", request: request.as_json, error: e)
|
|
655
|
+
raise
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Hook to be overridden, while still retaining
|
|
659
|
+
# top-level upsert_webhook functionality like error handling.
|
|
660
|
+
#
|
|
661
|
+
# @param request [Webhookdb::Replicator::WebhookRequest]
|
|
662
|
+
# @param upsert [Boolean] If false, just return what would be upserted.
|
|
663
|
+
def _upsert_webhook(request, upsert: true)
|
|
664
|
+
resource, event = self._resource_and_event(request)
|
|
665
|
+
return nil if resource.nil?
|
|
666
|
+
enrichment = self._fetch_enrichment(resource, event, request)
|
|
667
|
+
prepared = self._prepare_for_insert(resource, event, request, enrichment)
|
|
668
|
+
raise Webhookdb::InvalidPostcondition if prepared.key?(:data)
|
|
669
|
+
inserting = {}
|
|
670
|
+
data_col_val = self._resource_to_data(resource, event, request, enrichment)
|
|
671
|
+
inserting[:data] = self._to_json(data_col_val)
|
|
672
|
+
inserting[:enrichment] = self._to_json(enrichment) if self._store_enrichment_body?
|
|
673
|
+
inserting.merge!(prepared)
|
|
674
|
+
return inserting unless upsert
|
|
675
|
+
remote_key_col = self._remote_key_column
|
|
676
|
+
updating = self._upsert_update_expr(inserting, enrichment:)
|
|
677
|
+
update_where = self._update_where_expr
|
|
678
|
+
upserted_rows = self.admin_dataset(timeout: :fast) do |ds|
|
|
679
|
+
ds.insert_conflict(
|
|
680
|
+
target: remote_key_col.name,
|
|
681
|
+
update: updating,
|
|
682
|
+
update_where:,
|
|
683
|
+
).insert(inserting)
|
|
684
|
+
end
|
|
685
|
+
row_changed = upserted_rows.present?
|
|
686
|
+
self._notify_dependents(inserting, row_changed)
|
|
687
|
+
self._publish_rowupsert(inserting) if row_changed
|
|
688
|
+
return inserting
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# The NULL ASCII character (\u0000), when present in a string ("\u0000"),
|
|
692
|
+
# and then encoded into JSON ("\\u0000") is invalid in PG JSONB- its strings cannot contain NULLs
|
|
693
|
+
# (note that JSONB does not store the encoded string verbatim, it parses it into PG types, and a PG string
|
|
694
|
+
# cannot contain NULL since C strings are NULL-terminated).
|
|
695
|
+
#
|
|
696
|
+
# So we remove the "\\u0000" character from encoded JSON- for example, in the hash {x: "\u0000"},
|
|
697
|
+
# if we #to_json, we end up with '{"x":"\\u0000"}'. The removal of encoded NULL gives us '{"x":""}'.
|
|
698
|
+
#
|
|
699
|
+
# HOWEVER, if the encoded null is itself escaped, we MUST NOT remove it.
|
|
700
|
+
# For example, in the hash {x: "\u0000".to_json}.to_json (ie, a JSON string which contains another JSON string),
|
|
701
|
+
# we end up with '{"x":"\\\\u0000"}`, That is, a string containing the *escaped* null character.
|
|
702
|
+
# This is valid for PG, because it's not a NULL- it's an escaped "\", followed by "u0000".
|
|
703
|
+
# If we were to remove the string "\\u0000", we'd end up with '{"x":"\\"}'. This creates an invalid document.
|
|
704
|
+
#
|
|
705
|
+
# So we remove only "\\u0000" by not replacing "\\\\u0000"- replace all occurences of
|
|
706
|
+
# "<any one character except backslash>\\u0000" with "<character before backslash>".
|
|
707
|
+
def _to_json(v)
|
|
708
|
+
return v.to_json.gsub(/(\\\\u0000|\\u0000)/, {"\\\\u0000" => "\\\\u0000", "\\u0000" => ""})
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# @param changed [Boolean]
|
|
712
|
+
def _notify_dependents(inserting, changed)
|
|
713
|
+
self.service_integration.dependents.each do |d|
|
|
714
|
+
d.replicator.on_dependency_webhook_upsert(self, inserting, changed:)
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def _any_subscriptions_to_notify?
|
|
719
|
+
return !self.service_integration.all_webhook_subscriptions_dataset.to_notify.empty?
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def _publish_rowupsert(row, check_for_subscriptions: true)
|
|
723
|
+
return unless check_for_subscriptions && self._any_subscriptions_to_notify?
|
|
724
|
+
payload = [
|
|
725
|
+
self.service_integration.id,
|
|
726
|
+
{
|
|
727
|
+
row:,
|
|
728
|
+
external_id_column: self._remote_key_column.name,
|
|
729
|
+
external_id: row[self._remote_key_column.name],
|
|
730
|
+
},
|
|
731
|
+
]
|
|
732
|
+
# We AVOID pubsub here because we do NOT want to go through the router
|
|
733
|
+
# and audit logger for this.
|
|
734
|
+
event = Amigo::Event.create("webhookdb.serviceintegration.rowupsert", payload.as_json)
|
|
735
|
+
Webhookdb::Jobs::SendWebhook.perform_async(event.as_json)
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Return true if the integration requires making an API call to upsert.
|
|
739
|
+
# This puts the sync into a lower-priority queue
|
|
740
|
+
# so it is less likely to block other processing.
|
|
741
|
+
# This is usually true if enrichments are involved.
|
|
742
|
+
# @return [Boolean]
|
|
743
|
+
def upsert_has_deps?
|
|
744
|
+
return false
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Given the resource that is going to be inserted and an optional event,
|
|
748
|
+
# make an API call to enrich it with further data if needed.
|
|
749
|
+
# The result of this is passed to _prepare_for_insert.
|
|
750
|
+
#
|
|
751
|
+
# @param [Hash,nil] resource
|
|
752
|
+
# @param [Hash,nil] event
|
|
753
|
+
# @param [Webhookdb::Replicator::WebhookRequest] request
|
|
754
|
+
# @return [*]
|
|
755
|
+
def _fetch_enrichment(resource, event, request)
|
|
756
|
+
return nil
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# The argument for insert_conflict update_where clause.
|
|
760
|
+
# Used to conditionally update, like updating only if a row is newer than what's stored.
|
|
761
|
+
# We must always have an 'update where' because we never want to overwrite with the same data
|
|
762
|
+
# as exists.
|
|
763
|
+
#
|
|
764
|
+
# @example With a meaningful timestmap
|
|
765
|
+
# self.qualified_table_sequel_identifier[:updated_at] < Sequel[:excluded][:updated_at]
|
|
766
|
+
#
|
|
767
|
+
# If an integration does not have any way to detect if a resource changed,
|
|
768
|
+
# it can compare data columns.
|
|
769
|
+
#
|
|
770
|
+
# @example Without a meaingful timestamp
|
|
771
|
+
# self.qualified_table_sequel_identifier[:data] !~ Sequel[:excluded][:data]
|
|
772
|
+
#
|
|
773
|
+
# @abstract
|
|
774
|
+
# @return [Sequel::SQL::Expression]
|
|
775
|
+
def _update_where_expr
|
|
776
|
+
raise NotImplementedError
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# Given a webhook/backfill item payload,
|
|
780
|
+
# return the resource hash, and an optional event hash.
|
|
781
|
+
# If 'body' is the resource itself,
|
|
782
|
+
# this method returns [body, nil].
|
|
783
|
+
# If 'body' is an event,
|
|
784
|
+
# this method returns [body.resource-key, body].
|
|
785
|
+
# Columns can check for whether there is an event and/or body
|
|
786
|
+
# when converting.
|
|
787
|
+
#
|
|
788
|
+
# If this returns nil, the upsert is skipped.
|
|
789
|
+
#
|
|
790
|
+
# For example, a Stripe customer backfill upsert would be `{id: 'cus_123'}`
|
|
791
|
+
# when we backfill, but `{type: 'event', data: {id: 'cus_123'}}` when handling an event.
|
|
792
|
+
#
|
|
793
|
+
# @abstract
|
|
794
|
+
# @param [Webhookdb::Replicator::WebhookRequest] request
|
|
795
|
+
# @return [Array<Hash>,nil]
|
|
796
|
+
def _resource_and_event(request)
|
|
797
|
+
raise NotImplementedError
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# Return the hash that should be inserted into the database,
|
|
801
|
+
# based on the denormalized columns and data given.
|
|
802
|
+
# @param [Hash,nil] resource
|
|
803
|
+
# @param [Hash,nil] event
|
|
804
|
+
# @param [Webhookdb::Replicator::WebhookRequest] request
|
|
805
|
+
# @param [Hash,nil] enrichment
|
|
806
|
+
# @return [Hash]
|
|
807
|
+
def _prepare_for_insert(resource, event, request, enrichment)
|
|
808
|
+
h = [self._remote_key_column].concat(self._denormalized_columns).each_with_object({}) do |col, memo|
|
|
809
|
+
value = col.to_ruby_value(resource:, event:, enrichment:, service_integration:)
|
|
810
|
+
skip = value.nil? && col.skip_nil?
|
|
811
|
+
memo[col.name] = value unless skip
|
|
812
|
+
end
|
|
813
|
+
return h
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Given the resource, return the value for the :data column.
|
|
817
|
+
# Only needed in rare situations where fields should be stored
|
|
818
|
+
# on the row, but not in :data.
|
|
819
|
+
# To skip :data column updates, return nil.
|
|
820
|
+
# @param [Hash,nil] resource
|
|
821
|
+
# @param [Hash,nil] event
|
|
822
|
+
# @param [Webhookdb::Replicator::WebhookRequest] request
|
|
823
|
+
# @param [Hash,nil] enrichment
|
|
824
|
+
# @return [Hash]
|
|
825
|
+
def _resource_to_data(resource, event, request, enrichment)
|
|
826
|
+
return resource
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# Given the hash that is passed to the Sequel insert
|
|
830
|
+
# (so contains all columns, including those from _prepare_for_insert),
|
|
831
|
+
# return the hash used for the insert_conflict(update:) keyword args.
|
|
832
|
+
#
|
|
833
|
+
# Rather than sending over the literal values in the inserting statement
|
|
834
|
+
# (which is pretty verbose, like the large 'data' column),
|
|
835
|
+
# make a smaller statement by using 'EXCLUDED'.
|
|
836
|
+
#
|
|
837
|
+
# This can be overriden when the service requires different values
|
|
838
|
+
# for inserting vs. updating, such as when a column's update value
|
|
839
|
+
# must use the EXCLUDED table in the upsert expression.
|
|
840
|
+
#
|
|
841
|
+
# Most commonly, the use case for this is when you want to provide a row a value,
|
|
842
|
+
# but ONLY on insert, OR on update by ONLY if the column is nil.
|
|
843
|
+
# In that case, pass the result of this base method to
|
|
844
|
+
# +_coalesce_excluded_on_update+ (see also for more details).
|
|
845
|
+
#
|
|
846
|
+
# You can also use this method to merge :data columns together. For example:
|
|
847
|
+
# `super_result[:data] = Sequel.lit("#{self.service_integration.table_name}.data || excluded.data")`
|
|
848
|
+
#
|
|
849
|
+
# By default, this will use the same values for UPDATE as are used for INSERT,
|
|
850
|
+
# like `email = EXCLUDED.email` (the 'EXCLUDED' row being the one that failed to insert).
|
|
851
|
+
def _upsert_update_expr(inserting, enrichment: nil)
|
|
852
|
+
result = inserting.each_with_object({}) { |(c, _), h| h[c] = Sequel[:excluded][c] }
|
|
853
|
+
return result
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# The string 'null' in a json column still represents 'null' but we'd rather have an actual NULL value,
|
|
857
|
+
# represented by 'nil'. So, return nil if the arg is nil (so we get NULL),
|
|
858
|
+
# otherwise return the argument.
|
|
859
|
+
protected def _nil_or_json(x)
|
|
860
|
+
return x.nil? ? nil : x.to_json
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# Have a column set itself only on insert or if nil.
|
|
864
|
+
#
|
|
865
|
+
# Given the payload to DO UPDATE, mutate it so that
|
|
866
|
+
# the column names included in 'column_names' use what is already in the table,
|
|
867
|
+
# and fall back to what's being inserted.
|
|
868
|
+
# This new payload should be passed to the `update` kwarg of `insert_conflict`:
|
|
869
|
+
#
|
|
870
|
+
# ds.insert_conflict(update: self._coalesce_excluded_on_update(payload, :created_at)).insert(payload)
|
|
871
|
+
#
|
|
872
|
+
# @param update [Hash]
|
|
873
|
+
# @param column_names [Array<Symbol>]
|
|
874
|
+
def _coalesce_excluded_on_update(update, column_names)
|
|
875
|
+
# Now replace just the specific columns we're overriding.
|
|
876
|
+
column_names.each do |c|
|
|
877
|
+
update[c] = Sequel.function(:coalesce, self.qualified_table_sequel_identifier[c], Sequel[:excluded][c])
|
|
878
|
+
end
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Yield to a dataset using the admin connection.
|
|
882
|
+
# @return [Sequel::Dataset]
|
|
883
|
+
def admin_dataset(**kw, &)
|
|
884
|
+
self.with_dataset(self.service_integration.organization.admin_connection_url_raw, **kw, &)
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# Yield to a dataset using the readonly connection.
|
|
888
|
+
# @return [Sequel::Dataset]
|
|
889
|
+
def readonly_dataset(**kw, &)
|
|
890
|
+
self.with_dataset(self.service_integration.organization.readonly_connection_url_raw, **kw, &)
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
protected def with_dataset(url, **kw, &block)
|
|
894
|
+
raise LocalJumpError if block.nil?
|
|
895
|
+
Webhookdb::ConnectionCache.borrow(url, **kw) do |conn|
|
|
896
|
+
yield(conn[self.qualified_table_sequel_identifier])
|
|
897
|
+
end
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
# Run the given block with a (try) advisory lock taken on a combination of:
|
|
901
|
+
#
|
|
902
|
+
# - The table OID for this replicator
|
|
903
|
+
# - The given key
|
|
904
|
+
#
|
|
905
|
+
# Note this this establishes a new DB connection for the advisory lock;
|
|
906
|
+
# we have had issues with advisory locks on reused connections,
|
|
907
|
+
# and this is safer than having a lock that is never released.
|
|
908
|
+
protected def with_advisory_lock(key, &)
|
|
909
|
+
url = self.service_integration.organization.admin_connection_url_raw
|
|
910
|
+
got = nil
|
|
911
|
+
Webhookdb::Dbutil.borrow_conn(url) do |conn|
|
|
912
|
+
table_oid = conn.select(
|
|
913
|
+
Sequel.function(:to_regclass, self.schema_and_table_symbols.join(".")).cast(:oid).as(:table_id),
|
|
914
|
+
).first[:table_id]
|
|
915
|
+
self.logger.debug("taking_replicator_advisory_lock", table_oid:, key_id: key)
|
|
916
|
+
Sequel::AdvisoryLock.new(conn, table_oid, key).with_lock? do
|
|
917
|
+
got = yield
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
return got
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
# Some replicators support 'instant sync', because they are upserted en-masse
|
|
924
|
+
# rather than row-by-row. That is, usually we run sync targets on a cron,
|
|
925
|
+
# because otherwise we'd need to run the sync target for every row.
|
|
926
|
+
# But if inserting is always done through backfilling,
|
|
927
|
+
# we know we have a useful set of results to sync, so don't need to wait for cron.
|
|
928
|
+
def enqueue_sync_targets
|
|
929
|
+
self.service_integration.sync_targets.each do |stgt|
|
|
930
|
+
Webhookdb::Jobs::SyncTargetRunSync.perform_async(stgt.id)
|
|
931
|
+
end
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
class CredentialVerificationResult < Webhookdb::TypedStruct
|
|
935
|
+
attr_reader :verified, :message
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# Try to verify backfill credentials, by fetching the first page of items.
|
|
939
|
+
# Only relevant for integrations supporting backfilling.
|
|
940
|
+
#
|
|
941
|
+
# If an error is received, return `_verify_backfill_<http status>_err_msg`
|
|
942
|
+
# as the error message, if defined. So for example, a 401 will call the method
|
|
943
|
+
# +_verify_backfill_401_err_msg+ if defined. If such a method is not defined,
|
|
944
|
+
# call and return +_verify_backfill_err_msg+.
|
|
945
|
+
#
|
|
946
|
+
# @return [Webhookdb::CredentialVerificationResult]
|
|
947
|
+
def verify_backfill_credentials
|
|
948
|
+
backfiller = self._backfillers.first
|
|
949
|
+
if backfiller.nil?
|
|
950
|
+
# If for some reason we do not have a backfiller,
|
|
951
|
+
# we can't verify credentials. This should never happen in practice,
|
|
952
|
+
# because we wouldn't call this method if the integration doesn't support it.
|
|
953
|
+
raise "No backfiller available for #{self.service_integration.inspect}"
|
|
954
|
+
end
|
|
955
|
+
begin
|
|
956
|
+
# begin backfill attempt but do not return backfill result
|
|
957
|
+
backfiller.fetch_backfill_page(nil, last_backfilled: nil)
|
|
958
|
+
rescue Webhookdb::Http::Error => e
|
|
959
|
+
msg = if self.respond_to?(:"_verify_backfill_#{e.status}_err_msg")
|
|
960
|
+
self.send(:"_verify_backfill_#{e.status}_err_msg")
|
|
961
|
+
else
|
|
962
|
+
self._verify_backfill_err_msg
|
|
963
|
+
end
|
|
964
|
+
return CredentialVerificationResult.new(verified: false, message: msg)
|
|
965
|
+
rescue TypeError, NoMethodError => e
|
|
966
|
+
# if we don't incur an HTTP error, but do incur an Error due to differences in the shapes of anticipated
|
|
967
|
+
# response data in the `fetch_backfill_page` function, we can assume that the credentials are okay
|
|
968
|
+
self.logger.info "verify_backfill_credentials_expected_failure", error: e
|
|
969
|
+
return CredentialVerificationResult.new(verified: true, message: "")
|
|
970
|
+
end
|
|
971
|
+
return CredentialVerificationResult.new(verified: true, message: "")
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
def _verify_backfill_err_msg
|
|
975
|
+
raise NotImplementedError, "each integration must provide an error message for unanticipated errors"
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def documentation_url = nil
|
|
979
|
+
|
|
980
|
+
# In order to backfill, we need to:
|
|
981
|
+
# - Iterate through pages of records from the external service
|
|
982
|
+
# - Upsert each record
|
|
983
|
+
# The caveats/complexities are:
|
|
984
|
+
# - The backfill method should take care of retrying fetches for failed pages.
|
|
985
|
+
# - That means it needs to keep track of some pagination token.
|
|
986
|
+
# @param job [Webhookdb::BackfillJob]
|
|
987
|
+
def backfill(job)
|
|
988
|
+
raise Webhookdb::InvalidPrecondition, "job is for different service integration" unless
|
|
989
|
+
job.service_integration === self.service_integration
|
|
990
|
+
|
|
991
|
+
raise Webhookdb::InvariantViolation, "manual backfill not supported" unless self.descriptor.supports_backfill?
|
|
992
|
+
|
|
993
|
+
sint = self.service_integration
|
|
994
|
+
raise Webhookdb::Replicator::CredentialsMissing if
|
|
995
|
+
sint.backfill_key.blank? && sint.backfill_secret.blank? && sint.depends_on.blank?
|
|
996
|
+
last_backfilled = job.incremental? ? sint.last_backfilled_at : nil
|
|
997
|
+
new_last_backfilled = Time.now
|
|
998
|
+
job.update(started_at: Time.now)
|
|
999
|
+
|
|
1000
|
+
backfillers = self._backfillers(**job.criteria.symbolize_keys)
|
|
1001
|
+
if self._parallel_backfill && self._parallel_backfill > 1
|
|
1002
|
+
# Create a dedicated threadpool for these backfillers,
|
|
1003
|
+
# with max parallelism determined by the replicator.
|
|
1004
|
+
pool = Concurrent::FixedThreadPool.new(self._parallel_backfill)
|
|
1005
|
+
# Record any errors that occur, since they won't raise otherwise.
|
|
1006
|
+
# Initialize a sized array to avoid any potential race conditions (though GIL should make it not an issue?).
|
|
1007
|
+
errors = Array.new(backfillers.size)
|
|
1008
|
+
backfillers.each_with_index do |bf, idx|
|
|
1009
|
+
pool.post do
|
|
1010
|
+
bf.backfill(last_backfilled)
|
|
1011
|
+
rescue StandardError => e
|
|
1012
|
+
errors[idx] = e
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
# We've enqueued all backfillers; do not accept anymore work.
|
|
1016
|
+
pool.shutdown
|
|
1017
|
+
loop do
|
|
1018
|
+
# We want to stop early if we find an error, so check for errors every 10 seconds.
|
|
1019
|
+
completed = pool.wait_for_termination(10)
|
|
1020
|
+
first_error = errors.find { |e| !e.nil? }
|
|
1021
|
+
if first_error.nil?
|
|
1022
|
+
# No error, and wait_for_termination returned true, so all work is done.
|
|
1023
|
+
break if completed
|
|
1024
|
+
# No error, but work is still going on, so loop again.
|
|
1025
|
+
next
|
|
1026
|
+
end
|
|
1027
|
+
# We have an error; don't run any more backfillers.
|
|
1028
|
+
pool.kill
|
|
1029
|
+
# Wait for all ongoing backfills before raising.
|
|
1030
|
+
pool.wait_for_termination
|
|
1031
|
+
raise first_error
|
|
1032
|
+
end
|
|
1033
|
+
else
|
|
1034
|
+
backfillers.each do |backfiller|
|
|
1035
|
+
backfiller.backfill(last_backfilled)
|
|
1036
|
+
end
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
sint.update(last_backfilled_at: new_last_backfilled) if job.incremental?
|
|
1040
|
+
job.update(finished_at: Time.now)
|
|
1041
|
+
job.enqueue_children
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# If this replicator supports backfilling in parallel (running multiple backfillers at a time),
|
|
1045
|
+
# return the degree of paralellism (or nil if not running in parallel).
|
|
1046
|
+
# We leave parallelism up to the replicator, not CPU count, since most work
|
|
1047
|
+
# involves waiting on APIs to return.
|
|
1048
|
+
#
|
|
1049
|
+
# NOTE: These threads are in addition to any worker threads, so it's important
|
|
1050
|
+
# to pay attention to memory use.
|
|
1051
|
+
def _parallel_backfill
|
|
1052
|
+
return nil
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# Return backfillers for the replicator.
|
|
1056
|
+
# We must use an array for 'data-based' backfillers,
|
|
1057
|
+
# like when we need to paginate for each row in another table.
|
|
1058
|
+
#
|
|
1059
|
+
# By default, return a ServiceBackfiller,
|
|
1060
|
+
# which will call _fetch_backfill_page on the receiver.
|
|
1061
|
+
#
|
|
1062
|
+
# @return [Array<Webhookdb::Backfiller>]
|
|
1063
|
+
def _backfillers
|
|
1064
|
+
return [ServiceBackfiller.new(self)]
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
# Basic backfiller that calls +_fetch_backfill_page+ on the given replicator.
|
|
1068
|
+
# Any timeouts or 5xx errors are automatically re-enqueued for a retry.
|
|
1069
|
+
# This behavior can be customized somewhat setting :backfiller_server_error_retries (default to 2)
|
|
1070
|
+
# and :backfiller_server_error_backoff on the replicator (default to 63 seconds),
|
|
1071
|
+
# though customization beyond that should use a custom backfiller.
|
|
1072
|
+
class ServiceBackfiller < Webhookdb::Backfiller
|
|
1073
|
+
# @!attribute svc
|
|
1074
|
+
# @return [Webhookdb::Replicator::Base]
|
|
1075
|
+
attr_reader :svc
|
|
1076
|
+
|
|
1077
|
+
attr_accessor :server_error_retries, :server_error_backoff
|
|
1078
|
+
|
|
1079
|
+
def initialize(svc)
|
|
1080
|
+
@svc = svc
|
|
1081
|
+
@server_error_retries = _getifrespondto(:backfiller_server_error_retries, 2)
|
|
1082
|
+
@server_error_backoff = _getifrespondto(:backfiller_server_error_backoff, 63.seconds)
|
|
1083
|
+
raise "#{svc} must implement :_fetch_backfill_page" unless svc.respond_to?(:_fetch_backfill_page)
|
|
1084
|
+
super()
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
private def _getifrespondto(sym, default)
|
|
1088
|
+
return default unless @svc.respond_to?(sym)
|
|
1089
|
+
return @svc.send(sym)
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
def handle_item(item)
|
|
1093
|
+
return @svc.upsert_webhook_body(item)
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
def fetch_backfill_page(pagination_token, last_backfilled:)
|
|
1097
|
+
return @svc._fetch_backfill_page(pagination_token, last_backfilled:)
|
|
1098
|
+
rescue ::Timeout::Error, ::SocketError
|
|
1099
|
+
self.__retryordie
|
|
1100
|
+
rescue Webhookdb::Http::Error => e
|
|
1101
|
+
self.__retryordie if e.status >= 500
|
|
1102
|
+
raise
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
def __retryordie
|
|
1106
|
+
raise Amigo::Retry::OrDie.new(self.server_error_retries, self.server_error_backoff)
|
|
1107
|
+
end
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
# Called when the upstream dependency upserts. In most cases, you can noop;
|
|
1111
|
+
# but in some cases, you may want to update or fetch rows.
|
|
1112
|
+
# One example would be a 'db only' integration, where values are taken from the parent service
|
|
1113
|
+
# and added to this service's table. We may want to upsert rows in our table
|
|
1114
|
+
# whenever a row in our parent table changes.
|
|
1115
|
+
#
|
|
1116
|
+
# @param replicator [Webhookdb::Replicator::Base]
|
|
1117
|
+
# @param payload [Hash]
|
|
1118
|
+
# @param changed [Boolean]
|
|
1119
|
+
def on_dependency_webhook_upsert(replicator, payload, changed:)
|
|
1120
|
+
raise NotImplementedError, "this must be overridden for replicators that have dependencies"
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
def calculate_dependency_state_machine_step(dependency_help:)
|
|
1124
|
+
raise Webhookdb::InvalidPrecondition, "#{self.descriptor.name} does not have a dependency" if
|
|
1125
|
+
self.class.descriptor.dependency_descriptor.nil?
|
|
1126
|
+
return nil if self.service_integration.depends_on_id
|
|
1127
|
+
step = Webhookdb::Replicator::StateMachineStep.new
|
|
1128
|
+
dep_descr = self.descriptor.dependency_descriptor
|
|
1129
|
+
candidates = self.service_integration.dependency_candidates
|
|
1130
|
+
if candidates.empty?
|
|
1131
|
+
step.output = %(This integration requires #{dep_descr.resource_name_plural} to sync.
|
|
1132
|
+
|
|
1133
|
+
You don't have any #{dep_descr.resource_name_singular} integrations yet. You can run:
|
|
1134
|
+
|
|
1135
|
+
webhookdb integrations create #{dep_descr.name}
|
|
1136
|
+
|
|
1137
|
+
to set one up. Then once that's complete, you can re-run:
|
|
1138
|
+
|
|
1139
|
+
webhookdb integrations create #{self.descriptor.name}
|
|
1140
|
+
|
|
1141
|
+
to keep going.
|
|
1142
|
+
)
|
|
1143
|
+
step.error_code = "no_candidate_dependency"
|
|
1144
|
+
return step.completed
|
|
1145
|
+
end
|
|
1146
|
+
choice_lines = candidates.each_with_index.
|
|
1147
|
+
map { |si, idx| "#{idx + 1} - #{si.table_name}" }.
|
|
1148
|
+
join("\n")
|
|
1149
|
+
step.output = %(This integration requires #{dep_descr.resource_name_plural} to sync.
|
|
1150
|
+
#{dependency_help.blank? ? '' : "\n#{dependency_help}\n"}
|
|
1151
|
+
Enter the number for the #{dep_descr.resource_name_singular} integration you want to use,
|
|
1152
|
+
or leave blank to choose the first option.
|
|
1153
|
+
|
|
1154
|
+
#{choice_lines}
|
|
1155
|
+
)
|
|
1156
|
+
step.prompting("Parent integration number")
|
|
1157
|
+
step.post_to_url = self.service_integration.authed_api_path + "/transition/dependency_choice"
|
|
1158
|
+
return step
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
def webhook_endpoint
|
|
1162
|
+
return self._webhook_endpoint
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
protected def _webhook_endpoint
|
|
1166
|
+
return self.service_integration.unauthed_webhook_endpoint
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
protected def _backfill_command
|
|
1170
|
+
return "webhookdb backfill #{self.service_integration.opaque_id}"
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
protected def _query_help_output(prefix: "You can query the table")
|
|
1174
|
+
sint = self.service_integration
|
|
1175
|
+
return %(#{prefix} through your organization's Postgres connection string:
|
|
1176
|
+
|
|
1177
|
+
psql #{sint.organization.readonly_connection_url}
|
|
1178
|
+
> SELECT * FROM #{sint.table_name}
|
|
1179
|
+
|
|
1180
|
+
You can also run a query through the CLI:
|
|
1181
|
+
|
|
1182
|
+
webhookdb db sql "SELECT * FROM #{sint.table_name}"
|
|
1183
|
+
)
|
|
1184
|
+
end
|
|
1185
|
+
end
|