Skip to content

feat(webhook): dedupe deliveries with logicalEventId - #350

Merged
telivity-otaip merged 7 commits into
mainfrom
cursor/webhook-logical-event-id-4a4f
Aug 27, 2026
Merged

feat(webhook): dedupe deliveries with logicalEventId#350
telivity-otaip merged 7 commits into
mainfrom
cursor/webhook-logical-event-id-4a4f

Conversation

@telivity-otaip

@telivity-otaip telivity-otaip commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Deduplicates webhook HTTP deliveries by persisted logical event id so crash-safe re-enqueue and replay do not create duplicate delivery rows or duplicate subscriber POSTs.

Changes

  • Migration 0022_webhook_logical_event_id.sqllogical_event_id column + unique index on (property_id, subscription_id, logical_event_id)
  • Migration runnerpnpm db:migrate now runs run-migrations.ts: push-schema baseline (0001–0021) + tracked SQL from 0022 onward via schema_migrations ledger (transactional, once-only)
  • logical_event_id DDL removed from push-schema.ts; upgrade path is migration 0022 only
  • dist/migrations/ shipped on build for Docker/Render deploys
  • WebhookDeliveryService.enqueue() accepts optional logicalEventId; replays reuse the same row and stable X-HAIP-Event-Id header
  • WebhookService.dispatchPersisted() forwards logical event id to async listeners
  • Connect wiring: ConnectEventsService forwards logicalEventId into enqueue() so agent webhook subscriptions dedupe end-to-end
  • Concurrency/replay tests for insert races and partial unique-index conflicts
  • PostgreSQL migration integration tests (fresh DB, pre-0022 upgrade, ledger idempotency, unique-index enforcement)
  • docs/webhooks.mdX-HAIP-Event-Id is the logical event uuid when present, otherwise the delivery row uuid (legacy events)

How to test

pnpm build
pnpm db:migrate
pnpm --filter @telivityhaip/database test
pnpm --filter @telivityhaip/api test -- src/modules/webhook/
pnpm --filter @telivityhaip/api test -- src/modules/connect/connect-events.service.spec.ts

Contributor credit

@agustinjch

Add logical_event_id column and unique index on webhook_deliveries.
Skip duplicate enqueue when the same logical event is already recorded.

Co-authored-by: Agus <agustin.jch@gmail.com>
cursor Bot pushed a commit that referenced this pull request Aug 26, 2026
Remove email transport, webhook dedup, shared utils, test-count sync, and
core webhook migration changes that land in separate focused PRs. Document
booking-requests in README packages section and optional enablement steps.

Depends on #349, #350, #351, #352, and #353.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursoragent and others added 2 commits August 26, 2026 19:57
Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
booking_request.created is not in the core WEBHOOK_EVENTS catalog; the
dedup envelope test should use a shipped event name.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@agustinjch

Copy link
Copy Markdown
Collaborator

Thanks — the database constraint and conflict-safe recovery are a good foundation. I checked the PR at 5cec622; typecheck and the focused webhook suite pass (12 tests).

I found three things to address before merge:

  1. packages/database/src/push-schema.ts adds the new column/index through sql.raw(...). The repository rules explicitly allow raw SQL only in migration files. 0022_webhook_logical_event_id.sql is the correct place for this DDL; please avoid adding the duplicate raw-SQL path to push-schema.ts (or formally establish a documented exception before extending it).
  2. The PR currently does not provide end-to-end deduplication on its own. dispatchPersisted() adds logicalEventId, but the real ConnectEventsService listener still calls WebhookDeliveryService.enqueue() without forwarding it. PostgreSQL therefore stores NULL and the unique index does not deduplicate those deliveries. If feat(booking-requests): add optional booking-requests module #348 intentionally supplies that wiring, please describe feat(webhook): dedupe deliveries with logicalEventId #350 as prerequisite infrastructure and add an integration test in feat(booking-requests): add optional booking-requests module #348 covering dispatchPersisted -> listener -> one delivery row; otherwise wire it here.
  3. The core webhook documentation still describes X-HAIP-Event-Id as a delivery UUID, while the implementation now emits the logical event ID when present. Please update the documented contract.

