Skip to content
Merged

V24.1 #546

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0c0a6e0
Fix stateful g-flag regex dropping consecutive resource_name fields i…
Lewis-Clayton Jun 10, 2026
3a5146a
Upgrade to Google Ads API v24.1 (google-ads-node 24.1.0-beta.0)
Lewis-Clayton Jun 10, 2026
c62ad96
Destroy stream pipeline in streamer() when consumers exit early
Lewis-Clayton Jun 10, 2026
aed5b38
Handle rejected close() in serviceCache disposal and stop returning s…
Lewis-Clayton Jun 10, 2026
5a03498
Guard recursiveFieldMaskSearch against circular references and typed …
Lewis-Clayton Jun 10, 2026
8d57b52
Close one-shot CustomerServiceClient in listAccessibleCustomers inste…
Lewis-Clayton Jun 10, 2026
5b4c31a
Convert Long values inside message-typed leaves to numbers in parsed …
Lewis-Clayton Jun 10, 2026
b66f9be
Guard against FAILURE_KEY drifting from the installed google-ads-node…
Lewis-Clayton Jun 10, 2026
7c14a65
24.1.0-beta.0
Lewis-Clayton Jun 10, 2026
e45fbc6
Fix lint errors and formatting in new tests and guards
Lewis-Clayton Jun 10, 2026
4ba72f8
Fix loadService returning undefined for expired cache entries awaitin…
Lewis-Clayton Jun 10, 2026
0671d93
Require @isaacs/ttlcache ^1.4.0 for checkAgeOnGet support
Lewis-Clayton Jun 10, 2026
ec28da2
Add 24.1.0 changelog entry
Lewis-Clayton Jun 10, 2026
d5a3b96
Treat Long values as leaf values in recursiveFieldMaskSearch
Lewis-Clayton Jun 10, 2026
b70c38e
Clear the shared serviceCache after each service spec test
Lewis-Clayton Jun 10, 2026
c7eb1ed
clarification
Lewis-Clayton Jun 11, 2026
d1a1329
Correct changelog: ten restored resource_name resources, accurate dis…
Lewis-Clayton Jun 15, 2026
e796e50
Release v24.1.0
Lewis-Clayton Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

### 24.1.0

### Version Upgrade

