@liiift-studio/sanity-visitor-insights 0.35.0 → 0.36.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/server.js CHANGED
@@ -470,6 +470,30 @@ async function requireStudioUser(req, res, sanityProjectId, requiredRoles) {
470
470
  return result.user;
471
471
  }
472
472
 
473
+ // src/server/fetchWithTimeout.ts
474
+ var REQUEST_TIMEOUT_MS = 12e3;
475
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
476
+ const controller = new AbortController();
477
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
478
+ const caller = init.signal;
479
+ const onCallerAbort = () => controller.abort();
480
+ if (caller) {
481
+ if (caller.aborted) controller.abort();
482
+ else caller.addEventListener("abort", onCallerAbort, { once: true });
483
+ }
484
+ try {
485
+ return await fetch(input, { ...init, signal: controller.signal });
486
+ } catch (error) {
487
+ if (controller.signal.aborted && !caller?.aborted) {
488
+ throw new Error(`Request timed out after ${timeoutMs}ms`);
489
+ }
490
+ throw error;
491
+ } finally {
492
+ clearTimeout(timer);
493
+ caller?.removeEventListener("abort", onCallerAbort);
494
+ }
495
+ }
496
+
473
497
  // src/server/googleAuth.ts
474
498
  var import_node_crypto = require("crypto");
475
499
  var ANALYTICS_READONLY_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
@@ -545,6 +569,7 @@ async function getAccessToken(key) {
545
569
  // src/server/ga4.ts
546
570
  var DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
547
571
  var DATA_API_ALPHA = "https://analyticsdata.googleapis.com/v1alpha";
572
+ var EMPTY_REPORT = { rows: [], thresholded: false, sampled: false, rowCount: 0 };
548
573
  function toMetricNumber(raw) {
549
574
  if (raw === void 0 || raw === "") return Number.NaN;
550
575
  const parsed = Number(raw);
@@ -616,6 +641,16 @@ function andFilters(...filters) {
616
641
  if (present.length === 1) return present[0];
617
642
  return { andGroup: { expressions: present } };
618
643
  }
644
+ function alignBatch(chunks) {
645
+ return chunks.flatMap(({ reports, expected }) => {
646
+ if (reports.length !== expected) {
647
+ console.error(
648
+ `Visitor insights: GA4 returned ${reports.length} of ${expected} reports in a batch; padding to keep results aligned.`
649
+ );
650
+ }
651
+ return Array.from({ length: expected }, (_, i) => reports[i] ?? EMPTY_REPORT);
652
+ });
653
+ }
619
654
  function createGa4Client(propertyId, key, options = {}) {
620
655
  const hosts = options.hostnames && options.hostnames.length > 0 ? options.hostnames : null;
621
656
  const hostFilter = hosts ? hostnameFilter(hosts) : void 0;
@@ -625,7 +660,7 @@ function createGa4Client(propertyId, key, options = {}) {
625
660
  }
626
661
  async function post(path, body, base = DATA_API_BASE) {
627
662
  const token = await getAccessToken(key);
628
- const response = await fetch(`${base}/properties/${propertyId}:${path}`, {
663
+ const response = await fetchWithTimeout(`${base}/properties/${propertyId}:${path}`, {
629
664
  method: "POST",
630
665
  headers: {
631
666
  Authorization: `Bearer ${token}`,
@@ -658,7 +693,12 @@ function createGa4Client(propertyId, key, options = {}) {
658
693
  (chunk) => post("batchRunReports", { requests: chunk.map(narrow) })
659
694
  )
660
695
  );
661
- return responses.flatMap((raw) => (raw.reports ?? []).map(parseReport));
696
+ return alignBatch(
697
+ responses.map((raw, index) => ({
698
+ reports: (raw.reports ?? []).map(parseReport),
699
+ expected: chunks[index].length
700
+ }))
701
+ );
662
702
  },
663
703
  async runFunnelReport(steps, range) {
664
704
  const body = {
@@ -741,7 +781,7 @@ function createVercelClient(projectId, token, teamId) {
741
781
  async function query(path, extra) {
742
782
  const params = new URLSearchParams({ projectId, ...extra });
743
783
  if (teamId) params.set("teamId", teamId);
744
- const response = await fetch(`${API_BASE}/${path}?${params.toString()}`, {
784
+ const response = await fetchWithTimeout(`${API_BASE}/${path}?${params.toString()}`, {
745
785
  headers: { Authorization: `Bearer ${token}` }
746
786
  });
747
787
  if (!response.ok) {
@@ -816,7 +856,7 @@ function createMailchimpClient(apiKey, listId) {
816
856
  const auth = `Basic ${Buffer.from(`key:${apiKey}`).toString("base64")}`;
817
857
  async function get(path, params = {}) {
818
858
  const query = new URLSearchParams(params).toString();
819
- const response = await fetch(`${base}${path}${query ? `?${query}` : ""}`, {
859
+ const response = await fetchWithTimeout(`${base}${path}${query ? `?${query}` : ""}`, {
820
860
  headers: { Authorization: auth }
821
861
  });
822
862
  if (!response.ok) {
@@ -903,11 +943,12 @@ function setCached(key, value, ttlMs = DEFAULT_TTL_MS) {
903
943
  }
904
944
  store.set(key, { value, expiresAt: Date.now() + ttlMs });
905
945
  }
906
- async function withCache(key, ttlMs, compute) {
946
+ async function withCache(key, ttlMs, compute, ttlFor) {
907
947
  const hit = getCached(key);
908
948
  if (hit !== void 0) return hit;
909
949
  const value = await compute();
910
- setCached(key, value, ttlMs);
950
+ const ttl = ttlFor ? ttlFor(value) : ttlMs;
951
+ if (ttl > 0) setCached(key, value, ttl);
911
952
  return value;
912
953
  }
913
954
  function clearCache() {
@@ -934,6 +975,21 @@ function orderQueryOptions(orders, range) {
934
975
  function orderFilter(documentType, excludeFilter) {
935
976
  const clauses = [
936
977
  `_type == $documentType`,
978
+ /*
979
+ * Published only.
980
+ *
981
+ * @sanity/client v6 defaults to the `raw` perspective for a token-authenticated request, and
982
+ * the sites pass a token — so an order that anyone has opened and edited in the Studio comes
983
+ * back twice, as `orderId` and as `drafts.orderId`. That double-counts it in the order total,
984
+ * in the revenue, and in its day's stem: a 14% error at seven orders a quarter, in the one
985
+ * figure this tool calls exact and calibrates the whole capture model against.
986
+ *
987
+ * Darden currently has no draft orders, so this is latent rather than live — which is exactly
988
+ * the kind of thing that stays latent until the day someone opens an order to check it.
989
+ * Filtering here rather than relying on a perspective the consumer configures, because the
990
+ * consumer is a site that has no reason to know this report depends on it.
991
+ */
992
+ `!(_id in path("drafts.**"))`,
937
993
  `_createdAt >= $start`,
938
994
  `_createdAt < $end`
939
995
  ];
@@ -2445,6 +2501,9 @@ function createVisitorInsightsHandler(options) {
2445
2501
  }
2446
2502
  } : {}
2447
2503
  };
2504
+ }, (envelope2) => {
2505
+ const degraded = Object.values(envelope2.sources).some((source) => source?.status !== "ok");
2506
+ return degraded ? Math.min(ttl, 2e4) : ttl;
2448
2507
  });
2449
2508
  res.status(200).json(envelope);
2450
2509
  } catch (e) {