@owox/connectors 0.36.0 → 0.37.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/dist/index.cjs +393 -93
- package/dist/index.js +393 -93
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -361,6 +361,7 @@ class AbstractConnector {
|
|
|
361
361
|
async run() {
|
|
362
362
|
var _a;
|
|
363
363
|
this.context.emit(new ControlEvent(CONTROL_ACTION.STARTED));
|
|
364
|
+
this._rowsByNode = /* @__PURE__ */ new Map();
|
|
364
365
|
try {
|
|
365
366
|
this.context.validate();
|
|
366
367
|
this._processRunConfig();
|
|
@@ -387,6 +388,7 @@ class AbstractConnector {
|
|
|
387
388
|
}
|
|
388
389
|
}
|
|
389
390
|
this._reportAccountOutcomes(state);
|
|
391
|
+
this._reportEmptyNodes([...plainNodes, ...timeSeriesNodes]);
|
|
390
392
|
this.source.onImportComplete(this.context);
|
|
391
393
|
this.context.emit(new ControlEvent(CONTROL_ACTION.COMPLETED));
|
|
392
394
|
} catch (error) {
|
|
@@ -522,10 +524,11 @@ class AbstractConnector {
|
|
|
522
524
|
* @param {object} state run state from _createRunState
|
|
523
525
|
* @param {object|null} account the account this unit belongs to
|
|
524
526
|
* @param {Function} work async thunk performing the fetch + write
|
|
527
|
+
* @param {string} subject what the unit imports -- a node name, or the day for day-by-day
|
|
525
528
|
* @returns {Promise<boolean>} true when the unit completed
|
|
526
529
|
* @private
|
|
527
530
|
*/
|
|
528
|
-
async _runForAccount(state, account, work) {
|
|
531
|
+
async _runForAccount(state, account, work, subject) {
|
|
529
532
|
try {
|
|
530
533
|
await work();
|
|
531
534
|
state.succeeded.add(this._accountKey(account));
|
|
@@ -533,7 +536,7 @@ class AbstractConnector {
|
|
|
533
536
|
} catch (error) {
|
|
534
537
|
this.source.onAccountError(account, error);
|
|
535
538
|
if (error == null ? void 0 : error[STORAGE_FAILURE]) throw error;
|
|
536
|
-
this._recordAccountFailure(state, account, error);
|
|
539
|
+
this._recordAccountFailure(state, account, error, subject);
|
|
537
540
|
return false;
|
|
538
541
|
}
|
|
539
542
|
}
|
|
@@ -572,23 +575,29 @@ class AbstractConnector {
|
|
|
572
575
|
* @param {object} state run state from _createRunState
|
|
573
576
|
* @param {object|null} account the account that failed
|
|
574
577
|
* @param {Error} error the failure to classify
|
|
578
|
+
* @param {string} [subject] what was being imported, named in the report as main did
|
|
575
579
|
* @private
|
|
576
580
|
*/
|
|
577
|
-
_recordAccountFailure(state, account, error) {
|
|
581
|
+
_recordAccountFailure(state, account, error, subject) {
|
|
578
582
|
const accountId = this._accountKey(account);
|
|
579
583
|
const isSkip = (error == null ? void 0 : error.isWarning) === true;
|
|
580
584
|
if (!isSkip) state.passHardFailures += 1;
|
|
581
585
|
let entry = state.issues.get(accountId);
|
|
582
586
|
if (!entry) {
|
|
583
|
-
entry = { errors: [], reported: /* @__PURE__ */ new Set() };
|
|
587
|
+
entry = { errors: [], reported: /* @__PURE__ */ new Set(), subjects: /* @__PURE__ */ new Set() };
|
|
584
588
|
state.issues.set(accountId, entry);
|
|
585
589
|
}
|
|
586
590
|
entry.errors.push(error);
|
|
591
|
+
if (subject) entry.subjects.add(subject);
|
|
587
592
|
const level = isSkip ? LOG_LEVEL.WARN : LOG_LEVEL.ERROR;
|
|
588
593
|
if (entry.reported.has(error.message)) return;
|
|
589
594
|
entry.reported.add(error.message);
|
|
590
595
|
const what = account === null || account === void 0 ? isSkip ? "Skipped" : "Import failed" : `${isSkip ? "Skipped account" : "Error processing account"} ${accountId}`;
|
|
591
|
-
|
|
596
|
+
const line = `${what}: ${error.message}`;
|
|
597
|
+
this.context.log(
|
|
598
|
+
level,
|
|
599
|
+
subject ? `Importing ${subject}: ${line[0].toLowerCase()}${line.slice(1)}` : line
|
|
600
|
+
);
|
|
592
601
|
}
|
|
593
602
|
/**
|
|
594
603
|
* Whether the account has failed outright (not merely been skipped) with the same error
|
|
@@ -647,7 +656,48 @@ class AbstractConnector {
|
|
|
647
656
|
*/
|
|
648
657
|
_emitCursor(state, date) {
|
|
649
658
|
if (state.cursorHalted) return;
|
|
650
|
-
|
|
659
|
+
const claimed = state.cursorCap && date > state.cursorCap ? state.cursorCap : date;
|
|
660
|
+
if (claimed !== date && claimed === state.lastClaimed) return;
|
|
661
|
+
state.lastClaimed = claimed;
|
|
662
|
+
this.context.emit(new StateEvent({ lastRequestedDate: claimed }));
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* The last day a node is asked for: the run's end, or `endLagDays` before today for an API
|
|
666
|
+
* that reports only completed days, whichever is earlier.
|
|
667
|
+
* @private
|
|
668
|
+
*/
|
|
669
|
+
_nodeWindowEnd(node, dateRange) {
|
|
670
|
+
var _a, _b;
|
|
671
|
+
const lag = ((_b = (_a = this.source).getEndLagDays) == null ? void 0 : _b.call(_a, node.name)) ?? 0;
|
|
672
|
+
if (!lag) return dateRange.endDate;
|
|
673
|
+
const today = this._parseDate(this._formatDate(/* @__PURE__ */ new Date()));
|
|
674
|
+
const lagged = this._formatDate(new Date(today - lag * 864e5));
|
|
675
|
+
return lagged < dateRange.endDate ? lagged : dateRange.endDate;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Notes where `endLagDays` stopped a manual backfill short of its EndDate. The run completes
|
|
679
|
+
* either way, and nothing else on it names the days it left out.
|
|
680
|
+
* @private
|
|
681
|
+
*/
|
|
682
|
+
_logLagCut(node, dateRange, end) {
|
|
683
|
+
var _a;
|
|
684
|
+
if (((_a = this.context.runConfig) == null ? void 0 : _a.type) !== RUN_CONFIG_TYPE.MANUAL_BACKFILL) return;
|
|
685
|
+
if (end >= dateRange.endDate) return;
|
|
686
|
+
this.context.log(
|
|
687
|
+
LOG_LEVEL.INFO,
|
|
688
|
+
`Node "${node.name}" stops at ${end}, not at the EndDate ${dateRange.endDate}: endLagDays ends its window that many days before today`
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Notes a node that has nothing to ask for this run because its lag ends its window
|
|
693
|
+
* before the run's first day.
|
|
694
|
+
* @private
|
|
695
|
+
*/
|
|
696
|
+
_logLaggedOut(node, dateRange, end) {
|
|
697
|
+
this.context.log(
|
|
698
|
+
LOG_LEVEL.INFO,
|
|
699
|
+
`Node "${node.name}" asks for nothing this run: endLagDays ends its window on ${end}, before the run's first day ${dateRange.startDate}`
|
|
700
|
+
);
|
|
651
701
|
}
|
|
652
702
|
/**
|
|
653
703
|
* Lists each account's latest error; for a source without accounts, just the error.
|
|
@@ -737,7 +787,9 @@ class AbstractConnector {
|
|
|
737
787
|
} else if (state.accountless) {
|
|
738
788
|
message = `Nothing was imported because access was refused: ${describe(entries)}`;
|
|
739
789
|
} else {
|
|
740
|
-
|
|
790
|
+
const subjects = new Set(entries.flatMap(([, entry]) => [...entry.subjects]));
|
|
791
|
+
const where = subjects.size === 1 ? ` while importing ${[...subjects][0]}` : "";
|
|
792
|
+
message = `All ${state.attemptedCount} accounts were skipped${where}, so nothing was imported. This points to a global failure, such as an expired access token, rather than individual accounts being inaccessible. Errors: ${describe(entries)}`;
|
|
741
793
|
}
|
|
742
794
|
const error = new Error(message);
|
|
743
795
|
error.isWarning = failed.length === 0;
|
|
@@ -820,11 +872,13 @@ class AbstractConnector {
|
|
|
820
872
|
} catch (error) {
|
|
821
873
|
throw asStorageFailure(error);
|
|
822
874
|
}
|
|
875
|
+
this._countRows(writer.nodeName, rows.length);
|
|
823
876
|
}
|
|
824
877
|
/**
|
|
825
878
|
* Resolves short links for the fields a schema node declares under `shortLinks`, writing
|
|
826
|
-
* each
|
|
827
|
-
* Links is off, or no spec has its field (and its `_parsed`
|
|
879
|
+
* the address each short link service points to next to the original. No-op when the node
|
|
880
|
+
* has no spec, Process Short Links is off, or no spec has its field (and its `_parsed`
|
|
881
|
+
* target) selected.
|
|
828
882
|
*
|
|
829
883
|
* The helpers are bare globals of Core/Utils/ShortLinksUtils.js, a script in the bundle's
|
|
830
884
|
* scope; nothing here touches them for a node without a spec.
|
|
@@ -835,7 +889,7 @@ class AbstractConnector {
|
|
|
835
889
|
* @returns {Promise<object[]>} records with resolved links, or the same array
|
|
836
890
|
*/
|
|
837
891
|
async resolveShortLinks(nodeName, data, fields) {
|
|
838
|
-
var _a, _b, _c, _d
|
|
892
|
+
var _a, _b, _c, _d;
|
|
839
893
|
const specs = (_c = (_b = (_a = this.source) == null ? void 0 : _a.fieldsSchema) == null ? void 0 : _b[nodeName]) == null ? void 0 : _c.shortLinks;
|
|
840
894
|
if (!Array.isArray(specs) || specs.length === 0 || !data.length) return data;
|
|
841
895
|
if (((_d = this.context.getParameter("ProcessShortLinks")) == null ? void 0 : _d.value) === false) return data;
|
|
@@ -846,12 +900,7 @@ class AbstractConnector {
|
|
|
846
900
|
if (activeSpecs.length === 0) return data;
|
|
847
901
|
const cache = this._shortLinksCache();
|
|
848
902
|
return resolveShortLinkFields(data, activeSpecs, {
|
|
849
|
-
|
|
850
|
-
...getShortLinkDomainsFromEnv(),
|
|
851
|
-
// Domains saved by the former Facebook "Short Link Domains" setting keep working
|
|
852
|
-
// until every environment sets CONNECTOR_SHORT_LINK_DOMAINS.
|
|
853
|
-
...parseShortLinkDomains((_e = this.context.getParameter("ShortLinkDomains")) == null ? void 0 : _e.value)
|
|
854
|
-
],
|
|
903
|
+
allowedHosts: getShortLinkDomainsFromEnv(),
|
|
855
904
|
resolvedLinksCache: cache.resolved,
|
|
856
905
|
failedLinks: cache.failed
|
|
857
906
|
});
|
|
@@ -930,7 +979,7 @@ class AbstractConnector {
|
|
|
930
979
|
onBatch: (batch) => this._writeBatch(writer, batch, fields)
|
|
931
980
|
});
|
|
932
981
|
await this._writeBatch(writer, data, fields);
|
|
933
|
-
});
|
|
982
|
+
}, nodeName);
|
|
934
983
|
}
|
|
935
984
|
}
|
|
936
985
|
/**
|
|
@@ -955,6 +1004,16 @@ class AbstractConnector {
|
|
|
955
1004
|
*/
|
|
956
1005
|
async _processTimeSeriesNodes(nodes, accounts, state) {
|
|
957
1006
|
if (!nodes.length) return;
|
|
1007
|
+
const lagging = nodes.filter(
|
|
1008
|
+
(node) => {
|
|
1009
|
+
var _a, _b;
|
|
1010
|
+
return this.source.getDateStrategy(node.name) !== DATE_STRATEGY.NONE && (((_b = (_a = this.source).getEndLagDays) == null ? void 0 : _b.call(_a, node.name)) ?? 0) > 0;
|
|
1011
|
+
}
|
|
1012
|
+
);
|
|
1013
|
+
const dateRange = lagging.length ? this.getDateRange() : null;
|
|
1014
|
+
if (dateRange) {
|
|
1015
|
+
state.cursorCap = lagging.map((node) => this._nodeWindowEnd(node, dateRange)).reduce((earliest, date) => date < earliest ? date : earliest);
|
|
1016
|
+
}
|
|
958
1017
|
const dayByDayNodes = [];
|
|
959
1018
|
const windowEndDates = [];
|
|
960
1019
|
for (const node of nodes) {
|
|
@@ -993,6 +1052,12 @@ class AbstractConnector {
|
|
|
993
1052
|
this.context.log(LOG_LEVEL.WARN, `Could not determine date range for node "${node.name}"`);
|
|
994
1053
|
return null;
|
|
995
1054
|
}
|
|
1055
|
+
const endDate = this._nodeWindowEnd(node, dateRange);
|
|
1056
|
+
if (endDate < dateRange.startDate) {
|
|
1057
|
+
this._logLaggedOut(node, dateRange, endDate);
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
this._logLagCut(node, dateRange, endDate);
|
|
996
1061
|
const writers = /* @__PURE__ */ new Map();
|
|
997
1062
|
let completedBy = 0;
|
|
998
1063
|
this._beginPass(state);
|
|
@@ -1004,17 +1069,17 @@ class AbstractConnector {
|
|
|
1004
1069
|
fields: node.fields,
|
|
1005
1070
|
accountId: (account == null ? void 0 : account.id) ?? null,
|
|
1006
1071
|
startDate: dateRange.startDate,
|
|
1007
|
-
endDate
|
|
1072
|
+
endDate
|
|
1008
1073
|
});
|
|
1009
1074
|
await this._writeBatch(writer, data, node.fields);
|
|
1010
|
-
});
|
|
1075
|
+
}, node.name);
|
|
1011
1076
|
if (done) completedBy += 1;
|
|
1012
1077
|
}
|
|
1013
1078
|
if (completedBy === 0 || state.passHardFailures > 0) {
|
|
1014
1079
|
state.cursorHalted = true;
|
|
1015
1080
|
return null;
|
|
1016
1081
|
}
|
|
1017
|
-
return
|
|
1082
|
+
return endDate;
|
|
1018
1083
|
}
|
|
1019
1084
|
/**
|
|
1020
1085
|
* DATE_STRATEGY.NONE: the node carries no date window at all, so there is
|
|
@@ -1054,8 +1119,15 @@ class AbstractConnector {
|
|
|
1054
1119
|
return;
|
|
1055
1120
|
}
|
|
1056
1121
|
const writers = /* @__PURE__ */ new Map();
|
|
1057
|
-
|
|
1122
|
+
const endDates = new Map(nodes.map((node) => [node, this._nodeWindowEnd(node, dateRange)]));
|
|
1123
|
+
for (const [node, end] of endDates) {
|
|
1124
|
+
if (end < dateRange.startDate) this._logLaggedOut(node, dateRange, end);
|
|
1125
|
+
else this._logLagCut(node, dateRange, end);
|
|
1126
|
+
}
|
|
1127
|
+
const lastDay = [...endDates.values()].reduce((latest, date) => date > latest ? date : latest);
|
|
1128
|
+
for (const date of this._iterateDates(dateRange.startDate, lastDay)) {
|
|
1058
1129
|
const formattedDate = this._formatDate(date);
|
|
1130
|
+
const dayNodes = nodes.filter((node) => endDates.get(node) >= formattedDate);
|
|
1059
1131
|
let completedBy = 0;
|
|
1060
1132
|
this._beginPass(state);
|
|
1061
1133
|
for (const account of accounts) {
|
|
@@ -1064,7 +1136,7 @@ class AbstractConnector {
|
|
|
1064
1136
|
continue;
|
|
1065
1137
|
}
|
|
1066
1138
|
const done = await this._runForAccount(state, account, async () => {
|
|
1067
|
-
for (const node of
|
|
1139
|
+
for (const node of dayNodes) {
|
|
1068
1140
|
const writer = this._nodeWriter(writers, node);
|
|
1069
1141
|
const data = await this.source.fetchData({
|
|
1070
1142
|
nodeName: node.name,
|
|
@@ -1075,7 +1147,7 @@ class AbstractConnector {
|
|
|
1075
1147
|
});
|
|
1076
1148
|
await this._writeBatch(writer, data, node.fields);
|
|
1077
1149
|
}
|
|
1078
|
-
});
|
|
1150
|
+
}, formattedDate);
|
|
1079
1151
|
if (done) completedBy += 1;
|
|
1080
1152
|
}
|
|
1081
1153
|
if (completedBy === 0 && state.passHardFailures === 0 && accounts.length > 0) {
|
|
@@ -1135,7 +1207,7 @@ class AbstractConnector {
|
|
|
1135
1207
|
endDate: null
|
|
1136
1208
|
});
|
|
1137
1209
|
if (data && data.length) batches.push(data);
|
|
1138
|
-
});
|
|
1210
|
+
}, nodeName);
|
|
1139
1211
|
if (!done) missing.add(this._accountKey(account));
|
|
1140
1212
|
}
|
|
1141
1213
|
if (missing.size) {
|
|
@@ -1183,6 +1255,29 @@ class AbstractConnector {
|
|
|
1183
1255
|
);
|
|
1184
1256
|
}
|
|
1185
1257
|
await storage.replaceData(snapshot);
|
|
1258
|
+
this._countRows(nodeName, snapshot.length);
|
|
1259
|
+
}
|
|
1260
|
+
/** @private */
|
|
1261
|
+
_countRows(nodeName, count) {
|
|
1262
|
+
if (!this._rowsByNode) return;
|
|
1263
|
+
this._rowsByNode.set(nodeName, (this._rowsByNode.get(nodeName) ?? 0) + count);
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Reports 0 rows written for every node that loaded nothing in a completed run. The
|
|
1267
|
+
* storages report only the rows they write, so without this Run History showed such a run
|
|
1268
|
+
* as a plain success with no count at all.
|
|
1269
|
+
*
|
|
1270
|
+
* @param {object[]} nodes the run's planned nodes
|
|
1271
|
+
* @private
|
|
1272
|
+
*/
|
|
1273
|
+
_reportEmptyNodes(nodes) {
|
|
1274
|
+
var _a;
|
|
1275
|
+
for (const node of nodes) {
|
|
1276
|
+
if (((_a = this._rowsByNode) == null ? void 0 : _a.get(node.name)) > 0) continue;
|
|
1277
|
+
this.context.emitAnalytics("rows_written", 0, {
|
|
1278
|
+
node: this.source.getDestinationName(node.name, node.schema)
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1186
1281
|
}
|
|
1187
1282
|
/**
|
|
1188
1283
|
* One row per unique key, the last one read: what a MERGE keeps. The storages dedupe
|
|
@@ -1788,6 +1883,13 @@ const _AbstractSource = class _AbstractSource {
|
|
|
1788
1883
|
getDateStrategy(nodeName) {
|
|
1789
1884
|
return DATE_STRATEGY.DAY_BY_DAY;
|
|
1790
1885
|
}
|
|
1886
|
+
/**
|
|
1887
|
+
* How many days before today a time-series node's window ends, for an API that reports
|
|
1888
|
+
* only completed days. 0: the window ends today.
|
|
1889
|
+
*/
|
|
1890
|
+
getEndLagDays(nodeName) {
|
|
1891
|
+
return 0;
|
|
1892
|
+
}
|
|
1791
1893
|
/**
|
|
1792
1894
|
* Called after all nodes for an account are processed.
|
|
1793
1895
|
*/
|
|
@@ -1920,14 +2022,19 @@ const _AbstractSource = class _AbstractSource {
|
|
|
1920
2022
|
if (!response.ok) {
|
|
1921
2023
|
const bodyText = typeof response.text === "function" ? await response.text().catch(() => "") : "";
|
|
1922
2024
|
const snippet = bodyText ? ` — ${bodyText.slice(0, 300)}` : "";
|
|
1923
|
-
|
|
1924
|
-
error.response = response;
|
|
1925
|
-
error.statusCode = response.status;
|
|
1926
|
-
error.responseBody = bodyText;
|
|
2025
|
+
let payload;
|
|
1927
2026
|
try {
|
|
1928
|
-
|
|
2027
|
+
payload = JSON.parse(bodyText);
|
|
1929
2028
|
} catch {
|
|
1930
2029
|
}
|
|
2030
|
+
const providerMessage = _AbstractSource._providerErrorMessage(payload);
|
|
2031
|
+
const error = new Error(
|
|
2032
|
+
providerMessage ? `HTTP ${response.status}: ${providerMessage}` : `HTTP ${response.status}: ${response.statusText}${snippet}`
|
|
2033
|
+
);
|
|
2034
|
+
error.response = response;
|
|
2035
|
+
error.statusCode = response.status;
|
|
2036
|
+
error.responseBody = bodyText;
|
|
2037
|
+
if (payload !== void 0) error.payload = payload;
|
|
1931
2038
|
if (await this.isValidToRetry(error) && attempt < totalAttempts - 1) {
|
|
1932
2039
|
const delay = this.calculateBackoff(attempt, initialDelay);
|
|
1933
2040
|
this.context.log(
|
|
@@ -1966,6 +2073,28 @@ const _AbstractSource = class _AbstractSource {
|
|
|
1966
2073
|
}
|
|
1967
2074
|
}
|
|
1968
2075
|
}
|
|
2076
|
+
/**
|
|
2077
|
+
* The reason a provider gave in its JSON error body, looked up where main's
|
|
2078
|
+
* _extractErrorInfo looked for it, and in a JSON:API error's `detail`. Not its `title`: that
|
|
2079
|
+
* is the same summary for every occurrence, and the body snippet it would replace names the
|
|
2080
|
+
* field in `source.pointer`.
|
|
2081
|
+
*
|
|
2082
|
+
* @param {*} payload - the parsed error body
|
|
2083
|
+
* @returns {string|null} null when the body names no message
|
|
2084
|
+
*/
|
|
2085
|
+
static _providerErrorMessage(payload) {
|
|
2086
|
+
var _a;
|
|
2087
|
+
const firstError = Array.isArray(payload == null ? void 0 : payload.errors) ? payload.errors[0] : void 0;
|
|
2088
|
+
const candidates = [
|
|
2089
|
+
(_a = payload == null ? void 0 : payload.error) == null ? void 0 : _a.message,
|
|
2090
|
+
payload == null ? void 0 : payload.message,
|
|
2091
|
+
payload == null ? void 0 : payload.errorMessage,
|
|
2092
|
+
payload == null ? void 0 : payload.error_message,
|
|
2093
|
+
firstError == null ? void 0 : firstError.message,
|
|
2094
|
+
firstError == null ? void 0 : firstError.detail
|
|
2095
|
+
];
|
|
2096
|
+
return candidates.find((message) => typeof message === "string" && message.trim()) ?? null;
|
|
2097
|
+
}
|
|
1969
2098
|
/**
|
|
1970
2099
|
* The fetch options to use for the next redirect hop.
|
|
1971
2100
|
*
|
|
@@ -2333,6 +2462,19 @@ class AbstractStorage {
|
|
|
2333
2462
|
return fieldsParam.value.split(",").map((field) => field.trim()).filter((field) => field !== "").map((field) => field.split(" ")).filter((field) => field.length === 2).map((field) => field[1]);
|
|
2334
2463
|
}
|
|
2335
2464
|
//----------------------------------------------------------------
|
|
2465
|
+
//---- missingSelectedFieldError ----------------------------------
|
|
2466
|
+
/**
|
|
2467
|
+
* The error for a field the Data Mart selects that the connector's schema does not have.
|
|
2468
|
+
* Its usual cause is a connector whose fields changed after the Data Mart was set up.
|
|
2469
|
+
* @param {string} columnName
|
|
2470
|
+
* @returns {Error}
|
|
2471
|
+
*/
|
|
2472
|
+
missingSelectedFieldError(columnName) {
|
|
2473
|
+
return new Error(
|
|
2474
|
+
`Field "${columnName}" is selected for import, but the connector does not provide it. If the connector's fields changed after this Data Mart was set up, open Edit Fields in the Data Mart's Input Source and save: a field the connector no longer provides is dropped.`
|
|
2475
|
+
);
|
|
2476
|
+
}
|
|
2477
|
+
//----------------------------------------------------------------
|
|
2336
2478
|
//---- getColumnType -----------------------------------------------
|
|
2337
2479
|
/**
|
|
2338
2480
|
* Get column type for storage from schema
|
|
@@ -2829,6 +2971,7 @@ class DeclarativeSource extends AbstractSource {
|
|
|
2829
2971
|
this._activeErrorHandler = null;
|
|
2830
2972
|
this._pendingBackoff = null;
|
|
2831
2973
|
this._tokenCache = /* @__PURE__ */ new Map();
|
|
2974
|
+
this._extractedByNode = /* @__PURE__ */ new Map();
|
|
2832
2975
|
}
|
|
2833
2976
|
_compileNodes(nodes = {}) {
|
|
2834
2977
|
const schema = {};
|
|
@@ -2879,8 +3022,13 @@ class DeclarativeSource extends AbstractSource {
|
|
|
2879
3022
|
getDateStrategy(nodeName) {
|
|
2880
3023
|
return nodeDateStrategy(this.model.nodes[nodeName]);
|
|
2881
3024
|
}
|
|
3025
|
+
getEndLagDays(nodeName) {
|
|
3026
|
+
const node = this.model.nodes[nodeName];
|
|
3027
|
+
if (nodeDateStrategy(node) === DATE_STRATEGY.NONE) return 0;
|
|
3028
|
+
return node.incremental.endLagDays ?? 0;
|
|
3029
|
+
}
|
|
2882
3030
|
async fetchData({ nodeName, fields, accountId, startDate, endDate }) {
|
|
2883
|
-
var _a, _b, _c, _d;
|
|
3031
|
+
var _a, _b, _c, _d, _e;
|
|
2884
3032
|
const node = this.model.nodes[nodeName];
|
|
2885
3033
|
if (!node) throw new Error(`DeclarativeSource: unknown node "${nodeName}"`);
|
|
2886
3034
|
const scope = this._scope({ accountId, startDate, endDate, fields });
|
|
@@ -2948,8 +3096,12 @@ class DeclarativeSource extends AbstractSource {
|
|
|
2948
3096
|
this.runLimits
|
|
2949
3097
|
);
|
|
2950
3098
|
const rawRecords = await retriever.run(scope);
|
|
3099
|
+
const nodeTag = ((_d = this.context.getParameter("DestinationTableName")) == null ? void 0 : _d.value) ?? nodeName;
|
|
3100
|
+
this._extractedByNode.set(nodeName, {
|
|
3101
|
+
tag: nodeTag,
|
|
3102
|
+
count: (((_e = this._extractedByNode.get(nodeName)) == null ? void 0 : _e.count) ?? 0) + rawRecords.length
|
|
3103
|
+
});
|
|
2951
3104
|
if (rawRecords.length > 0) {
|
|
2952
|
-
const nodeTag = ((_d = this.context.getParameter("DestinationTableName")) == null ? void 0 : _d.value) ?? nodeName;
|
|
2953
3105
|
this.context.emitAnalytics("rows_extracted", rawRecords.length, { node: nodeTag });
|
|
2954
3106
|
}
|
|
2955
3107
|
const recordFilter = node.recordFilter ? new RecordFilter(node.recordFilter) : null;
|
|
@@ -2964,6 +3116,37 @@ class DeclarativeSource extends AbstractSource {
|
|
|
2964
3116
|
}
|
|
2965
3117
|
return this._selectFields(new FieldCaster(node.fields || {}).cast(records), node, fields);
|
|
2966
3118
|
}
|
|
3119
|
+
/**
|
|
3120
|
+
* Names each node whose requests got no records in the whole run. A record path that
|
|
3121
|
+
* misses the data or a wrong parameter value completes like a run with nothing new, so this
|
|
3122
|
+
* is the only hint the run leaves. INFO, not WARN: an API with nothing for the window is
|
|
3123
|
+
* also a normal outcome.
|
|
3124
|
+
*/
|
|
3125
|
+
onImportComplete() {
|
|
3126
|
+
var _a;
|
|
3127
|
+
for (const [nodeName, { tag, count }] of this._extractedByNode) {
|
|
3128
|
+
if (count > 0) continue;
|
|
3129
|
+
this.context.emitAnalytics("rows_extracted", 0, { node: tag });
|
|
3130
|
+
this.context.log(
|
|
3131
|
+
LOG_LEVEL.INFO,
|
|
3132
|
+
`No records came back for node "${nodeName}" in this run. If you expected data, check the node's record path, and the parameters and dates its request uses.` + DeclarativeSource._partitionHint((_a = this.model.nodes[nodeName]) == null ? void 0 : _a.partitionRouter)
|
|
3133
|
+
);
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* A partitioned node sends one request per slice, and none when there are no slices, so
|
|
3138
|
+
* its own request is not where an empty run starts. Named as the builder's Partition form
|
|
3139
|
+
* names the fields.
|
|
3140
|
+
*/
|
|
3141
|
+
static _partitionHint(partitionRouter) {
|
|
3142
|
+
if ((partitionRouter == null ? void 0 : partitionRouter.type) === "substream") {
|
|
3143
|
+
return " Its requests run once per parent record, so also check Parent record path and Parent key: when they find nothing, the node sends no requests.";
|
|
3144
|
+
}
|
|
3145
|
+
if ((partitionRouter == null ? void 0 : partitionRouter.type) === "list") {
|
|
3146
|
+
return " Its requests run once per value of its list, so also check Values or Values from parameter: an empty list sends no requests.";
|
|
3147
|
+
}
|
|
3148
|
+
return "";
|
|
3149
|
+
}
|
|
2967
3150
|
/**
|
|
2968
3151
|
* Keeps the fields the Data Mart selected, and the node's unique keys, which the storage
|
|
2969
3152
|
* merges on. A bundled source reads only the selected fields; casting every declared one
|
|
@@ -2994,9 +3177,10 @@ class DeclarativeSource extends AbstractSource {
|
|
|
2994
3177
|
async isValidToRetry(error) {
|
|
2995
3178
|
const handler = this._activeErrorHandler;
|
|
2996
3179
|
if (!handler) {
|
|
2997
|
-
this._pendingBackoff = null;
|
|
2998
3180
|
error._declAction = null;
|
|
2999
|
-
|
|
3181
|
+
const retry2 = this._defaultRetryable(error);
|
|
3182
|
+
this._pendingBackoff = retry2 ? { filter: null, response: error.response } : null;
|
|
3183
|
+
return retry2;
|
|
3000
3184
|
}
|
|
3001
3185
|
let bodyText = "";
|
|
3002
3186
|
let bodyJson = null;
|
|
@@ -3015,13 +3199,23 @@ class DeclarativeSource extends AbstractSource {
|
|
|
3015
3199
|
return retry;
|
|
3016
3200
|
}
|
|
3017
3201
|
calculateBackoff(attempt, initialDelay) {
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
const ms = this._activeErrorHandler.delayMs(
|
|
3202
|
+
const pending = this._pendingBackoff;
|
|
3203
|
+
this._pendingBackoff = null;
|
|
3204
|
+
if (pending && this._activeErrorHandler) {
|
|
3205
|
+
const ms = this._activeErrorHandler.delayMs(
|
|
3206
|
+
pending.filter,
|
|
3207
|
+
pending.response,
|
|
3208
|
+
attempt,
|
|
3209
|
+
initialDelay
|
|
3210
|
+
);
|
|
3022
3211
|
if (ms != null) return ms;
|
|
3023
3212
|
}
|
|
3024
|
-
|
|
3213
|
+
const fallback = super.calculateBackoff(attempt, initialDelay);
|
|
3214
|
+
if (pending && !process.env.OW_TEST) {
|
|
3215
|
+
const ms = retryAfterDelayMs(pending.response);
|
|
3216
|
+
if (ms != null) return Math.max(ms, fallback);
|
|
3217
|
+
}
|
|
3218
|
+
return fallback;
|
|
3025
3219
|
}
|
|
3026
3220
|
/**
|
|
3027
3221
|
* A 401 or 403 skips the account as refused access, but not one a filter mapped to FAIL:
|
|
@@ -3232,6 +3426,16 @@ function clampHeaderDelay(ms) {
|
|
|
3232
3426
|
if (!Number.isFinite(ms)) return null;
|
|
3233
3427
|
return Math.min(Math.max(0, ms), MAX_HEADER_RETRY_DELAY_MS);
|
|
3234
3428
|
}
|
|
3429
|
+
function retryAfterDelayMs(response, headerName = "Retry-After") {
|
|
3430
|
+
var _a, _b;
|
|
3431
|
+
const raw = (_b = (_a = response == null ? void 0 : response.headers) == null ? void 0 : _a.get) == null ? void 0 : _b.call(_a, headerName);
|
|
3432
|
+
if (raw == null) return null;
|
|
3433
|
+
const secs = Number(raw);
|
|
3434
|
+
if (Number.isFinite(secs)) return clampHeaderDelay(secs * 1e3);
|
|
3435
|
+
const dateMs = Date.parse(raw);
|
|
3436
|
+
if (!Number.isNaN(dateMs)) return clampHeaderDelay(dateMs - Date.now());
|
|
3437
|
+
return null;
|
|
3438
|
+
}
|
|
3235
3439
|
class ErrorHandler {
|
|
3236
3440
|
/**
|
|
3237
3441
|
* @param {{ responseFilters?: Array<object>, backoff?: object }} config
|
|
@@ -3302,16 +3506,7 @@ class ErrorHandler {
|
|
|
3302
3506
|
}
|
|
3303
3507
|
}
|
|
3304
3508
|
_waitTimeFromHeader(spec, response) {
|
|
3305
|
-
|
|
3306
|
-
if (!response) return null;
|
|
3307
|
-
const headerName = spec && spec.header || "Retry-After";
|
|
3308
|
-
const raw = (_b = (_a = response.headers) == null ? void 0 : _a.get) == null ? void 0 : _b.call(_a, headerName);
|
|
3309
|
-
if (raw == null) return null;
|
|
3310
|
-
const secs = Number(raw);
|
|
3311
|
-
if (Number.isFinite(secs)) return clampHeaderDelay(secs * 1e3);
|
|
3312
|
-
const dateMs = Date.parse(raw);
|
|
3313
|
-
if (!Number.isNaN(dateMs)) return clampHeaderDelay(dateMs - Date.now());
|
|
3314
|
-
return null;
|
|
3509
|
+
return retryAfterDelayMs(response, spec && spec.header || "Retry-After");
|
|
3315
3510
|
}
|
|
3316
3511
|
_waitUntilTimeFromHeader(spec, response) {
|
|
3317
3512
|
var _a, _b;
|
|
@@ -3509,6 +3704,45 @@ const DATE_STRATEGIES = /* @__PURE__ */ new Set(["day-by-day", "range", "none"])
|
|
|
3509
3704
|
const PAGINATION_TYPES = /* @__PURE__ */ new Set(["none", "offset", "page", "cursor"]);
|
|
3510
3705
|
const INJECT_TARGETS = /* @__PURE__ */ new Set(["query", "header", "body", "path"]);
|
|
3511
3706
|
const RESPONSE_FORMATS = /* @__PURE__ */ new Set(["json", "csv", "jsonl"]);
|
|
3707
|
+
const MAX_END_LAG_DAYS = 30;
|
|
3708
|
+
const DATE_WINDOW_PLACEHOLDER = /\{\{\s*(dateWindow(?:\.[a-zA-Z0-9_]+)*)\s*\}\}/;
|
|
3709
|
+
function dateWindowTemplateIn(value) {
|
|
3710
|
+
var _a;
|
|
3711
|
+
if (typeof value === "string") return ((_a = DATE_WINDOW_PLACEHOLDER.exec(value)) == null ? void 0 : _a[1]) ?? null;
|
|
3712
|
+
if (value && typeof value === "object") {
|
|
3713
|
+
for (const item of Object.values(value)) {
|
|
3714
|
+
const path = dateWindowTemplateIn(item);
|
|
3715
|
+
if (path) return path;
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
return null;
|
|
3719
|
+
}
|
|
3720
|
+
function assertNoStrictDateWindowTemplate(nodeName, node) {
|
|
3721
|
+
var _a, _b, _c;
|
|
3722
|
+
const isAsync = ((_a = node.retriever) == null ? void 0 : _a.type) === "async";
|
|
3723
|
+
for (const [where, spec] of [
|
|
3724
|
+
// An async node sends its retriever's requests, never `request`.
|
|
3725
|
+
["request", isAsync ? void 0 : node.request],
|
|
3726
|
+
["retriever.submit", isAsync ? node.retriever.submit : void 0],
|
|
3727
|
+
["retriever.poll", isAsync ? node.retriever.poll : void 0],
|
|
3728
|
+
["partitionRouter.parent.request", (_c = (_b = node.partitionRouter) == null ? void 0 : _b.parent) == null ? void 0 : _c.request]
|
|
3729
|
+
]) {
|
|
3730
|
+
if (!spec || typeof spec !== "object") continue;
|
|
3731
|
+
const sendsBody = String(spec.method || "GET").toUpperCase() !== "GET";
|
|
3732
|
+
for (const [place, value] of [
|
|
3733
|
+
[`${where}.path`, spec.path],
|
|
3734
|
+
[`${where}.body`, sendsBody ? spec.body : void 0]
|
|
3735
|
+
]) {
|
|
3736
|
+
const path = dateWindowTemplateIn(value);
|
|
3737
|
+
if (path) {
|
|
3738
|
+
const fix = node.isFullRefresh ? 'Remove "isFullRefresh" and add an "incremental" block to fetch by date window, or remove the template.' : 'Add an "incremental" block to the node, or remove the template.';
|
|
3739
|
+
throw new Error(
|
|
3740
|
+
`ManifestParser: node "${nodeName}" uses "{{ ${path} }}" in ${place}, but the node is never given a date window: only a node with an "incremental" strategy, and without "isFullRefresh", is fetched one window at a time. ${fix}`
|
|
3741
|
+
);
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3512
3746
|
const LEGACY_INJECT_PARAM = {
|
|
3513
3747
|
cursor: "cursorParam",
|
|
3514
3748
|
offset: "offsetParam",
|
|
@@ -3632,10 +3866,12 @@ function validateBackoff(b, nodeName, where) {
|
|
|
3632
3866
|
class ManifestParser {
|
|
3633
3867
|
/**
|
|
3634
3868
|
* @param {string} json
|
|
3869
|
+
* @param {{ authoring?: boolean }} [options] - `authoring` adds the checks for a manifest
|
|
3870
|
+
* being published or tested, which a stored version must not start failing on.
|
|
3635
3871
|
* @returns {object} ManifestModel
|
|
3636
3872
|
*/
|
|
3637
|
-
parse(json) {
|
|
3638
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z;
|
|
3873
|
+
parse(json, { authoring = false } = {}) {
|
|
3874
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _A;
|
|
3639
3875
|
let raw;
|
|
3640
3876
|
try {
|
|
3641
3877
|
raw = JSON.parse(json);
|
|
@@ -3811,11 +4047,27 @@ class ManifestParser {
|
|
|
3811
4047
|
`ManifestParser: node "${nodeName}" has unknown incremental.strategy "${strategy}"`
|
|
3812
4048
|
);
|
|
3813
4049
|
}
|
|
4050
|
+
const endLagDays = (_w = node.incremental) == null ? void 0 : _w.endLagDays;
|
|
4051
|
+
if (endLagDays !== void 0) {
|
|
4052
|
+
if (!Number.isInteger(endLagDays) || endLagDays < 0 || endLagDays > MAX_END_LAG_DAYS) {
|
|
4053
|
+
throw new Error(
|
|
4054
|
+
`ManifestParser: node "${nodeName}" incremental.endLagDays must be a whole number of days from 0 to ${MAX_END_LAG_DAYS}, e.g. 1 to leave out today`
|
|
4055
|
+
);
|
|
4056
|
+
}
|
|
4057
|
+
if (strategy === "none") {
|
|
4058
|
+
throw new Error(
|
|
4059
|
+
`ManifestParser: node "${nodeName}" incremental.endLagDays needs a "day-by-day" or "range" strategy — a node with no date window has no window end to move`
|
|
4060
|
+
);
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
3814
4063
|
if (declaresDateWindow(node) && node.isFullRefresh) {
|
|
3815
4064
|
throw new Error(
|
|
3816
4065
|
`ManifestParser: node "${nodeName}" declares both "incremental" and "isFullRefresh", which are mutually exclusive — a full-refresh node replaces the whole table and is never given a date window, so the incremental block would be silently ignored. Remove "isFullRefresh" to fetch by date window, or remove the "incremental" block.`
|
|
3817
4066
|
);
|
|
3818
4067
|
}
|
|
4068
|
+
if (authoring && !declaresDateWindow(node)) {
|
|
4069
|
+
assertNoStrictDateWindowTemplate(nodeName, node);
|
|
4070
|
+
}
|
|
3819
4071
|
if (node.transformations !== void 0) {
|
|
3820
4072
|
if (!Array.isArray(node.transformations)) {
|
|
3821
4073
|
throw new Error(`ManifestParser: node "${nodeName}" transformations must be an array`);
|
|
@@ -3911,7 +4163,7 @@ class ManifestParser {
|
|
|
3911
4163
|
}
|
|
3912
4164
|
}
|
|
3913
4165
|
if (eh.backoff !== void 0) validateBackoff(eh.backoff, nodeName, "errorHandler");
|
|
3914
|
-
if (((
|
|
4166
|
+
if (((_x = node.retriever) == null ? void 0 : _x.type) === "async") {
|
|
3915
4167
|
throw new Error(
|
|
3916
4168
|
`ManifestParser: node "${nodeName}" errorHandler is not supported with an async retriever — an async node paces itself through "retriever.poll.backoff" instead, so these filters and this backoff would never run. Remove "errorHandler", or make the node sync.`
|
|
3917
4169
|
);
|
|
@@ -3964,7 +4216,7 @@ class ManifestParser {
|
|
|
3964
4216
|
);
|
|
3965
4217
|
}
|
|
3966
4218
|
}
|
|
3967
|
-
if (((
|
|
4219
|
+
if (((_y = node.retriever) == null ? void 0 : _y.type) === "async") {
|
|
3968
4220
|
throw new Error(
|
|
3969
4221
|
`ManifestParser: node "${nodeName}" partitionRouter is not supported with an async retriever`
|
|
3970
4222
|
);
|
|
@@ -4010,7 +4262,7 @@ class ManifestParser {
|
|
|
4010
4262
|
}
|
|
4011
4263
|
}
|
|
4012
4264
|
}
|
|
4013
|
-
const rType = ((
|
|
4265
|
+
const rType = ((_z = node.retriever) == null ? void 0 : _z.type) ?? "sync";
|
|
4014
4266
|
if (rType === "sync") {
|
|
4015
4267
|
if (!node.request) {
|
|
4016
4268
|
throw new Error(`ManifestParser: node "${nodeName}" is missing "request"`);
|
|
@@ -4142,7 +4394,7 @@ class ManifestParser {
|
|
|
4142
4394
|
);
|
|
4143
4395
|
}
|
|
4144
4396
|
for (const key of ["maxAttempts", "initialMs", "maxMs"]) {
|
|
4145
|
-
const value = (
|
|
4397
|
+
const value = (_A = r.poll.backoff) == null ? void 0 : _A[key];
|
|
4146
4398
|
if (value === void 0) continue;
|
|
4147
4399
|
const valid = typeof value === "number" && value > 0 && (key === "maxAttempts" ? Number.isInteger(value) : Number.isFinite(value));
|
|
4148
4400
|
if (!valid) {
|
|
@@ -4221,7 +4473,7 @@ class ManifestParser {
|
|
|
4221
4473
|
const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
4222
4474
|
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(nodeName)) {
|
|
4223
4475
|
throw new Error(
|
|
4224
|
-
`ManifestParser: node "${nodeName}" must start with a letter and contain only letters, digits and underscores`
|
|
4476
|
+
`ManifestParser: node "${nodeName}" must start with a letter and contain only letters, digits and underscores` + (nodeName.includes("/") ? `; the API path goes in the node's request.path` : "")
|
|
4225
4477
|
);
|
|
4226
4478
|
}
|
|
4227
4479
|
if (node.destinationName !== void 0 && !identifier.test(String(node.destinationName))) {
|
|
@@ -5041,6 +5293,9 @@ const BLOCKED_IPV6 = [
|
|
|
5041
5293
|
];
|
|
5042
5294
|
const CONCLUSIVE_DNS_FAILURES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA"]);
|
|
5043
5295
|
const INCONCLUSIVE_LOOKUP_ATTEMPTS = 3;
|
|
5296
|
+
function refusal(message) {
|
|
5297
|
+
return Object.assign(new Error(message), { isRefusal: true });
|
|
5298
|
+
}
|
|
5044
5299
|
class SsrfGuard {
|
|
5045
5300
|
/**
|
|
5046
5301
|
* True if `ip` is a private/loopback/link-local address. Accepts both the
|
|
@@ -5150,11 +5405,11 @@ class SsrfGuard {
|
|
|
5150
5405
|
}
|
|
5151
5406
|
if (SsrfGuard._allowLocalEgress()) return;
|
|
5152
5407
|
if (url.protocol !== "https:") {
|
|
5153
|
-
throw
|
|
5408
|
+
throw refusal(`SsrfGuard: URL must use https, got "${url.protocol}"`);
|
|
5154
5409
|
}
|
|
5155
5410
|
const host = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
5156
5411
|
if (this._isBlockedLiteralHost(host)) {
|
|
5157
|
-
throw
|
|
5412
|
+
throw refusal(`SsrfGuard: blocked IP/host "${host}"`);
|
|
5158
5413
|
}
|
|
5159
5414
|
await this._assertResolvesToPublic(host);
|
|
5160
5415
|
}
|
|
@@ -5204,7 +5459,7 @@ class SsrfGuard {
|
|
|
5204
5459
|
for (const a of list) {
|
|
5205
5460
|
const address = a && typeof a === "object" ? a.address : a;
|
|
5206
5461
|
if (SsrfGuard.isBlockedIp(address)) {
|
|
5207
|
-
throw
|
|
5462
|
+
throw refusal(`SsrfGuard: host "${host}" resolves to blocked address "${address}"`);
|
|
5208
5463
|
}
|
|
5209
5464
|
}
|
|
5210
5465
|
}
|
|
@@ -6638,25 +6893,28 @@ var OAuthUtils = {
|
|
|
6638
6893
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
6639
6894
|
}
|
|
6640
6895
|
};
|
|
6641
|
-
async function resolveShortLinkFields(data, specs, {
|
|
6896
|
+
async function resolveShortLinkFields(data, specs, { allowedHosts = [], resolvedLinksCache = /* @__PURE__ */ new Map(), failedLinks = /* @__PURE__ */ new Set() } = {}) {
|
|
6642
6897
|
if (!Array.isArray(data) || data.length === 0 || !Array.isArray(specs) || specs.length === 0) return data;
|
|
6643
|
-
const candidates = _collectCandidateUrls(data, specs,
|
|
6898
|
+
const candidates = _collectCandidateUrls(data, specs, allowedHosts);
|
|
6644
6899
|
const hasPlainTargets = specs.some((spec) => !spec.urlKey);
|
|
6645
6900
|
if (candidates.length === 0 && !hasPlainTargets) return data;
|
|
6646
6901
|
const uncachedLinks = candidates.filter((url) => !resolvedLinksCache.has(url)).map((originalUrl) => ({ originalUrl }));
|
|
6647
|
-
const freshlyResolved = await _resolveShortLinks(uncachedLinks);
|
|
6902
|
+
const freshlyResolved = await _resolveShortLinks(uncachedLinks, allowedHosts);
|
|
6648
6903
|
freshlyResolved.forEach((link) => {
|
|
6649
6904
|
resolvedLinksCache.set(link.originalUrl, link.resolvedUrl);
|
|
6650
6905
|
if (!link.ok) failedLinks.add(link.originalUrl);
|
|
6651
6906
|
});
|
|
6652
|
-
|
|
6907
|
+
const resolvable = new Set(candidates);
|
|
6908
|
+
return data.map(
|
|
6909
|
+
(record) => specs.reduce((current, spec) => _applySpec(current, spec, resolvedLinksCache, resolvable), record)
|
|
6910
|
+
);
|
|
6653
6911
|
}
|
|
6654
|
-
function _collectCandidateUrls(data, specs,
|
|
6912
|
+
function _collectCandidateUrls(data, specs, allowedHosts) {
|
|
6655
6913
|
const unique = /* @__PURE__ */ new Set();
|
|
6656
6914
|
data.forEach((record) => {
|
|
6657
6915
|
specs.forEach((spec) => {
|
|
6658
6916
|
_readUrls(record[spec.field], spec).forEach((url) => {
|
|
6659
|
-
if (_isPotentialShortLink(url,
|
|
6917
|
+
if (_isPotentialShortLink(url, allowedHosts)) unique.add(url);
|
|
6660
6918
|
});
|
|
6661
6919
|
});
|
|
6662
6920
|
});
|
|
@@ -6681,9 +6939,9 @@ function _asArray(value) {
|
|
|
6681
6939
|
}
|
|
6682
6940
|
return null;
|
|
6683
6941
|
}
|
|
6684
|
-
function _applySpec(record, spec, cache) {
|
|
6942
|
+
function _applySpec(record, spec, cache, resolvable) {
|
|
6685
6943
|
const value = record[spec.field];
|
|
6686
|
-
const resolve = (url) => typeof url === "string" && cache.has(url) ? cache.get(url) : url;
|
|
6944
|
+
const resolve = (url) => typeof url === "string" && resolvable.has(url) && cache.has(url) ? cache.get(url) : url;
|
|
6687
6945
|
if (spec.urlKey) {
|
|
6688
6946
|
const original = value && value[spec.urlKey];
|
|
6689
6947
|
const resolved = resolve(original);
|
|
@@ -6708,7 +6966,15 @@ function getShortLinkDomainsFromEnv(env = typeof process !== "undefined" ? proce
|
|
|
6708
6966
|
}
|
|
6709
6967
|
function parseShortLinkDomains(value) {
|
|
6710
6968
|
if (!value) return [];
|
|
6711
|
-
return String(value).split(/[,;\s]+/).map((entry) => entry.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0].replace(/\.$/, "").trim().toLowerCase()).filter((host) => host.includes("."));
|
|
6969
|
+
return String(value).split(/[,;\s]+/).map((entry) => entry.replace(/^[a-z]+:\/\//i, "").replace(/^\*?\./, "").split("/")[0].split(":")[0].replace(/\.$/, "").trim().toLowerCase()).map(_toAsciiHost).filter((host) => host.includes(".") && !/^\d+(\.\d+){3}$/.test(host));
|
|
6970
|
+
}
|
|
6971
|
+
function _toAsciiHost(host) {
|
|
6972
|
+
if (!host) return "";
|
|
6973
|
+
try {
|
|
6974
|
+
return new URL(`https://${host}`).hostname;
|
|
6975
|
+
} catch (_error) {
|
|
6976
|
+
return "";
|
|
6977
|
+
}
|
|
6712
6978
|
}
|
|
6713
6979
|
const SHORT_LINKS_STATE_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
6714
6980
|
const SHORT_LINKS_STATE_MAX_BYTES = 24 * 1024;
|
|
@@ -6737,60 +7003,94 @@ function buildShortLinksState(entries, now = Date.now()) {
|
|
|
6737
7003
|
return state;
|
|
6738
7004
|
}
|
|
6739
7005
|
const UTM_PARAM_PATTERN = /utm_(source|medium|campaign|term|content)/;
|
|
6740
|
-
|
|
7006
|
+
const KNOWN_SHORT_LINK_DOMAINS = [
|
|
7007
|
+
"bit.ly",
|
|
7008
|
+
"tinyurl.com",
|
|
7009
|
+
"t.co",
|
|
7010
|
+
"lnkd.in",
|
|
7011
|
+
"youtu.be",
|
|
7012
|
+
"amzn.to",
|
|
7013
|
+
"ow.ly",
|
|
7014
|
+
"buff.ly",
|
|
7015
|
+
"cutt.ly",
|
|
7016
|
+
"is.gd",
|
|
7017
|
+
"rebrand.ly"
|
|
7018
|
+
];
|
|
7019
|
+
function _isPotentialShortLink(url, allowedHosts) {
|
|
6741
7020
|
if (!url || typeof url !== "string") return false;
|
|
6742
7021
|
try {
|
|
6743
7022
|
const parsedUrl = new URL(url);
|
|
6744
7023
|
if (parsedUrl.protocol !== "https:" || url.includes("?") || UTM_PARAM_PATTERN.test(url)) {
|
|
6745
7024
|
return false;
|
|
6746
7025
|
}
|
|
6747
|
-
|
|
6748
|
-
const pathSegments = _getPathSegments(parsedUrl.pathname
|
|
6749
|
-
|
|
6750
|
-
return pathSegments.length === 1 || isConfiguredHost;
|
|
7026
|
+
if (!_isAllowedShortLinkHost(parsedUrl.hostname, allowedHosts)) return false;
|
|
7027
|
+
const pathSegments = _getPathSegments(parsedUrl.pathname);
|
|
7028
|
+
return pathSegments.every(Boolean);
|
|
6751
7029
|
} catch (_error) {
|
|
6752
7030
|
return false;
|
|
6753
7031
|
}
|
|
6754
7032
|
}
|
|
6755
|
-
function _getPathSegments(pathname
|
|
7033
|
+
function _getPathSegments(pathname) {
|
|
6756
7034
|
const segments = pathname.slice(1).split("/");
|
|
6757
7035
|
const hasTrailingSlash = segments.length > 1 && segments[segments.length - 1] === "";
|
|
6758
|
-
return
|
|
7036
|
+
return hasTrailingSlash ? segments.slice(0, -1) : segments;
|
|
6759
7037
|
}
|
|
6760
|
-
function
|
|
6761
|
-
|
|
7038
|
+
function _isAllowedShortLinkHost(hostname, allowedHosts) {
|
|
7039
|
+
const isMatch = (host) => hostname === host || hostname.endsWith(`.${host}`);
|
|
7040
|
+
return KNOWN_SHORT_LINK_DOMAINS.some(isMatch) || allowedHosts.some(isMatch);
|
|
6762
7041
|
}
|
|
6763
7042
|
const SHORT_LINK_FETCH_TIMEOUT_MS = 1e4;
|
|
6764
7043
|
const SHORT_LINK_MAX_REDIRECTS = 20;
|
|
6765
7044
|
const SHORT_LINK_CONCURRENCY = 10;
|
|
6766
|
-
async function _resolveShortLinks(shortLinks) {
|
|
7045
|
+
async function _resolveShortLinks(shortLinks, allowedHosts = []) {
|
|
6767
7046
|
const results = [];
|
|
6768
7047
|
for (let i = 0; i < shortLinks.length; i += SHORT_LINK_CONCURRENCY) {
|
|
6769
7048
|
const batch = shortLinks.slice(i, i + SHORT_LINK_CONCURRENCY);
|
|
6770
|
-
results.push(...await Promise.all(batch.map(_resolveShortLink)));
|
|
7049
|
+
results.push(...await Promise.all(batch.map((link) => _resolveShortLink(link, allowedHosts))));
|
|
6771
7050
|
}
|
|
6772
7051
|
return results;
|
|
6773
7052
|
}
|
|
6774
|
-
async function _resolveShortLink(linkObj) {
|
|
7053
|
+
async function _resolveShortLink(linkObj, allowedHosts = []) {
|
|
6775
7054
|
const originalUrl = linkObj.originalUrl;
|
|
6776
7055
|
try {
|
|
6777
|
-
return { originalUrl, resolvedUrl: await _followRedirects(originalUrl), ok: true };
|
|
7056
|
+
return { originalUrl, resolvedUrl: await _followRedirects(originalUrl, allowedHosts), ok: true };
|
|
6778
7057
|
} catch (error) {
|
|
6779
|
-
console.log(`Failed to resolve short link ${originalUrl}: ${error.message}`);
|
|
6780
|
-
return { originalUrl, resolvedUrl: originalUrl, ok:
|
|
7058
|
+
console.log(`Failed to resolve short link ${_describeUrl(originalUrl)}: ${error.message}`);
|
|
7059
|
+
return { originalUrl, resolvedUrl: originalUrl, ok: Boolean(error.isRefusal) };
|
|
7060
|
+
}
|
|
7061
|
+
}
|
|
7062
|
+
function _describeUrl(url) {
|
|
7063
|
+
try {
|
|
7064
|
+
const { origin, pathname } = new URL(url);
|
|
7065
|
+
return `${origin}${pathname}`;
|
|
7066
|
+
} catch (_error) {
|
|
7067
|
+
return "[unparseable URL]";
|
|
6781
7068
|
}
|
|
6782
7069
|
}
|
|
6783
|
-
|
|
7070
|
+
const SHORT_LINK_TRANSIENT_STATUS = (status) => status === 408 || status === 429 || status >= 500;
|
|
7071
|
+
async function _followRedirects(startUrl, allowedHosts = []) {
|
|
7072
|
+
var _a;
|
|
6784
7073
|
let currentUrl = startUrl;
|
|
6785
7074
|
for (let hop = 0; hop <= SHORT_LINK_MAX_REDIRECTS; hop++) {
|
|
6786
7075
|
if (!_isPublicHttpUrl(currentUrl)) {
|
|
6787
|
-
|
|
7076
|
+
const refusal2 = new Error(`Refusing non-public URL ${_describeUrl(currentUrl)}`);
|
|
7077
|
+
refusal2.isRefusal = true;
|
|
7078
|
+
throw refusal2;
|
|
6788
7079
|
}
|
|
7080
|
+
if (hop > 0 && !_isAllowedShortLinkHost(new URL(currentUrl).hostname, allowedHosts)) {
|
|
7081
|
+
return currentUrl;
|
|
7082
|
+
}
|
|
7083
|
+
await new SsrfGuard([]).assertPublicHttps(currentUrl);
|
|
6789
7084
|
const response = await fetch(currentUrl, {
|
|
6790
7085
|
method: "GET",
|
|
6791
7086
|
redirect: "manual",
|
|
6792
7087
|
signal: AbortSignal.timeout(SHORT_LINK_FETCH_TIMEOUT_MS)
|
|
6793
7088
|
});
|
|
7089
|
+
await ((_a = response.body) == null ? void 0 : _a.cancel().catch(() => {
|
|
7090
|
+
}));
|
|
7091
|
+
if (SHORT_LINK_TRANSIENT_STATUS(response.status)) {
|
|
7092
|
+
throw new Error(`Short link service answered ${response.status}`);
|
|
7093
|
+
}
|
|
6794
7094
|
const location = _getRedirectLocation(response);
|
|
6795
7095
|
if (!location) return currentUrl;
|
|
6796
7096
|
currentUrl = new URL(location, currentUrl).toString();
|
|
@@ -7099,7 +7399,7 @@ const Snowflake = (function() {
|
|
|
7099
7399
|
let columnName = tableColumns[i];
|
|
7100
7400
|
let columnDescription = "";
|
|
7101
7401
|
if (!(columnName in this.schema)) {
|
|
7102
|
-
throw
|
|
7402
|
+
throw this.missingSelectedFieldError(columnName);
|
|
7103
7403
|
}
|
|
7104
7404
|
let columnType = this.getColumnType(columnName);
|
|
7105
7405
|
if ("description" in this.schema[columnName]) {
|
|
@@ -7727,7 +8027,7 @@ const GoogleBigQuery = (function() {
|
|
|
7727
8027
|
let columnName = tableColumns[i];
|
|
7728
8028
|
let columnDescription = "";
|
|
7729
8029
|
if (!(columnName in this.schema)) {
|
|
7730
|
-
throw
|
|
8030
|
+
throw this.missingSelectedFieldError(columnName);
|
|
7731
8031
|
}
|
|
7732
8032
|
let columnType = this.getColumnType(columnName);
|
|
7733
8033
|
if ("description" in this.schema[columnName]) {
|
|
@@ -8753,7 +9053,7 @@ const Databricks = (function() {
|
|
|
8753
9053
|
let columnName = tableColumns[i];
|
|
8754
9054
|
let columnDescription = "";
|
|
8755
9055
|
if (!(columnName in this.schema)) {
|
|
8756
|
-
throw
|
|
9056
|
+
throw this.missingSelectedFieldError(columnName);
|
|
8757
9057
|
}
|
|
8758
9058
|
let columnType = this.getColumnType(columnName);
|
|
8759
9059
|
const isPartOfPK = pkColumns.includes(columnName);
|
|
@@ -12356,7 +12656,7 @@ const XAds = (function() {
|
|
|
12356
12656
|
requiredType: "boolean",
|
|
12357
12657
|
default: true,
|
|
12358
12658
|
label: "Process Short Links",
|
|
12359
|
-
description: "Resolve short links in landing URL fields to their
|
|
12659
|
+
description: "Resolve short links in landing URL fields to the address their short link service points to. The resolved value is written to the matching _parsed field (for example website_url_parsed)",
|
|
12360
12660
|
attributes: [CONFIG_ATTRIBUTES2.ADVANCED]
|
|
12361
12661
|
},
|
|
12362
12662
|
CreateEmptyTables: {
|
|
@@ -14348,7 +14648,7 @@ const TikTokAds = (function() {
|
|
|
14348
14648
|
requiredType: "boolean",
|
|
14349
14649
|
default: true,
|
|
14350
14650
|
label: "Process Short Links",
|
|
14351
|
-
description: "Resolve short links in landing URL fields to their
|
|
14651
|
+
description: "Resolve short links in landing URL fields to the address their short link service points to. The resolved value is written to the matching _parsed field (for example landing_page_url_parsed)",
|
|
14352
14652
|
attributes: [CONFIG_ATTRIBUTES2.ADVANCED]
|
|
14353
14653
|
},
|
|
14354
14654
|
CreateEmptyTables: {
|
|
@@ -19982,7 +20282,7 @@ const RedditAds = (function() {
|
|
|
19982
20282
|
requiredType: "boolean",
|
|
19983
20283
|
default: true,
|
|
19984
20284
|
label: "Process Short Links",
|
|
19985
|
-
description: "Resolve short links in landing URL fields to their
|
|
20285
|
+
description: "Resolve short links in landing URL fields to the address their short link service points to. The resolved value is written to the matching _parsed field (for example click_url_parsed)",
|
|
19986
20286
|
attributes: [CONFIG_ATTRIBUTES2.ADVANCED]
|
|
19987
20287
|
},
|
|
19988
20288
|
CreateEmptyTables: {
|
|
@@ -22159,7 +22459,7 @@ API Response: ${JSON.stringify(statusResult, null, 2)}`);
|
|
|
22159
22459
|
requiredType: "boolean",
|
|
22160
22460
|
default: true,
|
|
22161
22461
|
label: "Process Short Links",
|
|
22162
|
-
description: "Resolve short links in landing URL fields to their
|
|
22462
|
+
description: "Resolve short links in landing URL fields to the address their short link service points to. The resolved value is written to the matching _parsed field (for example FinalUrlParsed)",
|
|
22163
22463
|
attributes: [CONFIG_ATTRIBUTES2.ADVANCED]
|
|
22164
22464
|
},
|
|
22165
22465
|
CreateEmptyTables: {
|
|
@@ -26862,7 +27162,7 @@ const GoogleAds = (function() {
|
|
|
26862
27162
|
requiredType: "boolean",
|
|
26863
27163
|
default: true,
|
|
26864
27164
|
label: "Process Short Links",
|
|
26865
|
-
description: "Resolve short links in landing URL fields to their
|
|
27165
|
+
description: "Resolve short links in landing URL fields to the address their short link service points to. The resolved value is written to the matching _parsed field (for example ad_final_urls_parsed)",
|
|
26866
27166
|
attributes: [CONFIG_ATTRIBUTES2.ADVANCED]
|
|
26867
27167
|
},
|
|
26868
27168
|
CreateEmptyTables: {
|
|
@@ -34411,7 +34711,7 @@ const FacebookMarketing = (function() {
|
|
|
34411
34711
|
requiredType: "boolean",
|
|
34412
34712
|
default: true,
|
|
34413
34713
|
label: "Process Short Links",
|
|
34414
|
-
description: "Resolve short links to their
|
|
34714
|
+
description: "Resolve short links to the address their short link service points to: link_url_asset.parsed_url on the Ad Insights by Link URL Asset endpoint, and link_url_parsed / object_url_parsed on Ad Creatives",
|
|
34415
34715
|
attributes: [CONFIG_ATTRIBUTES2.ADVANCED]
|
|
34416
34716
|
},
|
|
34417
34717
|
CreateEmptyTables: {
|