@koda-sl/baker-cli 0.347.0 → 0.348.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -304,7 +304,7 @@ Read it before interpreting any null. Field coverage is folded page by page on t
304
304
  }
305
305
  ```
306
306
 
307
- Never total or count from a response carrying it — re-run with `--all --limit 10000 --out data.csv` (the API accepts page sizes up to 10,000) and check the re-run came back without the warning. A read that returns one row by construction — `FROM customer` with no segment selected, such as `--preset account-summary` — has no page to fill, so it never carries `TRUNCATED_RESULTS`, nor `CONVERSION_MIX`, since one account-wide row has no campaigns to total or compare. Warnings reach every output format, so no `--output` choice can hide one:
307
+ Never total or count from a response carrying it — re-run with `--all --limit 8192 --out data.csv` (one read returns at most 8,192 rows; split a bigger data set into narrower queries — a shorter date range or one campaign each) and check the re-run came back without the warning. A read that returns one row by construction — `FROM customer` with no segment selected, such as `--preset account-summary` — has no page to fill, so it never carries `TRUNCATED_RESULTS`, nor `CONVERSION_MIX`, since one account-wide row has no campaigns to total or compare. Warnings reach every output format, so no `--output` choice can hide one:
308
308
 
309
309
  | `--output` | Where warnings land |
310
310
  |------------|---------------------|
@@ -343,11 +343,11 @@ The same lines also go to stderr. A read with nothing to report prints none of i
343
343
  |-----------------|--------------------------------------------------------------|
344
344
  | `--customer-id` | Google Ads customer ID (10 digits, no dashes). Falls back to `BAKER_GOOGLE_ADS_CUSTOMER_ID` env var. |
345
345
  | `--preset` | Named query template (see presets below) |
346
- | `--date-range` | Override preset date range (LAST_7_DAYS, LAST_30_DAYS, etc.) |
346
+ | `--date-range` | Override preset date range: a Google literal (LAST_7_DAYS, LAST_30_DAYS, LAST_MONTH, etc.) or `BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'` — there is no LAST_90_DAYS |
347
347
  | `--include-paused` | Include paused entities in preset results. Off by default: presets return only actually-serving entities (enabled campaign **and** ad group **and** ad). Google Ads has no single serving flag, so the whole chain must be `ENABLED` for an entity to serve. |
348
- | `--limit` | Max rows per page (default 200) |
348
+ | `--limit` | Max rows per page (default 200, at most 8,192) |
349
349
  | `--cursor` | Pagination cursor from previous response |
350
- | `--all` | Auto-paginate all results. Exhausts the pages, not the statement's `LIMIT` — pair it with a `--limit` above the expected row count, and a read that still fills its cap is flagged `TRUNCATED_RESULTS` either way |
350
+ | `--all` | Auto-paginate all results. Exhausts the pages, not the statement's `LIMIT` — pair it with a `LIMIT` in the statement above the expected row count (at most 8,192 — split bigger reads into narrower queries), and a read that still fills its cap is flagged `TRUNCATED_RESULTS` either way |
351
351
  | `--out` | Write data to file (format from extension: .csv, .jsonl, .json) |
352
352
  | `--append` | Append to existing file (skip CSV headers) |
353
353
  | `--output` | Output format: `json` \| `csv` \| `jsonl` \| `md` |
@@ -391,6 +391,8 @@ The CLI rejects or automatically corrects common GAQL mistakes before hitting th
391
391
  | `shopping_performance_view.product_*` | Rewrites to `segments.product_*` | Field renamed |
392
392
  | `campaign.status = 'ACTIVE'` | Rewrites to `= 'ENABLED'` | Enum corrected |
393
393
  | `segments.conversion_action_name` with `metrics.cost_micros` | Rejects; split into separate conversion-action and spend queries | `INCOMPATIBLE_FIELDS` with `fix.action: "split_query"` |
