@koda-sl/baker-cli 0.347.0 → 0.349.0-dev.c05788ee3

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/cli.js CHANGED
@@ -67,7 +67,7 @@ import {
67
67
  validateCanvasDeep,
68
68
  withoutCastName,
69
69
  ytDlpBlockSignal
70
- } from "./chunk-YBD3W4M7.js";
70
+ } from "./chunk-CEFJ5YSG.js";
71
71
  import {
72
72
  csvOrJson,
73
73
  daysAgoIso,
@@ -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
  }
@@ -9778,6 +9848,27 @@ var CORRECTION_RULES = [
9778
9848
  };
9779
9849
  }
9780
9850
  },
9851
+ // 0b. OPERATOR_FIELD_MISMATCH — the field refuses this operator, and Google names the
9852
+ // ones it takes. Kept ahead of the field-specific rules for the same reason as rule 0.
9853
+ // A repeated field (final_urls, labels, …) takes only CONTAINS ALL/ANY/NONE, so it has
9854
+ // no null test at all: read it unfiltered and filter the rows instead.
9855
+ {
9856
+ matchApiError: /OPERATOR_FIELD_MISMATCH|is not a valid operator to use with/i,
9857
+ fix: (ctx) => {
9858
+ const message = ctx.apiErrorMessage ?? "";
9859
+ const field = message.match(/valid operator to use with '([\w.]+)'/i)?.[1] ?? "this field";
9860
+ const operator = message.match(/([A-Z][A-Z_ =!<>]*?) is not a valid operator/)?.[1]?.trim();
9861
+ const valid = message.match(/Valid operators: (.+?)\.?(?:"|$)/i)?.[1]?.trim();
9862
+ const refused = operator ? `${operator} cannot be used on ${field}` : `${field} does not take that operator`;
9863
+ const allowed = valid ? `; its valid operators are ${valid}` : "";
9864
+ const repeated = valid?.split(",").every((op) => /^CONTAINS (ALL|ANY|NONE)$/i.test(op.trim())) ?? false;
9865
+ const guidance = repeated ? `. ${field} is a list field, so it cannot be tested for null or compared with =: to find rows that have it, drop that condition, keep ${field} in SELECT and filter the rows (rows without it omit the key); to match values, use ${field} CONTAINS ANY ('value')` : ". Rewrite that condition with one of them, or drop it and filter the rows";
9866
+ return {
9867
+ action: "change_operator",
9868
+ explanation: `${refused}${allowed}${guidance}`
9869
+ };
9870
+ }
9871
+ },
9781
9872
  // 1. bare keyword.text → ad_group_criterion.keyword.text (leaves <resource>.keyword.text untouched)
9782
9873
  {
9783
9874
  matchQuery: /(?<![\w.])keyword\.text\b/,
@@ -9820,10 +9911,11 @@ var CORRECTION_RULES = [
9820
9911
  };
9821
9912
  }
9822
9913
  },
9823
- // 4. CONTAINS → LIKE
9914
+ // 4. CONTAINS → LIKE. Keyed on the query alone: Google's error envelope ("Request contains an
9915
+ // invalid argument … Valid operators: CONTAINS ANY") matches any loose error pattern, and this
9916
+ // advice only makes sense when the query actually wrote CONTAINS(…).
9824
9917
  {
9825
9918
  matchQuery: /CONTAINS\s*\(/i,
9826
- matchApiError: /CONTAINS.*not.*supported|invalid.*operator.*CONTAINS/i,
9827
9919
  fix: (ctx) => {
9828
9920
  const match = ctx.originalQuery.match(/CONTAINS\s*\(\s*([^,]+),\s*'([^']+)'\s*\)/i);
9829
9921
  if (match) {
@@ -10087,7 +10179,7 @@ var CORRECTION_RULES = [
10087
10179
  matchApiError: /changeStatusError\W{0,4}START_DATE_TOO_OLD/i,
10088
10180
  fix: () => ({
10089
10181
  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."
10182
+ 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
10183
  })
10092
10184
  },
10093
10185
  // 18c. change_status rejects a date range it cannot bound. Google raises this both for
@@ -11914,9 +12006,16 @@ import { resolve } from "path";
11914
12006
  import { defineCommand as defineCommand28 } from "citty";
11915
12007
 
11916
12008
  // 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;
12009
+ 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
12010
  function isValidDateRange(value) {
11919
- return GAQL_DATE_RANGE_RE.test(value.trim());
12011
+ const trimmed = value.trim();
12012
+ return isDuringLiteral(trimmed) || BETWEEN_RE.test(trimmed);
12013
+ }
12014
+ function dateRangeError(dateRange, now) {
12015
+ if (/^[A-Za-z0-9_]+$/.test(dateRange.trim())) {
12016
+ return `Invalid date range: "${dateRange}". ${duringLiteralExplanation(dateRange, equivalentBetween(dateRange, now))} Pass it as --date-range "BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'".`;
12017
+ }
12018
+ 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
12019
  }
11921
12020
  var SERVING_CHAINS = {
11922
12021
  keyword_view: ["campaign.status", "ad_group.status", "ad_group_criterion.status"],
@@ -12052,17 +12151,15 @@ function buildStatusScope(statusFields, includePaused) {
12052
12151
  const predicate = includePaused ? "!= 'REMOVED'" : "= 'ENABLED'";
12053
12152
  return statusFields.map((field) => ` AND ${field} ${predicate}`).join("");
12054
12153
  }
12055
- function expandPreset(preset, params) {
12154
+ function expandPreset(preset, params, now = /* @__PURE__ */ new Date()) {
12056
12155
  const dateRange = params.dateRange ?? preset.defaultDateRange;
12057
12156
  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
- };
12157
+ if (preset.gaqlTemplate.includes("{dateRange}") && !isValidDateRange(dateRange)) {
12158
+ return { query: "", dateRangeError: dateRangeError(dateRange, now) };
12063
12159
  }
12064
12160
  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);
12161
+ const range = dateRange.trim();
12162
+ 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
12163
  return { query };
12067
12164
  }
12068
12165
 
@@ -12221,6 +12318,49 @@ function applyAutoFixes(query, limit) {
12221
12318
  }
12222
12319
  return { query: corrected, warnings };
12223
12320
  }
12321
+ var DURING_CLAUSE = /(?<=[a-z_]\.[a-z_.]+\s+)DURING\s+([A-Za-z0-9_]+)/gi;
12322
+ function rejectUnsupportedDuring(query, customerId) {
12323
+ const unsupported = [...query.matchAll(DURING_CLAUSE)].map((match) => match[1] ?? "").filter((literal2) => !isDuringLiteral(literal2));
12324
+ const literal = unsupported[0];
12325
+ if (literal === void 0) return null;
12326
+ const now = /* @__PURE__ */ new Date();
12327
+ const between = equivalentBetween(literal, now);
12328
+ const fixed = unsupported.every((l) => equivalentBetween(l, now) !== null) ? query.replace(
12329
+ DURING_CLAUSE,
12330
+ (clause, l) => isDuringLiteral(l) ? clause : equivalentBetween(l, now) ?? clause
12331
+ ) : void 0;
12332
+ return {
12333
+ ok: false,
12334
+ error: {
12335
+ code: "INVALID_DATE_RANGE",
12336
+ message: `DURING ${literal} is not a Google Ads date range, so Google would refuse this query.`,
12337
+ fix: {
12338
+ action: "narrow_date_range",
12339
+ ...fixed ? { correctedCommand: buildCommand2(fixed, customerId) } : {},
12340
+ explanation: duringLiteralExplanation(literal, between)
12341
+ },
12342
+ retryable: false
12343
+ }
12344
+ };
12345
+ }
12346
+ function rejectOversizedLimit(query, customerId) {
12347
+ const match = query.match(/\bLIMIT\s+(\d+)\s*$/i);
12348
+ if (!match || Number(match[1]) <= GOOGLE_ADS_QUERY_MAX_PAGE_SIZE) return null;
12349
+ const fixed = query.replace(/\bLIMIT\s+\d+\s*$/i, `LIMIT ${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE}`);
12350
+ return {
12351
+ ok: false,
12352
+ error: {
12353
+ code: "VALIDATION_ERROR",
12354
+ message: `LIMIT ${match[1]} is more rows than one read can return (${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE}).`,
12355
+ fix: {
12356
+ action: "retry_with_flag",
12357
+ correctedCommand: buildCommand2(fixed, customerId),
12358
+ 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.`
12359
+ },
12360
+ retryable: false
12361
+ }
12362
+ };
12363
+ }
12224
12364
  function validatePreflight(query, customerId, limit) {
12225
12365
  const warnings = [];
12226
12366
  const corrected = query;
@@ -12292,6 +12432,10 @@ function validatePreflight(query, customerId, limit) {
12292
12432
  }
12293
12433
  };
12294
12434
  }
12435
+ const duringRejection = rejectUnsupportedDuring(corrected, customerId);
12436
+ if (duringRejection) return { valid: false, warnings: [], error: duringRejection };
12437
+ const limitRejection = rejectOversizedLimit(corrected, customerId);
12438
+ if (limitRejection) return { valid: false, warnings: [], error: limitRejection };
12295
12439
  if (/\bsegments\.conversion_action_name\b/i.test(corrected) && /\bmetrics\.cost_micros\b/i.test(corrected)) {
12296
12440
  return {
12297
12441
  valid: false,
@@ -12500,7 +12644,7 @@ registerSchema({
12500
12644
  },
12501
12645
  "date-range": {
12502
12646
  type: "string",
12503
- description: "Date range for presets (LAST_7_DAYS, LAST_30_DAYS, LAST_90_DAYS, or BETWEEN 'X' AND 'Y')",
12647
+ 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
12648
  required: false
12505
12649
  },
12506
12650
  "include-paused": {
@@ -12510,7 +12654,7 @@ registerSchema({
12510
12654
  },
12511
12655
  limit: {
12512
12656
  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.",
12657
+ 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
12658
  required: false,
12515
12659
  default: 200
12516
12660
  },
@@ -12518,7 +12662,7 @@ registerSchema({
12518
12662
  cursor: { type: "string", description: "Pagination cursor from previous response", required: false },
12519
12663
  all: {
12520
12664
  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.",
12665
+ 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
12666
  required: false
12523
12667
  },
12524
12668
  out: {
@@ -12605,7 +12749,8 @@ function completenessWarnings(read) {
12605
12749
  const filledTheCap = cap3 !== void 0 && cap3 > 0 && read.rowCount >= cap3;
12606
12750
  if (!read.hasMore && !filledTheCap) return [];
12607
12751
  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}.`;
12752
+ const atPageCap = cap3 !== void 0 && cap3 >= GOOGLE_ADS_QUERY_MAX_PAGE_SIZE;
12753
+ 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
12754
  return [
12610
12755
  {
12611
12756
  code: "TRUNCATED_RESULTS",
@@ -12939,9 +13084,35 @@ async function executeQuery(finalQuery, args5, customerId, limit, warnings, useC
12939
13084
  function shouldReadCache(args5) {
12940
13085
  return !args5["no-cache"] && !args5.out;
12941
13086
  }
13087
+ var DEFAULT_QUERY_LIMIT = 200;
13088
+ function parseQueryLimit(raw) {
13089
+ if (raw === void 0 || raw === null || raw === "") return { ok: true, limit: DEFAULT_QUERY_LIMIT };
13090
+ const limit = Number(raw);
13091
+ if (Number.isInteger(limit) && limit >= 1 && limit <= GOOGLE_ADS_QUERY_MAX_PAGE_SIZE) return { ok: true, limit };
13092
+ return {
13093
+ ok: false,
13094
+ error: {
13095
+ ok: false,
13096
+ error: {
13097
+ code: "VALIDATION_ERROR",
13098
+ message: `--limit must be a whole number from 1 to ${GOOGLE_ADS_QUERY_MAX_PAGE_SIZE}, got '${String(raw)}'.`,
13099
+ fix: {
13100
+ action: "retry_with_flag",
13101
+ 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.`
13102
+ },
13103
+ retryable: false
13104
+ }
13105
+ }
13106
+ };
13107
+ }
12942
13108
  async function validateArgs(args5) {
13109
+ const parsedLimit = parseQueryLimit(args5.limit);
13110
+ if (!parsedLimit.ok) {
13111
+ writeAdsJson(parsedLimit.error);
13112
+ process.exit(1);
13113
+ }
13114
+ const { limit } = parsedLimit;
12943
13115
  const customerId = await resolveCustomerId(args5);
12944
- const limit = args5.limit ? Number(args5.limit) : 200;
12945
13116
  const gaql = resolveGaql(args5, limit);
12946
13117
  if (!gaql) {
12947
13118
  process.exit(1);