@voyant-travel/distribution 0.109.8

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.
Files changed (168) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +42 -0
  3. package/dist/booking-extension.d.ts +168 -0
  4. package/dist/booking-extension.d.ts.map +1 -0
  5. package/dist/booking-extension.js +102 -0
  6. package/dist/channel-push/admin-routes.d.ts +31 -0
  7. package/dist/channel-push/admin-routes.d.ts.map +1 -0
  8. package/dist/channel-push/admin-routes.js +165 -0
  9. package/dist/channel-push/availability-push.d.ts +76 -0
  10. package/dist/channel-push/availability-push.d.ts.map +1 -0
  11. package/dist/channel-push/availability-push.js +236 -0
  12. package/dist/channel-push/booking-push-helpers.d.ts +36 -0
  13. package/dist/channel-push/booking-push-helpers.d.ts.map +1 -0
  14. package/dist/channel-push/booking-push-helpers.js +169 -0
  15. package/dist/channel-push/booking-push.d.ts +108 -0
  16. package/dist/channel-push/booking-push.d.ts.map +1 -0
  17. package/dist/channel-push/booking-push.js +335 -0
  18. package/dist/channel-push/boundary-sql.d.ts +23 -0
  19. package/dist/channel-push/boundary-sql.d.ts.map +1 -0
  20. package/dist/channel-push/boundary-sql.js +75 -0
  21. package/dist/channel-push/content-push.d.ts +60 -0
  22. package/dist/channel-push/content-push.d.ts.map +1 -0
  23. package/dist/channel-push/content-push.js +252 -0
  24. package/dist/channel-push/index.d.ts +15 -0
  25. package/dist/channel-push/index.d.ts.map +1 -0
  26. package/dist/channel-push/index.js +18 -0
  27. package/dist/channel-push/plugin.d.ts +18 -0
  28. package/dist/channel-push/plugin.d.ts.map +1 -0
  29. package/dist/channel-push/plugin.js +21 -0
  30. package/dist/channel-push/reconciler.d.ts +85 -0
  31. package/dist/channel-push/reconciler.d.ts.map +1 -0
  32. package/dist/channel-push/reconciler.js +179 -0
  33. package/dist/channel-push/subscriber.d.ts +40 -0
  34. package/dist/channel-push/subscriber.d.ts.map +1 -0
  35. package/dist/channel-push/subscriber.js +199 -0
  36. package/dist/channel-push/types.d.ts +43 -0
  37. package/dist/channel-push/types.d.ts.map +1 -0
  38. package/dist/channel-push/types.js +32 -0
  39. package/dist/channel-push/workflows.d.ts +56 -0
  40. package/dist/channel-push/workflows.d.ts.map +1 -0
  41. package/dist/channel-push/workflows.js +100 -0
  42. package/dist/external-refs/index.d.ts +11 -0
  43. package/dist/external-refs/index.d.ts.map +1 -0
  44. package/dist/external-refs/index.js +12 -0
  45. package/dist/external-refs/routes.d.ts +253 -0
  46. package/dist/external-refs/routes.d.ts.map +1 -0
  47. package/dist/external-refs/routes.js +52 -0
  48. package/dist/external-refs/schema.d.ts +251 -0
  49. package/dist/external-refs/schema.d.ts.map +1 -0
  50. package/dist/external-refs/schema.js +32 -0
  51. package/dist/external-refs/service.d.ts +82 -0
  52. package/dist/external-refs/service.d.ts.map +1 -0
  53. package/dist/external-refs/service.js +112 -0
  54. package/dist/external-refs/validation.d.ts +91 -0
  55. package/dist/external-refs/validation.d.ts.map +1 -0
  56. package/dist/external-refs/validation.js +40 -0
  57. package/dist/index.d.ts +21 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +20 -0
  60. package/dist/interface-types.d.ts +128 -0
  61. package/dist/interface-types.d.ts.map +1 -0
  62. package/dist/interface-types.js +1 -0
  63. package/dist/interface.d.ts +10 -0
  64. package/dist/interface.d.ts.map +1 -0
  65. package/dist/interface.js +286 -0
  66. package/dist/rate-limit.d.ts +69 -0
  67. package/dist/rate-limit.d.ts.map +1 -0
  68. package/dist/rate-limit.js +135 -0
  69. package/dist/routes/batch.d.ts +200 -0
  70. package/dist/routes/batch.d.ts.map +1 -0
  71. package/dist/routes/batch.js +52 -0
  72. package/dist/routes/env.d.ts +8 -0
  73. package/dist/routes/env.d.ts.map +1 -0
  74. package/dist/routes/env.js +1 -0
  75. package/dist/routes/inventory.d.ts +604 -0
  76. package/dist/routes/inventory.d.ts.map +1 -0
  77. package/dist/routes/inventory.js +138 -0
  78. package/dist/routes/settlements.d.ts +1649 -0
  79. package/dist/routes/settlements.d.ts.map +1 -0
  80. package/dist/routes/settlements.js +265 -0
  81. package/dist/routes.d.ts +3909 -0
  82. package/dist/routes.d.ts.map +1 -0
  83. package/dist/routes.js +323 -0
  84. package/dist/schema-automation.d.ts +680 -0
  85. package/dist/schema-automation.d.ts.map +1 -0
  86. package/dist/schema-automation.js +76 -0
  87. package/dist/schema-core.d.ts +1674 -0
  88. package/dist/schema-core.d.ts.map +1 -0
  89. package/dist/schema-core.js +227 -0
  90. package/dist/schema-finance.d.ts +1372 -0
  91. package/dist/schema-finance.d.ts.map +1 -0
  92. package/dist/schema-finance.js +153 -0
  93. package/dist/schema-inventory.d.ts +855 -0
  94. package/dist/schema-inventory.d.ts.map +1 -0
  95. package/dist/schema-inventory.js +102 -0
  96. package/dist/schema-push-intents.d.ts +387 -0
  97. package/dist/schema-push-intents.d.ts.map +1 -0
  98. package/dist/schema-push-intents.js +77 -0
  99. package/dist/schema-relations.d.ts +95 -0
  100. package/dist/schema-relations.d.ts.map +1 -0
  101. package/dist/schema-relations.js +196 -0
  102. package/dist/schema-shared.d.ts +24 -0
  103. package/dist/schema-shared.d.ts.map +1 -0
  104. package/dist/schema-shared.js +123 -0
  105. package/dist/schema.d.ts +9 -0
  106. package/dist/schema.d.ts.map +1 -0
  107. package/dist/schema.js +8 -0
  108. package/dist/service/channels.d.ts +167 -0
  109. package/dist/service/channels.d.ts.map +1 -0
  110. package/dist/service/channels.js +305 -0
  111. package/dist/service/commercial.d.ts +385 -0
  112. package/dist/service/commercial.d.ts.map +1 -0
  113. package/dist/service/commercial.js +248 -0
  114. package/dist/service/helpers.d.ts +10 -0
  115. package/dist/service/helpers.d.ts.map +1 -0
  116. package/dist/service/helpers.js +7 -0
  117. package/dist/service/inventory.d.ts +193 -0
  118. package/dist/service/inventory.d.ts.map +1 -0
  119. package/dist/service/inventory.js +154 -0
  120. package/dist/service/settlement-policies.d.ts +325 -0
  121. package/dist/service/settlement-policies.d.ts.map +1 -0
  122. package/dist/service/settlement-policies.js +272 -0
  123. package/dist/service/settlements.d.ts +357 -0
  124. package/dist/service/settlements.d.ts.map +1 -0
  125. package/dist/service/settlements.js +319 -0
  126. package/dist/service/types.d.ts +60 -0
  127. package/dist/service/types.d.ts.map +1 -0
  128. package/dist/service/types.js +1 -0
  129. package/dist/service.d.ts +1418 -0
  130. package/dist/service.d.ts.map +1 -0
  131. package/dist/service.js +17 -0
  132. package/dist/suppliers/index.d.ts +15 -0
  133. package/dist/suppliers/index.d.ts.map +1 -0
  134. package/dist/suppliers/index.js +23 -0
  135. package/dist/suppliers/routes.d.ts +1202 -0
  136. package/dist/suppliers/routes.d.ts.map +1 -0
  137. package/dist/suppliers/routes.js +290 -0
  138. package/dist/suppliers/schema.d.ts +1272 -0
  139. package/dist/suppliers/schema.d.ts.map +1 -0
  140. package/dist/suppliers/schema.js +219 -0
  141. package/dist/suppliers/service-aggregates.d.ts +23 -0
  142. package/dist/suppliers/service-aggregates.d.ts.map +1 -0
  143. package/dist/suppliers/service-aggregates.js +51 -0
  144. package/dist/suppliers/service-core.d.ts +89 -0
  145. package/dist/suppliers/service-core.d.ts.map +1 -0
  146. package/dist/suppliers/service-core.js +164 -0
  147. package/dist/suppliers/service-identity.d.ts +162 -0
  148. package/dist/suppliers/service-identity.d.ts.map +1 -0
  149. package/dist/suppliers/service-identity.js +101 -0
  150. package/dist/suppliers/service-operations.d.ts +1500 -0
  151. package/dist/suppliers/service-operations.d.ts.map +1 -0
  152. package/dist/suppliers/service-operations.js +157 -0
  153. package/dist/suppliers/service-shared.d.ts +45 -0
  154. package/dist/suppliers/service-shared.d.ts.map +1 -0
  155. package/dist/suppliers/service-shared.js +294 -0
  156. package/dist/suppliers/service.d.ts +41 -0
  157. package/dist/suppliers/service.d.ts.map +1 -0
  158. package/dist/suppliers/service.js +40 -0
  159. package/dist/suppliers/validation.d.ts +2 -0
  160. package/dist/suppliers/validation.d.ts.map +1 -0
  161. package/dist/suppliers/validation.js +1 -0
  162. package/dist/validation.d.ts +1371 -0
  163. package/dist/validation.d.ts.map +1 -0
  164. package/dist/validation.js +445 -0
  165. package/dist/webhook-deliveries.d.ts +86 -0
  166. package/dist/webhook-deliveries.d.ts.map +1 -0
  167. package/dist/webhook-deliveries.js +296 -0
  168. package/package.json +71 -0