394
+ | `DURING` with a literal Google does not accept (`LAST_90_DAYS`, `LAST_12_MONTHS`, `THIS_YEAR` …) | Rejects before any request; `DURING` takes only `TODAY`, `YESTERDAY`, `LAST_7_DAYS`, `LAST_14_DAYS`, `LAST_30_DAYS`, `LAST_BUSINESS_WEEK`, `THIS_MONTH`, `LAST_MONTH`, `THIS_WEEK_SUN_TODAY`, `THIS_WEEK_MON_TODAY`, `LAST_WEEK_SUN_SAT`, `LAST_WEEK_MON_SUN` | `INVALID_DATE_RANGE`, with `fix.correctedCommand` spelling the same window as `BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'` |
395
+ | `--limit` outside 1–8,192, or a statement `LIMIT` above 8,192 | Rejects before any request — one read returns at most 8,192 rows | `VALIDATION_ERROR` naming the cap and how to split a bigger read; a statement `LIMIT` also gets `fix.correctedCommand` at `LIMIT 8192` |
394
396
  | `FROM ad_group_criterion` without `negative` field | Warns | `"ad_group_criterion returns BOTH positive and negative keywords. Use --preset positive-keywords or negative-keywords."` |
395
397
 
396
398
  Auto-fixed queries proceed normally. Warnings appear in the response:
package/dist/cli.js CHANGED
@@ -1957,6 +1957,7 @@ var GOOGLE_ADS_LIMITS = {
1957
1957
  businessNameMax: 25
1958
1958
  }
1959
1959
  };
1960
+ var GOOGLE_ADS_QUERY_MAX_PAGE_SIZE = 8192;
1960
1961
  var KEYWORD_MATCH_TYPES = ["EXACT", "PHRASE", "BROAD"];
1961
1962
  var ADVERTISING_CHANNEL_TYPES = [
1962
1963
  "SEARCH",
@@ -9674,7 +9675,76 @@ function urlOptionsHints(report) {
9674
9675
  return hints2;
9675
9676
  }
9676
9677
 
9678
+ // src/commands/ads/google/during-literals.ts
9679
+ var GAQL_DURING_LITERALS = [
9680
+ "TODAY",
9681
+ "YESTERDAY",
9682
+ "LAST_7_DAYS",
9683
+ "LAST_14_DAYS",
9684
+ "LAST_30_DAYS",
9685
+ "LAST_BUSINESS_WEEK",
9686
+ "THIS_MONTH",
9687
+ "LAST_MONTH",
9688
+ "THIS_WEEK_SUN_TODAY",
9689
+ "THIS_WEEK_MON_TODAY",
9690
+ "LAST_WEEK_SUN_SAT",
9691
+ "LAST_WEEK_MON_SUN"
9692
+ ];
9693
+ function isDuringLiteral(value) {
9694
+ return GAQL_DURING_LITERALS.includes(value.toUpperCase());
9695
+ }
9696
+ function isoDay(date) {
9697
+ return date.toISOString().slice(0, 10);
9698
+ }
9699
+ function utcDate(year, month, day) {
9700
+ return new Date(Date.UTC(year, month, day));
9701
+ }
9702
+ function monthsBefore(date, months) {
9703
+ const lastDayOfTarget = utcDate(date.getUTCFullYear(), date.getUTCMonth() - months + 1, 0).getUTCDate();
9704
+ return utcDate(date.getUTCFullYear(), date.getUTCMonth() - months, Math.min(date.getUTCDate(), lastDayOfTarget));
9705
+ }
9706
+ function equivalentBetween(literal, now) {
9707
+ const upper2 = literal.toUpperCase();
9708
+ const today = utcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
9709
+ const yesterday = utcDate(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1);
9710
+ const range = (from, to) => `BETWEEN '${isoDay(from)}' AND '${isoDay(to)}'`;
9711
+ const year = today.getUTCFullYear();
9712
+ const quarterStart = Math.floor(today.getUTCMonth() / 3) * 3;
9713
+ const rolling = upper2.match(/^LAST_(\d+)_(DAYS|WEEKS|MONTHS|YEARS)$/);
9714
+ if (rolling) {
9715
+ const n = Number(rolling[1]);
9716
+ const unit = rolling[2];
9717
+ if (n < 1) return null;
9718
+ if (unit === "DAYS" || unit === "WEEKS") {
9719
+ const days = unit === "DAYS" ? n : n * 7;
9720
+ return range(utcDate(year, today.getUTCMonth(), today.getUTCDate() - days), yesterday);
9721
+ }
9722
+ const months = unit === "MONTHS" ? n : n * 12;
9723
+ return range(monthsBefore(today, months), yesterday);
9724
+ }
9725
+ switch (upper2) {
9726
+ case "THIS_YEAR":
9727
+ return range(utcDate(year, 0, 1), today);
9728
+ case "LAST_YEAR":
9729
+ return range(utcDate(year - 1, 0, 1), utcDate(year - 1, 11, 31));
9730
+ case "THIS_QUARTER":
9731
+ return range(utcDate(year, quarterStart, 1), today);
9732
+ case "LAST_QUARTER":
9733
+ return range(utcDate(year, quarterStart - 3, 1), utcDate(year, quarterStart, 0));
9734
+ default:
9735
+ return null;
9736
+ }
9737
+ }
9738
+ function duringLiteralExplanation(literal, between) {
9739
+ const accepted = GAQL_DURING_LITERALS.join(", ");
9740
+ const lead = between ? `Google does not accept ${literal} after DURING. The same window as explicit dates is ${between}.` : `Google does not accept ${literal} after DURING, and it names no fixed window \u2014 use BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD', or drop the date filter.`;
9741
+ return `${lead} DURING accepts only: ${accepted}. Any other window needs BETWEEN.`;
9742
+ }
9743
+
9677
9744
  // src/commands/ads/google/changes-window.ts
9745
+ function monthlySpendTrendQuery(now = /* @__PURE__ */ new Date()) {
9746
+ return `SELECT campaign.name, segments.month, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date ${equivalentBetween("LAST_12_MONTHS", now)}`;
9747
+ }
9678
9748
  function isChangeScope(value) {
9679
9749
  return CHANGE_SCOPES.includes(value);
9680
9750
  }
@@ -9728,7 +9798,7 @@ function lookbackTooLong(scope, days, max) {
9728
9798
  message: `Google's account history API reaches back ${CHANGE_SCOPE_MAX_DAYS.summary} days at most; ${days} days is outside that window.`,
9729
9799
  fix: {
9730
9800
  action: "use_different_resource",
9731
- explanation: "Do not abort \u2014 infer the change from performance instead. Query monthly metrics per campaign (SELECT campaign.name, segments.month, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date DURING LAST_12_MONTHS) to see which campaigns stopped or started spending. That shows what changed and when, though not who changed it. Older history exists only in the Google Ads web UI, which has no API \u2014 ask the user to check it if the 'who' matters."
9801
+ explanation: `Do not abort \u2014 infer the change from performance instead. Query monthly metrics per campaign (${monthlySpendTrendQuery()}) to see which campaigns stopped or started spending. That shows what changed and when, though not who changed it. Older history exists only in the Google Ads web UI, which has no API \u2014 ask the user to check it if the 'who' matters.`
9732
9802
  }
9733
9803
  };
9734
9804
  }
