Skip to content

Commit 4327f99

Browse files
authored
fix: fix cursor pagination and improve null safety across commands (#10)
Fix cursor pagination by using pageInfo.endCursor instead of edge cursors, add null safety across commands, and paginate bulk-delete and users get. Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
1 parent 839c193 commit 4327f99

3 files changed

Lines changed: 152 additions & 76 deletions

File tree

src/commands/members.ts

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ const printMemberPreview = (member: Member): void => {
6262

6363
const fetchAllMembers = async (
6464
spinner: ReturnType<typeof yoctoSpinner>,
65-
order = "ASC",
6665
filters?: Record<string, unknown>
6766
): Promise<{ members: Member[]; totalCount: number }> => {
6867
const allMembers: Member[] = [];
@@ -74,22 +73,24 @@ const fetchAllMembers = async (
7473
do {
7574
const result = await graphqlRequest<{
7675
getMembers: {
77-
edges: { cursor: string; node: Member }[];
76+
edges: { node: Member }[];
77+
pageInfo: { endCursor: string | null };
7878
};
7979
}>({
80-
query: `query($first: Int, $after: String, $order: OrderByInput, $filters: MemberFilter) {
81-
getMembers(first: $first, after: $after, order: $order, filters: $filters) {
82-
edges { cursor node { ${MEMBER_FIELDS} } }
80+
query: `query($first: Int, $after: String, $filters: MemberFilter) {
81+
getMembers(first: $first, after: $after, filters: $filters) {
82+
edges { node { ${MEMBER_FIELDS} } }
83+
pageInfo { endCursor }
8384
}
8485
}`,
85-
variables: { first: pageSize, after: cursor, order, filters },
86+
variables: { first: pageSize, after: cursor, filters },
8687
});
8788

88-
const { edges } = result.getMembers;
89+
const { edges, pageInfo } = result.getMembers;
8990
allMembers.push(...edges.map((e) => e.node));
9091

91-
if (edges.length === pageSize) {
92-
cursor = edges.at(-1)?.cursor;
92+
if (edges.length === pageSize && pageInfo.endCursor) {
93+
cursor = pageInfo.endCursor;
9394
spinner.text = `Fetching members... (${allMembers.length} so far)`;
9495
} else {
9596
cursor = undefined;
@@ -107,8 +108,12 @@ const flattenMember = (member: Member): Record<string, unknown> => ({
107108
createdAt: member.createdAt,
108109
lastLogin: member.lastLogin ?? "",
109110
loginRedirect: member.loginRedirect ?? "",
110-
permissions: member.permissions.all.join(", "),
111-
plans: member.planConnections.map((p) => p.plan.id).join(", "),
111+
permissions: member.permissions?.all?.join(", ") ?? "",
112+
plans:
113+
member.planConnections
114+
?.map((p) => p.plan?.id)
115+
.filter(Boolean)
116+
.join(", ") ?? "",
112117
...Object.fromEntries(
113118
Object.entries(member.customFields ?? {}).map(([k, v]) => [
114119
`customFields.${k}`,
@@ -205,7 +210,7 @@ membersCommand
205210
"--after <cursor>",
206211
"Pagination cursor (endCursor from previous page)"
207212
)
208-
.option("--order <order>", "Sort order (ASC or DESC)", "ASC")
213+
.option("--order <order>", "Sort order (ASC or DESC)")
209214
.option("--limit <number>", "Max members to return (default: 50, max: 200)")
210215
.option("--all", "Auto-paginate and fetch all members")
211216
.action(async (options: MembersListOptions) => {
@@ -223,29 +228,39 @@ membersCommand
223228

224229
const result = await graphqlRequest<{
225230
getMembers: {
226-
edges: { cursor: string; node: Member }[];
231+
edges: { node: Member }[];
232+
pageInfo: { endCursor: string | null };
227233
};
228234
}>({
229-
query: `query($first: Int, $after: String, $order: OrderByInput) {
230-
getMembers(first: $first, after: $after, order: $order) {
231-
edges { cursor node { ${MEMBER_FIELDS} } }
235+
query: `query($first: Int, $after: String) {
236+
getMembers(first: $first, after: $after) {
237+
edges { node { ${MEMBER_FIELDS} } }
238+
pageInfo { endCursor }
232239
}
233240
}`,
234-
variables: { first: perPage, after: cursor, order: options.order },
241+
variables: { first: perPage, after: cursor },
235242
});
236243

237-
const { edges } = result.getMembers;
244+
const { edges, pageInfo } = result.getMembers;
238245
const members = edges.map((e) => e.node);
239246
allMembers.push(...members);
240247

241-
if (allMembers.length < target && edges.length === perPage) {
242-
cursor = edges.at(-1)?.cursor;
248+
if (
249+
allMembers.length < target &&
250+
edges.length === perPage &&
251+
pageInfo.endCursor
252+
) {
253+
cursor = pageInfo.endCursor;
243254
spinner.text = `Fetching members... (${allMembers.length} so far)`;
244255
} else {
245256
cursor = undefined;
246257
}
247258
} while (cursor);
248259

260+
if (options.order === "DESC") {
261+
allMembers.reverse();
262+
}
263+
249264
spinner.stop();
250265

251266
const [first] = allMembers;
@@ -279,11 +294,16 @@ membersCommand
279294
const spinner = yoctoSpinner({ text: "Fetching member..." }).start();
280295
try {
281296
if (idOrEmail.startsWith("mem_")) {
282-
const result = await graphqlRequest<{ currentMember: Member }>({
297+
const result = await graphqlRequest<{
298+
currentMember: Member | null;
299+
}>({
283300
query: `query($id: ID) { currentMember(id: $id) { ${MEMBER_FIELDS} } }`,
284301
variables: { id: idOrEmail },
285302
});
286303
spinner.stop();
304+
if (!result.currentMember) {
305+
throw new Error(`Member not found: ${idOrEmail}`);
306+
}
287307
printRecord(result.currentMember);
288308
} else {
289309
const result = await graphqlRequest<{
@@ -418,6 +438,11 @@ membersCommand
418438
if (member) {
419439
printSuccess(`Member updated: ${member.id}`);
420440
printRecord(member);
441+
} else {
442+
printError(
443+
"No update options provided. Use --help to see available options."
444+
);
445+
process.exitCode = 1;
421446
}
422447
} catch (error) {
423448
spinner.stop();
@@ -627,7 +652,7 @@ membersCommand
627652
let members: Member[];
628653

629654
if (hasPlanFilter && !hasFieldFilter) {
630-
const { members: fetched } = await fetchAllMembers(spinner, "ASC", {
655+
const { members: fetched } = await fetchAllMembers(spinner, {
631656
planIds: [options.plan],
632657
});
633658
members = fetched;
@@ -692,8 +717,10 @@ membersCommand
692717
inactive++;
693718
}
694719

695-
for (const conn of member.planConnections) {
696-
planCounts[conn.plan.id] = (planCounts[conn.plan.id] ?? 0) + 1;
720+
for (const conn of member.planConnections ?? []) {
721+
if (conn.plan?.id) {
722+
planCounts[conn.plan.id] = (planCounts[conn.plan.id] ?? 0) + 1;
723+
}
697724
}
698725

699726
const created = new Date(member.createdAt).getTime();

src/commands/records.ts

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,49 @@ const extractDataFields = (
6464
return data;
6565
};
6666

67+
const fetchAllRecords = async (
68+
spinner: ReturnType<typeof yoctoSpinner>,
69+
tableId: string,
70+
filter?: Record<string, unknown>
71+
): Promise<DataRecord[]> => {
72+
const allRecords: DataRecord[] = [];
73+
let cursor: string | undefined;
74+
const pageSize = 100;
75+
76+
do {
77+
const result = await graphqlRequest<{
78+
dataRecords: {
79+
edges: { node: DataRecord }[];
80+
pageInfo: { endCursor: string | null };
81+
};
82+
}>({
83+
query: `query($tableId: ID!, $filter: DataRecordsFilterInput, $pagination: DataRecordsPaginationInput) {
84+
dataRecords(tableId: $tableId, filter: $filter, pagination: $pagination) {
85+
edges { node { ${DATA_RECORD_FIELDS} } }
86+
pageInfo { endCursor }
87+
}
88+
}`,
89+
variables: {
90+
tableId,
91+
filter,
92+
pagination: { first: pageSize, after: cursor },
93+
},
94+
});
95+
96+
const { edges, pageInfo } = result.dataRecords;
97+
allRecords.push(...edges.map((e) => e.node));
98+
99+
if (edges.length === pageSize && pageInfo.endCursor) {
100+
cursor = pageInfo.endCursor;
101+
spinner.text = `Fetching records... (${allRecords.length} so far)`;
102+
} else {
103+
cursor = undefined;
104+
}
105+
} while (cursor);
106+
107+
return allRecords;
108+
};
109+
67110
const resolveTableId = async (tableKey: string): Promise<string> => {
68111
const result = await graphqlRequest<{ dataTable: { id: string } }>({
69112
query: "query($key: String!) { dataTable(key: $key) { id } }",
@@ -273,7 +316,7 @@ recordsCommand
273316
createdAt: e.node.createdAt,
274317
updatedAt: e.node.updatedAt,
275318
...Object.fromEntries(
276-
Object.entries(e.node.data).map(([k, v]) => [`data.${k}`, v])
319+
Object.entries(e.node.data ?? {}).map(([k, v]) => [`data.${k}`, v])
277320
),
278321
}));
279322
printSuccess(`Found ${records.length} record(s)`);
@@ -304,12 +347,14 @@ recordsCommand
304347
const pageSize = 100;
305348
const result = await graphqlRequest<{
306349
dataRecords: {
307-
edges: { cursor: string; node: DataRecord }[];
350+
edges: { node: DataRecord }[];
351+
pageInfo: { endCursor: string | null };
308352
};
309353
}>({
310354
query: `query($tableId: ID!, $pagination: DataRecordsPaginationInput) {
311355
dataRecords(tableId: $tableId, pagination: $pagination) {
312-
edges { cursor node { ${DATA_RECORD_FIELDS} } }
356+
edges { node { ${DATA_RECORD_FIELDS} } }
357+
pageInfo { endCursor }
313358
}
314359
}`,
315360
variables: {
@@ -318,26 +363,29 @@ recordsCommand
318363
},
319364
});
320365

321-
const { edges } = result.dataRecords;
366+
const { edges, pageInfo } = result.dataRecords;
322367

323368
for (const { node: record } of edges) {
324369
allRecords.push({
325370
id: record.id,
326371
createdAt: record.createdAt,
327372
updatedAt: record.updatedAt,
328373
...Object.fromEntries(
329-
Object.entries(record.data).map(([k, v]) => [`data.${k}`, v])
374+
Object.entries(record.data ?? {}).map(([k, v]) => [
375+
`data.${k}`,
376+
v,
377+
])
330378
),
331379
});
332380
}
333381

334-
if (edges.length === pageSize) {
335-
cursor = edges.at(-1)?.cursor;
382+
if (edges.length === pageSize && pageInfo.endCursor) {
383+
cursor = pageInfo.endCursor;
336384
spinner.text = `Fetching records... (${allRecords.length} so far)`;
337385
} else {
338386
cursor = undefined;
339387
}
340-
} while (cursor !== undefined);
388+
} while (cursor);
341389

342390
spinner.text = "Writing file...";
343391

@@ -492,29 +540,11 @@ recordsCommand
492540
const spinner = yoctoSpinner({ text: "Querying records..." }).start();
493541
try {
494542
const tableId = await resolveTableId(tableKey);
495-
const variables: Record<string, unknown> = {
496-
tableId,
497-
pagination: { first: 100 },
498-
};
499-
500-
if (options.where?.length) {
501-
variables.filter = { fieldFilters: parseWhereClause(options.where) };
502-
}
503-
504-
const result = await graphqlRequest<{
505-
dataRecords: {
506-
edges: { node: DataRecord }[];
507-
};
508-
}>({
509-
query: `query($tableId: ID!, $filter: DataRecordsFilterInput, $pagination: DataRecordsPaginationInput) {
510-
dataRecords(tableId: $tableId, filter: $filter, pagination: $pagination) {
511-
edges { node { ${DATA_RECORD_FIELDS} } }
512-
}
513-
}`,
514-
variables,
515-
});
543+
const filter = options.where?.length
544+
? { fieldFilters: parseWhereClause(options.where) }
545+
: undefined;
516546

517-
const targets = result.dataRecords.edges.map((e) => e.node);
547+
const targets = await fetchAllRecords(spinner, tableId, filter);
518548

519549
if (targets.length === 0) {
520550
spinner.stop();

0 commit comments

Comments
 (0)