@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.
Files changed (3) hide show
  1. package/dist/index.cjs +393 -93
  2. package/dist/index.js +393 -93
  3. 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
- this.context.log(level, `${what}: ${error.message}`);
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
- this.context.emit(new StateEvent({ lastRequestedDate: date }));
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
- message = `All ${state.attemptedCount} accounts were skipped, 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)}`;
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 landing page next to its original. No-op when the node has no spec, Process Short
827
- * Links is off, or no spec has its field (and its `_parsed` target) selected.
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, _e;
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
- nestedPathHosts: [
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: dateRange.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 dateRange.endDate;
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
- for (const date of this._iterateDates(dateRange.startDate, dateRange.endDate)) {
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 nodes) {
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
- const error = new Error(`HTTP ${response.status}: ${response.statusText}${snippet}`);
1924
- error.response = response;
1925
- error.statusCode = response.status;
1926
- error.responseBody = bodyText;
2025
+ let payload;
1927
2026
  try {
1928
- error.payload = JSON.parse(bodyText);
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
- return this._defaultRetryable(error);
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
- if (this._pendingBackoff && this._activeErrorHandler) {
3019
- const { filter, response } = this._pendingBackoff;
3020
- this._pendingBackoff = null;
3021
- const ms = this._activeErrorHandler.delayMs(filter, response, attempt, initialDelay);
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
- return super.calculateBackoff(attempt, initialDelay);
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
- var _a, _b;
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 (((_w = node.retriever) == null ? void 0 : _w.type) === "async") {
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 (((_x = node.retriever) == null ? void 0 : _x.type) === "async") {
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 = ((_y = node.retriever) == null ? void 0 : _y.type) ?? "sync";
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 = (_z = r.poll.backoff) == null ? void 0 : _z[key];
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 new Error(`SsrfGuard: URL must use https, got "${url.protocol}"`);
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 new Error(`SsrfGuard: blocked IP/host "${host}"`);
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 new Error(`SsrfGuard: host "${host}" resolves to blocked address "${address}"`);
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, { nestedPathHosts = [], resolvedLinksCache = /* @__PURE__ */ new Map(), failedLinks = /* @__PURE__ */ new Set() } = {}) {
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, nestedPathHosts);
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
- return data.map((record) => specs.reduce((current, spec) => _applySpec(current, spec, resolvedLinksCache), record));
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, nestedPathHosts) {
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, nestedPathHosts)) unique.add(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
- function _isPotentialShortLink(url, nestedPathHosts) {
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
- const isConfiguredHost = _isNestedPathHost(parsedUrl.hostname, nestedPathHosts);
6748
- const pathSegments = _getPathSegments(parsedUrl.pathname, isConfiguredHost);
6749
- if (!pathSegments.every(Boolean)) return false;
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, allowTrailingSlash) {
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 allowTrailingSlash && hasTrailingSlash ? segments.slice(0, -1) : segments;
7036
+ return hasTrailingSlash ? segments.slice(0, -1) : segments;
6759
7037
  }
6760
- function _isNestedPathHost(hostname, nestedPathHosts) {
6761
- return nestedPathHosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
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: false };
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
- async function _followRedirects(startUrl) {
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
- throw new Error(`Refusing to request non-public URL ${currentUrl}`);
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 new Error(`Required field ${columnName} not found in schema`);
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 new Error(`Required field ${columnName} not found in schema`);
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 new Error(`Required field ${columnName} not found in schema`);
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 landing page. The resolved value is written to the matching _parsed field (for example website_url_parsed)",
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 landing page. The resolved value is written to the matching _parsed field (for example landing_page_url_parsed)",
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 landing page. The resolved value is written to the matching _parsed field (for example click_url_parsed)",
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 landing page. The resolved value is written to the matching _parsed field (for example FinalUrlParsed)",
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 landing page. The resolved value is written to the matching _parsed field (for example ad_final_urls_parsed)",
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 landing page: link_url_asset.parsed_url on the Ad Insights by Link URL Asset endpoint, and link_url_parsed / object_url_parsed on Ad Creatives",
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: {