The concurrency/replay tests around the new row identity look good. My blocker here is mainly the raw-SQL violation and making the actual scope of the deduplication explicit.

Forward logicalEventId from Connect event listener to delivery enqueue.
Keep logical_event_id DDL in migration/CREATE only (not push-schema alters).
Document X-HAIP-Event-Id logical-vs-legacy contract.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@telivity-otaip
telivity-otaip marked this pull request as ready for review August 27, 2026 01:55
@telivity-otaip

Copy link
Copy Markdown
Collaborator Author

Thanks for the review — all three blockers addressed on 73b42b1 (current head).

1. push-schema duplicate DDL
Removed the ALTER TABLE / CREATE UNIQUE INDEX duplicates from the push-schema alter block. Upgrade path is migration 0022_webhook_logical_event_id.sql only. Fresh docker/demo installs still get logical_event_id from the CREATE TABLE webhook_deliveries definition (same pattern as other push-schema tables).

2. End-to-end deduplication
Wired here: ConnectEventsService.handleEvent() forwards payload.logicalEventId to WebhookDeliveryService.enqueue() when present. Added coverage in connect-events.service.spec.ts (forwards logicalEventId to WebhookDeliveryService for persisted dedup).

3. Documentation
Updated docs/webhooks.md: X-HAIP-Event-Id is the logical event uuid when present, otherwise the delivery row uuid for legacy events.

PR description updated with scope and push-schema note. Ready for your re-review.

Merge origin/main into cursor/webhook-logical-event-id-4a4f.

Conflicts were README-only (test count badges). Took main baseline
and regenerated counts (1585 tests / 220 files) after merge.

No webhook/connect code conflicts.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@agustinjch

Copy link
Copy Markdown
Collaborator

@telivity-otaip Thanks for addressing the earlier points. The remaining blocker is that pnpm db:migrate still runs push-schema.ts, but that path does not execute 0022_webhook_logical_event_id.sql.

My preferred fix is:

  1. Make the core migrate command actually execute numbered SQL migrations.
  2. Since the legacy schema pusher already incorporates migrations 0001-0021, establish those as the baseline and begin tracked execution with 0022.
  3. Record applied migrations in a migration ledger and execute each new migration once, transactionally.
  4. Remove the new logical_event_id and unique-index DDL from push-schema.ts; keep the new DDL exclusively in 0022_webhook_logical_event_id.sql.
  5. Ensure migration SQL files are present in the deployment/package path used by the migrate command.

Please add real PostgreSQL coverage for:

  • a fresh database
  • an existing pre-0022 database
  • running the migration twice
  • rejection of duplicate (property_id, subscription_id, logical_event_id) values

I would not try to rewrite all historical raw SQL in this PR. A sensible boundary is to baseline the legacy pusher and require all new schema evolution from 0022 onward to go through the real migration runner. That resolves this regression and stops adding new raw DDL outside migrations.

Implement Agus review feedback for PR #350:

- Add migration-runner with schema_migrations ledger; execute 0022+ SQL
  files once inside transactions after push-schema baseline (0001-0021)
- Point db:migrate, Docker, Render, and release smoke at run-migrations.js
- Copy src/migrations into dist/ on build so deployment ships SQL files
- Remove logical_event_id DDL from push-schema CREATE TABLE (0022 only)
- Guard push-schema CLI so importing it does not double-run baseline DDL
- Add PostgreSQL integration tests: fresh DB, pre-0022 upgrade, ledger
  idempotency, and unique-index duplicate rejection

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@telivity-otaip

Copy link
Copy Markdown
Collaborator Author

Thanks @agustinjch — all five migration-runner points addressed on 89cf718.

