@gscdump/analysis 0.38.2 → 0.39.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.
@@ -6,7 +6,7 @@ import { num } from "@gscdump/engine/analysis-types";
6
6
  import { err, ok, unwrapResult } from "gscdump/result";
7
7
  import { between, date, extractDateRange, gsc, page, query } from "gscdump/query";
8
8
  import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
9
- import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
9
+ import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
10
10
  function rowNumber(value) {
11
11
  if (typeof value === "number") return Number.isFinite(value) ? value : 0;
12
12
  if (typeof value === "bigint") return Number(value);
@@ -665,7 +665,7 @@ function createMetricSorter(defaultMetric, orderByMetric) {
665
665
  return [...items].sort((a, b) => (a[sortBy] - b[sortBy]) * mult);
666
666
  };
667
667
  }
668
- const sortRowResults$1 = createSorter((item, metric) => {
668
+ const sortRowResults = createSorter((item, metric) => {
669
669
  switch (metric) {
670
670
  case "clicks": return item.totalClicks;
671
671
  case "impressions": return item.totalImpressions;
@@ -712,7 +712,7 @@ function analyzeCannibalization(rows, options = {}) {
712
712
  positionSpread
713
713
  });
714
714
  }
715
- return sortRowResults$1(results, sortBy, sortOrder);
715
+ return sortRowResults(results, sortBy, sortOrder);
716
716
  }
717
717
  const cannibalizationAnalyzer = defineAnalyzer({
718
718
  id: "cannibalization",
@@ -2250,14 +2250,9 @@ function shapeDataQueryRows(rows, params, extras) {
2250
2250
  function buildDataDetailPlan(params, options) {
2251
2251
  const state = requireBuilderState(params.q, "data-detail");
2252
2252
  if (!state.dimensions.includes("date")) throw new Error("data-detail: `date` dimension is required");
2253
- const main = resolveToSQL(state, options);
2254
- const totals = buildTotalsSql(state, options);
2253
+ const main = resolveToSQLOptimized(state, options);
2255
2254
  const prev = optionalBuilderState(params.qc, "data-detail", "qc");
2256
- const extraQueries = [{
2257
- name: "totals",
2258
- sql: totals.sql,
2259
- params: totals.params
2260
- }];
2255
+ const extraQueries = [];
2261
2256
  if (prev) {
2262
2257
  const previousTotals = buildTotalsSql(prev, options);
2263
2258
  extraQueries.push({
@@ -2275,18 +2270,22 @@ function buildDataDetailPlan(params, options) {
2275
2270
  }
2276
2271
  function shapeDataDetailRows(rows, params, extras) {
2277
2272
  const { startDate: rangeStart, endDate: rangeEnd } = extractDateRange(requireBuilderState(params.q, "data-detail").filter);
2278
- const coerced = rows.map(coerceNumericCols);
2273
+ const first = rows[0];
2274
+ const totals = {
2275
+ clicks: Number(first?.totalClicks ?? 0),
2276
+ impressions: Number(first?.totalImpressions ?? 0),
2277
+ ctr: Number(first?.totalCtr ?? 0),
2278
+ position: Number(first?.totalPosition ?? 0)
2279
+ };
2280
+ const coerced = rows.map((raw) => {
2281
+ const { totalCount: _tc, totalClicks: _tclk, totalImpressions: _timp, totalCtr: _tctr, totalPosition: _tpos, sum_position: _sp, ...rest } = raw;
2282
+ return coerceNumericCols(rest);
2283
+ });
2279
2284
  const daily = rangeStart && rangeEnd ? padTimeseries(coerced, {
2280
2285
  startDate: rangeStart,
2281
2286
  endDate: rangeEnd
2282
2287
  }) : coerced;
2283
- const totalsRow = extras?.totals?.[0] ?? {};
2284
- const meta = { totals: {
2285
- clicks: Number(totalsRow.clicks ?? 0),
2286
- impressions: Number(totalsRow.impressions ?? 0),
2287
- ctr: Number(totalsRow.ctr ?? 0),
2288
- position: Number(totalsRow.position ?? 0)
2289
- } };
2288
+ const meta = { totals };
2290
2289
  if (extras?.prevTotals) {
2291
2290
  const previousTotalsRow = extras.prevTotals[0] ?? {};
2292
2291
  meta.previousTotals = {
@@ -2403,13 +2402,75 @@ function paginateInMemory(rows, input) {
2403
2402
  const o = clampOffset(input.offset);
2404
2403
  return rows.slice(o, o + l);
2405
2404
  }
2405
+ function paginateSortedInMemory(rows, input, compare) {
2406
+ const limit = clampLimit(input.limit, rows.length);
2407
+ const offset = clampOffset(input.offset);
2408
+ const selectedCount = Math.min(rows.length, offset + limit);
2409
+ if (limit === 0 || offset >= rows.length || selectedCount === 0) return [];
2410
+ const fullSort = () => [...rows].sort(compare).slice(offset, offset + limit);
2411
+ if (selectedCount >= rows.length / 2) return fullSort();
2412
+ let invalidComparison = false;
2413
+ const compareStable = (left, right) => {
2414
+ const order = compare(left.value, right.value);
2415
+ if (Number.isNaN(order)) {
2416
+ invalidComparison = true;
2417
+ return left.index - right.index;
2418
+ }
2419
+ return order || left.index - right.index;
2420
+ };
2421
+ const isWorse = (left, right) => compareStable(left, right) > 0;
2422
+ const heap = [];
2423
+ const siftUp = (start) => {
2424
+ let index = start;
2425
+ while (index > 0) {
2426
+ const parent = index - 1 >>> 1;
2427
+ if (!isWorse(heap[index], heap[parent])) break;
2428
+ const swap = heap[parent];
2429
+ heap[parent] = heap[index];
2430
+ heap[index] = swap;
2431
+ index = parent;
2432
+ }
2433
+ };
2434
+ const siftDown = () => {
2435
+ let index = 0;
2436
+ for (;;) {
2437
+ const left = index * 2 + 1;
2438
+ if (left >= heap.length) return;
2439
+ const right = left + 1;
2440
+ let worse = left;
2441
+ if (right < heap.length && isWorse(heap[right], heap[left])) worse = right;
2442
+ if (!isWorse(heap[worse], heap[index])) return;
2443
+ const swap = heap[index];
2444
+ heap[index] = heap[worse];
2445
+ heap[worse] = swap;
2446
+ index = worse;
2447
+ }
2448
+ };
2449
+ for (let index = 0; index < rows.length; index++) {
2450
+ const candidate = {
2451
+ value: rows[index],
2452
+ index
2453
+ };
2454
+ if (heap.length < selectedCount) {
2455
+ heap.push(candidate);
2456
+ siftUp(heap.length - 1);
2457
+ } else if (compareStable(candidate, heap[0]) < 0) {
2458
+ heap[0] = candidate;
2459
+ siftDown();
2460
+ }
2461
+ }
2462
+ if (invalidComparison) return fullSort();
2463
+ heap.sort(compareStable);
2464
+ if (invalidComparison) return fullSort();
2465
+ return heap.slice(offset, offset + limit).map((entry) => entry.value);
2466
+ }
2406
2467
  function resolveSort(input, allowed, defaults) {
2407
2468
  return {
2408
2469
  sortBy: input.sortBy && allowed.includes(input.sortBy) ? input.sortBy : defaults.sortBy,
2409
2470
  sortDir: input.sortDir === "asc" || input.sortDir === "desc" ? input.sortDir : defaults.sortDir
2410
2471
  };
2411
2472
  }
2412
- const sortResults$1 = createMetricSorter("lostClicks", {
2473
+ const sortResults = createMetricSorter("lostClicks", {
2413
2474
  lostClicks: "desc",
2414
2475
  declinePercent: "desc",
2415
2476
  currentClicks: "asc"
@@ -2442,7 +2503,7 @@ function analyzeDecay(input, options = {}) {
2442
2503
  positionDrop: currentPosition != null ? currentPosition - prev.position : null
2443
2504
  });
2444
2505
  }
2445
- return sortResults$1(results, sortBy);
2506
+ return sortResults(results, sortBy);
2446
2507
  }
2447
2508
  const decayAnalyzer = defineAnalyzer({
2448
2509
  id: "decay",
@@ -3441,12 +3502,6 @@ function calculateCtrGapScore(actualCtr, position) {
3441
3502
  const gap = expectedCtr - actualCtr;
3442
3503
  return Math.min(gap / expectedCtr, 1);
3443
3504
  }
3444
- const sortResults = createMetricSorter("opportunityScore", {
3445
- opportunityScore: "desc",
3446
- potentialClicks: "desc",
3447
- impressions: "desc",
3448
- position: "asc"
3449
- });
3450
3505
  const opportunityAnalyzer = defineAnalyzer({
3451
3506
  id: "opportunity",
3452
3507
  buildSql(params) {
@@ -3596,15 +3651,14 @@ const opportunityAnalyzer = defineAnalyzer({
3596
3651
  }
3597
3652
  });
3598
3653
  }
3599
- const sorted = sortResults(results, sortBy);
3600
- const paged = paginateInMemory(sorted, {
3654
+ const paged = paginateSortedInMemory(results, {
3601
3655
  limit: params.limit,
3602
3656
  offset: params.offset
3603
- });
3657
+ }, (left, right) => right[sortBy] - left[sortBy]);
3604
3658
  return {
3605
3659
  results: paged,
3606
3660
  meta: {
3607
- total: sorted.length,
3661
+ total: results.length,
3608
3662
  returned: paged.length
3609
3663
  }
3610
3664
  };
@@ -4306,11 +4360,10 @@ const strikingDistanceAnalyzer = defineAnalyzer({
4306
4360
  id: "striking-distance",
4307
4361
  reduce(rows, params) {
4308
4362
  const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
4309
- results.sort((a, b) => b.potentialClicks - a.potentialClicks);
4310
- const paged = paginateInMemory(results, {
4363
+ const paged = paginateSortedInMemory(results, {
4311
4364
  limit: params.limit ?? 1e3,
4312
4365
  offset: params.offset
4313
- });
4366
+ }, (left, right) => right.potentialClicks - left.potentialClicks);
4314
4367
  return {
4315
4368
  results: paged,
4316
4369
  meta: {
@@ -4686,7 +4739,6 @@ const trendsAnalyzer = defineAnalyzer({
4686
4739
  }
4687
4740
  });
4688
4741
  const DEFAULT_ROW_LIMIT = 25e3;
4689
- const sortRowResults = createSorter((item) => item.impressions, "impressions");
4690
4742
  const zeroClickAnalyzer = defineAnalyzer({
4691
4743
  id: "zero-click",
4692
4744
  buildSql(params) {
@@ -4789,11 +4841,11 @@ const zeroClickAnalyzer = defineAnalyzer({
4789
4841
  position: row.position
4790
4842
  });
4791
4843
  }
4792
- const results = sortRowResults(Array.from(queryMap.values()), "impressions", "desc");
4793
- const paged = paginateInMemory(results, {
4844
+ const results = Array.from(queryMap.values());
4845
+ const paged = paginateSortedInMemory(results, {
4794
4846
  limit: params.limit,
4795
4847
  offset: params.offset
4796
- });
4848
+ }, (left, right) => right.impressions - left.impressions);
4797
4849
  return {
4798
4850
  results: paged,
4799
4851
  meta: {
@@ -6,7 +6,7 @@ import { num } from "@gscdump/engine/analysis-types";
6
6
  import { err, ok, unwrapResult } from "gscdump/result";
7
7
  import { between, date, extractDateRange, gsc, page, query } from "gscdump/query";
8
8
  import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
9
- import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
9
+ import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
10
10
  function rowNumber(value) {
11
11
  if (typeof value === "number") return Number.isFinite(value) ? value : 0;
12
12
  if (typeof value === "bigint") return Number(value);
@@ -665,7 +665,7 @@ function createMetricSorter(defaultMetric, orderByMetric) {
665
665
  return [...items].sort((a, b) => (a[sortBy] - b[sortBy]) * mult);
666
666
  };
667
667
  }
668
- const sortRowResults$1 = createSorter((item, metric) => {
668
+ const sortRowResults = createSorter((item, metric) => {
669
669
  switch (metric) {
670
670
  case "clicks": return item.totalClicks;
671
671
  case "impressions": return item.totalImpressions;
@@ -712,7 +712,7 @@ function analyzeCannibalization(rows, options = {}) {
712
712
  positionSpread
713
713
  });
714
714
  }
715
- return sortRowResults$1(results, sortBy, sortOrder);
715
+ return sortRowResults(results, sortBy, sortOrder);
716
716
  }
717
717
  const cannibalizationAnalyzer = defineAnalyzer({
718
718
  id: "cannibalization",
@@ -2250,14 +2250,9 @@ function shapeDataQueryRows(rows, params, extras) {
2250
2250
  function buildDataDetailPlan(params, options) {
2251
2251
  const state = requireBuilderState(params.q, "data-detail");
2252
2252
  if (!state.dimensions.includes("date")) throw new Error("data-detail: `date` dimension is required");
2253
- const main = resolveToSQL(state, options);
2254
- const totals = buildTotalsSql(state, options);
2253
+ const main = resolveToSQLOptimized(state, options);
2255
2254
  const prev = optionalBuilderState(params.qc, "data-detail", "qc");
2256
- const extraQueries = [{
2257
- name: "totals",
2258
- sql: totals.sql,
2259
- params: totals.params
2260
- }];
2255
+ const extraQueries = [];
2261
2256
  if (prev) {
2262
2257
  const previousTotals = buildTotalsSql(prev, options);
2263
2258
  extraQueries.push({
@@ -2275,18 +2270,22 @@ function buildDataDetailPlan(params, options) {
2275
2270
  }
2276
2271
  function shapeDataDetailRows(rows, params, extras) {
2277
2272
  const { startDate: rangeStart, endDate: rangeEnd } = extractDateRange(requireBuilderState(params.q, "data-detail").filter);
2278
- const coerced = rows.map(coerceNumericCols);
2273
+ const first = rows[0];
2274
+ const totals = {
2275
+ clicks: Number(first?.totalClicks ?? 0),
2276
+ impressions: Number(first?.totalImpressions ?? 0),
2277
+ ctr: Number(first?.totalCtr ?? 0),
2278
+ position: Number(first?.totalPosition ?? 0)
2279
+ };
2280
+ const coerced = rows.map((raw) => {
2281
+ const { totalCount: _tc, totalClicks: _tclk, totalImpressions: _timp, totalCtr: _tctr, totalPosition: _tpos, sum_position: _sp, ...rest } = raw;
2282
+ return coerceNumericCols(rest);
2283
+ });
2279
2284
  const daily = rangeStart && rangeEnd ? padTimeseries(coerced, {
2280
2285
  startDate: rangeStart,
2281
2286
  endDate: rangeEnd
2282
2287
  }) : coerced;
2283
- const totalsRow = extras?.totals?.[0] ?? {};
2284
- const meta = { totals: {
2285
- clicks: Number(totalsRow.clicks ?? 0),
2286
- impressions: Number(totalsRow.impressions ?? 0),
2287
- ctr: Number(totalsRow.ctr ?? 0),
2288
- position: Number(totalsRow.position ?? 0)
2289
- } };
2288
+ const meta = { totals };
2290
2289
  if (extras?.prevTotals) {
2291
2290
  const previousTotalsRow = extras.prevTotals[0] ?? {};
2292
2291
  meta.previousTotals = {
@@ -2403,7 +2402,69 @@ function paginateInMemory(rows, input) {
2403
2402
  const o = clampOffset(input.offset);
2404
2403
  return rows.slice(o, o + l);
2405
2404
  }
2406
- const sortResults$1 = createMetricSorter("lostClicks", {
2405
+ function paginateSortedInMemory(rows, input, compare) {
2406
+ const limit = clampLimit(input.limit, rows.length);
2407
+ const offset = clampOffset(input.offset);
2408
+ const selectedCount = Math.min(rows.length, offset + limit);
2409
+ if (limit === 0 || offset >= rows.length || selectedCount === 0) return [];
2410
+ const fullSort = () => [...rows].sort(compare).slice(offset, offset + limit);
2411
+ if (selectedCount >= rows.length / 2) return fullSort();
2412
+ let invalidComparison = false;
2413
+ const compareStable = (left, right) => {
2414
+ const order = compare(left.value, right.value);
2415
+ if (Number.isNaN(order)) {
2416
+ invalidComparison = true;
2417
+ return left.index - right.index;
2418
+ }
2419
+ return order || left.index - right.index;
2420
+ };
2421
+ const isWorse = (left, right) => compareStable(left, right) > 0;
2422
+ const heap = [];
2423
+ const siftUp = (start) => {
2424
+ let index = start;
2425
+ while (index > 0) {
2426
+ const parent = index - 1 >>> 1;
2427
+ if (!isWorse(heap[index], heap[parent])) break;
2428
+ const swap = heap[parent];
2429
+ heap[parent] = heap[index];
2430
+ heap[index] = swap;
2431
+ index = parent;
2432
+ }
2433
+ };
2434
+ const siftDown = () => {
2435
+ let index = 0;
2436
+ for (;;) {
2437
+ const left = index * 2 + 1;
2438
+ if (left >= heap.length) return;
2439
+ const right = left + 1;
2440
+ let worse = left;
2441
+ if (right < heap.length && isWorse(heap[right], heap[left])) worse = right;
2442
+ if (!isWorse(heap[worse], heap[index])) return;
2443
+ const swap = heap[index];
2444
+ heap[index] = heap[worse];
2445
+ heap[worse] = swap;
2446
+ index = worse;
2447
+ }
2448
+ };
2449
+ for (let index = 0; index < rows.length; index++) {
2450
+ const candidate = {
2451
+ value: rows[index],
2452
+ index
2453
+ };
2454
+ if (heap.length < selectedCount) {
2455
+ heap.push(candidate);
2456
+ siftUp(heap.length - 1);
2457
+ } else if (compareStable(candidate, heap[0]) < 0) {
2458
+ heap[0] = candidate;
2459
+ siftDown();
2460
+ }
2461
+ }
2462
+ if (invalidComparison) return fullSort();
2463
+ heap.sort(compareStable);
2464
+ if (invalidComparison) return fullSort();
2465
+ return heap.slice(offset, offset + limit).map((entry) => entry.value);
2466
+ }
2467
+ const sortResults = createMetricSorter("lostClicks", {
2407
2468
  lostClicks: "desc",
2408
2469
  declinePercent: "desc",
2409
2470
  currentClicks: "asc"
@@ -2436,7 +2497,7 @@ function analyzeDecay(input, options = {}) {
2436
2497
  positionDrop: currentPosition != null ? currentPosition - prev.position : null
2437
2498
  });
2438
2499
  }
2439
- return sortResults$1(results, sortBy);
2500
+ return sortResults(results, sortBy);
2440
2501
  }
2441
2502
  const decayAnalyzer = defineAnalyzer({
2442
2503
  id: "decay",
@@ -3435,12 +3496,6 @@ function calculateCtrGapScore(actualCtr, position) {
3435
3496
  const gap = expectedCtr - actualCtr;
3436
3497
  return Math.min(gap / expectedCtr, 1);
3437
3498
  }
3438
- const sortResults = createMetricSorter("opportunityScore", {
3439
- opportunityScore: "desc",
3440
- potentialClicks: "desc",
3441
- impressions: "desc",
3442
- position: "asc"
3443
- });
3444
3499
  const opportunityAnalyzer = defineAnalyzer({
3445
3500
  id: "opportunity",
3446
3501
  buildSql(params) {
@@ -3590,15 +3645,14 @@ const opportunityAnalyzer = defineAnalyzer({
3590
3645
  }
3591
3646
  });
3592
3647
  }
3593
- const sorted = sortResults(results, sortBy);
3594
- const paged = paginateInMemory(sorted, {
3648
+ const paged = paginateSortedInMemory(results, {
3595
3649
  limit: params.limit,
3596
3650
  offset: params.offset
3597
- });
3651
+ }, (left, right) => right[sortBy] - left[sortBy]);
3598
3652
  return {
3599
3653
  results: paged,
3600
3654
  meta: {
3601
- total: sorted.length,
3655
+ total: results.length,
3602
3656
  returned: paged.length
3603
3657
  }
3604
3658
  };
@@ -4300,11 +4354,10 @@ const strikingDistanceAnalyzer = defineAnalyzer({
4300
4354
  id: "striking-distance",
4301
4355
  reduce(rows, params) {
4302
4356
  const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
4303
- results.sort((a, b) => b.potentialClicks - a.potentialClicks);
4304
- const paged = paginateInMemory(results, {
4357
+ const paged = paginateSortedInMemory(results, {
4305
4358
  limit: params.limit ?? 1e3,
4306
4359
  offset: params.offset
4307
- });
4360
+ }, (left, right) => right.potentialClicks - left.potentialClicks);
4308
4361
  return {
4309
4362
  results: paged,
4310
4363
  meta: {
@@ -4680,7 +4733,6 @@ const trendsAnalyzer = defineAnalyzer({
4680
4733
  }
4681
4734
  });
4682
4735
  const DEFAULT_ROW_LIMIT = 25e3;
4683
- const sortRowResults = createSorter((item) => item.impressions, "impressions");
4684
4736
  const defaultAnalyzerRegistry = createAnalyzerRegistry({ defined: [
4685
4737
  bayesianCtrAnalyzer,
4686
4738
  bipartitePagerankAnalyzer,
@@ -4812,11 +4864,11 @@ const defaultAnalyzerRegistry = createAnalyzerRegistry({ defined: [
4812
4864
  position: row.position
4813
4865
  });
4814
4866
  }
4815
- const results = sortRowResults(Array.from(queryMap.values()), "impressions", "desc");
4816
- const paged = paginateInMemory(results, {
4867
+ const results = Array.from(queryMap.values());
4868
+ const paged = paginateSortedInMemory(results, {
4817
4869
  limit: params.limit,
4818
4870
  offset: params.offset
4819
- });
4871
+ }, (left, right) => right.impressions - left.impressions);
4820
4872
  return {
4821
4873
  results: paged,
4822
4874
  meta: {
package/dist/index.mjs CHANGED
@@ -6,7 +6,7 @@ import { num, num as num$1 } from "@gscdump/engine/analysis-types";
6
6
  import { err, ok, unwrapResult } from "gscdump/result";
7
7
  import { between, date, extractDateRange, gsc, page, query } from "gscdump/query";
8
8
  import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
9
- import { buildExtrasQueries, buildTotalsSql, mergeExtras, pgResolverAdapter, resolveComparisonSQL, resolveToSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
9
+ import { buildExtrasQueries, buildTotalsSql, mergeExtras, pgResolverAdapter, resolveComparisonSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
10
10
  import pluralize from "pluralize";
11
11
  import { ENGINE_QUERY_CAPABILITIES, createAttachedTableSource, createEngineQuerySource, queryComparisonRows, queryRows, rewriteForTableSource, runAnalyzerWithEngine, typedQuery } from "@gscdump/engine/source";
12
12
  import { computeInputHash, createReportRegistry, defineReport } from "@gscdump/engine/report";
@@ -897,7 +897,7 @@ function createMetricSorter(defaultMetric, orderByMetric) {
897
897
  return [...items].sort((a, b) => (a[sortBy] - b[sortBy]) * mult);
898
898
  };
899
899
  }
900
- const sortRowResults$1 = createSorter((item, metric) => {
900
+ const sortRowResults = createSorter((item, metric) => {
901
901
  switch (metric) {
902
902
  case "clicks": return item.totalClicks;
903
903
  case "impressions": return item.totalImpressions;
@@ -944,7 +944,7 @@ function analyzeCannibalization(rows, options = {}) {
944
944
  positionSpread
945
945
  });
946
946
  }
947
- return sortRowResults$1(results, sortBy, sortOrder);
947
+ return sortRowResults(results, sortBy, sortOrder);
948
948
  }
949
949
  const cannibalizationAnalyzer = defineAnalyzer$1({
950
950
  id: "cannibalization",
@@ -2482,14 +2482,9 @@ function shapeDataQueryRows(rows, params, extras) {
2482
2482
  function buildDataDetailPlan(params, options) {
2483
2483
  const state = requireBuilderState(params.q, "data-detail");
2484
2484
  if (!state.dimensions.includes("date")) throw new Error("data-detail: `date` dimension is required");
2485
- const main = resolveToSQL(state, options);
2486
- const totals = buildTotalsSql(state, options);
2485
+ const main = resolveToSQLOptimized(state, options);
2487
2486
  const prev = optionalBuilderState(params.qc, "data-detail", "qc");
2488
- const extraQueries = [{
2489
- name: "totals",
2490
- sql: totals.sql,
2491
- params: totals.params
2492
- }];
2487
+ const extraQueries = [];
2493
2488
  if (prev) {
2494
2489
  const previousTotals = buildTotalsSql(prev, options);
2495
2490
  extraQueries.push({
@@ -2507,18 +2502,22 @@ function buildDataDetailPlan(params, options) {
2507
2502
  }
2508
2503
  function shapeDataDetailRows(rows, params, extras) {
2509
2504
  const { startDate: rangeStart, endDate: rangeEnd } = extractDateRange(requireBuilderState(params.q, "data-detail").filter);
2510
- const coerced = rows.map(coerceNumericCols);
2505
+ const first = rows[0];
2506
+ const totals = {
2507
+ clicks: Number(first?.totalClicks ?? 0),
2508
+ impressions: Number(first?.totalImpressions ?? 0),
2509
+ ctr: Number(first?.totalCtr ?? 0),
2510
+ position: Number(first?.totalPosition ?? 0)
2511
+ };
2512
+ const coerced = rows.map((raw) => {
2513
+ const { totalCount: _tc, totalClicks: _tclk, totalImpressions: _timp, totalCtr: _tctr, totalPosition: _tpos, sum_position: _sp, ...rest } = raw;
2514
+ return coerceNumericCols(rest);
2515
+ });
2511
2516
  const daily = rangeStart && rangeEnd ? padTimeseries$1(coerced, {
2512
2517
  startDate: rangeStart,
2513
2518
  endDate: rangeEnd
2514
2519
  }) : coerced;
2515
- const totalsRow = extras?.totals?.[0] ?? {};
2516
- const meta = { totals: {
2517
- clicks: Number(totalsRow.clicks ?? 0),
2518
- impressions: Number(totalsRow.impressions ?? 0),
2519
- ctr: Number(totalsRow.ctr ?? 0),
2520
- position: Number(totalsRow.position ?? 0)
2521
- } };
2520
+ const meta = { totals };
2522
2521
  if (extras?.prevTotals) {
2523
2522
  const previousTotalsRow = extras.prevTotals[0] ?? {};
2524
2523
  meta.previousTotals = {
@@ -2647,10 +2646,12 @@ const SEPARATOR_RE$1 = /[-_/.@#:+]+/g;
2647
2646
  const WHITESPACE_RE$1 = /\s+/g;
2648
2647
  const DIACRITICS_RE$1 = /\p{Diacritic}/gu;
2649
2648
  function tokenize(query) {
2650
- const text = query.normalize("NFKD").replace(DIACRITICS_RE$1, "").toLowerCase().replace(SEPARATOR_RE$1, " ").replace(WHITESPACE_RE$1, " ").trim();
2649
+ let index = 0;
2650
+ while (index < query.length && query.charCodeAt(index) <= 127) index++;
2651
+ const text = (index === query.length ? query : query.normalize("NFKD").replace(DIACRITICS_RE$1, "")).toLowerCase().replace(SEPARATOR_RE$1, " ").replace(WHITESPACE_RE$1, " ").trim();
2651
2652
  return {
2652
2653
  text,
2653
- tokens: text.split(" ").filter(Boolean)
2654
+ tokens: text.length === 0 ? [] : text.split(" ")
2654
2655
  };
2655
2656
  }
2656
2657
  function classifyQueryIntent(query) {
@@ -2816,15 +2817,27 @@ const NO_STRIP_S = /* @__PURE__ */ new Set([
2816
2817
  "nervous",
2817
2818
  "conscious"
2818
2819
  ]);
2820
+ const DEPLURALIZED_CACHE_MAX = 4096;
2821
+ const depluralizedCache = /* @__PURE__ */ new Map();
2819
2822
  function depluralize(token) {
2820
2823
  if (token.length <= 3 || NO_STRIP_S.has(token) || token.endsWith("sis")) return token;
2821
- return pluralize.singular(token);
2824
+ const last = token.charCodeAt(token.length - 1);
2825
+ if (last < 97 || last > 122) return token;
2826
+ const cached = depluralizedCache.get(token);
2827
+ if (cached !== void 0) return cached;
2828
+ const singular = pluralize.singular(token);
2829
+ if (depluralizedCache.size >= DEPLURALIZED_CACHE_MAX) depluralizedCache.clear();
2830
+ depluralizedCache.set(token, singular);
2831
+ return singular;
2822
2832
  }
2823
2833
  const SEPARATOR_RE = /[-_/.@#:+]+/g;
2824
2834
  const WHITESPACE_RE = /\s+/g;
2825
2835
  const DIACRITICS_RE = /\p{Diacritic}/gu;
2826
2836
  const NORMALIZER_VERSION = 2;
2827
2837
  function foldUnicode(s) {
2838
+ let index = 0;
2839
+ while (index < s.length && s.charCodeAt(index) <= 127) index++;
2840
+ if (index === s.length) return s;
2828
2841
  return s.normalize("NFKD").replace(DIACRITICS_RE, "");
2829
2842
  }
2830
2843
  const DIRECTIONAL_CONNECTORS = /* @__PURE__ */ new Set([
@@ -2860,9 +2873,15 @@ function isOrderSensitive(tokens) {
2860
2873
  return false;
2861
2874
  }
2862
2875
  function normalizeQuery(query) {
2863
- const cleaned = foldUnicode(query).toLowerCase().replace(SEPARATOR_RE, " ").replace(WHITESPACE_RE, " ").trim().split(" ").filter(Boolean);
2864
- const mapped = cleaned.map((token) => SYNONYMS[token] ?? token).filter(Boolean);
2865
- const tokens = (mapped.length > 0 ? mapped : cleaned).map(depluralize);
2876
+ const normalized = foldUnicode(query).toLowerCase().replace(SEPARATOR_RE, " ").replace(WHITESPACE_RE, " ").trim();
2877
+ if (normalized.length === 0) return "";
2878
+ const cleaned = normalized.split(" ");
2879
+ const mapped = [];
2880
+ for (const token of cleaned) {
2881
+ const synonym = SYNONYMS[token] ?? token;
2882
+ if (synonym) mapped.push(depluralize(synonym));
2883
+ }
2884
+ const tokens = mapped.length > 0 ? mapped : cleaned.map(depluralize);
2866
2885
  return (isOrderSensitive(tokens) ? tokens : tokens.sort()).join(" ");
2867
2886
  }
2868
2887
  const dataDetailAnalyzer = defineAnalyzer$1({
@@ -2967,7 +2986,69 @@ function paginateInMemory(rows, input) {
2967
2986
  const o = clampOffset(input.offset);
2968
2987
  return rows.slice(o, o + l);
2969
2988
  }
2970
- const sortResults$1 = createMetricSorter("lostClicks", {
2989
+ function paginateSortedInMemory(rows, input, compare) {
2990
+ const limit = clampLimit(input.limit, rows.length);
2991
+ const offset = clampOffset(input.offset);
2992
+ const selectedCount = Math.min(rows.length, offset + limit);
2993
+ if (limit === 0 || offset >= rows.length || selectedCount === 0) return [];
2994
+ const fullSort = () => [...rows].sort(compare).slice(offset, offset + limit);
2995
+ if (selectedCount >= rows.length / 2) return fullSort();
2996
+ let invalidComparison = false;
2997
+ const compareStable = (left, right) => {
2998
+ const order = compare(left.value, right.value);
2999
+ if (Number.isNaN(order)) {
3000
+ invalidComparison = true;
3001
+ return left.index - right.index;
3002
+ }
3003
+ return order || left.index - right.index;
3004
+ };
3005
+ const isWorse = (left, right) => compareStable(left, right) > 0;
3006
+ const heap = [];
3007
+ const siftUp = (start) => {
3008
+ let index = start;
3009
+ while (index > 0) {
3010
+ const parent = index - 1 >>> 1;
3011
+ if (!isWorse(heap[index], heap[parent])) break;
3012
+ const swap = heap[parent];
3013
+ heap[parent] = heap[index];
3014
+ heap[index] = swap;
3015
+ index = parent;
3016
+ }
3017
+ };
3018
+ const siftDown = () => {
3019
+ let index = 0;
3020
+ for (;;) {
3021
+ const left = index * 2 + 1;
3022
+ if (left >= heap.length) return;
3023
+ const right = left + 1;
3024
+ let worse = left;
3025
+ if (right < heap.length && isWorse(heap[right], heap[left])) worse = right;
3026
+ if (!isWorse(heap[worse], heap[index])) return;
3027
+ const swap = heap[index];
3028
+ heap[index] = heap[worse];
3029
+ heap[worse] = swap;
3030
+ index = worse;
3031
+ }
3032
+ };
3033
+ for (let index = 0; index < rows.length; index++) {
3034
+ const candidate = {
3035
+ value: rows[index],
3036
+ index
3037
+ };
3038
+ if (heap.length < selectedCount) {
3039
+ heap.push(candidate);
3040
+ siftUp(heap.length - 1);
3041
+ } else if (compareStable(candidate, heap[0]) < 0) {
3042
+ heap[0] = candidate;
3043
+ siftDown();
3044
+ }
3045
+ }
3046
+ if (invalidComparison) return fullSort();
3047
+ heap.sort(compareStable);
3048
+ if (invalidComparison) return fullSort();
3049
+ return heap.slice(offset, offset + limit).map((entry) => entry.value);
3050
+ }
3051
+ const sortResults = createMetricSorter("lostClicks", {
2971
3052
  lostClicks: "desc",
2972
3053
  declinePercent: "desc",
2973
3054
  currentClicks: "asc"
@@ -3000,7 +3081,7 @@ function analyzeDecay(input, options = {}) {
3000
3081
  positionDrop: currentPosition != null ? currentPosition - prev.position : null
3001
3082
  });
3002
3083
  }
3003
- return sortResults$1(results, sortBy);
3084
+ return sortResults(results, sortBy);
3004
3085
  }
3005
3086
  const decayAnalyzer = defineAnalyzer$1({
3006
3087
  id: "decay",
@@ -3995,12 +4076,6 @@ function calculateCtrGapScore(actualCtr, position) {
3995
4076
  const gap = expectedCtr - actualCtr;
3996
4077
  return Math.min(gap / expectedCtr, 1);
3997
4078
  }
3998
- const sortResults = createMetricSorter("opportunityScore", {
3999
- opportunityScore: "desc",
4000
- potentialClicks: "desc",
4001
- impressions: "desc",
4002
- position: "asc"
4003
- });
4004
4079
  const opportunityAnalyzer = defineAnalyzer$1({
4005
4080
  id: "opportunity",
4006
4081
  buildSql(params) {
@@ -4150,15 +4225,14 @@ const opportunityAnalyzer = defineAnalyzer$1({
4150
4225
  }
4151
4226
  });
4152
4227
  }
4153
- const sorted = sortResults(results, sortBy);
4154
- const paged = paginateInMemory(sorted, {
4228
+ const paged = paginateSortedInMemory(results, {
4155
4229
  limit: params.limit,
4156
4230
  offset: params.offset
4157
- });
4231
+ }, (left, right) => right[sortBy] - left[sortBy]);
4158
4232
  return {
4159
4233
  results: paged,
4160
4234
  meta: {
4161
- total: sorted.length,
4235
+ total: results.length,
4162
4236
  returned: paged.length
4163
4237
  }
4164
4238
  };
@@ -4860,11 +4934,10 @@ const strikingDistanceAnalyzer = defineAnalyzer$1({
4860
4934
  id: "striking-distance",
4861
4935
  reduce(rows, params) {
4862
4936
  const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
4863
- results.sort((a, b) => b.potentialClicks - a.potentialClicks);
4864
- const paged = paginateInMemory(results, {
4937
+ const paged = paginateSortedInMemory(results, {
4865
4938
  limit: params.limit ?? 1e3,
4866
4939
  offset: params.offset
4867
- });
4940
+ }, (left, right) => right.potentialClicks - left.potentialClicks);
4868
4941
  return {
4869
4942
  results: paged,
4870
4943
  meta: {
@@ -5240,7 +5313,6 @@ const trendsAnalyzer = defineAnalyzer$1({
5240
5313
  }
5241
5314
  });
5242
5315
  const DEFAULT_ROW_LIMIT = 25e3;
5243
- const sortRowResults = createSorter((item) => item.impressions, "impressions");
5244
5316
  const ALL_ANALYZERS = [
5245
5317
  bayesianCtrAnalyzer,
5246
5318
  bipartitePagerankAnalyzer,
@@ -5372,11 +5444,11 @@ const ALL_ANALYZERS = [
5372
5444
  position: row.position
5373
5445
  });
5374
5446
  }
5375
- const results = sortRowResults(Array.from(queryMap.values()), "impressions", "desc");
5376
- const paged = paginateInMemory(results, {
5447
+ const results = Array.from(queryMap.values());
5448
+ const paged = paginateSortedInMemory(results, {
5377
5449
  limit: params.limit,
5378
5450
  offset: params.offset
5379
- });
5451
+ }, (left, right) => right.impressions - left.impressions);
5380
5452
  return {
5381
5453
  results: paged,
5382
5454
  meta: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/analysis",
3
3
  "type": "module",
4
- "version": "0.38.2",
4
+ "version": "0.39.0",
5
5
  "description": "GSC analyzers — striking-distance, opportunity, movers, decay, brand, clustering, concentration, seasonality. Pure row-based + DuckDB-native.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -76,9 +76,9 @@
76
76
  "dependencies": {
77
77
  "drizzle-orm": "1.0.0-rc.3",
78
78
  "pluralize": "^8.0.0",
79
- "@gscdump/engine": "0.38.2",
80
- "gscdump": "0.38.2",
81
- "@gscdump/engine-gsc-api": "0.38.2"
79
+ "@gscdump/engine": "0.39.0",
80
+ "@gscdump/engine-gsc-api": "0.39.0",
81
+ "gscdump": "0.39.0"
82
82
  },
83
83
  "devDependencies": {
84
84
  "vitest": "^4.1.10"