@voyantjs/flights 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/dist/contract/adapter.d.ts +121 -0
- package/dist/contract/adapter.d.ts.map +1 -0
- package/dist/contract/adapter.js +43 -0
- package/dist/contract/types.d.ts +222 -0
- package/dist/contract/types.d.ts.map +1 -0
- package/dist/contract/types.js +33 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/orchestration/fan-out.d.ts +81 -0
- package/dist/orchestration/fan-out.d.ts.map +1 -0
- package/dist/orchestration/fan-out.js +132 -0
- package/dist/orchestration/fan-out.test.d.ts +2 -0
- package/dist/orchestration/fan-out.test.d.ts.map +1 -0
- package/dist/orchestration/fan-out.test.js +237 -0
- package/dist/orchestration/fingerprint.d.ts +20 -0
- package/dist/orchestration/fingerprint.d.ts.map +1 -0
- package/dist/orchestration/fingerprint.js +22 -0
- package/dist/orchestration/fingerprint.test.d.ts +2 -0
- package/dist/orchestration/fingerprint.test.d.ts.map +1 -0
- package/dist/orchestration/fingerprint.test.js +91 -0
- package/dist/reference/contract.d.ts +90 -0
- package/dist/reference/contract.d.ts.map +1 -0
- package/dist/reference/contract.js +26 -0
- package/dist/reference/local-postgres.d.ts +390 -0
- package/dist/reference/local-postgres.d.ts.map +1 -0
- package/dist/reference/local-postgres.js +194 -0
- package/dist/reference/static-bundle.d.ts +29 -0
- package/dist/reference/static-bundle.d.ts.map +1 -0
- package/dist/reference/static-bundle.js +83 -0
- package/dist/reference/static-bundle.test.d.ts +2 -0
- package/dist/reference/static-bundle.test.d.ts.map +1 -0
- package/dist/reference/static-bundle.test.js +75 -0
- package/dist/snapshot.d.ts +50 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +65 -0
- package/dist/snapshot.test.d.ts +2 -0
- package/dist/snapshot.test.d.ts.map +1 -0
- package/dist/snapshot.test.js +96 -0
- package/package.json +95 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-connection fan-out search.
|
|
3
|
+
*
|
|
4
|
+
* Parallel `searchFlights` across all of an operator's flight connections
|
|
5
|
+
* with per-connection timeouts, partial-success handling, and merge by
|
|
6
|
+
* itinerary fingerprint. Returns a merged result set with the cheapest
|
|
7
|
+
* offer per itinerary as the primary rank, alternates from other
|
|
8
|
+
* connections beneath it, and a per-connection status map.
|
|
9
|
+
*
|
|
10
|
+
* Implements voyant-cloud's `MergedFlightOffer` shape so consumers see the
|
|
11
|
+
* same result format regardless of where the orchestration runs.
|
|
12
|
+
*
|
|
13
|
+
* See `docs/architecture/catalog-flights-architecture.md` §4.
|
|
14
|
+
*/
|
|
15
|
+
import { itineraryFingerprint } from "./fingerprint.js";
|
|
16
|
+
/**
|
|
17
|
+
* Fan out a flight search across an operator's connections, parallelized
|
|
18
|
+
* with per-connection timeout, then merge by itinerary fingerprint.
|
|
19
|
+
*
|
|
20
|
+
* Partial-success semantics: connections that time out / error / report
|
|
21
|
+
* capability-missing are flagged in `perConnection`; the orchestration
|
|
22
|
+
* still returns whatever responding connections produced.
|
|
23
|
+
*/
|
|
24
|
+
export async function fanOutFlightSearch(options) {
|
|
25
|
+
const timeoutMs = options.perConnectionTimeoutMs ?? 5000;
|
|
26
|
+
const settled = await Promise.all(options.adapters.map(async ({ connectionId, adapter, context }) => {
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
try {
|
|
29
|
+
// Capability check up front. If the adapter declares a max-slices
|
|
30
|
+
// limit and the request exceeds it, fail fast as `capability_missing`.
|
|
31
|
+
const max = adapter.capabilities.maxSlicesPerSearch;
|
|
32
|
+
if (max != null && options.request.slices.length > max) {
|
|
33
|
+
return {
|
|
34
|
+
connectionId,
|
|
35
|
+
status: "capability_missing",
|
|
36
|
+
offers: [],
|
|
37
|
+
latencyMs: Date.now() - start,
|
|
38
|
+
errorMessage: `Connection supports max ${max} slices; request had ${options.request.slices.length}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const ctx = { connectionId, ...context };
|
|
42
|
+
const response = await withTimeout(adapter.searchFlights(ctx, options.request), timeoutMs, `connection ${connectionId} timed out after ${timeoutMs}ms`);
|
|
43
|
+
return {
|
|
44
|
+
connectionId,
|
|
45
|
+
status: "ok",
|
|
46
|
+
offers: response.offers,
|
|
47
|
+
latencyMs: Date.now() - start,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
52
|
+
const isTimeout = message.includes("timed out");
|
|
53
|
+
return {
|
|
54
|
+
connectionId,
|
|
55
|
+
status: isTimeout ? "timeout" : "error",
|
|
56
|
+
offers: [],
|
|
57
|
+
latencyMs: Date.now() - start,
|
|
58
|
+
errorMessage: message,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}));
|
|
62
|
+
// Build per-connection status report.
|
|
63
|
+
const perConnection = settled.map((r) => ({
|
|
64
|
+
connectionId: r.connectionId,
|
|
65
|
+
status: r.status,
|
|
66
|
+
count: r.offers.length,
|
|
67
|
+
latencyMs: r.latencyMs,
|
|
68
|
+
errorMessage: r.errorMessage,
|
|
69
|
+
}));
|
|
70
|
+
// Group offers by itinerary fingerprint, building MergedFlightOffer per group.
|
|
71
|
+
const merged = mergeByFingerprint(settled);
|
|
72
|
+
// Sort merged offers by cheapest price ascending.
|
|
73
|
+
merged.sort((a, b) => compareMoney(a.cheapest.totalPrice, b.cheapest.totalPrice));
|
|
74
|
+
const limited = options.limit != null ? merged.slice(0, options.limit) : merged;
|
|
75
|
+
return { offers: limited, perConnection };
|
|
76
|
+
}
|
|
77
|
+
function mergeByFingerprint(results) {
|
|
78
|
+
const buckets = new Map();
|
|
79
|
+
for (const { connectionId, offers } of results) {
|
|
80
|
+
for (const offer of offers) {
|
|
81
|
+
const key = itineraryFingerprint(offer);
|
|
82
|
+
const bucket = buckets.get(key);
|
|
83
|
+
if (bucket) {
|
|
84
|
+
bucket.offers.push({ connectionId, offer });
|
|
85
|
+
bucket.sourceConnectionIds.add(connectionId);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
buckets.set(key, {
|
|
89
|
+
offers: [{ connectionId, offer }],
|
|
90
|
+
sourceConnectionIds: new Set([connectionId]),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const merged = [];
|
|
96
|
+
for (const [fingerprint, bucket] of buckets) {
|
|
97
|
+
// Sort offers within the bucket by total price ascending.
|
|
98
|
+
bucket.offers.sort((a, b) => compareMoney(a.offer.totalPrice, b.offer.totalPrice));
|
|
99
|
+
const cheapestEntry = bucket.offers[0];
|
|
100
|
+
if (!cheapestEntry)
|
|
101
|
+
continue;
|
|
102
|
+
merged.push({
|
|
103
|
+
itineraryFingerprint: fingerprint,
|
|
104
|
+
cheapest: cheapestEntry.offer,
|
|
105
|
+
alternates: bucket.offers.slice(1).map((entry) => entry.offer),
|
|
106
|
+
sourceConnectionIds: Array.from(bucket.sourceConnectionIds),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return merged;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Compare two `Money` values. Currencies must match — cross-currency
|
|
113
|
+
* comparison would require live FX which the orchestration doesn't have.
|
|
114
|
+
* Returns negative if `a < b`, positive if `a > b`, 0 if equal.
|
|
115
|
+
*/
|
|
116
|
+
function compareMoney(a, b) {
|
|
117
|
+
if (a.currency !== b.currency) {
|
|
118
|
+
// Fall back to string compare on amount when currencies differ —
|
|
119
|
+
// produces a stable but not-meaningful order. Real deployments
|
|
120
|
+
// normalize currency upstream of the orchestration.
|
|
121
|
+
return a.amount.localeCompare(b.amount);
|
|
122
|
+
}
|
|
123
|
+
const aNum = Number.parseFloat(a.amount);
|
|
124
|
+
const bNum = Number.parseFloat(b.amount);
|
|
125
|
+
return aNum - bNum;
|
|
126
|
+
}
|
|
127
|
+
async function withTimeout(promise, ms, message) {
|
|
128
|
+
return await Promise.race([
|
|
129
|
+
promise,
|
|
130
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms)),
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fan-out.test.d.ts","sourceRoot":"","sources":["../../src/orchestration/fan-out.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fanOutFlightSearch } from "./fan-out.js";
|
|
3
|
+
function makeOffer(overrides) {
|
|
4
|
+
return {
|
|
5
|
+
offerId: overrides.offerId ?? "ofr_default",
|
|
6
|
+
source: overrides.source ?? "test",
|
|
7
|
+
itineraries: [
|
|
8
|
+
{
|
|
9
|
+
segments: [
|
|
10
|
+
{
|
|
11
|
+
segmentId: "s1",
|
|
12
|
+
carrierCode: "BA",
|
|
13
|
+
flightNumber: "177",
|
|
14
|
+
departure: { iataCode: "LHR", at: "2026-10-15T11:00:00+00:00" },
|
|
15
|
+
arrival: { iataCode: "JFK", at: "2026-10-15T14:00:00-04:00" },
|
|
16
|
+
cabin: "economy",
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
fareBreakdowns: [
|
|
22
|
+
{
|
|
23
|
+
passengerType: "adult",
|
|
24
|
+
passengerCount: 1,
|
|
25
|
+
baseFare: { amount: "500", currency: "USD" },
|
|
26
|
+
taxes: { amount: "100", currency: "USD" },
|
|
27
|
+
total: { amount: "600", currency: "USD" },
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
totalPrice: overrides.totalPrice ?? { amount: "600", currency: "USD" },
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function makeAdapter(provider, behaviour = {}) {
|
|
35
|
+
return {
|
|
36
|
+
capabilities: {
|
|
37
|
+
provider,
|
|
38
|
+
declared: [],
|
|
39
|
+
maxSlicesPerSearch: behaviour.maxSlices,
|
|
40
|
+
},
|
|
41
|
+
async searchFlights() {
|
|
42
|
+
if (behaviour.delayMs) {
|
|
43
|
+
await new Promise((r) => setTimeout(r, behaviour.delayMs));
|
|
44
|
+
}
|
|
45
|
+
if (behaviour.throws)
|
|
46
|
+
throw behaviour.throws;
|
|
47
|
+
return { offers: behaviour.offers ?? [] };
|
|
48
|
+
},
|
|
49
|
+
async priceOffer() {
|
|
50
|
+
throw new Error("not implemented in test");
|
|
51
|
+
},
|
|
52
|
+
async bookFlight() {
|
|
53
|
+
throw new Error("not implemented in test");
|
|
54
|
+
},
|
|
55
|
+
async getOrder() {
|
|
56
|
+
throw new Error("not implemented in test");
|
|
57
|
+
},
|
|
58
|
+
async cancelOrder() {
|
|
59
|
+
throw new Error("not implemented in test");
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const oneSliceRequest = {
|
|
64
|
+
slices: [{ origin: "LHR", destination: "JFK", departureDate: "2026-10-15" }],
|
|
65
|
+
passengers: { adults: 1 },
|
|
66
|
+
cabin: "economy",
|
|
67
|
+
};
|
|
68
|
+
describe("fanOutFlightSearch", () => {
|
|
69
|
+
it("merges identical itineraries from multiple providers, keeping cheapest as primary", async () => {
|
|
70
|
+
const result = await fanOutFlightSearch({
|
|
71
|
+
adapters: [
|
|
72
|
+
{
|
|
73
|
+
connectionId: "conn_amadeus",
|
|
74
|
+
adapter: makeAdapter("amadeus", {
|
|
75
|
+
offers: [
|
|
76
|
+
makeOffer({ source: "amadeus", totalPrice: { amount: "650", currency: "USD" } }),
|
|
77
|
+
],
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
connectionId: "conn_hisky",
|
|
82
|
+
adapter: makeAdapter("hisky", {
|
|
83
|
+
offers: [
|
|
84
|
+
makeOffer({ source: "hisky", totalPrice: { amount: "600", currency: "USD" } }),
|
|
85
|
+
],
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
request: oneSliceRequest,
|
|
90
|
+
});
|
|
91
|
+
expect(result.offers).toHaveLength(1);
|
|
92
|
+
expect(result.offers[0]?.cheapest.totalPrice.amount).toBe("600");
|
|
93
|
+
expect(result.offers[0]?.cheapest.source).toBe("hisky");
|
|
94
|
+
expect(result.offers[0]?.alternates).toHaveLength(1);
|
|
95
|
+
expect(result.offers[0]?.alternates[0]?.source).toBe("amadeus");
|
|
96
|
+
expect(result.offers[0]?.sourceConnectionIds.sort()).toEqual(["conn_amadeus", "conn_hisky"]);
|
|
97
|
+
});
|
|
98
|
+
it("sorts merged offers by cheapest price ascending", async () => {
|
|
99
|
+
const result = await fanOutFlightSearch({
|
|
100
|
+
adapters: [
|
|
101
|
+
{
|
|
102
|
+
connectionId: "conn_a",
|
|
103
|
+
adapter: makeAdapter("a", {
|
|
104
|
+
offers: [
|
|
105
|
+
makeOffer({
|
|
106
|
+
offerId: "ofr_expensive",
|
|
107
|
+
source: "a",
|
|
108
|
+
itineraries: [
|
|
109
|
+
{
|
|
110
|
+
segments: [
|
|
111
|
+
{
|
|
112
|
+
segmentId: "s",
|
|
113
|
+
carrierCode: "VS",
|
|
114
|
+
flightNumber: "3",
|
|
115
|
+
departure: { iataCode: "LHR", at: "2026-10-15T15:00:00+00:00" },
|
|
116
|
+
arrival: { iataCode: "JFK", at: "2026-10-15T18:00:00-04:00" },
|
|
117
|
+
cabin: "economy",
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
totalPrice: { amount: "1000", currency: "USD" },
|
|
123
|
+
}),
|
|
124
|
+
makeOffer({
|
|
125
|
+
offerId: "ofr_cheap",
|
|
126
|
+
source: "a",
|
|
127
|
+
totalPrice: { amount: "300", currency: "USD" },
|
|
128
|
+
}),
|
|
129
|
+
],
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
request: oneSliceRequest,
|
|
134
|
+
});
|
|
135
|
+
expect(result.offers).toHaveLength(2);
|
|
136
|
+
expect(result.offers[0]?.cheapest.totalPrice.amount).toBe("300");
|
|
137
|
+
expect(result.offers[1]?.cheapest.totalPrice.amount).toBe("1000");
|
|
138
|
+
});
|
|
139
|
+
it("flags timed-out connections and returns results from responding ones", async () => {
|
|
140
|
+
const result = await fanOutFlightSearch({
|
|
141
|
+
adapters: [
|
|
142
|
+
{
|
|
143
|
+
connectionId: "conn_fast",
|
|
144
|
+
adapter: makeAdapter("fast", { offers: [makeOffer({ source: "fast" })] }),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
connectionId: "conn_slow",
|
|
148
|
+
adapter: makeAdapter("slow", { delayMs: 200 }),
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
request: oneSliceRequest,
|
|
152
|
+
perConnectionTimeoutMs: 50,
|
|
153
|
+
});
|
|
154
|
+
expect(result.offers).toHaveLength(1);
|
|
155
|
+
const fast = result.perConnection.find((c) => c.connectionId === "conn_fast");
|
|
156
|
+
const slow = result.perConnection.find((c) => c.connectionId === "conn_slow");
|
|
157
|
+
expect(fast?.status).toBe("ok");
|
|
158
|
+
expect(slow?.status).toBe("timeout");
|
|
159
|
+
});
|
|
160
|
+
it("flags connections that throw as 'error' without losing other results", async () => {
|
|
161
|
+
const result = await fanOutFlightSearch({
|
|
162
|
+
adapters: [
|
|
163
|
+
{
|
|
164
|
+
connectionId: "conn_ok",
|
|
165
|
+
adapter: makeAdapter("ok", { offers: [makeOffer({})] }),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
connectionId: "conn_fail",
|
|
169
|
+
adapter: makeAdapter("fail", { throws: new Error("provider down") }),
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
request: oneSliceRequest,
|
|
173
|
+
});
|
|
174
|
+
expect(result.offers).toHaveLength(1);
|
|
175
|
+
const fail = result.perConnection.find((c) => c.connectionId === "conn_fail");
|
|
176
|
+
expect(fail?.status).toBe("error");
|
|
177
|
+
expect(fail?.errorMessage).toBe("provider down");
|
|
178
|
+
});
|
|
179
|
+
it("flags connections that don't support the request's slice count as capability_missing", async () => {
|
|
180
|
+
const result = await fanOutFlightSearch({
|
|
181
|
+
adapters: [
|
|
182
|
+
{
|
|
183
|
+
connectionId: "conn_pp",
|
|
184
|
+
adapter: makeAdapter("point-to-point", {
|
|
185
|
+
maxSlices: 1,
|
|
186
|
+
offers: [makeOffer({})],
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
request: {
|
|
191
|
+
...oneSliceRequest,
|
|
192
|
+
slices: [
|
|
193
|
+
...oneSliceRequest.slices,
|
|
194
|
+
{ origin: "JFK", destination: "LAX", departureDate: "2026-10-20" },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
expect(result.offers).toHaveLength(0);
|
|
199
|
+
expect(result.perConnection[0]?.status).toBe("capability_missing");
|
|
200
|
+
});
|
|
201
|
+
it("respects the limit parameter on the merged result", async () => {
|
|
202
|
+
const result = await fanOutFlightSearch({
|
|
203
|
+
adapters: [
|
|
204
|
+
{
|
|
205
|
+
connectionId: "conn_a",
|
|
206
|
+
adapter: makeAdapter("a", {
|
|
207
|
+
offers: [
|
|
208
|
+
makeOffer({ offerId: "1", totalPrice: { amount: "100", currency: "USD" } }),
|
|
209
|
+
makeOffer({
|
|
210
|
+
offerId: "2",
|
|
211
|
+
itineraries: [
|
|
212
|
+
{
|
|
213
|
+
segments: [
|
|
214
|
+
{
|
|
215
|
+
segmentId: "s",
|
|
216
|
+
carrierCode: "AA",
|
|
217
|
+
flightNumber: "1",
|
|
218
|
+
departure: { iataCode: "LHR", at: "2026-10-15T20:00:00+00:00" },
|
|
219
|
+
arrival: { iataCode: "JFK", at: "2026-10-15T23:00:00-04:00" },
|
|
220
|
+
cabin: "economy",
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
totalPrice: { amount: "200", currency: "USD" },
|
|
226
|
+
}),
|
|
227
|
+
],
|
|
228
|
+
}),
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
request: oneSliceRequest,
|
|
232
|
+
limit: 1,
|
|
233
|
+
});
|
|
234
|
+
expect(result.offers).toHaveLength(1);
|
|
235
|
+
expect(result.offers[0]?.cheapest.totalPrice.amount).toBe("100");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Itinerary fingerprint — deterministic key derived from a `FlightOffer`'s
|
|
3
|
+
* segments. Two providers selling the same flight produce identical
|
|
4
|
+
* fingerprints; the multi-connection fan-out uses this to merge offers
|
|
5
|
+
* across connections.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors voyant-cloud's `itineraryFingerprint` so fingerprints are
|
|
8
|
+
* portable: an offer fingerprinted in voyant-cloud and another fingerprinted
|
|
9
|
+
* here for the same physical flight match by string equality.
|
|
10
|
+
*
|
|
11
|
+
* See `docs/architecture/catalog-flights-architecture.md` §4.
|
|
12
|
+
*/
|
|
13
|
+
import type { FlightOffer } from "../contract/types.js";
|
|
14
|
+
/**
|
|
15
|
+
* Deterministic key derived from segments: carrier code + flight number +
|
|
16
|
+
* departure/arrival airports + times + cabin. Two providers selling the
|
|
17
|
+
* same flight produce identical fingerprints.
|
|
18
|
+
*/
|
|
19
|
+
export declare function itineraryFingerprint(offer: FlightOffer): string;
|
|
20
|
+
//# sourceMappingURL=fingerprint.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fingerprint.d.ts","sourceRoot":"","sources":["../../src/orchestration/fingerprint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAEvD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAS/D"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Itinerary fingerprint — deterministic key derived from a `FlightOffer`'s
|
|
3
|
+
* segments. Two providers selling the same flight produce identical
|
|
4
|
+
* fingerprints; the multi-connection fan-out uses this to merge offers
|
|
5
|
+
* across connections.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors voyant-cloud's `itineraryFingerprint` so fingerprints are
|
|
8
|
+
* portable: an offer fingerprinted in voyant-cloud and another fingerprinted
|
|
9
|
+
* here for the same physical flight match by string equality.
|
|
10
|
+
*
|
|
11
|
+
* See `docs/architecture/catalog-flights-architecture.md` §4.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Deterministic key derived from segments: carrier code + flight number +
|
|
15
|
+
* departure/arrival airports + times + cabin. Two providers selling the
|
|
16
|
+
* same flight produce identical fingerprints.
|
|
17
|
+
*/
|
|
18
|
+
export function itineraryFingerprint(offer) {
|
|
19
|
+
return offer.itineraries
|
|
20
|
+
.flatMap((itinerary) => itinerary.segments.map((s) => `${s.carrierCode}${s.flightNumber}|${s.departure.iataCode}|${s.departure.at}|${s.arrival.iataCode}|${s.arrival.at}|${s.cabin}`))
|
|
21
|
+
.join("→");
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fingerprint.test.d.ts","sourceRoot":"","sources":["../../src/orchestration/fingerprint.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { itineraryFingerprint } from "./fingerprint.js";
|
|
3
|
+
const offerLondonToNYC = {
|
|
4
|
+
offerId: "ofr_a",
|
|
5
|
+
source: "amadeus",
|
|
6
|
+
itineraries: [
|
|
7
|
+
{
|
|
8
|
+
segments: [
|
|
9
|
+
{
|
|
10
|
+
segmentId: "s1",
|
|
11
|
+
carrierCode: "BA",
|
|
12
|
+
flightNumber: "177",
|
|
13
|
+
departure: { iataCode: "LHR", at: "2026-10-15T11:00:00+00:00" },
|
|
14
|
+
arrival: { iataCode: "JFK", at: "2026-10-15T14:00:00-04:00" },
|
|
15
|
+
cabin: "economy",
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
fareBreakdowns: [
|
|
21
|
+
{
|
|
22
|
+
passengerType: "adult",
|
|
23
|
+
passengerCount: 1,
|
|
24
|
+
baseFare: { amount: "500", currency: "USD" },
|
|
25
|
+
taxes: { amount: "100", currency: "USD" },
|
|
26
|
+
total: { amount: "600", currency: "USD" },
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
totalPrice: { amount: "600", currency: "USD" },
|
|
30
|
+
};
|
|
31
|
+
describe("itineraryFingerprint", () => {
|
|
32
|
+
it("produces a deterministic key from segments", () => {
|
|
33
|
+
const fp = itineraryFingerprint(offerLondonToNYC);
|
|
34
|
+
expect(fp).toBe("BA177|LHR|2026-10-15T11:00:00+00:00|JFK|2026-10-15T14:00:00-04:00|economy");
|
|
35
|
+
});
|
|
36
|
+
it("two providers selling the same flight produce identical fingerprints", () => {
|
|
37
|
+
const fromAmadeus = { ...offerLondonToNYC, offerId: "ofr_amadeus", source: "amadeus" };
|
|
38
|
+
const fromHisky = { ...offerLondonToNYC, offerId: "ofr_hisky", source: "hisky" };
|
|
39
|
+
expect(itineraryFingerprint(fromAmadeus)).toBe(itineraryFingerprint(fromHisky));
|
|
40
|
+
});
|
|
41
|
+
it("differs when carrier changes", () => {
|
|
42
|
+
const altered = {
|
|
43
|
+
...offerLondonToNYC,
|
|
44
|
+
itineraries: [
|
|
45
|
+
{
|
|
46
|
+
segments: [
|
|
47
|
+
{
|
|
48
|
+
...offerLondonToNYC.itineraries[0].segments[0],
|
|
49
|
+
carrierCode: "VS",
|
|
50
|
+
flightNumber: "3",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
expect(itineraryFingerprint(altered)).not.toBe(itineraryFingerprint(offerLondonToNYC));
|
|
57
|
+
});
|
|
58
|
+
it("differs when cabin class changes (price tier matters for grouping)", () => {
|
|
59
|
+
const business = {
|
|
60
|
+
...offerLondonToNYC,
|
|
61
|
+
itineraries: [
|
|
62
|
+
{
|
|
63
|
+
segments: [{ ...offerLondonToNYC.itineraries[0].segments[0], cabin: "business" }],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
expect(itineraryFingerprint(business)).not.toBe(itineraryFingerprint(offerLondonToNYC));
|
|
68
|
+
});
|
|
69
|
+
it("multi-leg itineraries fingerprint with arrow separator between segments", () => {
|
|
70
|
+
const multiLeg = {
|
|
71
|
+
...offerLondonToNYC,
|
|
72
|
+
itineraries: [
|
|
73
|
+
{
|
|
74
|
+
segments: [
|
|
75
|
+
offerLondonToNYC.itineraries[0].segments[0],
|
|
76
|
+
{
|
|
77
|
+
segmentId: "s2",
|
|
78
|
+
carrierCode: "AA",
|
|
79
|
+
flightNumber: "100",
|
|
80
|
+
departure: { iataCode: "JFK", at: "2026-10-22T10:00:00-04:00" },
|
|
81
|
+
arrival: { iataCode: "LHR", at: "2026-10-22T22:00:00+00:00" },
|
|
82
|
+
cabin: "economy",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
expect(itineraryFingerprint(multiLeg)).toContain("→");
|
|
89
|
+
expect(itineraryFingerprint(multiLeg).split("→")).toHaveLength(2);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ReferenceDataProvider` contract — swappable provider for global
|
|
3
|
+
* reference data (airlines, airports, aircraft, currencies, countries).
|
|
4
|
+
*
|
|
5
|
+
* Per architecture §5.11.6 / §6, implementable at any layer:
|
|
6
|
+
* - **In-deployment local Postgres** (the simplest case)
|
|
7
|
+
* - Static JSON / CSV bundle
|
|
8
|
+
* - Internal data lake / warehouse
|
|
9
|
+
* - Third-party services (OAG, Cirium, RouteHappy)
|
|
10
|
+
* - GDS-bundled reference subscriptions
|
|
11
|
+
* - Voyant Data (the hosted default)
|
|
12
|
+
*
|
|
13
|
+
* No implementer is privileged. Operators can run a fully self-contained
|
|
14
|
+
* Voyant deployment with all reference data in their own database, no
|
|
15
|
+
* external dependency.
|
|
16
|
+
*
|
|
17
|
+
* See `docs/architecture/catalog-flights-architecture.md` §6.
|
|
18
|
+
*/
|
|
19
|
+
export interface Airline {
|
|
20
|
+
iataCode: string;
|
|
21
|
+
icaoCode?: string;
|
|
22
|
+
name: string;
|
|
23
|
+
/** ISO 3166-1 alpha-2 country code. */
|
|
24
|
+
country?: string;
|
|
25
|
+
/** Optional logo URL. */
|
|
26
|
+
logoUrl?: string;
|
|
27
|
+
/** Optional alliance affiliation: `"oneworld"`, `"star-alliance"`, `"skyteam"`. */
|
|
28
|
+
alliance?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface Airport {
|
|
31
|
+
iataCode: string;
|
|
32
|
+
icaoCode?: string;
|
|
33
|
+
name: string;
|
|
34
|
+
city: string;
|
|
35
|
+
/** ISO 3166-1 alpha-2. */
|
|
36
|
+
country: string;
|
|
37
|
+
timezone?: string;
|
|
38
|
+
latitude?: number;
|
|
39
|
+
longitude?: number;
|
|
40
|
+
}
|
|
41
|
+
export interface Aircraft {
|
|
42
|
+
iataCode: string;
|
|
43
|
+
icaoCode?: string;
|
|
44
|
+
name: string;
|
|
45
|
+
manufacturer?: string;
|
|
46
|
+
/** Optional capacity hint (typical seat count). */
|
|
47
|
+
typicalSeats?: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Capabilities declared by the provider. Lets the catalog plane fail
|
|
51
|
+
* fast when a deployment requests reference data the provider can't
|
|
52
|
+
* serve (e.g. asking for currencies from a provider that only covers
|
|
53
|
+
* airlines/airports).
|
|
54
|
+
*/
|
|
55
|
+
export interface ReferenceDataCapabilities {
|
|
56
|
+
coversAirlines: boolean;
|
|
57
|
+
coversAirports: boolean;
|
|
58
|
+
coversAircraft: boolean;
|
|
59
|
+
coversCurrencies: boolean;
|
|
60
|
+
coversCountries: boolean;
|
|
61
|
+
/** True for hosted/read-only providers; false for ones that allow upserts. */
|
|
62
|
+
isReadOnly: boolean;
|
|
63
|
+
/** How often the provider's data refreshes from upstream. */
|
|
64
|
+
refreshCadence: "static" | "weekly" | "daily" | "on-demand";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The reference data contract. Used by flight integrations to hydrate
|
|
68
|
+
* IATA codes into human-readable names + metadata; usable by any other
|
|
69
|
+
* vertical that needs the same lookup surface.
|
|
70
|
+
*/
|
|
71
|
+
export interface ReferenceDataProvider {
|
|
72
|
+
readonly capabilities: ReferenceDataCapabilities;
|
|
73
|
+
/** Look up one airline by IATA code. Returns null if not found. */
|
|
74
|
+
getAirline(iataCode: string): Promise<Airline | null>;
|
|
75
|
+
/** Look up one airport by IATA code. */
|
|
76
|
+
getAirport(iataCode: string): Promise<Airport | null>;
|
|
77
|
+
/** Look up one aircraft type by IATA code. */
|
|
78
|
+
getAircraft(iataCode: string): Promise<Aircraft | null>;
|
|
79
|
+
/** Batch lookup — preferred for hydrating offers / orders with many codes. */
|
|
80
|
+
getAirlines(iataCodes: string[]): Promise<Map<string, Airline>>;
|
|
81
|
+
getAirports(iataCodes: string[]): Promise<Map<string, Airport>>;
|
|
82
|
+
getAircraftBatch(iataCodes: string[]): Promise<Map<string, Aircraft>>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Helper that hydrates a batch of distinct IATA codes once and returns a
|
|
86
|
+
* Map. Used internally by providers that fetch from a slow upstream and
|
|
87
|
+
* want to serve repeated lookups from an in-memory cache.
|
|
88
|
+
*/
|
|
89
|
+
export declare function dedupeCodes(codes: string[]): string[];
|
|
90
|
+
//# sourceMappingURL=contract.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../../src/reference/contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,OAAO;IACtB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;IACZ,uCAAuC;IACvC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,mFAAmF;IACnF,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,OAAO;IACtB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;IACZ,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,mDAAmD;IACnD,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED;;;;;GAKG;AACH,MAAM,WAAW,yBAAyB;IACxC,cAAc,EAAE,OAAO,CAAA;IACvB,cAAc,EAAE,OAAO,CAAA;IACvB,cAAc,EAAE,OAAO,CAAA;IACvB,gBAAgB,EAAE,OAAO,CAAA;IACzB,eAAe,EAAE,OAAO,CAAA;IACxB,8EAA8E;IAC9E,UAAU,EAAE,OAAO,CAAA;IACnB,6DAA6D;IAC7D,cAAc,EAAE,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,WAAW,CAAA;CAC5D;AAED;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,YAAY,EAAE,yBAAyB,CAAA;IAEhD,mEAAmE;IACnE,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IAErD,wCAAwC;IACxC,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IAErD,8CAA8C;IAC9C,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAA;IAEvD,8EAA8E;IAC9E,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAC/D,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAC/D,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAA;CACtE;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAErD"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ReferenceDataProvider` contract — swappable provider for global
|
|
3
|
+
* reference data (airlines, airports, aircraft, currencies, countries).
|
|
4
|
+
*
|
|
5
|
+
* Per architecture §5.11.6 / §6, implementable at any layer:
|
|
6
|
+
* - **In-deployment local Postgres** (the simplest case)
|
|
7
|
+
* - Static JSON / CSV bundle
|
|
8
|
+
* - Internal data lake / warehouse
|
|
9
|
+
* - Third-party services (OAG, Cirium, RouteHappy)
|
|
10
|
+
* - GDS-bundled reference subscriptions
|
|
11
|
+
* - Voyant Data (the hosted default)
|
|
12
|
+
*
|
|
13
|
+
* No implementer is privileged. Operators can run a fully self-contained
|
|
14
|
+
* Voyant deployment with all reference data in their own database, no
|
|
15
|
+
* external dependency.
|
|
16
|
+
*
|
|
17
|
+
* See `docs/architecture/catalog-flights-architecture.md` §6.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Helper that hydrates a batch of distinct IATA codes once and returns a
|
|
21
|
+
* Map. Used internally by providers that fetch from a slow upstream and
|
|
22
|
+
* want to serve repeated lookups from an in-memory cache.
|
|
23
|
+
*/
|
|
24
|
+
export function dedupeCodes(codes) {
|
|
25
|
+
return Array.from(new Set(codes.filter((c) => c.length > 0)));
|
|
26
|
+
}
|