Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/afraid-boxes-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@builder.io/sdk': minor
'@builder.io/react': minor
---

Added fetchTotalCount param to getAll() in gen1 sdks
52 changes: 52 additions & 0 deletions packages/core/src/builder.class.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1391,4 +1391,56 @@ describe('getAll', () => {
{ headers: { Authorization: `Bearer ${AUTH_TOKEN}` } }
);
});

test('includes fetchTotalCount=true in URL when option is set', async () => {
builder.apiEndpoint = 'content';
await builder.getAll('blog-post', { fetchTotalCount: true });

expect(builder['makeFetchApiCall']).toBeCalledWith(
expect.stringContaining('fetchTotalCount=true'),
expect.anything()
);
});

test('does not include fetchTotalCount in URL when option is not set', async () => {
builder.apiEndpoint = 'content';
await builder.getAll('blog-post', {});

expect(builder['makeFetchApiCall']).not.toBeCalledWith(
expect.stringContaining('fetchTotalCount'),
expect.anything()
);
});

test('resolves to { results, totalCount } when fetchTotalCount is true', async () => {
const mockResults = [{ id: 'abc', data: {} }];
const mockTotalCount = 99;

builder['makeFetchApiCall'] = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ results: mockResults, totalCount: mockTotalCount }),
})
);

builder.apiEndpoint = 'content';
const result = await builder.getAll('blog-post', { fetchTotalCount: true });

expect(result).toEqual({ results: mockResults, totalCount: mockTotalCount });
});

test('resolves to BuilderContent[] (not wrapped) when fetchTotalCount is not set', async () => {
const mockResults = [{ id: 'abc', data: {} }];

builder['makeFetchApiCall'] = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ results: mockResults }),
})
);

builder.apiEndpoint = 'content';
const result = await builder.getAll('blog-post', {});

expect(Array.isArray(result)).toBe(true);
expect(result).toEqual(mockResults);
});
});
48 changes: 39 additions & 9 deletions packages/core/src/builder.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,12 @@ export type GetContentOptions = AllowEnrich & {
*/
includeUnpublished?: boolean;

/**
* When `true`, the API will also return the total number of matching entries
* regardless of `limit`/`offset`. Useful for building numbered pagination.
*/
fetchTotalCount?: boolean;

/**
* Options to configure how enrichment works.
* @see {@link https://www.builder.io/c/docs/content-api#code-enrich-options-code}
Expand Down Expand Up @@ -2336,6 +2342,7 @@ export class Builder {

private getContentQueue: null | GetContentOptions[] = null;
private priorContentQueue: null | GetContentOptions[] = null;
private totalCountByKey: Record<string, number> = {};

setUserAttributes(options: object) {
assign(Builder.overrideUserAttributes, options);
Expand Down Expand Up @@ -2775,6 +2782,7 @@ export class Builder {
'rev',
'static',
'includeRefs',
'fetchTotalCount',
];

for (const key of properties) {
Expand Down Expand Up @@ -2871,6 +2879,9 @@ export class Builder {
return;
}
const data = isApiCallForCodegenOrQuery ? result[keyName] : result.results;
if (result.totalCount !== undefined) {
this.totalCountByKey[keyName] = result.totalCount;
}
const sorted = data; // sortBy(data, item => item.priority);
if (data) {
const testModifiedResults = Builder.isServer
Expand Down Expand Up @@ -3028,6 +3039,16 @@ export class Builder {
return this.queueGetContent(modelName, options);
}

getAll<T extends boolean = false>(
modelName: string,
options?: GetContentOptions & {
req?: IncomingMessage;
res?: ServerResponse;
apiKey?: string;
authToken?: string;
fetchTotalCount?: T;
}
): Promise<T extends true ? { results: BuilderContent[]; totalCount: number } : BuilderContent[]>;
getAll(
modelName: string,
options: GetContentOptions & {
Expand All @@ -3036,7 +3057,7 @@ export class Builder {
apiKey?: string;
authToken?: string;
} = {}
): Promise<BuilderContent[]> {
): Promise<BuilderContent[] | { results: BuilderContent[]; totalCount: number }> {
Comment thread
cursor[bot] marked this conversation as resolved.
let instance: Builder = this;
if (!Builder.isBrowser) {
instance = new Builder(
Expand All @@ -3047,6 +3068,7 @@ export class Builder {
options.authToken || this.authToken,
options.apiVersion || this.apiVersion
);
instance.apiEndpoint = this.apiEndpoint;
instance.setUserAttributes(this.getUserAttributes());
} else {
// NOTE: All these are when .init is not called and the customer
Expand All @@ -3067,18 +3089,26 @@ export class Builder {
options.noTraverse = true;
}

const key =
(options.key || Builder.isBrowser || options.fetchTotalCount)
? `${modelName}:${hash(omit(options, 'initialContent', 'req', 'res'))}`
: undefined;

return instance
.getContent(modelName, {
limit: 30,
...options,
key:
options.key ||
// Make the key include all options, so we don't reuse cache for the same content fetched
// with different options
Builder.isBrowser
? `${modelName}:${hash(omit(options, 'initialContent', 'req', 'res'))}`
: undefined,
key,
})
.promise();
.promise()
.then(results => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this required when we are already passing fetchTotalCount in request params?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

observer.next(testModifiedResults) (https://github.com/BuilderIO/builder/blob/566a33238fb147a30b9390ea6fc8709a6939902a/packages/core/src/builder.class.ts#L2879C50-L2880C)
This only emits BuilderContent[] and the totalCount returned by result.totalCount is discarded at this point. That is why totalCountByKey is used as a cache to store totalCount during this flush.

if (options.fetchTotalCount) {
return {
results,
totalCount: key !== undefined ? instance.totalCountByKey[key] ?? 0 : 0,
Comment thread
cursor[bot] marked this conversation as resolved.
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale totalCount returned when key resolves to cached observable

Medium Severity

When queueGetContent finds an existing observable for the same key (line ~2484 in the currentObservable check), it replays the cached value via nextTick without making an API call. In that case, totalCountByKey is never populated (or holds a stale value from a previous call), but the .then() in getAll still reads from instance.totalCountByKey[key], returning either 0 or an outdated totalCount. This means repeated getAll calls with fetchTotalCount: true on the browser (where instance === this and observables persist) can return a stale or zero totalCount when the cache short-circuits the fetch.

Additional Locations (1)
Fix in Cursor Fix in Web

}
return results;
});
}
}
Loading