1. Core migrate executes numbered SQL migrations
pnpm db:migrate now runs run-migrations.ts (also wired in Docker, Render preDeploy, release smoke).

2. Baseline 0001–0021 / tracked from 0022
Push-schema remains the legacy idempotent baseline; the runner applies .sql files with version ≥ 0022.

3. Migration ledger + transactional once-only execution
New schema_migrations table records applied versions; each pending file runs inside sql.begin() and is skipped on re-run.

4. logical_event_id removed from push-schema
Column/index DDL lives exclusively in 0022_webhook_logical_event_id.sql. Also guarded push-schema CLI so importing it from the runner does not double-execute baseline DDL.

5. SQL files ship in deployment path
tsup onSuccess copies src/migrationsdist/migrations; production entrypoint is node packages/database/dist/run-migrations.js.

PostgreSQL coverage added (migration-runner.spec.ts):

  • Fresh database → 0022 applied, column + unique index present
  • Pre-0022 database (push-schema only) → upgrade via 0022
  • Second migrate pass → ledger skip (no re-apply)
  • Duplicate (property_id, subscription_id, logical_event_id)23505 unique violation

PR #350 scope verified intact: ConnectEventsService forwards logicalEventId, webhook docs updated, webhook + connect specs pass (27 + 5 migration tests).

Ready for re-review.

Resolve README/test-stats conflicts after #349 merge; sync test counts.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@telivity-otaip
telivity-otaip merged commit 2945d99 into main Aug 27, 2026
5 checks passed
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
Resolve README/test-stats conflicts after #349/#350 merges; sync counts.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
--check now compares the entire README to applyCounts output so badge,
heading, command count, and malformed-row normalization cannot go stale
while CI still passes. Adds regression coverage for each managed site.