- Upgraded google-ads-api version to v24.1. Refer to Google Ads release notes [here](https://developers.google.com/google-ads/api/docs/release-notes) for changes.
- Upgraded google-ads-node dependency to v24.1.0
- Upgraded google-gax dependency to v5.0.7
- @isaacs/ttlcache now requires ^1.4.0

### New Features

- New services: `ReservationService`, `YouTubeVideoUploadService`
- New resource names: `AppTopCombinationView`, `CartDataSalesView`, `VideoEnhancement`, `YouTubeVideoUpload`
- New enums include `ReservationRequestType`, `PreviewType`, and new `ExperimentType` values (`ADOPT_AI_MAX`, `ADOPT_BROAD_MATCH_KEYWORDS`, `OPTIMIZE_ASSETS`, `PMAX_REPLACEMENT_SHOPPING`)
- New v24.1 fields such as `segments.mobile_device_platform` and `customer_user_access.passkey_enabled`

### Breaking Changes

- `parse()` now returns plain objects for message-typed leaves (e.g. `change_event.old_resource`) instead of protobuf message instances, and converts all nested int64 values from Long objects to JavaScript numbers. Code reading `.low`/`.high` from nested values, or relying on protobuf prototypes, must use the plain values instead. Note that int64 values above `Number.MAX_SAFE_INTEGER` (2^53 - 1) lose precision when converted, consistent with the existing behaviour for directly-selected fields.

### Fixes

- `resource_name` attributes for ten view resources (including `keyword_view` and `paid_organic_search_term_view`) were missing from parsed results due to a code generation bug; they are now included.
- `getFieldMask()` now throws a descriptive `Error` on objects containing circular references instead of overflowing the stack, and treats typed arrays (`Buffer`, `Uint8Array`) and `Long` values as leaf values. Previously, update masks built from payloads containing `Long` values included invalid `.low`/`.high`/`.unsigned` paths that the API rejected.
- Report streams now destroy their underlying response stream and JSON parser when consumers exit iteration early
- Rejected `close()` calls during gRPC service cache eviction are now caught instead of surfacing as unhandled promise rejections (the dispose callback was previously `async` and fire-and-forget)
- Cached gRPC service clients past their TTL are no longer returned, and expired entries awaiting purge no longer resolve to `undefined`
- `listAccessibleCustomers()` closes its one-shot `CustomerServiceClient` after the call instead of caching it

### 23.0.0

### Version Upgrade
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "google-ads-api",
"version": "23.0.0",
"version": "24.1.0",
"description": "Google Ads API Client Library for Node.js",
"repository": "https://github.com/Opteo/google-ads-api",
"main": "build/src/index.js",
Expand All @@ -18,12 +18,12 @@
"author": "Opteo",
"license": "MIT",
"dependencies": {
"@isaacs/ttlcache": "^1.2.2",
"@isaacs/ttlcache": "^1.4.0",
"axios": "^1.6.7",
"circ-json": "^1.0.4",
"google-ads-node": "23.0.0",
"google-ads-node": "24.1.0",
"google-auth-library": "^9.15.1",
"google-gax": "^5.0.6",
"google-gax": "^5.0.7",
"long": "^4.0.0",
"stream-json": "^1.8.0"
},
Expand Down
22 changes: 18 additions & 4 deletions scripts/fields.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ describe("other checkers", () => {
expect(isResourceName(nonResourceNameField)).toBeFalsy();
});

it("isResourceName matches consecutive resource_name fields", () => {
// Regression: a stateful /g regex carried lastIndex between calls,
// silently dropping resource_name fields tested back-to-back
const fields = [
"keyword_theme_constant.resource_name",
"keyword_view.resource_name",
"label.resource_name",
].map((name) => new resources.GoogleAdsField({ name }));

expect(fields.map((field) => isResourceName(field))).toEqual([
true,
true,
true,
]);
});

it("hasEnumDataType", () => {
const doesHaveEnumDataType = new resources.GoogleAdsField({
data_type: "ENUM",
Expand All @@ -82,12 +98,10 @@ describe("other checkers", () => {

it("getEnumName", () => {
const attributeEnumField = new resources.GoogleAdsField({
type_url:
`google.ads.googleads.${googleAdsVersion}.enums.CampaignStatusEnum.CampaignStatus`,
type_url: `google.ads.googleads.${googleAdsVersion}.enums.CampaignStatusEnum.CampaignStatus`,
});
const metricEnumField = new resources.GoogleAdsField({
type_url:
`google.ads.googleads.${googleAdsVersion}.enums.QualityScoreBucketEnum.QualityScoreBucket`,
type_url: `google.ads.googleads.${googleAdsVersion}.enums.QualityScoreBucketEnum.QualityScoreBucket`,
});
const segmentEnumField = new resources.GoogleAdsField({
type_url: `google.ads.googleads.${googleAdsVersion}.enums.DayOfWeekEnum.DayOfWeek`,
Expand Down
2 changes: 1 addition & 1 deletion scripts/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@

See fieldDataTypes in autogen/fields.ts for an idea of the final product.
*/
let mega: any = {};

Check warning on line 197 in scripts/fields.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Unexpected any. Specify a different type

Check warning on line 197 in scripts/fields.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Unexpected any. Specify a different type
function assembleMega(typeForLookup: string) {
if (typeForLookup.startsWith("com.")) {
typeForLookup = typeForLookup.replace("com.", "");
Expand All @@ -212,7 +212,7 @@
}
const fields = foundMessage.fields;

const o: any = {};

Check warning on line 215 in scripts/fields.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Unexpected any. Specify a different type

Check warning on line 215 in scripts/fields.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Unexpected any. Specify a different type
mega[foundMessage.name] = o;
for (const fieldKey in fields) {
const fieldKeyType = fields[fieldKey].type;
Expand Down Expand Up @@ -245,13 +245,13 @@
// Messages are the main challenge here, as they can contain other messages,
// Sometimes with infite recursion.
for (const field of fields.filter((field) => field.data_type === "MESSAGE")) {
_.set(mega, field.name!, assembleMega(field.type_url as string));

Check warning on line 248 in scripts/fields.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Forbidden non-null assertion

Check warning on line 248 in scripts/fields.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Forbidden non-null assertion
}
for (const field of fields.filter((field) => field.data_type !== "MESSAGE")) {
if (!_.get(mega, field.name!)) {

Check warning on line 251 in scripts/fields.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Forbidden non-null assertion

Check warning on line 251 in scripts/fields.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Forbidden non-null assertion
_.set(
mega,
field.name!,

Check warning on line 254 in scripts/fields.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Forbidden non-null assertion

Check warning on line 254 in scripts/fields.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Forbidden non-null assertion
(field.data_type as string).toUpperCase() as string
);
}
Expand Down Expand Up @@ -318,7 +318,7 @@
);
}

const resourceNameRegex = new RegExp(/^.*\.resource_name$/g);
const resourceNameRegex = /^.*\.resource_name$/;

export function isResourceName(field: resources.GoogleAdsField): boolean {
if (isDefinedAndNotNull(field.name)) {
Expand Down
53 changes: 53 additions & 0 deletions src/client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Client } from "./client";
import { Service } from "./service";

describe("listAccessibleCustomers", () => {
afterEach(() => {
jest.restoreAllMocks();
});

const newClient = () =>
new Client({
client_id: "MOCK_CLIENT_ID",
client_secret: "MOCK_CLIENT_SECRET",
developer_token: "MOCK_DEVELOPER_TOKEN",
});

it("closes the one-shot customer service after the call", async () => {
const client = newClient();
const close = jest.fn().mockResolvedValue(undefined);
const listAccessibleCustomers = jest
.fn()
.mockResolvedValue([{ resource_names: ["customers/123"] }]);

const loadServiceSpy = jest
.spyOn(Service.prototype as any, "loadService")

Check warning on line 24 in src/client.spec.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Unexpected any. Specify a different type

Check warning on line 24 in src/client.spec.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Unexpected any. Specify a different type
.mockReturnValue({ listAccessibleCustomers, close });

const response = await client.listAccessibleCustomers("MOCK_REFRESH_TOKEN");

expect(response).toEqual({ resource_names: ["customers/123"] });
expect(loadServiceSpy).toHaveBeenCalledWith("CustomerServiceClient", {
skipCache: true,
});
expect(close).toHaveBeenCalledTimes(1);
});

it("closes the one-shot customer service when the call fails", async () => {
const client = newClient();
const close = jest.fn().mockResolvedValue(undefined);

jest.spyOn(Service.prototype as any, "loadService").mockReturnValue({

Check warning on line 40 in src/client.spec.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Unexpected any. Specify a different type

Check warning on line 40 in src/client.spec.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20.x)

Unexpected any. Specify a different type
listAccessibleCustomers: jest
.fn()
.mockRejectedValue(new Error("rpc failed")),
close,
});
jest.spyOn(console, "log").mockImplementation(() => undefined);

await expect(
client.listAccessibleCustomers("MOCK_REFRESH_TOKEN")
).rejects.toThrow("rpc failed");
expect(close).toHaveBeenCalledTimes(1);
});
});
8 changes: 6 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ export class Client {
refresh_token: refreshToken,
});
// @ts-expect-error Protected usage is fine here
const customerService = await service.loadService<services.CustomerService>(
"CustomerServiceClient"
const customerService = service.loadService<services.CustomerService>(
"CustomerServiceClient",
{ skipCache: true }
);
try {
// @ts-expect-error Type definition is incorrect, response is an array
Expand All @@ -52,6 +53,9 @@ export class Client {
console.log(err);
// @ts-expect-error Protected usage is fine here
throw service.getGoogleAdsError(err);
} finally {
// @ts-expect-error close() exists on the gax client but not on the proto type
await customerService.close().catch(() => null);
}
}
}
21 changes: 21 additions & 0 deletions src/customer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { operationsProtos } from "google-gax";
import { Readable } from "stream";
import { Hooks } from "./hooks";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
Expand Down Expand Up @@ -458,6 +459,26 @@ describe("reportStream", () => {
expect(acc).toEqual(mockQueryReturnValue);
});

it("destroys the response stream when the consumer exits early", async () => {
const customer = newCustomer({});
mockGetAccessToken(customer);

const json = JSON.stringify(mockSearchRawResult);
const sourceStream = new Readable({ read: jest.fn() });
sourceStream.push(json.slice(0, -1));

axiosMock.onPost().reply(200, sourceStream);

const stream = customer.reportStream(mockReportOptions);

for await (const row of stream) {
void row;
break;
}

expect(sourceStream.destroyed).toBe(true);
});

it("skips reportStream parsing if it is disabled in the client options", async () => {
const disableParsing = true;
const customer = newCustomer({}, disableParsing);
Expand Down
40 changes: 23 additions & 17 deletions src/customer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,25 +498,31 @@ export class Customer extends ServiceFactory {
const pipeline = chain([stream, parser, streamArray()]);
let count = 0;

for await (const data of pipeline) {
const results =
data.value.results ??
(data.value.summaryRow ? [data.value.summaryRow] : undefined) ??
[];

count += results.length;
if (
this.clientOptions.max_reporting_rows &&
count > this.clientOptions.max_reporting_rows &&
!this.gaqlQueryStringIncludesLimit(gaqlQuery)
) {
throw this.generateTooManyRowsError();
}
try {
for await (const data of pipeline) {
const results =
data.value.results ??
(data.value.summaryRow ? [data.value.summaryRow] : undefined) ??
[];

count += results.length;
if (
this.clientOptions.max_reporting_rows &&
count > this.clientOptions.max_reporting_rows &&
!this.gaqlQueryStringIncludesLimit(gaqlQuery)
) {
throw this.generateTooManyRowsError();
}

for (const row of results) {
const parsed = this.decamelizeKeysIfNeeded(row);
yield parsed as T;
for (const row of results) {
const parsed = this.decamelizeKeysIfNeeded(row);
yield parsed as T;
}
}
} finally {
pipeline.destroy();
parser.destroy();
stream.destroy();
}

return;
Expand Down
9 changes: 5 additions & 4 deletions src/parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,11 +408,12 @@ describe("parseRows", () => {
result[0].change_event?.old_resource?.campaign_criterion?.campaign
).toBe("customers/4517895542/campaigns/21890334919");
expect(
(
result[0].change_event?.old_resource?.campaign_criterion
?.criterion_id as any
)?.low
result[0].change_event?.old_resource?.campaign_criterion?.criterion_id
).toBe(23340370);
expect(
typeof result[0].change_event?.old_resource?.campaign_criterion
?.criterion_id
).toBe("number");
expect(
result[0].change_event?.old_resource?.campaign_criterion?.negative
).toBe(true);
Expand Down
35 changes: 29 additions & 6 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,32 @@ export function parseRows(
return newRows;
}

function convertLongsToNumbers(value: any): any {
const isPrimitive = value === null || typeof value !== "object";
if (isPrimitive) {
return value;
}

if (long.isLong(value)) {
return value.toNumber();
}

if (Array.isArray(value)) {
return value.map(convertLongsToNumbers);
}

const isBytesField = ArrayBuffer.isView(value);
if (isBytesField) {
return value;
}

const converted: Record<string, any> = {};
for (const [key, nestedValue] of Object.entries(value)) {
converted[key] = convertLongsToNumbers(nestedValue);
}
return converted;
}

function parseNestedValues(
row: services.IGoogleAdsRow,
data: any,
Expand All @@ -137,12 +163,9 @@ function parseNestedValues(
const [parentField, ...childFields] = paths;
if (!row) row = {};
if (childFields.length === 0) {
const rawVal = data[parentField];
const parsedVal = long.isLong(rawVal)
? new long(rawVal.low, rawVal.high, rawVal.unsigned).toNumber()
: rawVal;

row[parentField as fields.Resource] = parsedVal;
row[parentField as fields.Resource] = convertLongsToNumbers(
data[parentField]
);
return row;
}

Expand Down
Loading
Loading