From e1d5f97022ac78422eb674dc9ef59cc47e2b21dd Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Fri, 20 Feb 2026 20:07:24 +1100 Subject: [PATCH] fix: fix cursor pagination and improve null safety across commands --- src/commands/members.ts | 75 +++++++++++++++++++++++----------- src/commands/records.ts | 90 +++++++++++++++++++++++++++-------------- src/commands/users.ts | 63 +++++++++++++++++++---------- 3 files changed, 152 insertions(+), 76 deletions(-) diff --git a/src/commands/members.ts b/src/commands/members.ts index 22d0921..2870739 100644 --- a/src/commands/members.ts +++ b/src/commands/members.ts @@ -62,7 +62,6 @@ const printMemberPreview = (member: Member): void => { const fetchAllMembers = async ( spinner: ReturnType, - order = "ASC", filters?: Record ): Promise<{ members: Member[]; totalCount: number }> => { const allMembers: Member[] = []; @@ -74,22 +73,24 @@ const fetchAllMembers = async ( do { const result = await graphqlRequest<{ getMembers: { - edges: { cursor: string; node: Member }[]; + edges: { node: Member }[]; + pageInfo: { endCursor: string | null }; }; }>({ - query: `query($first: Int, $after: String, $order: OrderByInput, $filters: MemberFilter) { - getMembers(first: $first, after: $after, order: $order, filters: $filters) { - edges { cursor node { ${MEMBER_FIELDS} } } + query: `query($first: Int, $after: String, $filters: MemberFilter) { + getMembers(first: $first, after: $after, filters: $filters) { + edges { node { ${MEMBER_FIELDS} } } + pageInfo { endCursor } } }`, - variables: { first: pageSize, after: cursor, order, filters }, + variables: { first: pageSize, after: cursor, filters }, }); - const { edges } = result.getMembers; + const { edges, pageInfo } = result.getMembers; allMembers.push(...edges.map((e) => e.node)); - if (edges.length === pageSize) { - cursor = edges.at(-1)?.cursor; + if (edges.length === pageSize && pageInfo.endCursor) { + cursor = pageInfo.endCursor; spinner.text = `Fetching members... (${allMembers.length} so far)`; } else { cursor = undefined; @@ -107,8 +108,12 @@ const flattenMember = (member: Member): Record => ({ createdAt: member.createdAt, lastLogin: member.lastLogin ?? "", loginRedirect: member.loginRedirect ?? "", - permissions: member.permissions.all.join(", "), - plans: member.planConnections.map((p) => p.plan.id).join(", "), + permissions: member.permissions?.all?.join(", ") ?? "", + plans: + member.planConnections + ?.map((p) => p.plan?.id) + .filter(Boolean) + .join(", ") ?? "", ...Object.fromEntries( Object.entries(member.customFields ?? {}).map(([k, v]) => [ `customFields.${k}`, @@ -205,7 +210,7 @@ membersCommand "--after ", "Pagination cursor (endCursor from previous page)" ) - .option("--order ", "Sort order (ASC or DESC)", "ASC") + .option("--order ", "Sort order (ASC or DESC)") .option("--limit ", "Max members to return (default: 50, max: 200)") .option("--all", "Auto-paginate and fetch all members") .action(async (options: MembersListOptions) => { @@ -223,29 +228,39 @@ membersCommand const result = await graphqlRequest<{ getMembers: { - edges: { cursor: string; node: Member }[]; + edges: { node: Member }[]; + pageInfo: { endCursor: string | null }; }; }>({ - query: `query($first: Int, $after: String, $order: OrderByInput) { - getMembers(first: $first, after: $after, order: $order) { - edges { cursor node { ${MEMBER_FIELDS} } } + query: `query($first: Int, $after: String) { + getMembers(first: $first, after: $after) { + edges { node { ${MEMBER_FIELDS} } } + pageInfo { endCursor } } }`, - variables: { first: perPage, after: cursor, order: options.order }, + variables: { first: perPage, after: cursor }, }); - const { edges } = result.getMembers; + const { edges, pageInfo } = result.getMembers; const members = edges.map((e) => e.node); allMembers.push(...members); - if (allMembers.length < target && edges.length === perPage) { - cursor = edges.at(-1)?.cursor; + if ( + allMembers.length < target && + edges.length === perPage && + pageInfo.endCursor + ) { + cursor = pageInfo.endCursor; spinner.text = `Fetching members... (${allMembers.length} so far)`; } else { cursor = undefined; } } while (cursor); + if (options.order === "DESC") { + allMembers.reverse(); + } + spinner.stop(); const [first] = allMembers; @@ -279,11 +294,16 @@ membersCommand const spinner = yoctoSpinner({ text: "Fetching member..." }).start(); try { if (idOrEmail.startsWith("mem_")) { - const result = await graphqlRequest<{ currentMember: Member }>({ + const result = await graphqlRequest<{ + currentMember: Member | null; + }>({ query: `query($id: ID) { currentMember(id: $id) { ${MEMBER_FIELDS} } }`, variables: { id: idOrEmail }, }); spinner.stop(); + if (!result.currentMember) { + throw new Error(`Member not found: ${idOrEmail}`); + } printRecord(result.currentMember); } else { const result = await graphqlRequest<{ @@ -418,6 +438,11 @@ membersCommand if (member) { printSuccess(`Member updated: ${member.id}`); printRecord(member); + } else { + printError( + "No update options provided. Use --help to see available options." + ); + process.exitCode = 1; } } catch (error) { spinner.stop(); @@ -627,7 +652,7 @@ membersCommand let members: Member[]; if (hasPlanFilter && !hasFieldFilter) { - const { members: fetched } = await fetchAllMembers(spinner, "ASC", { + const { members: fetched } = await fetchAllMembers(spinner, { planIds: [options.plan], }); members = fetched; @@ -692,8 +717,10 @@ membersCommand inactive++; } - for (const conn of member.planConnections) { - planCounts[conn.plan.id] = (planCounts[conn.plan.id] ?? 0) + 1; + for (const conn of member.planConnections ?? []) { + if (conn.plan?.id) { + planCounts[conn.plan.id] = (planCounts[conn.plan.id] ?? 0) + 1; + } } const created = new Date(member.createdAt).getTime(); diff --git a/src/commands/records.ts b/src/commands/records.ts index c4360db..a1f01fc 100644 --- a/src/commands/records.ts +++ b/src/commands/records.ts @@ -64,6 +64,49 @@ const extractDataFields = ( return data; }; +const fetchAllRecords = async ( + spinner: ReturnType, + tableId: string, + filter?: Record +): Promise => { + const allRecords: DataRecord[] = []; + let cursor: string | undefined; + const pageSize = 100; + + do { + const result = await graphqlRequest<{ + dataRecords: { + edges: { node: DataRecord }[]; + pageInfo: { endCursor: string | null }; + }; + }>({ + query: `query($tableId: ID!, $filter: DataRecordsFilterInput, $pagination: DataRecordsPaginationInput) { + dataRecords(tableId: $tableId, filter: $filter, pagination: $pagination) { + edges { node { ${DATA_RECORD_FIELDS} } } + pageInfo { endCursor } + } +}`, + variables: { + tableId, + filter, + pagination: { first: pageSize, after: cursor }, + }, + }); + + const { edges, pageInfo } = result.dataRecords; + allRecords.push(...edges.map((e) => e.node)); + + if (edges.length === pageSize && pageInfo.endCursor) { + cursor = pageInfo.endCursor; + spinner.text = `Fetching records... (${allRecords.length} so far)`; + } else { + cursor = undefined; + } + } while (cursor); + + return allRecords; +}; + const resolveTableId = async (tableKey: string): Promise => { const result = await graphqlRequest<{ dataTable: { id: string } }>({ query: "query($key: String!) { dataTable(key: $key) { id } }", @@ -273,7 +316,7 @@ recordsCommand createdAt: e.node.createdAt, updatedAt: e.node.updatedAt, ...Object.fromEntries( - Object.entries(e.node.data).map(([k, v]) => [`data.${k}`, v]) + Object.entries(e.node.data ?? {}).map(([k, v]) => [`data.${k}`, v]) ), })); printSuccess(`Found ${records.length} record(s)`); @@ -304,12 +347,14 @@ recordsCommand const pageSize = 100; const result = await graphqlRequest<{ dataRecords: { - edges: { cursor: string; node: DataRecord }[]; + edges: { node: DataRecord }[]; + pageInfo: { endCursor: string | null }; }; }>({ query: `query($tableId: ID!, $pagination: DataRecordsPaginationInput) { dataRecords(tableId: $tableId, pagination: $pagination) { - edges { cursor node { ${DATA_RECORD_FIELDS} } } + edges { node { ${DATA_RECORD_FIELDS} } } + pageInfo { endCursor } } }`, variables: { @@ -318,7 +363,7 @@ recordsCommand }, }); - const { edges } = result.dataRecords; + const { edges, pageInfo } = result.dataRecords; for (const { node: record } of edges) { allRecords.push({ @@ -326,18 +371,21 @@ recordsCommand createdAt: record.createdAt, updatedAt: record.updatedAt, ...Object.fromEntries( - Object.entries(record.data).map(([k, v]) => [`data.${k}`, v]) + Object.entries(record.data ?? {}).map(([k, v]) => [ + `data.${k}`, + v, + ]) ), }); } - if (edges.length === pageSize) { - cursor = edges.at(-1)?.cursor; + if (edges.length === pageSize && pageInfo.endCursor) { + cursor = pageInfo.endCursor; spinner.text = `Fetching records... (${allRecords.length} so far)`; } else { cursor = undefined; } - } while (cursor !== undefined); + } while (cursor); spinner.text = "Writing file..."; @@ -492,29 +540,11 @@ recordsCommand const spinner = yoctoSpinner({ text: "Querying records..." }).start(); try { const tableId = await resolveTableId(tableKey); - const variables: Record = { - tableId, - pagination: { first: 100 }, - }; - - if (options.where?.length) { - variables.filter = { fieldFilters: parseWhereClause(options.where) }; - } - - const result = await graphqlRequest<{ - dataRecords: { - edges: { node: DataRecord }[]; - }; - }>({ - query: `query($tableId: ID!, $filter: DataRecordsFilterInput, $pagination: DataRecordsPaginationInput) { - dataRecords(tableId: $tableId, filter: $filter, pagination: $pagination) { - edges { node { ${DATA_RECORD_FIELDS} } } - } -}`, - variables, - }); + const filter = options.where?.length + ? { fieldFilters: parseWhereClause(options.where) } + : undefined; - const targets = result.dataRecords.edges.map((e) => e.node); + const targets = await fetchAllRecords(spinner, tableId, filter); if (targets.length === 0) { spinner.stop(); diff --git a/src/commands/users.ts b/src/commands/users.ts index 67eb95a..ec3470d 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -50,22 +50,24 @@ usersCommand do { const result = await graphqlRequest<{ getUsers: { - edges: { cursor: string; node: AppUser }[]; + edges: { node: AppUser }[]; + pageInfo: { endCursor: string | null }; }; }>({ query: `query($first: Int, $after: String) { getUsers(first: $first, after: $after) { - edges { cursor node { ${USER_FIELDS} } } + edges { node { ${USER_FIELDS} } } + pageInfo { endCursor } } }`, variables: { first: pageSize, after: cursor }, }); - const { edges } = result.getUsers; + const { edges, pageInfo } = result.getUsers; allUsers.push(...edges.map((e) => e.node)); - if (edges.length === pageSize) { - cursor = edges.at(-1)?.cursor; + if (edges.length === pageSize && pageInfo.endCursor) { + cursor = pageInfo.endCursor; spinner.text = `Fetching users... (${allUsers.length} so far)`; } else { cursor = undefined; @@ -97,24 +99,41 @@ usersCommand .action(async (idOrEmail: string) => { const spinner = yoctoSpinner({ text: "Fetching users..." }).start(); try { - const result = await graphqlRequest<{ - getUsers: { - edges: { cursor: string; node: AppUser }[]; - }; - }>({ - query: `query { - getUsers { + const allUsers: AppUser[] = []; + let getCursor: string | undefined; + const getPageSize = 200; + + do { + const result = await graphqlRequest<{ + getUsers: { + edges: { node: AppUser }[]; + pageInfo: { endCursor: string | null }; + }; + }>({ + query: `query($first: Int, $after: String) { + getUsers(first: $first, after: $after) { edges { node { ${USER_FIELDS} } } + pageInfo { endCursor } } }`, - }); + variables: { first: getPageSize, after: getCursor }, + }); + + const { edges, pageInfo } = result.getUsers; + allUsers.push(...edges.map((e) => e.node)); + + if (edges.length === getPageSize && pageInfo.endCursor) { + getCursor = pageInfo.endCursor; + } else { + getCursor = undefined; + } + } while (getCursor); + spinner.stop(); const isEmail = idOrEmail.includes("@"); - const match = result.getUsers.edges.find((e) => - isEmail - ? e.node.user.auth.email === idOrEmail - : e.node.user.id === idOrEmail + const match = allUsers.find((u) => + isEmail ? u.user.auth.email === idOrEmail : u.user.id === idOrEmail ); if (!match) { @@ -124,11 +143,11 @@ usersCommand } printRecord({ - id: match.node.user.id, - email: match.node.user.auth.email, - firstName: match.node.user.profile.firstName ?? "", - lastName: match.node.user.profile.lastName ?? "", - role: match.node.role, + id: match.user.id, + email: match.user.auth.email, + firstName: match.user.profile.firstName ?? "", + lastName: match.user.profile.lastName ?? "", + role: match.role, }); } catch (error) { spinner.stop();