@@ -10087,7 +10157,7 @@ var CORRECTION_RULES = [
10087
10157
  matchApiError: /changeStatusError\W{0,4}START_DATE_TOO_OLD/i,
10088
10158
  fix: () => ({
10089
10159
  action: "use_different_resource",
10090
- explanation: "Google's account history reaches back 90 days at most, and this query asks for more. Do not abort \u2014 infer the change from performance: SELECT campaign.name, segments.month, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date DURING LAST_12_MONTHS shows which campaigns started or stopped spending and when, though not who changed them. Anything older with attribution exists only in the Google Ads web UI, which has no API."
10160
+ explanation: `Google's account history reaches back 90 days at most, and this query asks for more. Do not abort \u2014 infer the change from performance: ${monthlySpendTrendQuery()} shows which campaigns started or stopped spending and when, though not who changed them. Anything older with attribution exists only in the Google Ads web UI, which has no API.`
10091
10161
  })
10092
10162
  },
10093
10163
  // 18c. change_status rejects a date range it cannot bound. Google raises this both for
@@ -11914,9 +11984,16 @@ import { resolve } from "path";
11914
11984
  import { defineCommand as defineCommand28 } from "citty";
11915
11985
 
11916
11986
  // src/commands/ads/google/presets.ts
11917
- var GAQL_DATE_RANGE_RE = /^(?:TODAY|YESTERDAY|LAST_7_DAYS|LAST_14_DAYS|LAST_30_DAYS|LAST_90_DAYS|THIS_MONTH|LAST_MONTH|THIS_QUARTER|LAST_QUARTER|THIS_YEAR|LAST_YEAR|ALL_TIME|BETWEEN\s+'[0-9]{4}-[0-9]{2}-[0-9]{2}'\s+AND\s+'[0-9]{4}-[0-9]{2}-[0-9]{2}')$/i;
11987
+ var BETWEEN_RE = /^BETWEEN\s+'[0-9]{4}-[0-9]{2}-[0-9]{2}'\s+AND\s+'[0-9]{4}-[0-9]{2}-[0-9]{2}'$/i;
11918
11988
  function isValidDateRange(value) {
11919
- return GAQL_DATE_RANGE_RE.test(value.trim());
11989
+ const trimmed = value.trim();
11990
+ return isDuringLiteral(trimmed) || BETWEEN_RE.test(trimmed);
11991
+ }
11992
+ function dateRangeError(dateRange, now) {
11993
+ if (/^[A-Za-z0-9_]+$/.test(dateRange.trim())) {
11994
+ return `Invalid date range: "${dateRange}". ${duringLiteralExplanation(dateRange, equivalentBetween(dateRange, now))} Pass it as --date-range "BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'".`;
11995
+ }
11996
+ return `Invalid date range: "${dateRange}". Use a GAQL date literal (e.g. LAST_30_DAYS, TODAY) or BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'.`;
11920
11997
  }