Rebuilt on current main (#349/#350/#351), regenerated counts: 1630/228.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
telivity-otaip added a commit that referenced this pull request Aug 27, 2026
--check now compares the entire README to applyCounts output so badge,
heading, command count, and malformed-row normalization cannot go stale
while CI still passes. Adds regression coverage for each managed site.

Rebuilt on current main (#349/#350/#351), regenerated counts: 1630/228.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
- Ship runnable migrator + SQL in dist; production db:migrate via node
- Wire VITE_HAIP_BOOKING_REQUESTS through Docker/compose/release
- Reject bookingMode=request when HAIP_BOOKING_REQUESTS is off
- Ledger + checksum for package migrations (auto-commit for PG enums)
- Drop unused stripeHandlerToken from root module options
- Fix EmailResult outcomeUnknown after #349 status contract
- Point regression/e2e installs at run-migrations.js (post-#350)
- Harden push-schema CLI path resolution; sync README (2175/259)

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
#350 moved webhook logical_event_id to migration 0022. Remove the column
and unique index from push-schema so the pre-0022 upgrade regression
passes again.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
telivity-otaip added a commit that referenced this pull request Aug 28, 2026
* Add opt-in booking-requests package with core seams and UI gates

Introduce @telivityhaip/booking-requests as a deploy-time optional module
(HAIP_BOOKING_REQUESTS=true) with separate migrations, Stripe handler
delegation, and dashboard/booking widget feature flags. Core instant booking
paths stay unchanged when the flag is off.

Includes booking request API/controllers, schema split (0022-0032), email
transport hardening, webhook logicalEventId dedup, and CI release-gate job.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* refactor(booking-requests): slim package PR to booking-requests scope

Remove email transport, webhook dedup, shared utils, test-count sync, and
core webhook migration changes that land in separate focused PRs. Document
booking-requests in README packages section and optional enablement steps.

Depends on #349, #350, #351, #352, and #353.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(ci): restore core seams, push-schema columns, and README test counts

Re-include prerequisite core changes so typecheck and docker seed pass.
Add booking-requests to CI/Docker builds. Sync README to 2120 tests / 252 files.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(ci): add charge amendment columns to push-schema and webhook spec

push-schema now creates adjusts_charge_id and source_key so seed and
docker init succeed. Webhook logicalEventId spec uses reservation.created
from the core WEBHOOK_EVENTS catalog.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(docker): lazy-load booking-requests only when feature flag is on

Static imports pulled @telivityhaip/booking-requests into the default demo
image and crashed startup with missing @nestjs/common. Gate the optional
package behind HAIP_BOOKING_REQUESTS and read the flag from core seams.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(docker): preload optional booking-requests modules async

Avoid loading @telivityhaip/booking-requests at startup when
HAIP_BOOKING_REQUESTS is off (docker demo). Preload before Nest bootstrap
when the flag is enabled; read the flag from core payment seams in
DatabaseModule.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(api): type bootstrap cache as DynamicModule array

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(booking-requests): address Agustin packaging/safety review

- Ship runnable migrator + SQL in dist; production db:migrate via node
- Wire VITE_HAIP_BOOKING_REQUESTS through Docker/compose/release
- Reject bookingMode=request when HAIP_BOOKING_REQUESTS is off
- Ledger + checksum for package migrations (auto-commit for PG enums)
- Drop unused stripeHandlerToken from root module options
- Fix EmailResult outcomeUnknown after #349 status contract
- Point regression/e2e installs at run-migrations.js (post-#350)
- Harden push-schema CLI path resolution; sync README (2175/259)

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(database): keep logical_event_id out of push-schema baseline

#350 moved webhook logical_event_id to migration 0022. Remove the column
and unique index from push-schema so the pre-0022 upgrade regression
passes again.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* chore: sync README test counts to 2180/260

Matches CI after push-schema logical_event_id cleanup (all packages green).

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* test(booking-requests): flag-off regression gate + port PR #347 safety specs

- Add a flag-OFF default-install regression spec: only core migrations run,
  HAIP_BOOKING_REQUESTS is unset, AppModule boots without the
  booking-requests module/tables, request mode is rejected, and instant
  booking + deposit capture + partial/full refund still work. Runs
  automatically under `pnpm test` (apps/api's normal *.spec.ts glob), so it
  is wired into CI without any workflow changes.
- Extract the ephemeral-database subprocess helpers shared by that spec and
  the existing default-flow regression spec into
  regression-database-utils.ts (createdb/dropdb, sanitized child-process
  errors) instead of duplicating them.
- Port PR #347's booking-request-schema.spec.ts and
  booking-request-migration-safety.spec.ts into the package, scoped to the
  tables/migrations this package now owns (the duplicate push-schema DDL
  those specs cross-checked no longer exists — it moved into this package's
  migrations).
- Port PR #347's booking-request-remediation.postgres.spec.ts, replaying
  migration 0032's SQL directly (instead of through push-schema) since the
  ledger-based migrator can't be re-run against a manually-reverted schema.
  Kept opt-in via BOOKING_REQUEST_REMEDIATION_LIVE_PG like the original.
- Document remaining scope (vertical-slice move, push-schema de-pollution)
  in the package README instead of attempting it in this pass.
- Sync README/test-stats test counts (2211 tests, 263 files).

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* feat(booking-requests): package-owned port for booking_mode/payment_method_collection/form_questions

Adds BOOKING_REQUEST_CONFIG_FIELDS_PORT + DrizzleBookingRequestConfigFieldsAdapter
so the package owns reading/writing booking_engine_config's request-mode-only
columns via its own Drizzle table fragment, instead of core declaring them.
Wired into BookingRequestModule.forRoot() (global) so it's injectable into
core's BookingEngineConfigService without that module importing this package.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* chore(database): remove request-only DDL from core push-schema/drizzle

Removes booking_mode/payment_method_collection/form_questions ALTERs on
booking_engine_config, audit_logs.booking_request_id (+ its timeline index),
and the request-shape unique indexes/checks on payments
(payments_property_request_*_unique, booking_request_parent_positive_check,
booking_request_child_shape_check) from core's push-schema.ts and Drizzle
schema. These are now declared and migrated exclusively by
packages/booking-requests. payments.booking_request_id/idempotency_key,
reservations.accepted_pricing_snapshot, and charges
adjusts_charge_id/source_key stay in core as documented in
push-schema-kept-fields.spec.ts.

Also adds the DRIZZLE injection token to @telivityhaip/database so packages
outside apps/api (booking-requests) can inject the shared Drizzle client
without importing apps/api.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* refactor(booking-requests): move Nest vertical slice into package

Own controllers/services/DTOs behind package ports; strip request-only
audit DDL and payment indexes from core schema; keep thin config/payment hooks.

- Nest controllers, DTOs, services, Stripe handler, and pricing/money/
  state/db/ledger/reconciler/template helpers now live in
  packages/booking-requests/src (http/ + domain/), with their unit specs.
  BookingRequestModule.forRoot(...) is a real DynamicModule owning those
  controllers/providers directly, not a facade over apps/api classes.
- apps/api/src/modules/booking-request/ keeps only the e2e, authorization,
  default-flow-regression, flag-off-instant-booking.regression, and
  transaction-seams specs plus regression-database-utils.ts.
- apps/api/src/booking-requests.bootstrap.ts wires every package-local port
  (folio, webhook, email, reservation, rate-plan, guest, ancillary,
  availability, booking-engine, booking-engine-config, plus guard-bridge
  ports) to the concrete core singleton via `useExisting`.
- Guard bridge classes (BookingKeyGuardBridge, BookingEngineScopeGuardBridge,
  BookingThrottleGuardBridge) resolve the "guards landmine": @UseGuards(...)
  is populated from decorator metadata, a separate path from `providers`, so
  a bare useExisting binding on an abstract port class is silently dropped —
  the bridges are real injectable classes referenced in @UseGuards(...).
- DRIZZLE token declaration moved to @telivityhaip/database; apps/api's
  DatabaseModule still @Global-provides it and merges in the package's
  optional schema only when HAIP_BOOKING_REQUESTS is on.
- Relocated pure/shared pieces to packages/shared: SAVED_PAYMENT_METHOD_GATEWAY
  / PAYMENT_GATEWAY / BOOKING_REQUEST_STRIPE_HANDLER interfaces, IsMoneyString,
  canonical calendar date validators, stayDates, AuditActor helpers,
  RequirePermissions/Public decorators, stripe-financial-state helpers, and
  the pure payment-ledger math (remainingCapturedAmount/sumRefundChildren) —
  bookingRequestPaymentSumWhere stays in core payment-ledger.
- Schema de-pollution: removed audit_logs.booking_request_id (+ its timeline
  index) and the request-shape payments unique indexes/checks from core
  push-schema/drizzle; kept booking_engine_config.booking_mode /
  payment_method_collection / form_questions, payments.booking_request_id /
  idempotency_key, reservations.accepted_pricing_snapshot, and charges
  adjusts_charge_id/source_key as thin config/payment hooks core still reads
  directly. packages/database/src/push-schema-kept-fields.spec.ts guards the
  contract; packages/booking-requests declares its own local audit table
  extension for the timeline index it still owns.
- EventEmitterModule.forRoot() re-enabled `wildcard: true` — required for the
  webhook fan-out (@onevent('**')) to receive booking-request events.
- README's "Package boundary" section replaces the old "Remaining work"
  deferrals list with an accurate description of the current split.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(docker): ship shared node_modules for Nest peer in prod image

Shared now requires @nestjs/common at runtime after the booking-requests
boundary move; copy packages/shared/node_modules into the API image so
demo smoke can boot. Sync published test counts to 2222/266.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(api): load AppModule after booking-requests preload

When HAIP_BOOKING_REQUESTS=true, AppModule evaluates bookingRequestsModules()
at import time; dynamic-import AppModule after preload so flag-on dev/prod
boot does not crash before Nest starts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants