@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.
- package/LICENSE +201 -0
- package/README.md +42 -0
- package/dist/booking-extension.d.ts +168 -0
- package/dist/booking-extension.d.ts.map +1 -0
- package/dist/booking-extension.js +102 -0
- package/dist/channel-push/admin-routes.d.ts +31 -0
- package/dist/channel-push/admin-routes.d.ts.map +1 -0
- package/dist/channel-push/admin-routes.js +165 -0
- package/dist/channel-push/availability-push.d.ts +76 -0
- package/dist/channel-push/availability-push.d.ts.map +1 -0
- package/dist/channel-push/availability-push.js +236 -0
- package/dist/channel-push/booking-push-helpers.d.ts +36 -0
- package/dist/channel-push/booking-push-helpers.d.ts.map +1 -0
- package/dist/channel-push/booking-push-helpers.js +169 -0
- package/dist/channel-push/booking-push.d.ts +108 -0
- package/dist/channel-push/booking-push.d.ts.map +1 -0
- package/dist/channel-push/booking-push.js +335 -0
- package/dist/channel-push/boundary-sql.d.ts +23 -0
- package/dist/channel-push/boundary-sql.d.ts.map +1 -0
- package/dist/channel-push/boundary-sql.js +75 -0
- package/dist/channel-push/content-push.d.ts +60 -0
- package/dist/channel-push/content-push.d.ts.map +1 -0
- package/dist/channel-push/content-push.js +252 -0
- package/dist/channel-push/index.d.ts +15 -0
- package/dist/channel-push/index.d.ts.map +1 -0
- package/dist/channel-push/index.js +18 -0
- package/dist/channel-push/plugin.d.ts +18 -0
- package/dist/channel-push/plugin.d.ts.map +1 -0
- package/dist/channel-push/plugin.js +21 -0
- package/dist/channel-push/reconciler.d.ts +85 -0
- package/dist/channel-push/reconciler.d.ts.map +1 -0
- package/dist/channel-push/reconciler.js +179 -0
- package/dist/channel-push/subscriber.d.ts +40 -0
- package/dist/channel-push/subscriber.d.ts.map +1 -0
- package/dist/channel-push/subscriber.js +199 -0
- package/dist/channel-push/types.d.ts +43 -0
- package/dist/channel-push/types.d.ts.map +1 -0
- package/dist/channel-push/types.js +32 -0
- package/dist/channel-push/workflows.d.ts +56 -0
- package/dist/channel-push/workflows.d.ts.map +1 -0
- package/dist/channel-push/workflows.js +100 -0
- package/dist/external-refs/index.d.ts +11 -0
- package/dist/external-refs/index.d.ts.map +1 -0
- package/dist/external-refs/index.js +12 -0
- package/dist/external-refs/routes.d.ts +253 -0
- package/dist/external-refs/routes.d.ts.map +1 -0
- package/dist/external-refs/routes.js +52 -0
- package/dist/external-refs/schema.d.ts +251 -0
- package/dist/external-refs/schema.d.ts.map +1 -0
- package/dist/external-refs/schema.js +32 -0
- package/dist/external-refs/service.d.ts +82 -0
- package/dist/external-refs/service.d.ts.map +1 -0
- package/dist/external-refs/service.js +112 -0
- package/dist/external-refs/validation.d.ts +91 -0
- package/dist/external-refs/validation.d.ts.map +1 -0
- package/dist/external-refs/validation.js +40 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/interface-types.d.ts +128 -0
- package/dist/interface-types.d.ts.map +1 -0
- package/dist/interface-types.js +1 -0
- package/dist/interface.d.ts +10 -0
- package/dist/interface.d.ts.map +1 -0
- package/dist/interface.js +286 -0
- package/dist/rate-limit.d.ts +69 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +135 -0
- package/dist/routes/batch.d.ts +200 -0
- package/dist/routes/batch.d.ts.map +1 -0
- package/dist/routes/batch.js +52 -0
- package/dist/routes/env.d.ts +8 -0
- package/dist/routes/env.d.ts.map +1 -0
- package/dist/routes/env.js +1 -0
- package/dist/routes/inventory.d.ts +604 -0
- package/dist/routes/inventory.d.ts.map +1 -0
- package/dist/routes/inventory.js +138 -0
- package/dist/routes/settlements.d.ts +1649 -0
- package/dist/routes/settlements.d.ts.map +1 -0
- package/dist/routes/settlements.js +265 -0
- package/dist/routes.d.ts +3909 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +323 -0
- package/dist/schema-automation.d.ts +680 -0
- package/dist/schema-automation.d.ts.map +1 -0
- package/dist/schema-automation.js +76 -0
- package/dist/schema-core.d.ts +1674 -0
- package/dist/schema-core.d.ts.map +1 -0
- package/dist/schema-core.js +227 -0
- package/dist/schema-finance.d.ts +1372 -0
- package/dist/schema-finance.d.ts.map +1 -0
- package/dist/schema-finance.js +153 -0
- package/dist/schema-inventory.d.ts +855 -0
- package/dist/schema-inventory.d.ts.map +1 -0
- package/dist/schema-inventory.js +102 -0
- package/dist/schema-push-intents.d.ts +387 -0
- package/dist/schema-push-intents.d.ts.map +1 -0
- package/dist/schema-push-intents.js +77 -0
- package/dist/schema-relations.d.ts +95 -0
- package/dist/schema-relations.d.ts.map +1 -0
- package/dist/schema-relations.js +196 -0
- package/dist/schema-shared.d.ts +24 -0
- package/dist/schema-shared.d.ts.map +1 -0
- package/dist/schema-shared.js +123 -0
- package/dist/schema.d.ts +9 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +8 -0
- package/dist/service/channels.d.ts +167 -0
- package/dist/service/channels.d.ts.map +1 -0
- package/dist/service/channels.js +305 -0
- package/dist/service/commercial.d.ts +385 -0
- package/dist/service/commercial.d.ts.map +1 -0
- package/dist/service/commercial.js +248 -0
- package/dist/service/helpers.d.ts +10 -0
- package/dist/service/helpers.d.ts.map +1 -0
- package/dist/service/helpers.js +7 -0
- package/dist/service/inventory.d.ts +193 -0
- package/dist/service/inventory.d.ts.map +1 -0
- package/dist/service/inventory.js +154 -0
- package/dist/service/settlement-policies.d.ts +325 -0
- package/dist/service/settlement-policies.d.ts.map +1 -0
- package/dist/service/settlement-policies.js +272 -0
- package/dist/service/settlements.d.ts +357 -0
- package/dist/service/settlements.d.ts.map +1 -0
- package/dist/service/settlements.js +319 -0
- package/dist/service/types.d.ts +60 -0
- package/dist/service/types.d.ts.map +1 -0
- package/dist/service/types.js +1 -0
- package/dist/service.d.ts +1418 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +17 -0
- package/dist/suppliers/index.d.ts +15 -0
- package/dist/suppliers/index.d.ts.map +1 -0
- package/dist/suppliers/index.js +23 -0
- package/dist/suppliers/routes.d.ts +1202 -0
- package/dist/suppliers/routes.d.ts.map +1 -0
- package/dist/suppliers/routes.js +290 -0
- package/dist/suppliers/schema.d.ts +1272 -0
- package/dist/suppliers/schema.d.ts.map +1 -0
- package/dist/suppliers/schema.js +219 -0
- package/dist/suppliers/service-aggregates.d.ts +23 -0
- package/dist/suppliers/service-aggregates.d.ts.map +1 -0
- package/dist/suppliers/service-aggregates.js +51 -0
- package/dist/suppliers/service-core.d.ts +89 -0
- package/dist/suppliers/service-core.d.ts.map +1 -0
- package/dist/suppliers/service-core.js +164 -0
- package/dist/suppliers/service-identity.d.ts +162 -0
- package/dist/suppliers/service-identity.d.ts.map +1 -0
- package/dist/suppliers/service-identity.js +101 -0
- package/dist/suppliers/service-operations.d.ts +1500 -0
- package/dist/suppliers/service-operations.d.ts.map +1 -0
- package/dist/suppliers/service-operations.js +157 -0
- package/dist/suppliers/service-shared.d.ts +45 -0
- package/dist/suppliers/service-shared.d.ts.map +1 -0
- package/dist/suppliers/service-shared.js +294 -0
- package/dist/suppliers/service.d.ts +41 -0
- package/dist/suppliers/service.d.ts.map +1 -0
- package/dist/suppliers/service.js +40 -0
- package/dist/suppliers/validation.d.ts +2 -0
- package/dist/suppliers/validation.d.ts.map +1 -0
- package/dist/suppliers/validation.js +1 -0
- package/dist/validation.d.ts +1371 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +445 -0
- package/dist/webhook-deliveries.d.ts +86 -0
- package/dist/webhook-deliveries.d.ts.map +1 -0
- package/dist/webhook-deliveries.js +296 -0
- package/package.json +71 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-push reconciler — self-healing catch-up for divergence.
|
|
3
|
+
*
|
|
4
|
+
* Eventually-consistent push works while the channel is reachable most
|
|
5
|
+
* of the time. After a long outage (or when an integration is first
|
|
6
|
+
* turned on for a channel that already has local state), the channel's
|
|
7
|
+
* view of our inventory diverges from ours. The reconciler closes that
|
|
8
|
+
* gap by re-reading current state from owned tables and recreating
|
|
9
|
+
* intent rows for divergent ones — same intent + worker pipeline, not
|
|
10
|
+
* a parallel push path.
|
|
11
|
+
*
|
|
12
|
+
* v1 cadences (per §13.2, tunable per channel):
|
|
13
|
+
* - Booking-link reconciler: every 15 min
|
|
14
|
+
* - Availability reconciler: hourly
|
|
15
|
+
* - Content reconciler: nightly
|
|
16
|
+
*
|
|
17
|
+
* Templates schedule these via cron / the workflows runtime; the
|
|
18
|
+
* functions themselves are plain async so they're testable.
|
|
19
|
+
*
|
|
20
|
+
* Per docs/architecture/channel-push-architecture.md §13.
|
|
21
|
+
*/
|
|
22
|
+
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
|
23
|
+
import { channelBookingLinks, channelInventoryAllotments, channelProductMappings, channels, } from "../schema.js";
|
|
24
|
+
import { upsertAvailabilityIntent } from "./availability-push.js";
|
|
25
|
+
import { processBookingPush } from "./booking-push.js";
|
|
26
|
+
import { loadContentPushProducts, loadRecentlyUpdatedAvailabilityPushSlots, } from "./boundary-sql.js";
|
|
27
|
+
import { canonicalHash, upsertContentIntent } from "./content-push.js";
|
|
28
|
+
import { defaultLogger, getChannelPushDepsOrThrow } from "./types.js";
|
|
29
|
+
/**
|
|
30
|
+
* Walk `channel_booking_links` where push_status != 'ok' and reissue
|
|
31
|
+
* via `processBookingPush`. The processor is idempotent on
|
|
32
|
+
* `idempotency_key`, so reissuing succeeded-then-edited links never
|
|
33
|
+
* doubles upstream.
|
|
34
|
+
*
|
|
35
|
+
* Per §13.1.
|
|
36
|
+
*/
|
|
37
|
+
export async function reconcileBookingLinks(options = {}, deps) {
|
|
38
|
+
const { db, logger = defaultLogger } = deps ?? getChannelPushDepsOrThrow();
|
|
39
|
+
const staleAfter = new Date(Date.now() - (options.staleAfterMs ?? 15 * 60 * 1000));
|
|
40
|
+
const limit = options.limit ?? 200;
|
|
41
|
+
const stale = (await db
|
|
42
|
+
.select({
|
|
43
|
+
bookingId: channelBookingLinks.bookingId,
|
|
44
|
+
})
|
|
45
|
+
.from(channelBookingLinks)
|
|
46
|
+
.where(and(
|
|
47
|
+
// agent-quality: raw-sql reviewed -- owner: distribution; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
48
|
+
sql `${channelBookingLinks.pushStatus} <> 'ok'`,
|
|
49
|
+
// agent-quality: raw-sql reviewed -- owner: distribution; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
50
|
+
sql `(${channelBookingLinks.lastPushAt} IS NULL OR ${channelBookingLinks.lastPushAt} < ${staleAfter})`, options.channelId ? eq(channelBookingLinks.channelId, options.channelId) : sql `true`))
|
|
51
|
+
.orderBy(asc(channelBookingLinks.lastPushAt))
|
|
52
|
+
.limit(limit));
|
|
53
|
+
if (stale.length === 0)
|
|
54
|
+
return { scanned: 0, triggered: 0 };
|
|
55
|
+
const uniqueBookings = Array.from(new Set(stale.map((r) => r.bookingId)));
|
|
56
|
+
let triggered = 0;
|
|
57
|
+
for (const bookingId of uniqueBookings) {
|
|
58
|
+
try {
|
|
59
|
+
const result = await processBookingPush({ bookingId }, deps);
|
|
60
|
+
if (result.attempted > 0)
|
|
61
|
+
triggered += 1;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
logger.error?.(`reconcileBookingLinks: processBookingPush failed for ${bookingId}`, {
|
|
65
|
+
error: err instanceof Error ? err.message : String(err),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { scanned: stale.length, triggered };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Walk recently-updated slots and ensure an intent row exists per
|
|
73
|
+
* (channel, slot) where the channel holds an active allotment. The
|
|
74
|
+
* worker's existing UNIQUE constraint collapses duplicates, so this is
|
|
75
|
+
* safe to re-run.
|
|
76
|
+
*
|
|
77
|
+
* Per §13.1 (availability reconciler).
|
|
78
|
+
*/
|
|
79
|
+
export async function reconcileAvailability(options = {}, deps) {
|
|
80
|
+
const { db } = deps ?? getChannelPushDepsOrThrow();
|
|
81
|
+
const lookback = new Date(Date.now() - (options.lookbackMs ?? 60 * 60 * 1000));
|
|
82
|
+
const limit = options.limit ?? 500;
|
|
83
|
+
const slots = await loadRecentlyUpdatedAvailabilityPushSlots(db, {
|
|
84
|
+
updatedAfter: lookback,
|
|
85
|
+
limit,
|
|
86
|
+
});
|
|
87
|
+
if (slots.length === 0)
|
|
88
|
+
return { scanned: 0, triggered: 0 };
|
|
89
|
+
// For each slot, find channels with active allotments + matching
|
|
90
|
+
// product mappings, and upsert an intent row.
|
|
91
|
+
let triggered = 0;
|
|
92
|
+
const productIds = Array.from(new Set(slots.map((s) => s.productId)));
|
|
93
|
+
const allotments = (await db
|
|
94
|
+
.select({
|
|
95
|
+
allotment: channelInventoryAllotments,
|
|
96
|
+
mapping: channelProductMappings,
|
|
97
|
+
})
|
|
98
|
+
.from(channelInventoryAllotments)
|
|
99
|
+
.innerJoin(channelProductMappings, and(eq(channelProductMappings.channelId, channelInventoryAllotments.channelId), eq(channelProductMappings.productId, channelInventoryAllotments.productId)))
|
|
100
|
+
.innerJoin(channels, eq(channelInventoryAllotments.channelId, channels.id))
|
|
101
|
+
.where(and(inArray(channelInventoryAllotments.productId, productIds), eq(channelInventoryAllotments.active, true), eq(channelProductMappings.active, true), eq(channelProductMappings.pushAvailability, true), eq(channels.status, "active"), options.channelId ? eq(channelInventoryAllotments.channelId, options.channelId) : sql `true`)));
|
|
102
|
+
for (const slot of slots) {
|
|
103
|
+
for (const row of allotments) {
|
|
104
|
+
if (row.allotment.productId !== slot.productId)
|
|
105
|
+
continue;
|
|
106
|
+
// Allotment may be option-scoped; null option_id matches all.
|
|
107
|
+
if (row.allotment.optionId && row.allotment.optionId !== slot.optionId)
|
|
108
|
+
continue;
|
|
109
|
+
if (!row.mapping.sourceConnectionId)
|
|
110
|
+
continue;
|
|
111
|
+
await upsertAvailabilityIntent(db, {
|
|
112
|
+
channelId: row.mapping.channelId,
|
|
113
|
+
sourceConnectionId: row.mapping.sourceConnectionId,
|
|
114
|
+
slotId: slot.id,
|
|
115
|
+
productId: slot.productId,
|
|
116
|
+
optionId: slot.optionId ?? null,
|
|
117
|
+
startsAt: slot.startsAt,
|
|
118
|
+
});
|
|
119
|
+
triggered += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { scanned: slots.length, triggered };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Walk syndicated products, hash current content, and recreate an
|
|
126
|
+
* intent row for every (channel, product) where the upstream's
|
|
127
|
+
* `last_pushed_content_hash` doesn't match. Per §13.1 (content
|
|
128
|
+
* reconciler) — content drift converges nightly.
|
|
129
|
+
*/
|
|
130
|
+
export async function reconcileContent(options = {}, deps) {
|
|
131
|
+
const { db } = deps ?? getChannelPushDepsOrThrow();
|
|
132
|
+
const limit = options.limit ?? 200;
|
|
133
|
+
const mappings = (await db
|
|
134
|
+
.select({
|
|
135
|
+
mapping: channelProductMappings,
|
|
136
|
+
})
|
|
137
|
+
.from(channelProductMappings)
|
|
138
|
+
.innerJoin(channels, eq(channelProductMappings.channelId, channels.id))
|
|
139
|
+
.where(and(eq(channelProductMappings.active, true), eq(channelProductMappings.pushContent, true), eq(channels.status, "active"), options.channelId ? eq(channelProductMappings.channelId, options.channelId) : sql `true`))
|
|
140
|
+
.limit(limit));
|
|
141
|
+
const productIds = Array.from(new Set(mappings.map((row) => row.mapping.productId)));
|
|
142
|
+
const products = await loadContentPushProducts(db, productIds);
|
|
143
|
+
let triggered = 0;
|
|
144
|
+
for (const { mapping } of mappings) {
|
|
145
|
+
if (!mapping.sourceConnectionId)
|
|
146
|
+
continue;
|
|
147
|
+
const product = products.get(mapping.productId);
|
|
148
|
+
if (!product)
|
|
149
|
+
continue;
|
|
150
|
+
const minimalContent = {
|
|
151
|
+
id: product.id,
|
|
152
|
+
name: product.name,
|
|
153
|
+
description: product.description ?? null,
|
|
154
|
+
};
|
|
155
|
+
const currentHash = canonicalHash(minimalContent);
|
|
156
|
+
if (mapping.lastPushedContentHash === currentHash)
|
|
157
|
+
continue;
|
|
158
|
+
await upsertContentIntent(db, {
|
|
159
|
+
channelId: mapping.channelId,
|
|
160
|
+
sourceConnectionId: mapping.sourceConnectionId,
|
|
161
|
+
productId: mapping.productId,
|
|
162
|
+
});
|
|
163
|
+
triggered += 1;
|
|
164
|
+
}
|
|
165
|
+
return { scanned: mappings.length, triggered };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Convenience: run all three reconcilers with default cadences.
|
|
169
|
+
* Templates can call this from a single nightly cron, or schedule
|
|
170
|
+
* each independently for finer control.
|
|
171
|
+
*/
|
|
172
|
+
export async function runAllReconcilers(deps) {
|
|
173
|
+
const [bookingsResult, availability, content] = await Promise.all([
|
|
174
|
+
reconcileBookingLinks({}, deps),
|
|
175
|
+
reconcileAvailability({}, deps),
|
|
176
|
+
reconcileContent({}, deps),
|
|
177
|
+
]);
|
|
178
|
+
return { bookings: bookingsResult, availability, content };
|
|
179
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-push EventBus subscribers.
|
|
3
|
+
*
|
|
4
|
+
* The `booking.confirmed` subscriber writes durable intent rows to
|
|
5
|
+
* `channel_booking_links` and returns immediately — per §4.5 the
|
|
6
|
+
* subscriber MUST NOT do HTTP work because the EventBus is in-process
|
|
7
|
+
* and sequential. The intent rows are drained by `processBookingPush`
|
|
8
|
+
* (called inline in v1, or by the durable workflow in production).
|
|
9
|
+
*
|
|
10
|
+
* Per docs/architecture/channel-push-architecture.md §4.1.
|
|
11
|
+
*/
|
|
12
|
+
import type { Subscriber } from "@voyant-travel/core";
|
|
13
|
+
import { type ChannelPushDeps } from "./types.js";
|
|
14
|
+
export interface ChannelPushSubscribersOptions {
|
|
15
|
+
/**
|
|
16
|
+
* When `true` (default), the subscriber drains intent rows inline
|
|
17
|
+
* after writing them. Useful for dev / single-process templates.
|
|
18
|
+
* Production deployments with the workflow runtime wired set this
|
|
19
|
+
* to `false` and rely on `channel.booking.push` to drain.
|
|
20
|
+
*/
|
|
21
|
+
drainInline?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Optional explicit deps. Falls back to `getChannelPushDeps()`.
|
|
24
|
+
* Tests pass deps directly so they don't have to wire the global.
|
|
25
|
+
*/
|
|
26
|
+
deps?: ChannelPushDeps;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Construct the channel-push subscriber bundle. Templates pass these to
|
|
30
|
+
* `registerPlugins({ subscribers })` or attach them to an EventBus
|
|
31
|
+
* directly via `eventBus.subscribe`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createChannelPushSubscribers(options?: ChannelPushSubscribersOptions): Subscriber[];
|
|
34
|
+
/**
|
|
35
|
+
* Trigger the booking-push pipeline for an arbitrary booking id —
|
|
36
|
+
* useful for the operator dashboard's "retry sync" button and for the
|
|
37
|
+
* reconciler (Phase G).
|
|
38
|
+
*/
|
|
39
|
+
export declare function triggerBookingPushForBooking(bookingId: string): Promise<void>;
|
|
40
|
+
//# sourceMappingURL=subscriber.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscriber.d.ts","sourceRoot":"","sources":["../../src/channel-push/subscriber.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAiB,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAiBpE,OAAO,EACL,KAAK,eAAe,EAIrB,MAAM,YAAY,CAAA;AAwEnB,MAAM,WAAW,6BAA6B;IAC5C;;;;;OAKG;IACH,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB;;;OAGG;IACH,IAAI,CAAC,EAAE,eAAe,CAAA;CACvB;AAED;;;;GAIG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,GAAE,6BAAkC,GAC1C,UAAU,EAAE,CAwHd;AAED;;;;GAIG;AACH,wBAAsB,4BAA4B,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAMnF"}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-push EventBus subscribers.
|
|
3
|
+
*
|
|
4
|
+
* The `booking.confirmed` subscriber writes durable intent rows to
|
|
5
|
+
* `channel_booking_links` and returns immediately — per §4.5 the
|
|
6
|
+
* subscriber MUST NOT do HTTP work because the EventBus is in-process
|
|
7
|
+
* and sequential. The intent rows are drained by `processBookingPush`
|
|
8
|
+
* (called inline in v1, or by the durable workflow in production).
|
|
9
|
+
*
|
|
10
|
+
* Per docs/architecture/channel-push-architecture.md §4.1.
|
|
11
|
+
*/
|
|
12
|
+
import { processAvailabilityPushIntents, resolveAllotmentTargetsForSlot, upsertAvailabilityIntent, } from "./availability-push.js";
|
|
13
|
+
import { processBookingPush, resolveBookingPushTargets, upsertPendingBookingLinks, } from "./booking-push.js";
|
|
14
|
+
import { processContentPushIntents, resolveContentPushTargets, upsertContentIntent, } from "./content-push.js";
|
|
15
|
+
import { defaultLogger, getChannelPushDeps, getChannelPushDepsOrThrow, } from "./types.js";
|
|
16
|
+
function coerceBookingConfirmed(envelope) {
|
|
17
|
+
const data = envelope.data;
|
|
18
|
+
if (data == null || typeof data !== "object")
|
|
19
|
+
return null;
|
|
20
|
+
const maybe = data;
|
|
21
|
+
if (typeof maybe.bookingId !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
return {
|
|
24
|
+
bookingId: maybe.bookingId,
|
|
25
|
+
...(typeof maybe.bookingNumber === "string" ? { bookingNumber: maybe.bookingNumber } : {}),
|
|
26
|
+
...(typeof maybe.actorId === "string" || maybe.actorId === null
|
|
27
|
+
? { actorId: maybe.actorId }
|
|
28
|
+
: {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function coerceSlotChanged(envelope) {
|
|
32
|
+
const data = envelope.data;
|
|
33
|
+
if (data == null || typeof data !== "object")
|
|
34
|
+
return null;
|
|
35
|
+
const maybe = data;
|
|
36
|
+
if (typeof maybe.slotId !== "string" || typeof maybe.productId !== "string")
|
|
37
|
+
return null;
|
|
38
|
+
if (typeof maybe.optionId !== "string" && maybe.optionId !== null)
|
|
39
|
+
return null;
|
|
40
|
+
if (!(maybe.startsAt instanceof Date) && typeof maybe.startsAt !== "string")
|
|
41
|
+
return null;
|
|
42
|
+
if (typeof maybe.remainingPax !== "number" && maybe.remainingPax !== null)
|
|
43
|
+
return null;
|
|
44
|
+
if (typeof maybe.unlimited !== "boolean" || typeof maybe.source !== "string")
|
|
45
|
+
return null;
|
|
46
|
+
return {
|
|
47
|
+
slotId: maybe.slotId,
|
|
48
|
+
productId: maybe.productId,
|
|
49
|
+
optionId: maybe.optionId,
|
|
50
|
+
startsAt: maybe.startsAt,
|
|
51
|
+
remainingPax: maybe.remainingPax,
|
|
52
|
+
unlimited: maybe.unlimited,
|
|
53
|
+
source: maybe.source,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function coerceContentChanged(envelope) {
|
|
57
|
+
const data = envelope.data;
|
|
58
|
+
if (data == null || typeof data !== "object")
|
|
59
|
+
return null;
|
|
60
|
+
const maybe = data;
|
|
61
|
+
if (typeof maybe.id !== "string")
|
|
62
|
+
return null;
|
|
63
|
+
return {
|
|
64
|
+
id: maybe.id,
|
|
65
|
+
...(typeof maybe.axis === "string" ? { axis: maybe.axis } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Construct the channel-push subscriber bundle. Templates pass these to
|
|
70
|
+
* `registerPlugins({ subscribers })` or attach them to an EventBus
|
|
71
|
+
* directly via `eventBus.subscribe`.
|
|
72
|
+
*/
|
|
73
|
+
export function createChannelPushSubscribers(options = {}) {
|
|
74
|
+
const drainInline = options.drainInline ?? true;
|
|
75
|
+
const handler = async (envelope) => {
|
|
76
|
+
const payload = coerceBookingConfirmed(envelope);
|
|
77
|
+
if (!payload)
|
|
78
|
+
return;
|
|
79
|
+
const deps = options.deps ?? getChannelPushDeps();
|
|
80
|
+
if (!deps) {
|
|
81
|
+
// Templates haven't wired channel-push — silent no-op so the rest
|
|
82
|
+
// of the booking flow isn't impacted by missing wiring.
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const logger = deps.logger ?? defaultLogger;
|
|
86
|
+
try {
|
|
87
|
+
const targets = await resolveBookingPushTargets(deps.db, payload.bookingId);
|
|
88
|
+
if (targets.length === 0)
|
|
89
|
+
return;
|
|
90
|
+
await upsertPendingBookingLinks(deps.db, payload.bookingId, targets);
|
|
91
|
+
if (drainInline) {
|
|
92
|
+
// Inline drain — simple deployments without the workflow runtime
|
|
93
|
+
// wired (dev templates, demo mode). Production uses the workflow
|
|
94
|
+
// and skips this branch.
|
|
95
|
+
await processBookingPush({ bookingId: payload.bookingId }, deps);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
logger.error("[channel-push] booking.confirmed subscriber failed", {
|
|
100
|
+
bookingId: payload.bookingId,
|
|
101
|
+
error: err instanceof Error ? err.message : String(err),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const slotHandler = async (envelope) => {
|
|
106
|
+
const payload = coerceSlotChanged(envelope);
|
|
107
|
+
if (!payload)
|
|
108
|
+
return;
|
|
109
|
+
const deps = options.deps ?? getChannelPushDeps();
|
|
110
|
+
if (!deps)
|
|
111
|
+
return;
|
|
112
|
+
const logger = deps.logger ?? defaultLogger;
|
|
113
|
+
try {
|
|
114
|
+
const targets = await resolveAllotmentTargetsForSlot(deps.db, {
|
|
115
|
+
slotId: payload.slotId,
|
|
116
|
+
productId: payload.productId,
|
|
117
|
+
optionId: payload.optionId,
|
|
118
|
+
});
|
|
119
|
+
if (targets.length === 0)
|
|
120
|
+
return;
|
|
121
|
+
const startsAt = payload.startsAt instanceof Date ? payload.startsAt : new Date(payload.startsAt);
|
|
122
|
+
// One intent per (channel, slot) pair — supersession collapses
|
|
123
|
+
// concurrent events.
|
|
124
|
+
for (const target of targets) {
|
|
125
|
+
await upsertAvailabilityIntent(deps.db, {
|
|
126
|
+
channelId: target.channelId,
|
|
127
|
+
sourceConnectionId: target.sourceConnectionId,
|
|
128
|
+
slotId: payload.slotId,
|
|
129
|
+
productId: payload.productId,
|
|
130
|
+
optionId: payload.optionId,
|
|
131
|
+
startsAt,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (drainInline) {
|
|
135
|
+
// Drain only this channel's intents to keep latency bounded.
|
|
136
|
+
// (v1 dev behavior; production runs the scheduled workflow.)
|
|
137
|
+
for (const target of targets) {
|
|
138
|
+
await processAvailabilityPushIntents({ channelId: target.channelId, limit: 50 }, deps);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
logger.error("[channel-push] availability.slot.changed subscriber failed", {
|
|
144
|
+
slotId: payload.slotId,
|
|
145
|
+
error: err instanceof Error ? err.message : String(err),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const contentHandler = async (envelope) => {
|
|
150
|
+
const payload = coerceContentChanged(envelope);
|
|
151
|
+
if (!payload)
|
|
152
|
+
return;
|
|
153
|
+
const deps = options.deps ?? getChannelPushDeps();
|
|
154
|
+
if (!deps)
|
|
155
|
+
return;
|
|
156
|
+
const logger = deps.logger ?? defaultLogger;
|
|
157
|
+
try {
|
|
158
|
+
const targets = await resolveContentPushTargets(deps.db, payload.id);
|
|
159
|
+
if (targets.length === 0)
|
|
160
|
+
return;
|
|
161
|
+
for (const target of targets) {
|
|
162
|
+
await upsertContentIntent(deps.db, {
|
|
163
|
+
channelId: target.channelId,
|
|
164
|
+
sourceConnectionId: target.sourceConnectionId,
|
|
165
|
+
productId: payload.id,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (drainInline) {
|
|
169
|
+
for (const target of targets) {
|
|
170
|
+
await processContentPushIntents({ channelId: target.channelId, limit: 50 }, deps);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
logger.error("[channel-push] product.content.changed subscriber failed", {
|
|
176
|
+
productId: payload.id,
|
|
177
|
+
error: err instanceof Error ? err.message : String(err),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
return [
|
|
182
|
+
{ event: "booking.confirmed", handler },
|
|
183
|
+
{ event: "availability.slot.changed", handler: slotHandler },
|
|
184
|
+
{ event: "product.content.changed", handler: contentHandler },
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Trigger the booking-push pipeline for an arbitrary booking id —
|
|
189
|
+
* useful for the operator dashboard's "retry sync" button and for the
|
|
190
|
+
* reconciler (Phase G).
|
|
191
|
+
*/
|
|
192
|
+
export async function triggerBookingPushForBooking(bookingId) {
|
|
193
|
+
const deps = getChannelPushDepsOrThrow();
|
|
194
|
+
const targets = await resolveBookingPushTargets(deps.db, bookingId);
|
|
195
|
+
if (targets.length === 0)
|
|
196
|
+
return;
|
|
197
|
+
await upsertPendingBookingLinks(deps.db, bookingId, targets);
|
|
198
|
+
await processBookingPush({ bookingId }, deps);
|
|
199
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the channel-push pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Per docs/architecture/channel-push-architecture.md §4-§6.
|
|
5
|
+
*/
|
|
6
|
+
import type { SourceAdapterRegistry } from "@voyant-travel/catalog/booking-engine";
|
|
7
|
+
import type { AnyDrizzleDb } from "@voyant-travel/db";
|
|
8
|
+
/**
|
|
9
|
+
* Process-local dependencies the channel-push workers and subscribers
|
|
10
|
+
* need. Templates wire this once at startup via `setChannelPushDeps`;
|
|
11
|
+
* callers retrieve via `getChannelPushDeps`.
|
|
12
|
+
*
|
|
13
|
+
* The runtime indirection lets workflows (which can't take closures in
|
|
14
|
+
* their durable input) and EventBus subscribers (which run in the
|
|
15
|
+
* emitter's process) share the same wiring without each consumer
|
|
16
|
+
* re-resolving services. Per §4.5 — subscribers stay write-only and
|
|
17
|
+
* delegate HTTP work to the workflow, which reaches into these deps.
|
|
18
|
+
*/
|
|
19
|
+
export interface ChannelPushDeps {
|
|
20
|
+
db: AnyDrizzleDb;
|
|
21
|
+
registry: SourceAdapterRegistry;
|
|
22
|
+
/**
|
|
23
|
+
* Optional logger. Defaults to a console fallback. Subscribers and
|
|
24
|
+
* workers log to this when they swallow errors per the EventBus
|
|
25
|
+
* fire-and-forget contract.
|
|
26
|
+
*/
|
|
27
|
+
logger?: ChannelPushLogger;
|
|
28
|
+
}
|
|
29
|
+
export interface ChannelPushLogger {
|
|
30
|
+
info?: (message: string, meta?: Record<string, unknown>) => void;
|
|
31
|
+
warn?: (message: string, meta?: Record<string, unknown>) => void;
|
|
32
|
+
error: (message: string, meta?: Record<string, unknown>) => void;
|
|
33
|
+
}
|
|
34
|
+
/** Wire the channel-push pipeline at process start. Idempotent. */
|
|
35
|
+
export declare function setChannelPushDeps(deps: ChannelPushDeps): void;
|
|
36
|
+
/** Retrieve the wired deps, throwing if templates haven't wired them. */
|
|
37
|
+
export declare function getChannelPushDepsOrThrow(): ChannelPushDeps;
|
|
38
|
+
/** Retrieve the wired deps, returning undefined when none are wired. */
|
|
39
|
+
export declare function getChannelPushDeps(): ChannelPushDeps | undefined;
|
|
40
|
+
/** Reset (test helper). */
|
|
41
|
+
export declare function clearChannelPushDeps(): void;
|
|
42
|
+
export declare const defaultLogger: ChannelPushLogger;
|
|
43
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/channel-push/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAA;AAClF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD;;;;;;;;;;GAUG;AACH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,YAAY,CAAA;IAChB,QAAQ,EAAE,qBAAqB,CAAA;IAC/B;;;;OAIG;IACH,MAAM,CAAC,EAAE,iBAAiB,CAAA;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IAChE,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IAChE,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;CACjE;AAUD,mEAAmE;AACnE,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI,CAE9D;AAED,yEAAyE;AACzE,wBAAgB,yBAAyB,IAAI,eAAe,CAQ3D;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,IAAI,eAAe,GAAG,SAAS,CAEhE;AAED,2BAA2B;AAC3B,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C;AAED,eAAO,MAAM,aAAa,EAAE,iBAI3B,CAAA"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the channel-push pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Per docs/architecture/channel-push-architecture.md §4-§6.
|
|
5
|
+
*/
|
|
6
|
+
const DEPS_KEY = Symbol.for("voyant.distribution.channel-push.deps");
|
|
7
|
+
const globalRef = globalThis;
|
|
8
|
+
/** Wire the channel-push pipeline at process start. Idempotent. */
|
|
9
|
+
export function setChannelPushDeps(deps) {
|
|
10
|
+
globalRef[DEPS_KEY] = deps;
|
|
11
|
+
}
|
|
12
|
+
/** Retrieve the wired deps, throwing if templates haven't wired them. */
|
|
13
|
+
export function getChannelPushDepsOrThrow() {
|
|
14
|
+
const deps = globalRef[DEPS_KEY];
|
|
15
|
+
if (!deps) {
|
|
16
|
+
throw new Error("channel-push deps not wired — call setChannelPushDeps({ db, registry }) at process start");
|
|
17
|
+
}
|
|
18
|
+
return deps;
|
|
19
|
+
}
|
|
20
|
+
/** Retrieve the wired deps, returning undefined when none are wired. */
|
|
21
|
+
export function getChannelPushDeps() {
|
|
22
|
+
return globalRef[DEPS_KEY];
|
|
23
|
+
}
|
|
24
|
+
/** Reset (test helper). */
|
|
25
|
+
export function clearChannelPushDeps() {
|
|
26
|
+
delete globalRef[DEPS_KEY];
|
|
27
|
+
}
|
|
28
|
+
export const defaultLogger = {
|
|
29
|
+
info: (message, meta) => console.log(`[channel-push] ${message}`, meta ?? ""),
|
|
30
|
+
warn: (message, meta) => console.warn(`[channel-push] ${message}`, meta ?? ""),
|
|
31
|
+
error: (message, meta) => console.error(`[channel-push] ${message}`, meta ?? ""),
|
|
32
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable channel-push workflows.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the inline-callable processors (`processBookingPush`,
|
|
5
|
+
* `processAvailabilityPushIntents`, `processContentPushIntents`) in
|
|
6
|
+
* `@voyant-travel/workflows` definitions so retries, sleeps, and resumption
|
|
7
|
+
* survive worker restarts. Importing this module registers the
|
|
8
|
+
* workflows in the global registry — Voyant Cloud orchestrator picks
|
|
9
|
+
* them up automatically.
|
|
10
|
+
*
|
|
11
|
+
* The workflow bodies look up `ChannelPushDeps` from the process-local
|
|
12
|
+
* holder set via `setChannelPushDeps`. Hosts wire deps at bootstrap;
|
|
13
|
+
* the orchestrator's Node container reads from the same global since
|
|
14
|
+
* it runs in the same isolate that loaded the user bundle.
|
|
15
|
+
*
|
|
16
|
+
* Dev / single-process deployments (e.g. the operator starter's
|
|
17
|
+
* inline drain) don't need to register these — the subscriber calls the
|
|
18
|
+
* processors directly. Production deployments with the Voyant Cloud
|
|
19
|
+
* orchestrator wired import this module to opt into durability.
|
|
20
|
+
*
|
|
21
|
+
* Per docs/architecture/channel-push-architecture.md §4.2 + §12.
|
|
22
|
+
*/
|
|
23
|
+
import { type ProcessAvailabilityPushInput, type ProcessAvailabilityPushResult } from "./availability-push.js";
|
|
24
|
+
import { type ProcessBookingPushInput, type ProcessBookingPushResult } from "./booking-push.js";
|
|
25
|
+
import { type ProcessContentPushInput, type ProcessContentPushResult } from "./content-push.js";
|
|
26
|
+
/**
|
|
27
|
+
* Per-booking saga workflow with compensation support.
|
|
28
|
+
*
|
|
29
|
+
* Concurrency is keyed by `bookingId` so two confirms of the same
|
|
30
|
+
* booking serialize (which can't actually happen given the booking
|
|
31
|
+
* state machine, but the perKey lock is cheap insurance). Retries on
|
|
32
|
+
* exponential backoff up to 5 attempts; the per-link compensation pass
|
|
33
|
+
* inside `processBookingPush` handles strict-atomic policy when
|
|
34
|
+
* `channel_contracts.policy.compensation = "strict-atomic"`.
|
|
35
|
+
*
|
|
36
|
+
* Per §4.2 + §12.1.
|
|
37
|
+
*/
|
|
38
|
+
export declare const channelBookingPushWorkflow: import("@voyant-travel/workflows").WorkflowDefinition<ProcessBookingPushInput, ProcessBookingPushResult>;
|
|
39
|
+
/**
|
|
40
|
+
* Scheduled batch worker for availability push. Runs every 30 seconds
|
|
41
|
+
* (tunable per channel via policy in a future iteration); each tick
|
|
42
|
+
* drains up to 100 pending intents, capped at one concurrent run per
|
|
43
|
+
* channel. Idempotency is upstream-side via `(slot_id, remaining_pax)`.
|
|
44
|
+
*
|
|
45
|
+
* Per §5.3 + §12.2.
|
|
46
|
+
*/
|
|
47
|
+
export declare const channelAvailabilityPushWorkflow: import("@voyant-travel/workflows").WorkflowDefinition<ProcessAvailabilityPushInput, ProcessAvailabilityPushResult>;
|
|
48
|
+
/**
|
|
49
|
+
* Scheduled batch worker for content push. Longer cadence (5m) since
|
|
50
|
+
* content drift is rarely time-critical; idempotency via the upstream's
|
|
51
|
+
* acknowledged-hash skip in `processContentPushIntents`.
|
|
52
|
+
*
|
|
53
|
+
* Per §6 + §12.3.
|
|
54
|
+
*/
|
|
55
|
+
export declare const channelContentPushWorkflow: import("@voyant-travel/workflows").WorkflowDefinition<ProcessContentPushInput, ProcessContentPushResult>;
|
|
56
|
+
//# sourceMappingURL=workflows.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../../src/channel-push/workflows.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAIH,OAAO,EAEL,KAAK,4BAA4B,EACjC,KAAK,6BAA6B,EAEnC,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAEL,KAAK,uBAAuB,EAC5B,KAAK,wBAAwB,EAE9B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAEL,KAAK,uBAAuB,EAC5B,KAAK,wBAAwB,EAE9B,MAAM,mBAAmB,CAAA;AAE1B;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,0GAiBrC,CAAA;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,oHAkB1C,CAAA;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,0GAkBrC,CAAA"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable channel-push workflows.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the inline-callable processors (`processBookingPush`,
|
|
5
|
+
* `processAvailabilityPushIntents`, `processContentPushIntents`) in
|
|
6
|
+
* `@voyant-travel/workflows` definitions so retries, sleeps, and resumption
|
|
7
|
+
* survive worker restarts. Importing this module registers the
|
|
8
|
+
* workflows in the global registry — Voyant Cloud orchestrator picks
|
|
9
|
+
* them up automatically.
|
|
10
|
+
*
|
|
11
|
+
* The workflow bodies look up `ChannelPushDeps` from the process-local
|
|
12
|
+
* holder set via `setChannelPushDeps`. Hosts wire deps at bootstrap;
|
|
13
|
+
* the orchestrator's Node container reads from the same global since
|
|
14
|
+
* it runs in the same isolate that loaded the user bundle.
|
|
15
|
+
*
|
|
16
|
+
* Dev / single-process deployments (e.g. the operator starter's
|
|
17
|
+
* inline drain) don't need to register these — the subscriber calls the
|
|
18
|
+
* processors directly. Production deployments with the Voyant Cloud
|
|
19
|
+
* orchestrator wired import this module to opt into durability.
|
|
20
|
+
*
|
|
21
|
+
* Per docs/architecture/channel-push-architecture.md §4.2 + §12.
|
|
22
|
+
*/
|
|
23
|
+
import { workflow } from "@voyant-travel/workflows";
|
|
24
|
+
import { CHANNEL_AVAILABILITY_PUSH_WORKFLOW_ID, processAvailabilityPushIntents, } from "./availability-push.js";
|
|
25
|
+
import { CHANNEL_BOOKING_PUSH_WORKFLOW_ID, processBookingPush, } from "./booking-push.js";
|
|
26
|
+
import { CHANNEL_CONTENT_PUSH_WORKFLOW_ID, processContentPushIntents, } from "./content-push.js";
|
|
27
|
+
/**
|
|
28
|
+
* Per-booking saga workflow with compensation support.
|
|
29
|
+
*
|
|
30
|
+
* Concurrency is keyed by `bookingId` so two confirms of the same
|
|
31
|
+
* booking serialize (which can't actually happen given the booking
|
|
32
|
+
* state machine, but the perKey lock is cheap insurance). Retries on
|
|
33
|
+
* exponential backoff up to 5 attempts; the per-link compensation pass
|
|
34
|
+
* inside `processBookingPush` handles strict-atomic policy when
|
|
35
|
+
* `channel_contracts.policy.compensation = "strict-atomic"`.
|
|
36
|
+
*
|
|
37
|
+
* Per §4.2 + §12.1.
|
|
38
|
+
*/
|
|
39
|
+
export const channelBookingPushWorkflow = workflow({
|
|
40
|
+
id: CHANNEL_BOOKING_PUSH_WORKFLOW_ID,
|
|
41
|
+
description: "Drain pending channel_booking_links and push to upstream channels",
|
|
42
|
+
retry: { backoff: "exponential", max: 5, initial: "5s", maxDelay: "5m" },
|
|
43
|
+
timeout: "1h",
|
|
44
|
+
concurrency: {
|
|
45
|
+
key: (input) => input.bookingId,
|
|
46
|
+
limit: 1,
|
|
47
|
+
strategy: "queue",
|
|
48
|
+
},
|
|
49
|
+
tags: ["channel-push", "booking"],
|
|
50
|
+
async run(input, ctx) {
|
|
51
|
+
return await ctx.step("process-booking-push", () => processBookingPush(input));
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* Scheduled batch worker for availability push. Runs every 30 seconds
|
|
56
|
+
* (tunable per channel via policy in a future iteration); each tick
|
|
57
|
+
* drains up to 100 pending intents, capped at one concurrent run per
|
|
58
|
+
* channel. Idempotency is upstream-side via `(slot_id, remaining_pax)`.
|
|
59
|
+
*
|
|
60
|
+
* Per §5.3 + §12.2.
|
|
61
|
+
*/
|
|
62
|
+
export const channelAvailabilityPushWorkflow = workflow({
|
|
63
|
+
id: CHANNEL_AVAILABILITY_PUSH_WORKFLOW_ID,
|
|
64
|
+
description: "Drain channel_availability_push_intents per channel",
|
|
65
|
+
schedule: { every: "30s" },
|
|
66
|
+
retry: { backoff: "exponential", max: 3, initial: "10s" },
|
|
67
|
+
timeout: "5m",
|
|
68
|
+
concurrency: {
|
|
69
|
+
key: (input) => input.channelId ?? "all",
|
|
70
|
+
limit: 1,
|
|
71
|
+
strategy: "queue",
|
|
72
|
+
},
|
|
73
|
+
tags: ["channel-push", "availability"],
|
|
74
|
+
async run(input, ctx) {
|
|
75
|
+
return await ctx.step("process-availability-push", () => processAvailabilityPushIntents(input));
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* Scheduled batch worker for content push. Longer cadence (5m) since
|
|
80
|
+
* content drift is rarely time-critical; idempotency via the upstream's
|
|
81
|
+
* acknowledged-hash skip in `processContentPushIntents`.
|
|
82
|
+
*
|
|
83
|
+
* Per §6 + §12.3.
|
|
84
|
+
*/
|
|
85
|
+
export const channelContentPushWorkflow = workflow({
|
|
86
|
+
id: CHANNEL_CONTENT_PUSH_WORKFLOW_ID,
|
|
87
|
+
description: "Drain channel_content_push_intents per channel",
|
|
88
|
+
schedule: { every: "5m" },
|
|
89
|
+
retry: { backoff: "exponential", max: 3, initial: "30s" },
|
|
90
|
+
timeout: "5m",
|
|
91
|
+
concurrency: {
|
|
92
|
+
key: (input) => input.channelId ?? "all",
|
|
93
|
+
limit: 1,
|
|
94
|
+
strategy: "queue",
|
|
95
|
+
},
|
|
96
|
+
tags: ["channel-push", "content"],
|
|
97
|
+
async run(input, ctx) {
|
|
98
|
+
return await ctx.step("process-content-push", () => processContentPushIntents(input));
|
|
99
|
+
},
|
|
100
|
+
});
|