11921
11998
  var SERVING_CHAINS = {
11922
11999
  keyword_view: ["campaign.status", "ad_group.status", "ad_group_criterion.status"],
@@ -12052,17 +12129,15 @@ function buildStatusScope(statusFields, includePaused) {
12052
12129
  const predicate = includePaused ? "!= 'REMOVED'" : "= 'ENABLED'";
12053
12130
  return statusFields.map((field) => ` AND ${field} ${predicate}`).join("");
12054
12131
  }
12055
- function expandPreset(preset, params) {
12132
+ function expandPreset(preset, params, now = /* @__PURE__ */ new Date()) {
12056
12133
  const dateRange = params.dateRange ?? preset.defaultDateRange;
12057
12134
  const limit = params.limit ?? preset.defaultLimit;
12058
- if (!isValidDateRange(dateRange)) {
12059
- return {
12060
- query: "",
12061
- dateRangeError: `Invalid date range: "${dateRange}". Use a GAQL date literal (e.g. LAST_30_DAYS, TODAY) or BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'.`
12062
- };
12135
+ if (preset.gaqlTemplate.includes("{dateRange}") && !isValidDateRange(dateRange)) {
12136
+ return { query: "", dateRangeError: dateRangeError(dateRange, now) };
12063
12137
  }
12064
12138
  const statusScope = buildStatusScope(preset.statusFields, params.includePaused ?? false);
12065
- const query = preset.gaqlTemplate.replace(/\{dateRange\}/g, dateRange).replace(/\{limit\}/g, String(limit)).replace(/\{statusScope\}/g, statusScope);
12139
+ const range = dateRange.trim();
12140
+ const query = preset.gaqlTemplate.replace(/DURING\s+\{dateRange\}/g, BETWEEN_RE.test(range) ? range : `DURING ${range}`).replace(/\{dateRange\}/g, range).replace(/\{limit\}/g, String(limit)).replace(/\{statusScope\}/g, statusScope);
12066
12141
  return { query };
12067
12142
  }
12068
12143
 
@@ -12221,6 +12296,49 @@ function applyAutoFixes(query, limit) {
12221
12296
  }
12222
12297
  return { query: corrected, warnings };
12223
12298
  }
12299
+ var DURING_CLAUSE = /(?<=[a-z_]\.[a-z_.]+\s+)DURING\s+([A-Za-z0-9_]+)/gi;
12300
+ function rejectUnsupportedDuring(query, customerId) {
12301
+ const unsupported = [...query.matchAll(DURING_CLAUSE)].map((match) => match[1] ?? "").filter((literal2) => !isDuringLiteral(literal2));
12302
+ const literal = unsupported[0];
12303
+ if (literal === void 0) return null;
12304
+ const now = /* @__PURE__ */ new Date();
12305
+ const between = equivalentBetween(literal, now);
12306
+ const fixed = unsupported.every((l) => equivalentBetween(l, now) !== null) ? query.replace(
12307
+ DURING_CLAUSE,
12308
+ (clause, l) => isDuringLiteral(l) ? clause : equivalentBetween(l, now) ?? clause
12309
+ ) : void 0;
12310
+ return {
12311
+ ok: false,
12312
+ error: {
12313
+ code: "INVALID_DATE_RANGE",
12314
+ message: `DURING ${literal} is not a Google Ads date range, so Google would refuse this query.`,
12315
+ fix: {
12316
+ action: "narrow_date_range",
12317
+ ...fixed ? { correctedCommand: buildCommand2(fixed, customerId) } : {},
12318
+ explanation: duringLiteralExplanation(literal, between)
12319
+ },
12320
+ retryable: false
12321
+ }
12322
+ };
12323
+ }
12324
+ function rejectOversizedLimit(query, customerId) {
12325
+ const match = query.match(/\bLIMIT\s+(\d+)\s*$/i);
12326
+ if (!match || Number(match[1]) <= GOOGLE_ADS_QUERY_MAX_PAGE_SIZE) return null;
12327
+ const fixed = query.replace(/\bLIMIT\s+\d+\s*$/i, `LIMIT ${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE}`);
12328
+ return {
12329
+ ok: false,
12330
+ error: {
12331
+ code: "VALIDATION_ERROR",
12332
+ message: `LIMIT ${match[1]} is more rows than one read can return (${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE}).`,
12333
+ fix: {
12334
+ action: "retry_with_flag",
12335
+ correctedCommand: buildCommand2(fixed, customerId),
12336
+ explanation: `One query returns at most ${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE} rows. For more, split the read into narrower queries \u2014 a shorter BETWEEN range each, or one campaign each \u2014 write each with --out, and combine the files.`
12337
+ },
12338
+ retryable: false
12339
+ }
12340
+ };
12341
+ }
12224
12342
  function validatePreflight(query, customerId, limit) {
12225
12343
  const warnings = [];
12226
12344
  const corrected = query;
@@ -12292,6 +12410,10 @@ function validatePreflight(query, customerId, limit) {
12292
12410
  }
12293
12411
  };
12294
12412
  }
12413
+ const duringRejection = rejectUnsupportedDuring(corrected, customerId);
12414
+ if (duringRejection) return { valid: false, warnings: [], error: duringRejection };
12415
+ const limitRejection = rejectOversizedLimit(corrected, customerId);
12416
+ if (limitRejection) return { valid: false, warnings: [], error: limitRejection };
12295
12417
  if (/\bsegments\.conversion_action_name\b/i.test(corrected) && /\bmetrics\.cost_micros\b/i.test(corrected)) {
12296
12418
  return {
12297
12419
  valid: false,
@@ -12500,7 +12622,7 @@ registerSchema({
12500
12622
  },
12501
12623
  "date-range": {
12502
12624
  type: "string",
12503
- description: "Date range for presets (LAST_7_DAYS, LAST_30_DAYS, LAST_90_DAYS, or BETWEEN 'X' AND 'Y')",
12625
+ description: "Date range for presets: a Google date literal (TODAY, YESTERDAY, LAST_7_DAYS, LAST_14_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH \u2026) or BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'. Google has no LAST_90_DAYS \u2014 use BETWEEN for any other window.",
12504
12626
  required: false
12505
12627
  },
12506
12628
  "include-paused": {
@@ -12510,7 +12632,7 @@ registerSchema({
12510
12632
  },
12511
12633
  limit: {
12512
12634
  type: "number",
12513
- description: "Max rows in ONE page (default 200), AND the LIMIT added to a query that has none \u2014 so it caps the whole read with --all too. Values up to 10000 are accepted. Any read that fills this cap comes back flagged TRUNCATED_RESULTS \u2014 never count or total from one.",
12635
+ description: "Max rows in ONE page (default 200), AND the LIMIT added to a query that has none \u2014 so it caps the whole read with --all too. Values up to 8192 are accepted \u2014 the most one read can return. Any read that fills this cap comes back flagged TRUNCATED_RESULTS \u2014 never count or total from one.",
12514
12636
  required: false,
12515
12637
  default: 200
12516
12638
  },
@@ -12518,7 +12640,7 @@ registerSchema({
12518
12640
  cursor: { type: "string", description: "Pagination cursor from previous response", required: false },
12519
12641
  all: {
12520
12642
  type: "boolean",
12521
- description: "Follow every page until the data set is exhausted (use with --out for large datasets). Needed for a read you may count or total, but not sufficient: it exhausts the pages, not the statement's LIMIT, so pair it with a --limit above the row count you expect (or your own LIMIT) and check the read came back without TRUNCATED_RESULTS.",
12643
+ description: "Follow every page until the data set is exhausted (use with --out for large datasets). Needed for a read you may count or total, but not sufficient: it exhausts the pages, not the statement's LIMIT, so pair it with a LIMIT in the statement above the row count you expect (at most 8192 \u2014 split bigger reads into narrower queries) and check the read came back without TRUNCATED_RESULTS.",
12522
12644
  required: false
12523
12645
  },
12524
12646
  out: {
@@ -12605,7 +12727,8 @@ function completenessWarnings(read) {
12605
12727
  const filledTheCap = cap3 !== void 0 && cap3 > 0 && read.rowCount >= cap3;
12606
12728
  if (!read.hasMore && !filledTheCap) return [];
12607
12729
  const cause = read.hasMore ? `More rows are waiting: the account returned a next-page cursor this run did not follow.` : `The ${read.rowCount} rows returned exactly fill the row cap in force (${cap3}).`;
12608
- const fix = read.all ? `Every page was followed, so the cap is the LIMIT in the statement itself \u2014 raise it above ${cap3}, or drop it and pass a larger --limit, then re-run.` : `Re-run with --all --out <file> to page through everything, or raise --limit above ${cap3}.`;
12730
+ const atPageCap = cap3 !== void 0 && cap3 >= GOOGLE_ADS_QUERY_MAX_PAGE_SIZE;
12731
+ const fix = atPageCap ? `${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE} rows is the most one read can return \u2014 split it into narrower queries (a shorter BETWEEN range each, or one campaign each), write each with --out, and combine the files.` : read.all ? `Every page was followed, so the cap is the LIMIT in the statement itself \u2014 raise it above ${cap3}, or drop it and pass a larger --limit (up to ${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE}), then re-run.` : `Re-run with --all --out <file> to page through everything, or raise --limit above ${cap3} (up to ${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE}).`;
12609
12732
  return [
12610
12733
  {
12611
12734
  code: "TRUNCATED_RESULTS",
@@ -12939,9 +13062,35 @@ async function executeQuery(finalQuery, args5, customerId, limit, warnings, useC
12939
13062
  function shouldReadCache(args5) {
12940
13063
  return !args5["no-cache"] && !args5.out;
12941
13064
  }
13065
+ var DEFAULT_QUERY_LIMIT = 200;
13066
+ function parseQueryLimit(raw) {
13067
+ if (raw === void 0 || raw === null || raw === "") return { ok: true, limit: DEFAULT_QUERY_LIMIT };
13068
+ const limit = Number(raw);
13069
+ if (Number.isInteger(limit) && limit >= 1 && limit <= GOOGLE_ADS_QUERY_MAX_PAGE_SIZE) return { ok: true, limit };
13070
+ return {
13071
+ ok: false,
13072
+ error: {
13073
+ ok: false,
13074
+ error: {
13075
+ code: "VALIDATION_ERROR",
13076
+ message: `--limit must be a whole number from 1 to ${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE}, got '${String(raw)}'.`,
13077
+ fix: {
13078
+ action: "retry_with_flag",
13079
+ explanation: `One read returns at most ${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE} rows. For more, split the read into narrower queries (a shorter BETWEEN range each, or one campaign each), write each with --out, and combine the files.`
13080
+ },
13081
+ retryable: false
13082
+ }
13083
+ }
13084
+ };
13085
+ }
12942
13086
  async function validateArgs(args5) {
13087
+ const parsedLimit = parseQueryLimit(args5.limit);
13088
+ if (!parsedLimit.ok) {
13089
+ writeAdsJson(parsedLimit.error);
13090
+ process.exit(1);
13091
+ }
13092
+ const { limit } = parsedLimit;
12943
13093
  const customerId = await resolveCustomerId(args5);
12944
- const limit = args5.limit ? Number(args5.limit) : 200;
12945
13094
  const gaql = resolveGaql(args5, limit);
12946
13095
  if (!gaql) {
12947
13096
  process.exit(1);