Skip to content
Open
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
4 changes: 1 addition & 3 deletions src/components/PaginatedTablePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export interface PaginatedTablePickerProps<TItem, TRow extends Record<string, un
errorMessage: (error: Error) => string;
emptyMessage: string;
emptyPageMessage: string;
maxPageSize?: number;
}

export function PaginatedTablePicker<TItem, TRow extends Record<string, unknown>>({
Expand All @@ -44,9 +43,8 @@ export function PaginatedTablePicker<TItem, TRow extends Record<string, unknown>
errorMessage,
emptyMessage,
emptyPageMessage,
maxPageSize,
}: PaginatedTablePickerProps<TItem, TRow>) {
const paging = usePagedList(maxPageSize);
const paging = usePagedList();
const list = useQuery({
queryKey: [...queryKey, paging.pageSize, paging.token],
queryFn: () => loadPage(paging.token, paging.pageSize),
Expand Down
8 changes: 2 additions & 6 deletions src/components/usePagedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,9 @@ function initialPagination(pageSize: number): PaginationState {
// usePagedList holds the server-side pagination state shared by the picker
// tables: a terminal-height-derived page size and the trail of nextTokens
// leading to the current page, so ←/h can walk back through cached pages.
//
// maxPageSize caps the terminal-derived page size for APIs that constrain
// maxResults (e.g. identity list operation capped at 20)
export function usePagedList(maxPageSize?: number): PagedList {
export function usePagedList(): PagedList {
const { rows } = useWindowSize();
const fitsTerminal = Math.max(3, rows - CHROME_ROWS);
const pageSize = maxPageSize ? Math.min(fitsTerminal, maxPageSize) : fitsTerminal;
const pageSize = Math.max(3, rows - CHROME_ROWS);

// A resize changes maxResults, which invalidates the token trail (tokens
// encode positions relative to the old page size) — so every read derives
Expand Down
108 changes: 107 additions & 1 deletion src/core/filteredPaginator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,31 @@ describe("FilteredPaginator", () => {
expect(second.nextToken).toBeUndefined();
});

test("exact landing: a page completed without surplus advances, so nothing repeats", async () => {
const src = rows("AB.C..DE");
const first = await FilteredPaginator.paginate({
fetchPage: makeSource(src, 3).fetchPage,
predicate: keep,
nextToken: undefined,
maxResults: 3,
defaultPageSize: 10,
resourceLabel: "Test",
});
expect(first.items.map((r) => r.id)).toEqual(["A", "B", "C"]);
expect(first.nextToken).toBe("6");

const second = await FilteredPaginator.paginate({
fetchPage: makeSource(src, 3).fetchPage,
predicate: keep,
nextToken: first.nextToken,
maxResults: 3,
defaultPageSize: 10,
resourceLabel: "Test",
});
expect(second.items.map((r) => r.id)).toEqual(["D", "E"]);
expect(second.nextToken).toBeUndefined();
});

test("guard: a single page holding a full page of matches over-returns and advances (no loop)", async () => {
const src = rows("ABCDE");
const first = await FilteredPaginator.paginate({
Expand Down Expand Up @@ -194,6 +219,87 @@ describe("FilteredPaginator", () => {
resourceLabel: "Test",
});
expect(page.items.map((r) => r.id)).toEqual(["A", "B"]);
expect(page.nextToken).toBe("1");
expect(page.nextToken).toBe("2");
});
});

describe("FilteredPaginator without a predicate", () => {
test("fills a page above the service cap, asking each scan only for what is still needed", async () => {
const src = rows("ABCDEFGHIJKLMNO");
const first = makeSource(src, 100);
const page = await FilteredPaginator.paginate({
fetchPage: first.fetchPage,
nextToken: undefined,
maxResults: 7,
defaultPageSize: 10,
scanPageSize: 3,
resourceLabel: "Test",
});
expect(first.calls.map((c) => c.size)).toEqual([3, 3, 1]);
expect(page.items.map((r) => r.id)).toEqual([..."ABCDEFG"]);
expect(page.nextToken).toBe("7");

const second = makeSource(src, 100);
const next = await FilteredPaginator.paginate({
fetchPage: second.fetchPage,
nextToken: page.nextToken,
maxResults: 7,
defaultPageSize: 10,
scanPageSize: 3,
resourceLabel: "Test",
});
expect(next.items.map((r) => r.id)).toEqual([..."HIJKLMN"]);
expect(next.nextToken).toBe("14");
});

test("a page within the cap is one request for exactly maxResults", async () => {
const { fetchPage, calls } = makeSource(rows("ABCDE"), 100);
const page = await FilteredPaginator.paginate({
fetchPage,
nextToken: undefined,
maxResults: 2,
defaultPageSize: 10,
scanPageSize: 20,
resourceLabel: "Test",
});
expect(calls).toEqual([{ token: undefined, size: 2 }]);
expect(page.items.map((r) => r.id)).toEqual(["A", "B"]);
expect(page.nextToken).toBe("2");
});

test("without a scan size it asks for the whole page at once", async () => {
const { fetchPage, calls } = makeSource(rows("ABCDE"), 100);
await FilteredPaginator.paginate({
fetchPage,
nextToken: undefined,
maxResults: 4,
defaultPageSize: 10,
resourceLabel: "Test",
});
expect(calls).toEqual([{ token: undefined, size: 4 }]);
});

test("tops up a service page that came back short", async () => {
const src = rows("ABCDE");
const sizes: (number | undefined)[] = [];
// Serves at most two items per call regardless of what was asked for.
const fetchPage = async (token: string | undefined, size: number | undefined) => {
sizes.push(size);
const start = token === undefined ? 0 : Number(token);
const items = src.slice(start, start + Math.min(size ?? 2, 2));
const end = start + items.length;
return { items, nextToken: end < src.length ? String(end) : undefined };
};
const page = await FilteredPaginator.paginate({
fetchPage,
nextToken: undefined,
maxResults: 5,
defaultPageSize: 10,
scanPageSize: 20,
resourceLabel: "Test",
});
expect(sizes).toEqual([5, 3, 1]);
expect(page.items.map((r) => r.id)).toEqual([..."ABCDE"]);
expect(page.nextToken).toBeUndefined();
});
});
19 changes: 15 additions & 4 deletions src/core/filteredPaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export type PaginateFilteredOptions<T> = {
token: string | undefined,
maxResults: number | undefined,
) => Promise<{ items: T[]; nextToken: string | undefined }>;
predicate: (item: T) => boolean;
// predicate keeps the items that belong to the narrower listing. Omit it when
// every item counts and the service merely caps maxResults below the page
// being assembled: each scan then asks only for what the page still needs, so
// the page lands exactly on maxResults with no duplicated seam.
predicate?: (item: T) => boolean;
nextToken: string | undefined;
maxResults: number | undefined;
defaultPageSize: number;
Expand Down Expand Up @@ -37,11 +41,18 @@ export class FilteredPaginator {

for (let scan = 0; scan < MAX_SCAN_REQUESTS; scan++) {
const requestToken = token;
const page = await fetchPage(requestToken, scanPageSize);
const matches = page.items.filter(predicate);
const remaining = pageSize - results.length;
const requestSize = predicate ? scanPageSize : Math.min(scanPageSize ?? remaining, remaining);
const page = await fetchPage(requestToken, requestSize);
const matches = predicate ? page.items.filter(predicate) : page.items;
results.push(...matches);

if (results.length >= pageSize) {
// Landed exactly: nothing from this page is left behind, so advance past it.
if (results.length === pageSize) {
return { items: results, nextToken: page.nextToken };
}

if (results.length > pageSize) {
// Page holds >= pageSize matches by itself. Return every match found (the
// page may exceed maxResults) and advance past it: replaying its token would
// loop, and skipping the surplus would drop matches — so we over-return.
Expand Down
127 changes: 127 additions & 0 deletions src/core/identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, mock, test } from "bun:test";
import {
ListApiKeyCredentialProvidersCommand,
ListOauth2CredentialProvidersCommand,
type ApiKeyCredentialProviderItem,
type Oauth2CredentialProviderItem,
} from "@aws-sdk/client-bedrock-agentcore-control";
import { InputValidationError } from "../errors";
import type { AwsClients } from "./types";
import { IdentityClient } from "./identity";

const options = { region: "us-west-2", endpointUrl: "https://agentcore.example.test" };

type ListCommand = ListApiKeyCredentialProvidersCommand | ListOauth2CredentialProvidersCommand;

function identityClient(send: (command: ListCommand) => Promise<unknown>): IdentityClient {
return new IdentityClient({
control: () => ({ send: mock(send) }) as never,
} as unknown as Pick<AwsClients, "control">);
}

const names = (from: number, to: number) =>
Array.from({ length: to - from + 1 }, (_, index) => `provider-${from + index}`);

// providerSource serves `total` providers in service pages of at most `cap`,
// like the Identity list APIs do, and records every request it sees.
function providerSource<T>(total: number, cap: number, make: (name: string) => T) {
const all = names(1, total).map(make);
const requests: Array<{ op: string; input: ListCommand["input"] }> = [];
const send = async (command: ListCommand) => {
requests.push({ op: command.constructor.name, input: command.input });
const { nextToken, maxResults } = command.input;
if (maxResults !== undefined && maxResults > cap) {
throw new Error(`maxResults ${maxResults} exceeds the service cap of ${cap}`);
}
const start = nextToken === undefined ? 0 : Number(nextToken);
const items = all.slice(start, start + (maxResults ?? all.length));
const end = start + items.length;
return { credentialProviders: items, nextToken: end < all.length ? String(end) : undefined };
};
return { send, requests };
}

const oauth2 = (name: string) => ({ name }) as Oauth2CredentialProviderItem;
const apiKey = (name: string) => ({ name }) as ApiKeyCredentialProviderItem;

describe("IdentityClient list pagination", () => {
test("OAuth2: a page above the service cap of 20 is assembled from consecutive calls", async () => {
const source = providerSource(50, 20, oauth2);
const client = identityClient(source.send);

const page = await client.listOauth2CredentialProviders(undefined, 45, options);

expect(source.requests).toEqual([
{
op: "ListOauth2CredentialProvidersCommand",
input: { nextToken: undefined, maxResults: 20 },
},
{ op: "ListOauth2CredentialProvidersCommand", input: { nextToken: "20", maxResults: 20 } },
{ op: "ListOauth2CredentialProvidersCommand", input: { nextToken: "40", maxResults: 5 } },
]);
expect(page.credentialProviders?.map((p) => p.name)).toEqual(names(1, 45));
expect(page.nextToken).toBe("45");
});

test("OAuth2: the returned token continues exactly where the page ended", async () => {
const source = providerSource(50, 20, oauth2);
const client = identityClient(source.send);

const page = await client.listOauth2CredentialProviders("45", 45, options);

expect(source.requests.map((r) => r.input)).toEqual([{ nextToken: "45", maxResults: 20 }]);
expect(page.credentialProviders?.map((p) => p.name)).toEqual(names(46, 50));
expect(page.nextToken).toBeUndefined();
});

test("a page within the cap is one call with maxResults passed as given", async () => {
const source = providerSource(50, 20, oauth2);
const client = identityClient(source.send);

const page = await client.listOauth2CredentialProviders(undefined, 5, options);

expect(source.requests.map((r) => r.input)).toEqual([{ nextToken: undefined, maxResults: 5 }]);
expect(page.credentialProviders?.map((p) => p.name)).toEqual(names(1, 5));
expect(page.nextToken).toBe("5");
});

test("no maxResults passes straight through and returns the service response", async () => {
const source = providerSource(50, 20, apiKey);
const client = identityClient(source.send);

const page = await client.listApiKeyCredentialProviders(undefined, undefined, options);

expect(source.requests).toEqual([
{
op: "ListApiKeyCredentialProvidersCommand",
input: { nextToken: undefined, maxResults: undefined },
},
]);
expect(page.credentialProviders?.map((p) => p.name)).toEqual(names(1, 50));
expect(page.nextToken).toBeUndefined();
});

test("API key: the service cap is 100", async () => {
const source = providerSource(150, 100, apiKey);
const client = identityClient(source.send);

const page = await client.listApiKeyCredentialProviders(undefined, 120, options);

expect(source.requests.map((r) => r.input)).toEqual([
{ nextToken: undefined, maxResults: 100 },
{ nextToken: "100", maxResults: 20 },
]);
expect(page.credentialProviders?.map((p) => p.name)).toEqual(names(1, 120));
expect(page.nextToken).toBe("120");
});

test("rejects a non-positive maxResults before calling the service", async () => {
const source = providerSource(5, 20, oauth2);
const client = identityClient(source.send);

await expect(
client.listOauth2CredentialProviders(undefined, 0, options),
).rejects.toBeInstanceOf(InputValidationError);
expect(source.requests).toEqual([]);
});
});
Loading
Loading