From 447385ce0b6bb72d5e0afb1e8c1c614dac63d7f2 Mon Sep 17 00:00:00 2001 From: Ian Macartney <366683+ianmacartney@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:37:59 -0700 Subject: [PATCH 1/2] Use built-in pagination instead of convex-helpers paginator Switch listMetadata to ctx.db.query(...).paginate(). Temporarily install convex-test from pkg.pr.new (convex-test#163). --- package-lock.json | 6 +++--- package.json | 2 +- src/component/lib.ts | 3 +-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ca6160..8f50764 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "clsx": "2.1.1", "convex": "1.46.0", "convex-svelte": "0.0.12", - "convex-test": "0.0.62", + "convex-test": "https://pkg.pr.new/convex-test@163", "convex-vue": "0.1.5", "eslint": "9.39.4", "eslint-plugin-react": "7.37.5", @@ -6288,8 +6288,8 @@ }, "node_modules/convex-test": { "version": "0.0.62", - "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.62.tgz", - "integrity": "sha512-gvFps/A1JhOlFToZKjStSX8lWQS8oPF4oKm6U5D/ZdmOtfC7bKqw5Ce5ccAf45jvxPKPcFiyWt8JrueE+byB2g==", + "resolved": "https://pkg.pr.new/convex-test@163", + "integrity": "sha512-hoq+HPdnwDs1BUwPe/VS+iwIzrKvtZJp6IvbhCaW7wDflviau0XVrY2A8Sd2yfxex7/GIy1XVsauYEECQcHM7Q==", "dev": true, "license": "Apache-2.0", "peerDependencies": { diff --git a/package.json b/package.json index 4041280..591317a 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "clsx": "2.1.1", "convex": "1.46.0", "convex-svelte": "0.0.12", - "convex-test": "0.0.62", + "convex-test": "https://pkg.pr.new/convex-test@163", "convex-vue": "0.1.5", "eslint": "9.39.4", "eslint-plugin-react": "7.37.5", diff --git a/src/component/lib.ts b/src/component/lib.ts index 13af8c1..62b1fab 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -17,7 +17,6 @@ import type { Doc, TableNames } from "./_generated/dataModel.js"; import { api, components } from "./_generated/api.js"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { asyncMap } from "convex-helpers"; -import { paginator } from "convex-helpers/server/pagination"; import { ActionRetrier } from "@convex-dev/action-retrier"; import type { R2Callbacks } from "../client/index.js"; @@ -101,7 +100,7 @@ export const listMetadata = query({ handler: async (ctx, args) => { const { limit, cursor, ...r2Config } = args; const r2 = createR2Client(r2Config); - const results = await paginator(ctx.db, schema) + const results = await ctx.db .query("metadata") .withIndex("bucket", (q) => q.eq("bucket", r2Config.bucket)) .paginate({ From a66f1233290876145170c142c2340caf455cb8be Mon Sep 17 00:00:00 2001 From: Ian Macartney <366683+ianmacartney@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:24:52 -0700 Subject: [PATCH 2/2] Forward endCursor and read limits in listMetadata listMetadata previously dropped paginationOpts.endCursor, so pages from usePaginatedQuery could shift. Add optional endCursor, maximumRowsRead and maximumBytesRead args to the component's listMetadata and forward them from the client. --- src/client/index.ts | 16 +++++++++ src/component/_generated/component.ts | 3 ++ src/component/lib.test.ts | 52 +++++++++++++++++++++++++++ src/component/lib.ts | 15 +++++++- 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/component/lib.test.ts diff --git a/src/client/index.ts b/src/client/index.ts index 37adac7..81192cf 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -6,6 +6,7 @@ import { type GenericDataModel, type GenericMutationCtx, type GenericQueryCtx, + type PaginationOptions, internalMutationGeneric, mutationGeneric, paginationOptsValidator, @@ -294,17 +295,27 @@ export class R2 { * * @param ctx - A Convex query context. * @param limit (optional) - The maximum number of documents to return. + * @param cursor (optional) - The cursor to start the page from. + * @param opts (optional) - `endCursor`, `maximumRowsRead` and + * `maximumBytesRead`, as in `PaginationOptions`. * @returns A promise that resolves to an array of metadata documents. */ async listMetadata( ctx: QueryCtx | MutationCtx | ActionCtx, limit?: number, cursor?: string | null, + opts?: Pick< + PaginationOptions, + "endCursor" | "maximumRowsRead" | "maximumBytesRead" + >, ) { return ctx.runQuery(this.component.lib.listMetadata, { ...this.config, limit: limit, cursor: cursor ?? undefined, + endCursor: opts?.endCursor, + maximumRowsRead: opts?.maximumRowsRead, + maximumBytesRead: opts?.maximumBytesRead, }); } /** @@ -484,6 +495,11 @@ export class R2 { ctx, args.paginationOpts.numItems, args.paginationOpts.cursor, + { + endCursor: args.paginationOpts.endCursor, + maximumRowsRead: args.paginationOpts.maximumRowsRead, + maximumBytesRead: args.paginationOpts.maximumBytesRead, + }, ); }, }), diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts index f20210e..42c9efd 100644 --- a/src/component/_generated/component.ts +++ b/src/component/_generated/component.ts @@ -87,8 +87,11 @@ export type ComponentApi = accessKeyId: string; bucket: string; cursor?: string; + endCursor?: string | null; endpoint: string; limit?: number; + maximumBytesRead?: number; + maximumRowsRead?: number; secretAccessKey: string; }, { diff --git a/src/component/lib.test.ts b/src/component/lib.test.ts new file mode 100644 index 0000000..af0246c --- /dev/null +++ b/src/component/lib.test.ts @@ -0,0 +1,52 @@ +/// +import { convexTest } from "convex-test"; +import { describe, expect, it } from "vitest"; +import { api } from "./_generated/api.js"; +import schema from "./schema.js"; + +const modules = import.meta.glob("./**/*.ts"); + +const r2Config = { + bucket: "test-bucket", + endpoint: "https://test.r2.cloudflarestorage.com", + accessKeyId: "test-key", + secretAccessKey: "test-secret", +}; + +describe("listMetadata", () => { + it("respects endCursor", async () => { + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + for (let i = 0; i < 5; i++) { + await ctx.db.insert("metadata", { + key: `key-${i}`, + bucket: r2Config.bucket, + lastModified: new Date(0).toISOString(), + link: `https://example.com/objects/key-${i}`, + }); + } + }); + const first = await t.query(api.lib.listMetadata, { + ...r2Config, + limit: 2, + }); + expect(first.page.map((d) => d.key)).toEqual(["key-0", "key-1"]); + expect(first.isDone).toBe(false); + + // With endCursor, the page ends at that cursor regardless of limit. + const refetched = await t.query(api.lib.listMetadata, { + ...r2Config, + limit: 1, + endCursor: first.continueCursor, + }); + expect(refetched.page.map((d) => d.key)).toEqual(["key-0", "key-1"]); + expect(refetched.continueCursor).toBe(first.continueCursor); + + const rest = await t.query(api.lib.listMetadata, { + ...r2Config, + cursor: first.continueCursor, + }); + expect(rest.page.map((d) => d.key)).toEqual(["key-2", "key-3", "key-4"]); + expect(rest.isDone).toBe(true); + }); +}); diff --git a/src/component/lib.ts b/src/component/lib.ts index 62b1fab..0d1d27e 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -88,6 +88,9 @@ export const listMetadata = query({ args: { limit: v.optional(v.number()), cursor: v.optional(v.string()), + endCursor: v.optional(v.union(v.string(), v.null())), + maximumRowsRead: v.optional(v.number()), + maximumBytesRead: v.optional(v.number()), ...r2ConfigValidator.fields, }, returns: paginationReturnValidator( @@ -98,7 +101,14 @@ export const listMetadata = query({ }), ), handler: async (ctx, args) => { - const { limit, cursor, ...r2Config } = args; + const { + limit, + cursor, + endCursor, + maximumRowsRead, + maximumBytesRead, + ...r2Config + } = args; const r2 = createR2Client(r2Config); const results = await ctx.db .query("metadata") @@ -106,6 +116,9 @@ export const listMetadata = query({ .paginate({ numItems: limit ?? DEFAULT_LIST_LIMIT, cursor: cursor ?? null, + endCursor, + maximumRowsRead, + maximumBytesRead, }); return { ...results,