@@ -0,0 +1,169 @@
1
+ import { bookingItems } from "@voyant-travel/bookings/schema";
2
+ import { channelBookingLinks, channelContracts, channelProductMappings, } from "@voyant-travel/distribution/schema";
3
+ import { and, asc, eq, lte, or, sql } from "drizzle-orm";
4
+ import { prepareOutboundEnvelope } from "../webhook-deliveries.js";
5
+ /**
6
+ * Read the compensation policy for a channel by walking
7
+ * `channel_contracts` (most-recent active contract wins). Returns
8
+ * `eventually-consistent` when no contract exists or no compensation
9
+ * key is set — that's the doc-default safe behavior for travel
10
+ * inventory.
11
+ */
12
+ export async function resolveCompensationPolicy(db, channelId) {
13
+ if (!channelId)
14
+ return "eventually-consistent";
15
+ const today = new Date().toISOString().slice(0, 10);
16
+ const [contract] = (await db
17
+ .select({ policy: channelContracts.policy })
18
+ .from(channelContracts)
19
+ .where(and(eq(channelContracts.channelId, channelId), eq(channelContracts.status, "active"),
20
+ // agent-quality: raw-sql reviewed -- owner: distribution; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
21
+ or(sql `${channelContracts.endsAt} IS NULL`, lte(channelContracts.startsAt, today))))
22
+ .orderBy(asc(channelContracts.startsAt))
23
+ .limit(1));
24
+ const raw = contract?.policy?.compensation;
25
+ return raw === "strict-atomic" ? "strict-atomic" : "eventually-consistent";
26
+ }
27
+ /**
28
+ * Roll back a succeeded link by calling `adapter.cancel` for the
29
+ * upstream reference. Marks the link `compensated` regardless of the
30
+ * cancel call's outcome — leaving it `ok` would lie to the operator
31
+ * dashboard. Per §4.2.
32
+ */
33
+ export async function compensateSucceededLink(db, entry, bookingId, logger) {
34
+ let cancelError = null;
35
+ if (entry.adapter.cancel) {
36
+ const envelope = await prepareOutboundEnvelope(db, {
37
+ sourceModule: "distribution",
38
+ sourceEvent: "channel.booking.compensate",
39
+ sourceEntityModule: "bookings",
40
+ sourceEntityId: bookingId,
41
+ targetUrl: `adapter:${entry.adapter.kind}`,
42
+ targetKind: `channel:${entry.adapter.kind}`,
43
+ targetRef: entry.channel.id,
44
+ requestMethod: "POST",
45
+ requestBody: { upstream_ref: entry.upstreamRef, reason: "channel-push-compensation" },
46
+ attemptNumber: 1,
47
+ idempotencyKey: `compensate:${entry.link.id}`,
48
+ });
49
+ try {
50
+ const result = await entry.adapter.cancel(entry.adapterCtx, {
51
+ upstream_ref: entry.upstreamRef,
52
+ reason: "channel-push-compensation",
53
+ });
54
+ await envelope.complete({ responseStatus: 200, responseBody: result });
55
+ }
56
+ catch (err) {
57
+ cancelError = err instanceof Error ? err.message : String(err);
58
+ await envelope.complete({ errorClass: "adapter_error", errorMessage: cancelError });
59
+ logger.warn?.(`compensateSucceededLink: cancel failed for ${entry.link.id}`, {
60
+ error: cancelError,
61
+ });
62
+ }
63
+ }
64
+ else {
65
+ cancelError = "adapter does not implement cancel";
66
+ logger.warn?.(`compensateSucceededLink: ${entry.adapter.kind} has no cancel method`, {
67
+ linkId: entry.link.id,
68
+ });
69
+ }
70
+ const now = new Date();
71
+ await db
72
+ .update(channelBookingLinks)
73
+ .set({
74
+ pushStatus: "compensated",
75
+ lastPushAt: now,
76
+ lastError: cancelError,
77
+ updatedAt: now,
78
+ })
79
+ .where(eq(channelBookingLinks.id, entry.link.id));
80
+ return true;
81
+ }
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+ // Helpers
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+ export async function markLinkOk(db, linkId, attempts, upstreamRef, externalReference, externalStatus) {
86
+ const now = new Date();
87
+ await db
88
+ .update(channelBookingLinks)
89
+ .set({
90
+ pushStatus: "ok",
91
+ pushAttempts: attempts,
92
+ lastPushAt: now,
93
+ lastError: null,
94
+ externalBookingId: upstreamRef,
95
+ externalReference,
96
+ externalStatus,
97
+ lastSyncedAt: now,
98
+ updatedAt: now,
99
+ })
100
+ .where(eq(channelBookingLinks.id, linkId));
101
+ }
102
+ export async function markLinkFailed(db, linkId, attempts, message) {
103
+ const now = new Date();
104
+ await db
105
+ .update(channelBookingLinks)
106
+ .set({
107
+ pushStatus: "failed",
108
+ pushAttempts: attempts,
109
+ lastPushAt: now,
110
+ lastError: message,
111
+ updatedAt: now,
112
+ })
113
+ .where(eq(channelBookingLinks.id, linkId));
114
+ }
115
+ export async function readMappingForLink(db, link, booking) {
116
+ // Walk via booking_items.product_id when the link is item-scoped;
117
+ // otherwise pick the first mapping for any of the booking's items
118
+ // (booking-level fallback used by bookings that fully syndicate).
119
+ let productId = null;
120
+ if (link.bookingItemId) {
121
+ const [row] = (await db
122
+ .select({ productId: bookingItems.productId })
123
+ .from(bookingItems)
124
+ .where(eq(bookingItems.id, link.bookingItemId))
125
+ .limit(1));
126
+ productId = row?.productId ?? null;
127
+ }
128
+ else {
129
+ const [row] = (await db
130
+ .select({ productId: bookingItems.productId })
131
+ .from(bookingItems)
132
+ .where(eq(bookingItems.bookingId, booking.id))
133
+ .limit(1));
134
+ productId = row?.productId ?? null;
135
+ }
136
+ if (!productId)
137
+ return null;
138
+ const [mapping] = (await db
139
+ .select()
140
+ .from(channelProductMappings)
141
+ .where(and(eq(channelProductMappings.channelId, link.channelId), eq(channelProductMappings.productId, productId)))
142
+ .limit(1));
143
+ return mapping ?? null;
144
+ }
145
+ export function rateLimitConfigForChannel(channel) {
146
+ if (!channel.rateLimitRps || !channel.rateLimitBurst)
147
+ return null;
148
+ return {
149
+ rps: channel.rateLimitRps,
150
+ burst: channel.rateLimitBurst,
151
+ priorityGates: channel.rateLimitPriorityGates ?? undefined,
152
+ };
153
+ }
154
+ export function serializeBookingForPush(booking, bookingItemId) {
155
+ // v1: ship a thin shape — booking number, dates, pax, optionally
156
+ // narrowed to the targeted item. PII redaction (per §15) is left to
157
+ // the adapter; future iterations push redaction up here.
158
+ return {
159
+ bookingId: booking.id,
160
+ bookingNumber: booking.bookingNumber,
161
+ bookingItemId,
162
+ status: booking.status,
163
+ startDate: booking.startDate,
164
+ endDate: booking.endDate,
165
+ pax: booking.pax,
166
+ sellCurrency: booking.sellCurrency,
167
+ sellAmountCents: booking.sellAmountCents,
168
+ };
169
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Booking-push pipeline.
3
+ *
4
+ * Triggered by `booking.confirmed`. The subscriber writes pending
5
+ * `channel_booking_links` rows and returns immediately (per the EventBus
6
+ * fire-and-forget contract). The durable processor (`processBookingPush`)
7
+ * drains those rows, calls `adapter.pushBooking()` per link, and marks
8
+ * each row `ok` or `failed`.
9
+ *
10
+ * Per docs/architecture/channel-push-architecture.md §4 + §12.1.
11
+ */
12
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
13
+ import { channelProductMappings, channels } from "@voyant-travel/distribution/schema";
14
+ import { type ChannelPushDeps } from "./types.js";
15
+ /** Stable string identifier for the booking-push workflow. */
16
+ export declare const CHANNEL_BOOKING_PUSH_WORKFLOW_ID: "channel.booking.push";
17
+ export interface ProcessBookingPushInput {
18
+ bookingId: string;
19
+ }
20
+ export interface ProcessBookingPushResult {
21
+ bookingId: string;
22
+ attempted: number;
23
+ succeeded: number;
24
+ failed: number;
25
+ /**
26
+ * Number of succeeded links that were compensated (rolled back via
27
+ * `adapter.cancel`) because the contract's `compensation` policy is
28
+ * `"strict-atomic"` and at least one sibling failed. Always 0 under
29
+ * the default `"eventually-consistent"` policy.
30
+ */
31
+ compensated: number;
32
+ /** Per-link outcomes for diagnostics. */
33
+ outcomes: Array<{
34
+ channelId: string;
35
+ bookingItemId: string | null;
36
+ status: "ok" | "failed" | "skipped" | "compensated";
37
+ upstreamRef?: string;
38
+ error?: string;
39
+ }>;
40
+ }
41
+ /**
42
+ * Compensation modes per `channel_contracts.policy.compensation`.
43
+ *
44
+ * - `eventually-consistent` (default): partial successes stay; ops gets
45
+ * alerted via `webhook_deliveries` and retries via the reconciler.
46
+ * Usually correct for travel inventory — succeeded channels know
47
+ * about the booking and will honor it; the failed ones converge.
48
+ * - `strict-atomic`: on any per-link failure, the engine calls
49
+ * `adapter.cancel` for succeeded siblings and marks them
50
+ * `push_status = 'compensated'`. Use only when ALL channels MUST
51
+ * agree on the booking's existence (rare).
52
+ *
53
+ * Per docs/architecture/channel-push-architecture.md §4.2 + §9.
54
+ */
55
+ export type CompensationPolicy = "strict-atomic" | "eventually-consistent";
56
+ /**
57
+ * Build the stable idempotency key the upstream uses to dedupe pushes
58
+ * across retries. Per §3.
59
+ */
60
+ export declare function bookingPushIdempotencyKey(bookingId: string, bookingItemId: string | null, channelId: string): string;
61
+ /**
62
+ * Resolve the channels that want a push for this booking. One row per
63
+ * (booking_item, channel) pair where the mapping has push_bookings =
64
+ * true and the channel is active. Booking-level pushes (no item id) are
65
+ * supported via a synthetic item id of null.
66
+ *
67
+ * Per §7.4 — booking push uses `channel_product_mappings` (not
68
+ * `channel_inventory_allotments`) so channels mapped to a product
69
+ * without a slot allotment still receive the push.
70
+ */
71
+ export declare function resolveBookingPushTargets(db: AnyDrizzleDb, bookingId: string): Promise<Array<{
72
+ bookingItemId: string | null;
73
+ productId: string;
74
+ mapping: typeof channelProductMappings.$inferSelect;
75
+ channel: typeof channels.$inferSelect;
76
+ }>>;
77
+ /**
78
+ * Insert pending `channel_booking_links` rows for each push target.
79
+ * `INSERT ... ON CONFLICT DO NOTHING` against the
80
+ * `(channel_id, booking_id, COALESCE(booking_item_id, ''))` unique
81
+ * index — durable handoff with no doubled-push risk per §7.1.
82
+ *
83
+ * Returns the count of newly-inserted rows. Subscribers don't strictly
84
+ * need this — the processor reads pending rows by query — but tests
85
+ * find it useful.
86
+ */
87
+ export declare function upsertPendingBookingLinks(db: AnyDrizzleDb, bookingId: string, targets: Array<{
88
+ bookingItemId: string | null;
89
+ mapping: typeof channelProductMappings.$inferSelect;
90
+ channel: typeof channels.$inferSelect;
91
+ }>): Promise<number>;
92
+ /**
93
+ * Drain pending `channel_booking_links` rows for one booking and call
94
+ * `adapter.pushBooking()` per link. Idempotent: re-running the
95
+ * processor against the same booking is safe — the `idempotency_key`
96
+ * column ensures retries don't double-push upstream.
97
+ *
98
+ * Each adapter call:
99
+ * 1. Acquires a token from the per-channel/connection bucket.
100
+ * 2. Calls the adapter through `prepareOutboundEnvelope` so every
101
+ * attempt lands in `webhook_deliveries` with redacted headers.
102
+ * 3. Updates the link to `ok` (with upstream_ref, hash) or `failed`
103
+ * (with last_error, attempts++).
104
+ *
105
+ * Per §4.2 + §12.1.
106
+ */
107
+ export declare function processBookingPush(input: ProcessBookingPushInput, deps?: ChannelPushDeps): Promise<ProcessBookingPushResult>;
108
+ //# sourceMappingURL=booking-push.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"booking-push.d.ts","sourceRoot":"","sources":["../../src/channel-push/booking-push.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AASH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,EAEL,sBAAsB,EACtB,QAAQ,EACT,MAAM,oCAAoC,CAAA;AAgB3C,OAAO,EAAE,KAAK,eAAe,EAA4C,MAAM,YAAY,CAAA;AAE3F,8DAA8D;AAC9D,eAAO,MAAM,gCAAgC,EAAG,sBAA+B,CAAA;AAE/E,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd;;;;;OAKG;IACH,WAAW,EAAE,MAAM,CAAA;IACnB,yCAAyC;IACzC,QAAQ,EAAE,KAAK,CAAC;QACd,SAAS,EAAE,MAAM,CAAA;QACjB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;QAC5B,MAAM,EAAE,IAAI,GAAG,QAAQ,GAAG,SAAS,GAAG,aAAa,CAAA;QACnD,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,KAAK,CAAC,EAAE,MAAM,CAAA;KACf,CAAC,CAAA;CACH;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,kBAAkB,GAAG,eAAe,GAAG,uBAAuB,CAAA;AAE1E;;;GAGG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,SAAS,EAAE,MAAM,GAChB,MAAM,CAER;AAED;;;;;;;;;GASG;AACH,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CACR,KAAK,CAAC;IACJ,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,sBAAsB,CAAC,YAAY,CAAA;IACnD,OAAO,EAAE,OAAO,QAAQ,CAAC,YAAY,CAAA;CACtC,CAAC,CACH,CAyDA;AAED;;;;;;;;;GASG;AACH,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,KAAK,CAAC;IACb,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,OAAO,EAAE,OAAO,sBAAsB,CAAC,YAAY,CAAA;IACnD,OAAO,EAAE,OAAO,QAAQ,CAAC,YAAY,CAAA;CACtC,CAAC,GACD,OAAO,CAAC,MAAM,CAAC,CAuBjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,uBAAuB,EAC9B,IAAI,CAAC,EAAE,eAAe,GACrB,OAAO,CAAC,wBAAwB,CAAC,CA8PnC"}
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Booking-push pipeline.
3
+ *
4
+ * Triggered by `booking.confirmed`. The subscriber writes pending
5
+ * `channel_booking_links` rows and returns immediately (per the EventBus
6
+ * fire-and-forget contract). The durable processor (`processBookingPush`)
7
+ * drains those rows, calls `adapter.pushBooking()` per link, and marks
8
+ * each row `ok` or `failed`.
9
+ *
10
+ * Per docs/architecture/channel-push-architecture.md §4 + §12.1.
11
+ */
12
+ import { bookingItems, bookings } from "@voyant-travel/bookings/schema";
13
+ import { AdapterRateLimitedError, } from "@voyant-travel/catalog";
14
+ import { newId } from "@voyant-travel/db/lib/typeid";
15
+ import { channelBookingLinks, channelProductMappings, channels, } from "@voyant-travel/distribution/schema";
16
+ import { and, eq, inArray } from "drizzle-orm";
17
+ import { acquireToken, channelScopeKey, drainBucket } from "../rate-limit.js";
18
+ import { prepareOutboundEnvelope } from "../webhook-deliveries.js";
19
+ import { compensateSucceededLink, markLinkFailed, markLinkOk, rateLimitConfigForChannel, readMappingForLink, resolveCompensationPolicy, serializeBookingForPush, } from "./booking-push-helpers.js";
20
+ import { defaultLogger, getChannelPushDepsOrThrow } from "./types.js";
21
+ /** Stable string identifier for the booking-push workflow. */
22
+ export const CHANNEL_BOOKING_PUSH_WORKFLOW_ID = "channel.booking.push";
23
+ /**
24
+ * Build the stable idempotency key the upstream uses to dedupe pushes
25
+ * across retries. Per §3.
26
+ */
27
+ export function bookingPushIdempotencyKey(bookingId, bookingItemId, channelId) {
28
+ return `book:${bookingId}:${bookingItemId ?? "*"}:${channelId}`;
29
+ }
30
+ /**
31
+ * Resolve the channels that want a push for this booking. One row per
32
+ * (booking_item, channel) pair where the mapping has push_bookings =
33
+ * true and the channel is active. Booking-level pushes (no item id) are
34
+ * supported via a synthetic item id of null.
35
+ *
36
+ * Per §7.4 — booking push uses `channel_product_mappings` (not
37
+ * `channel_inventory_allotments`) so channels mapped to a product
38
+ * without a slot allotment still receive the push.
39
+ */
40
+ export async function resolveBookingPushTargets(db, bookingId) {
41
+ const items = (await db
42
+ .select({
43
+ id: bookingItems.id,
44
+ productId: bookingItems.productId,
45
+ })
46
+ .from(bookingItems)
47
+ .where(eq(bookingItems.bookingId, bookingId)));
48
+ if (items.length === 0)
49
+ return [];
50
+ const productIds = Array.from(new Set(items.filter((i) => i.productId).map((i) => i.productId)));
51
+ if (productIds.length === 0)
52
+ return [];
53
+ const rows = (await db
54
+ .select({
55
+ mapping: channelProductMappings,
56
+ channel: channels,
57
+ })
58
+ .from(channelProductMappings)
59
+ .innerJoin(channels, eq(channelProductMappings.channelId, channels.id))
60
+ .where(and(eq(channelProductMappings.active, true), eq(channelProductMappings.pushBookings, true), inArray(channelProductMappings.productId, productIds), eq(channels.status, "active"))));
61
+ if (rows.length === 0)
62
+ return [];
63
+ const out = [];
64
+ for (const item of items) {
65
+ if (!item.productId)
66
+ continue;
67
+ for (const row of rows) {
68
+ if (row.mapping.productId !== item.productId)
69
+ continue;
70
+ out.push({
71
+ bookingItemId: item.id,
72
+ productId: item.productId,
73
+ mapping: row.mapping,
74
+ channel: row.channel,
75
+ });
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ /**
81
+ * Insert pending `channel_booking_links` rows for each push target.
82
+ * `INSERT ... ON CONFLICT DO NOTHING` against the
83
+ * `(channel_id, booking_id, COALESCE(booking_item_id, ''))` unique
84
+ * index — durable handoff with no doubled-push risk per §7.1.
85
+ *
86
+ * Returns the count of newly-inserted rows. Subscribers don't strictly
87
+ * need this — the processor reads pending rows by query — but tests
88
+ * find it useful.
89
+ */
90
+ export async function upsertPendingBookingLinks(db, bookingId, targets) {
91
+ if (targets.length === 0)
92
+ return 0;
93
+ const rows = targets.map((target) => ({
94
+ id: newId("channel_booking_links"),
95
+ channelId: target.channel.id,
96
+ bookingId,
97
+ bookingItemId: target.bookingItemId,
98
+ sourceKind: target.mapping.sourceKind ?? null,
99
+ sourceConnectionId: target.mapping.sourceConnectionId ?? null,
100
+ pushStatus: "pending",
101
+ idempotencyKey: bookingPushIdempotencyKey(bookingId, target.bookingItemId, target.channel.id),
102
+ }));
103
+ // Drizzle's onConflictDoNothing without an explicit target falls back
104
+ // to the (channel, booking, item) unique index we created in §7.1.
105
+ const inserted = (await db
106
+ .insert(channelBookingLinks)
107
+ .values(rows)
108
+ .onConflictDoNothing()
109
+ .returning());
110
+ return inserted.length;
111
+ }
112
+ /**
113
+ * Drain pending `channel_booking_links` rows for one booking and call
114
+ * `adapter.pushBooking()` per link. Idempotent: re-running the
115
+ * processor against the same booking is safe — the `idempotency_key`
116
+ * column ensures retries don't double-push upstream.
117
+ *
118
+ * Each adapter call:
119
+ * 1. Acquires a token from the per-channel/connection bucket.
120
+ * 2. Calls the adapter through `prepareOutboundEnvelope` so every
121
+ * attempt lands in `webhook_deliveries` with redacted headers.
122
+ * 3. Updates the link to `ok` (with upstream_ref, hash) or `failed`
123
+ * (with last_error, attempts++).
124
+ *
125
+ * Per §4.2 + §12.1.
126
+ */
127
+ export async function processBookingPush(input, deps) {
128
+ const { db, registry, logger = defaultLogger } = deps ?? getChannelPushDepsOrThrow();
129
+ const outcomes = [];
130
+ const links = (await db
131
+ .select({
132
+ link: channelBookingLinks,
133
+ channel: channels,
134
+ })
135
+ .from(channelBookingLinks)
136
+ .innerJoin(channels, eq(channelBookingLinks.channelId, channels.id))
137
+ .where(and(eq(channelBookingLinks.bookingId, input.bookingId), eq(channelBookingLinks.pushStatus, "pending"))));
138
+ if (links.length === 0) {
139
+ return {
140
+ bookingId: input.bookingId,
141
+ attempted: 0,
142
+ succeeded: 0,
143
+ failed: 0,
144
+ compensated: 0,
145
+ outcomes,
146
+ };
147
+ }
148
+ const [booking] = (await db
149
+ .select()
150
+ .from(bookings)
151
+ .where(eq(bookings.id, input.bookingId))
152
+ .limit(1));
153
+ if (!booking) {
154
+ logger.error?.(`processBookingPush: booking ${input.bookingId} not found`, {});
155
+ return {
156
+ bookingId: input.bookingId,
157
+ attempted: 0,
158
+ succeeded: 0,
159
+ failed: 0,
160
+ compensated: 0,
161
+ outcomes,
162
+ };
163
+ }
164
+ let succeeded = 0;
165
+ let failed = 0;
166
+ // Track succeeded links so we can compensate them if a sibling fails
167
+ // and the contract policy demands strict-atomicity. Per §4.2.
168
+ const successList = [];
169
+ for (const { link, channel } of links) {
170
+ const connectionId = link.sourceConnectionId ?? channel.id;
171
+ const adapter = registry.resolveByConnection(connectionId);
172
+ if (!adapter) {
173
+ // Skip — no adapter wired for this connection. Mark the row
174
+ // failed so ops sees it; the reconciler retries when the adapter
175
+ // shows up.
176
+ await markLinkFailed(db, link.id, link.pushAttempts + 1, "no_adapter_registered");
177
+ outcomes.push({
178
+ channelId: channel.id,
179
+ bookingItemId: link.bookingItemId ?? null,
180
+ status: "failed",
181
+ error: "no_adapter_registered",
182
+ });
183
+ failed += 1;
184
+ continue;
185
+ }
186
+ if (!adapter.capabilities.supportsBookingPush || !adapter.pushBooking) {
187
+ await markLinkFailed(db, link.id, link.pushAttempts + 1, "adapter_unsupported");
188
+ outcomes.push({
189
+ channelId: channel.id,
190
+ bookingItemId: link.bookingItemId ?? null,
191
+ status: "failed",
192
+ error: "adapter_unsupported",
193
+ });
194
+ failed += 1;
195
+ continue;
196
+ }
197
+ // Resolve the per-(channel, item) mapping for the external ids.
198
+ const mapping = await readMappingForLink(db, link, booking);
199
+ if (!mapping) {
200
+ await markLinkFailed(db, link.id, link.pushAttempts + 1, "no_mapping");
201
+ outcomes.push({
202
+ channelId: channel.id,
203
+ bookingItemId: link.bookingItemId ?? null,
204
+ status: "failed",
205
+ error: "no_mapping",
206
+ });
207
+ failed += 1;
208
+ continue;
209
+ }
210
+ // Rate limit before dispatching.
211
+ const rlConfig = rateLimitConfigForChannel(channel);
212
+ if (rlConfig) {
213
+ const acq = await acquireToken(db, channelScopeKey(channel.id, connectionId), rlConfig, "booking");
214
+ if (!acq.acquired) {
215
+ // Bookings are supposed to pre-empt other flows; if we can't
216
+ // acquire, the bucket is over-tight or the channel just got
217
+ // 429'd. Mark the link failed and move on — reconciler retries.
218
+ await markLinkFailed(db, link.id, link.pushAttempts + 1, "rate_limited");
219
+ outcomes.push({
220
+ channelId: channel.id,
221
+ bookingItemId: link.bookingItemId ?? null,
222
+ status: "failed",
223
+ error: "rate_limited",
224
+ });
225
+ failed += 1;
226
+ continue;
227
+ }
228
+ }
229
+ const request = {
230
+ idempotencyKey: link.idempotencyKey ??
231
+ bookingPushIdempotencyKey(input.bookingId, link.bookingItemId ?? null, channel.id),
232
+ bookingId: input.bookingId,
233
+ bookingItemId: link.bookingItemId ?? undefined,
234
+ externalProductId: mapping.externalProductId ?? "",
235
+ externalRateId: mapping.externalRateId ?? undefined,
236
+ externalCategoryId: mapping.externalCategoryId ?? undefined,
237
+ channelId: channel.id,
238
+ contractPolicy: undefined,
239
+ payload: serializeBookingForPush(booking, link.bookingItemId ?? null),
240
+ };
241
+ const adapterCtx = {
242
+ connection_id: connectionId,
243
+ };
244
+ // Every attempt writes a webhook_deliveries row through the
245
+ // redactor — direct INSERTs are forbidden per §11.3.
246
+ const envelope = await prepareOutboundEnvelope(db, {
247
+ sourceModule: "distribution",
248
+ sourceEvent: "channel.booking.push",
249
+ sourceEntityModule: "bookings",
250
+ sourceEntityId: input.bookingId,
251
+ targetUrl: `adapter:${adapter.kind}`,
252
+ targetKind: `channel:${adapter.kind}`,
253
+ targetRef: channel.id,
254
+ requestMethod: "POST",
255
+ requestBody: request,
256
+ attemptNumber: link.pushAttempts + 1,
257
+ idempotencyKey: request.idempotencyKey,
258
+ });
259
+ try {
260
+ const result = await adapter.pushBooking(adapterCtx, request);
261
+ await envelope.complete({
262
+ responseStatus: 200,
263
+ responseBody: result,
264
+ });
265
+ await markLinkOk(db, link.id, link.pushAttempts + 1, result.upstreamRef, result.externalReference ?? null, result.externalStatus ?? null);
266
+ outcomes.push({
267
+ channelId: channel.id,
268
+ bookingItemId: link.bookingItemId ?? null,
269
+ status: "ok",
270
+ upstreamRef: result.upstreamRef,
271
+ });
272
+ succeeded += 1;
273
+ successList.push({ link, channel, adapter, adapterCtx, upstreamRef: result.upstreamRef });
274
+ }
275
+ catch (err) {
276
+ const message = err instanceof Error ? err.message : String(err);
277
+ // 429 from upstream — drain the bucket for the cooldown so
278
+ // concurrent dispatchers also see "no tokens" until the channel
279
+ // is ready, and stamp the delivery with the rate-limited class
280
+ // (per §14.4).
281
+ const isRateLimited = err instanceof AdapterRateLimitedError;
282
+ if (isRateLimited) {
283
+ await drainBucket(db, channelScopeKey(channel.id, connectionId), err.retryAfterMs);
284
+ }
285
+ await envelope.complete({
286
+ errorClass: isRateLimited ? "rate_limited" : "adapter_error",
287
+ errorMessage: message,
288
+ });
289
+ await markLinkFailed(db, link.id, link.pushAttempts + 1, message);
290
+ outcomes.push({
291
+ channelId: channel.id,
292
+ bookingItemId: link.bookingItemId ?? null,
293
+ status: "failed",
294
+ error: message,
295
+ });
296
+ failed += 1;
297
+ logger.error?.(`pushBooking failed for ${link.id}`, { error: message });
298
+ }
299
+ }
300
+ // Compensation pass: if any link failed and the channel-contract
301
+ // policy is strict-atomic, roll back succeeded siblings so all
302
+ // channels see a consistent "no booking" state. Per §4.2.
303
+ let compensated = 0;
304
+ if (failed > 0 && successList.length > 0) {
305
+ const policy = await resolveCompensationPolicy(db, links[0]?.channel.id ?? null);
306
+ if (policy === "strict-atomic") {
307
+ for (const entry of successList) {
308
+ const success = await compensateSucceededLink(db, entry, input.bookingId, logger);
309
+ if (success) {
310
+ compensated += 1;
311
+ // Update the existing outcome row to compensated.
312
+ for (const outcome of outcomes) {
313
+ if (outcome.channelId === entry.channel.id &&
314
+ outcome.bookingItemId === (entry.link.bookingItemId ?? null) &&
315
+ outcome.status === "ok") {
316
+ outcome.status = "compensated";
317
+ break;
318
+ }
319
+ }
320
+ }
321
+ }
322
+ if (compensated > 0) {
323
+ logger.warn?.(`processBookingPush: compensated ${compensated} succeeded link(s) under strict-atomic policy`, { bookingId: input.bookingId, compensated, failed });
324
+ }
325
+ }
326
+ }
327
+ return {
328
+ bookingId: input.bookingId,
329
+ attempted: links.length,
330
+ succeeded,
331
+ failed,
332
+ compensated,
333
+ outcomes,
334
+ };
335
+ }
@@ -0,0 +1,23 @@
1
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
2
+ export interface AvailabilityPushSlot {
3
+ id: string;
4
+ productId: string;
5
+ optionId: string | null;
6
+ startsAt: Date;
7
+ unlimited: boolean;
8
+ remainingPax: number | null;
9
+ updatedAt: Date;
10
+ }
11
+ export interface ContentPushProduct {
12
+ id: string;
13
+ name: string;
14
+ description: string | null;
15
+ }
16
+ export declare function loadAvailabilityPushSlot(db: AnyDrizzleDb, slotId: string): Promise<AvailabilityPushSlot | null>;
17
+ export declare function loadRecentlyUpdatedAvailabilityPushSlots(db: AnyDrizzleDb, input: {
18
+ updatedAfter: Date;
19
+ limit: number;
20
+ }): Promise<AvailabilityPushSlot[]>;
21
+ export declare function loadContentPushProduct(db: AnyDrizzleDb, productId: string): Promise<ContentPushProduct | null>;
22
+ export declare function loadContentPushProducts(db: AnyDrizzleDb, productIds: readonly string[]): Promise<Map<string, ContentPushProduct>>;
23
+ //# sourceMappingURL=boundary-sql.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"boundary-sql.d.ts","sourceRoot":"","sources":["../../src/channel-push/boundary-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAGrD,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,QAAQ,EAAE,IAAI,CAAA;IACd,SAAS,EAAE,OAAO,CAAA;IAClB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,SAAS,EAAE,IAAI,CAAA;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B;AAqDD,wBAAsB,wBAAwB,CAC5C,EAAE,EAAE,YAAY,EAChB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAYtC;AAED,wBAAsB,wCAAwC,CAC5D,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE;IAAE,YAAY,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC3C,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAajC;AAED,wBAAsB,sBAAsB,CAC1C,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAYpC;AAED,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,YAAY,EAChB,UAAU,EAAE,SAAS,MAAM,EAAE,GAC5B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAY1C"}