From ad6cc10403fc905c92de013c530a845229e01896 Mon Sep 17 00:00:00 2001 From: Jordan Hunt Date: Mon, 14 Jul 2025 13:46:32 -0700 Subject: [PATCH 1/6] fix --- convex/pagination.test.ts | 41 +++++++++ index.ts | 170 +++++++++++++++++++++++++++++++++++++- 2 files changed, 207 insertions(+), 4 deletions(-) diff --git a/convex/pagination.test.ts b/convex/pagination.test.ts index 4e5126c..eee47a6 100644 --- a/convex/pagination.test.ts +++ b/convex/pagination.test.ts @@ -55,3 +55,44 @@ test("paginate", async () => { expect(page3).toMatchObject([]); expect(isDone3).toEqual(true); }); + +test("paginate with deletes", async () => { + const t = convexTest(schema); + await t.run(async (ctx) => { + await ctx.db.insert("messages", { author: "sarah", body: "hello1" }); + await ctx.db.insert("messages", { author: "michal", body: "boo" }); + await ctx.db.insert("messages", { author: "sarah", body: "hello2" }); + await ctx.db.insert("messages", { author: "sarah", body: "hello3" }); + await ctx.db.insert("messages", { author: "sarah", body: "hello4" }); + await ctx.db.insert("messages", { author: "michal", body: "boing" }); + await ctx.db.insert("messages", { author: "sarah", body: "hello5" }); + }); + const { continueCursor, isDone, page } = await t.query(api.pagination.list, { + author: "sarah", + paginationOptions: { + cursor: null, + numItems: 2, + }, + }); + expect(page).toMatchObject([ + { author: "sarah", body: "hello1" }, + { author: "sarah", body: "hello2" }, + ]); + expect(isDone).toEqual(false); + await t.run(async (ctx) => { + await ctx.db.delete(page[1]._id); + }); + const { isDone: isDone2, page: page2 } = await t.query(api.pagination.list, { + author: "sarah", + paginationOptions: { + cursor: continueCursor, + numItems: 4, + }, + }); + expect(page2).toMatchObject([ + { author: "sarah", body: "hello3" }, + { author: "sarah", body: "hello4" }, + { author: "sarah", body: "hello5" }, + ]); + expect(isDone2).toEqual(true); +}); diff --git a/index.ts b/index.ts index 8fbf86f..8dbc175 100644 --- a/index.ts +++ b/index.ts @@ -372,6 +372,34 @@ class DatabaseFake { let isInPage = cursor === null; let isDone = false; let continueCursor = null; + + // Parse cursor to get the index key values if it's not a document ID + let cursorIndexKeys: any[] | null = null; + if (cursor && cursor !== "_end_cursor") { + try { + // Try to parse as JSON array (index keys) + const parsed = JSON.parse(cursor); + if (Array.isArray(parsed)) { + cursorIndexKeys = parsed; + } else { + // Not an array, treat as legacy cursor + cursorIndexKeys = null; + } + } catch { + // If parsing fails, treat as legacy document ID cursor + cursorIndexKeys = null; + } + } + + // If cursor is "_end_cursor", we're already at the end + if (cursor === "_end_cursor") { + return { + page: [], + isDone: true, + continueCursor: "_end_cursor", + }; + } + for (;;) { const { value, done } = this.queryNext(queryId); if (done) { @@ -381,16 +409,42 @@ class DatabaseFake { continueCursor = "_end_cursor"; break; } + + // First determine if we should start including documents in the page + if (cursor === null) { + isInPage = true; + } else if (cursor === "_end_cursor") { + // Already at end, should not happen + break; + } else if (cursorIndexKeys) { + // Compare based on index key values - start from the next doc after cursor + const shouldStart = this._shouldStartFromDocument(query, value!, cursorIndexKeys); + if (!isInPage && shouldStart) { + isInPage = true; + } + } else { + // Legacy: exact document ID match - start from next doc after cursor + if (!isInPage && value!._id === cursor) { + isInPage = true; + } + } + + // Then, if we're in the page, add the document if (isInPage) { page.push(value); if (page.length >= pageSize) { - continueCursor = value!._id; + // Generate cursor based on index key values + continueCursor = this._generateIndexCursor(query, value!); + + // Check if there are more documents after this one + const { done: nextDone } = this.queryNext(queryId); + if (nextDone) { + isDone = true; + continueCursor = "_end_cursor"; + } break; } } - if (value!._id === cursor) { - isInPage = true; - } } return { page, @@ -399,6 +453,114 @@ class DatabaseFake { }; } + private _generateIndexCursor(query: SerializedQuery, document: GenericDocument): string { + const source = query.source; + + if (source.type === "IndexRange") { + const [tableName, indexName] = source.indexName.split("."); + let fields: string[]; + + if (indexName === "by_creation_time") { + fields = ["_creationTime", "_id"]; + } else if (indexName === "by_id") { + fields = ["_id"]; + } else { + const indexes = this._schema?.tables.get(tableName)?.indexes; + const index = indexes?.find( + ({ indexDescriptor }: { indexDescriptor: string }) => + indexDescriptor === indexName, + ); + if (index) { + fields = index.fields.concat(["_creationTime", "_id"]); + } else { + // Fallback to document ID + return document._id as string; + } + } + + // Extract the index key values from the document + const indexValues = fields.map(field => evaluateFieldPath(field, document)); + return JSON.stringify(indexValues); + } + + if (source.type === "FullTableScan") { + // For FullTableScan, use the natural sort order which is by _creationTime, _id + const fields = ["_creationTime", "_id"]; + const indexValues = fields.map(field => evaluateFieldPath(field, document)); + return JSON.stringify(indexValues); + } + + // For other query types, fallback to document ID + return document._id as string; + } + + private _shouldStartFromDocument(query: SerializedQuery, document: GenericDocument, cursorIndexKeys: any[]): boolean { + const source = query.source; + + if (source.type === "IndexRange") { + const [tableName, indexName] = source.indexName.split("."); + let fields: string[]; + + if (indexName === "by_creation_time") { + fields = ["_creationTime", "_id"]; + } else if (indexName === "by_id") { + fields = ["_id"]; + } else { + const indexes = this._schema?.tables.get(tableName)?.indexes; + const index = indexes?.find( + ({ indexDescriptor }: { indexDescriptor: string }) => + indexDescriptor === indexName, + ); + if (index) { + fields = index.fields.concat(["_creationTime", "_id"]); + } else { + // Fallback to document ID comparison + return (document._id as string) > JSON.stringify(cursorIndexKeys); + } + } + + // Extract the index key values from the document + const documentIndexValues = fields.map(field => evaluateFieldPath(field, document)); + + // Compare the index values according to the sort order + const order = source.order ?? "asc"; + const comparison = this._compareIndexValues(documentIndexValues, cursorIndexKeys, order); + + // Start from the first document that comes after the cursor + return comparison > 0; + } + + if (source.type === "FullTableScan") { + // For FullTableScan, use the natural sort order which is by _creationTime, _id + const fields = ["_creationTime", "_id"]; + const documentIndexValues = fields.map(field => evaluateFieldPath(field, document)); + + // Compare the index values according to the sort order + const order = source.order ?? "asc"; + const comparison = this._compareIndexValues(documentIndexValues, cursorIndexKeys, order); + + // Start from the first document that comes after the cursor + return comparison > 0; + } + + // For other query types, fallback to document ID comparison + return (document._id as string) > JSON.stringify(cursorIndexKeys); + } + + private _compareIndexValues(a: any[], b: any[], order: "asc" | "desc"): number { + const orderMultiplier = order === "asc" ? 1 : -1; + + for (let i = 0; i < Math.min(a.length, b.length); i++) { + const comparison = compareValues(a[i], b[i]); + if (comparison !== 0) { + return comparison * orderMultiplier; + } + } + + // If all compared values are equal, shorter array comes first + return (a.length - b.length) * orderMultiplier; + } + private _iterateDocs( tableName: string, callback: (doc: GenericDocument) => void, From a61a1a9fdb214a3e135fbdd5a283750711f9ec31 Mon Sep 17 00:00:00 2001 From: Jordan Hunt Date: Mon, 14 Jul 2025 14:02:16 -0700 Subject: [PATCH 2/6] update tests --- convex/pagination.test.ts | 54 +++++++++++++++++++++++++++++++++++++++ convex/pagination.ts | 13 ++++++++++ 2 files changed, 67 insertions(+) diff --git a/convex/pagination.test.ts b/convex/pagination.test.ts index eee47a6..eee8d23 100644 --- a/convex/pagination.test.ts +++ b/convex/pagination.test.ts @@ -96,3 +96,57 @@ test("paginate with deletes", async () => { ]); expect(isDone2).toEqual(true); }); + +test("paginate with deletes and indexes", async () => { + const t = convexTest(schema); + await t.run(async (ctx) => { + await ctx.db.insert("messages", { author: "sarah", body: "hello1" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello4" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello3" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello2" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello1" }); + }); + const { continueCursor, isDone, page } = await t.query( + api.pagination.listWithIndex, + { + author: "jordan", + paginationOptions: { + cursor: null, + numItems: 1, + }, + }, + ); + expect(page).toMatchObject([{ author: "jordan", body: "hello1" }]); + expect(isDone).toEqual(false); + await t.run(async (ctx) => { + await ctx.db.delete(page[0]._id); + }); + const { + isDone: isDone2, + page: page2, + continueCursor: continueCursor2, + } = await t.query(api.pagination.listWithIndex, { + author: "jordan", + paginationOptions: { + cursor: continueCursor, + numItems: 2, + }, + }); + expect(page2).toMatchObject([ + { author: "jordan", body: "hello2" }, + { author: "jordan", body: "hello3" }, + ]); + expect(isDone2).toEqual(false); + await t.run(async (ctx) => { + await ctx.db.delete(page2[1]._id); + }); + const { isDone: isDone3, page: page3 } = await t.query(api.pagination.list, { + author: "jordan", + paginationOptions: { + cursor: continueCursor2, + numItems: 1, + }, + }); + expect(page3).toMatchObject([{ author: "jordan", body: "hello4" }]); + expect(isDone3).toEqual(true); +}); diff --git a/convex/pagination.ts b/convex/pagination.ts index e142431..51c39c9 100644 --- a/convex/pagination.ts +++ b/convex/pagination.ts @@ -16,3 +16,16 @@ export const list = query({ .paginate(args.paginationOptions); }, }); + +export const listWithIndex = query({ + args: { + author: v.string(), + paginationOptions: paginationOptsValidator, + }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .withIndex("author", (q) => q.eq("author", args.author)) + .paginate(args.paginationOptions); + }, +}); From d4b2faefddfb8b43b428dfc232d68d86a285727f Mon Sep 17 00:00:00 2001 From: Jordan Hunt Date: Mon, 14 Jul 2025 18:17:23 -0700 Subject: [PATCH 3/6] fixes --- convex/pagination.test.ts | 21 ++++---- index.ts | 105 ++++++++++++++++++++++++-------------- 2 files changed, 80 insertions(+), 46 deletions(-) diff --git a/convex/pagination.test.ts b/convex/pagination.test.ts index eee8d23..458c068 100644 --- a/convex/pagination.test.ts +++ b/convex/pagination.test.ts @@ -116,7 +116,7 @@ test("paginate with deletes and indexes", async () => { }, }, ); - expect(page).toMatchObject([{ author: "jordan", body: "hello1" }]); + expect(page).toMatchObject([{ author: "jordan", body: "hello4" }]); expect(isDone).toEqual(false); await t.run(async (ctx) => { await ctx.db.delete(page[0]._id); @@ -133,20 +133,23 @@ test("paginate with deletes and indexes", async () => { }, }); expect(page2).toMatchObject([ - { author: "jordan", body: "hello2" }, { author: "jordan", body: "hello3" }, + { author: "jordan", body: "hello2" }, ]); expect(isDone2).toEqual(false); await t.run(async (ctx) => { await ctx.db.delete(page2[1]._id); }); - const { isDone: isDone3, page: page3 } = await t.query(api.pagination.list, { - author: "jordan", - paginationOptions: { - cursor: continueCursor2, - numItems: 1, + const { isDone: isDone3, page: page3 } = await t.query( + api.pagination.listWithIndex, + { + author: "jordan", + paginationOptions: { + cursor: continueCursor2, + numItems: 1, + }, }, - }); - expect(page3).toMatchObject([{ author: "jordan", body: "hello4" }]); + ); + expect(page3).toMatchObject([{ author: "jordan", body: "hello1" }]); expect(isDone3).toEqual(true); }); diff --git a/index.ts b/index.ts index 8dbc175..b2d6e6c 100644 --- a/index.ts +++ b/index.ts @@ -372,7 +372,7 @@ class DatabaseFake { let isInPage = cursor === null; let isDone = false; let continueCursor = null; - + // Parse cursor to get the index key values if it's not a document ID let cursorIndexKeys: any[] | null = null; if (cursor && cursor !== "_end_cursor") { @@ -381,7 +381,7 @@ class DatabaseFake { const parsed = JSON.parse(cursor); if (Array.isArray(parsed)) { cursorIndexKeys = parsed; - } else { + } else { // Not an array, treat as legacy cursor cursorIndexKeys = null; } @@ -390,7 +390,7 @@ class DatabaseFake { cursorIndexKeys = null; } } - + // If cursor is "_end_cursor", we're already at the end if (cursor === "_end_cursor") { return { @@ -399,7 +399,7 @@ class DatabaseFake { continueCursor: "_end_cursor", }; } - + for (;;) { const { value, done } = this.queryNext(queryId); if (done) { @@ -409,7 +409,7 @@ class DatabaseFake { continueCursor = "_end_cursor"; break; } - + // First determine if we should start including documents in the page if (cursor === null) { isInPage = true; @@ -418,9 +418,15 @@ class DatabaseFake { break; } else if (cursorIndexKeys) { // Compare based on index key values - start from the next doc after cursor - const shouldStart = this._shouldStartFromDocument(query, value!, cursorIndexKeys); - if (!isInPage && shouldStart) { - isInPage = true; + if (!isInPage) { + const shouldStart = this._shouldStartFromDocument( + query, + value!, + cursorIndexKeys, + ); + if (shouldStart) { + isInPage = true; + } } } else { // Legacy: exact document ID match - start from next doc after cursor @@ -428,14 +434,14 @@ class DatabaseFake { isInPage = true; } } - + // Then, if we're in the page, add the document if (isInPage) { page.push(value); if (page.length >= pageSize) { // Generate cursor based on index key values continueCursor = this._generateIndexCursor(query, value!); - + // Check if there are more documents after this one const { done: nextDone } = this.queryNext(queryId); if (nextDone) { @@ -453,13 +459,16 @@ class DatabaseFake { }; } - private _generateIndexCursor(query: SerializedQuery, document: GenericDocument): string { + private _generateIndexCursor( + query: SerializedQuery, + document: GenericDocument, + ): string { const source = query.source; - + if (source.type === "IndexRange") { const [tableName, indexName] = source.indexName.split("."); let fields: string[]; - + if (indexName === "by_creation_time") { fields = ["_creationTime", "_id"]; } else if (indexName === "by_id") { @@ -477,30 +486,38 @@ class DatabaseFake { return document._id as string; } } - + // Extract the index key values from the document - const indexValues = fields.map(field => evaluateFieldPath(field, document)); + const indexValues = fields.map((field) => + evaluateFieldPath(field, document), + ); return JSON.stringify(indexValues); } - + if (source.type === "FullTableScan") { // For FullTableScan, use the natural sort order which is by _creationTime, _id const fields = ["_creationTime", "_id"]; - const indexValues = fields.map(field => evaluateFieldPath(field, document)); + const indexValues = fields.map((field) => + evaluateFieldPath(field, document), + ); return JSON.stringify(indexValues); } - + // For other query types, fallback to document ID return document._id as string; } - private _shouldStartFromDocument(query: SerializedQuery, document: GenericDocument, cursorIndexKeys: any[]): boolean { + private _shouldStartFromDocument( + query: SerializedQuery, + document: GenericDocument, + cursorIndexKeys: any[], + ): boolean { const source = query.source; - + if (source.type === "IndexRange") { const [tableName, indexName] = source.indexName.split("."); let fields: string[]; - + if (indexName === "by_creation_time") { fields = ["_creationTime", "_id"]; } else if (indexName === "by_id") { @@ -518,45 +535,59 @@ class DatabaseFake { return (document._id as string) > JSON.stringify(cursorIndexKeys); } } - + // Extract the index key values from the document - const documentIndexValues = fields.map(field => evaluateFieldPath(field, document)); - + const documentIndexValues = fields.map((field) => + evaluateFieldPath(field, document), + ); + // Compare the index values according to the sort order const order = source.order ?? "asc"; - const comparison = this._compareIndexValues(documentIndexValues, cursorIndexKeys, order); - - // Start from the first document that comes after the cursor + const comparison = this._compareIndexValues( + documentIndexValues, + cursorIndexKeys, + order, + ); + return comparison > 0; } - + if (source.type === "FullTableScan") { // For FullTableScan, use the natural sort order which is by _creationTime, _id const fields = ["_creationTime", "_id"]; - const documentIndexValues = fields.map(field => evaluateFieldPath(field, document)); - + const documentIndexValues = fields.map((field) => + evaluateFieldPath(field, document), + ); + // Compare the index values according to the sort order const order = source.order ?? "asc"; - const comparison = this._compareIndexValues(documentIndexValues, cursorIndexKeys, order); - - // Start from the first document that comes after the cursor + const comparison = this._compareIndexValues( + documentIndexValues, + cursorIndexKeys, + order, + ); + return comparison > 0; } - + // For other query types, fallback to document ID comparison return (document._id as string) > JSON.stringify(cursorIndexKeys); } - private _compareIndexValues(a: any[], b: any[], order: "asc" | "desc"): number { + private _compareIndexValues( + a: any[], + b: any[], + order: "asc" | "desc", + ): number { const orderMultiplier = order === "asc" ? 1 : -1; - + for (let i = 0; i < Math.min(a.length, b.length); i++) { const comparison = compareValues(a[i], b[i]); if (comparison !== 0) { return comparison * orderMultiplier; } } - + // If all compared values are equal, shorter array comes first return (a.length - b.length) * orderMultiplier; } From 93b0529e97cfad2267f9e8288fbfc97535468d2c Mon Sep 17 00:00:00 2001 From: Jordan Hunt Date: Tue, 15 Jul 2025 14:36:26 -0700 Subject: [PATCH 4/6] nits --- index.ts | 50 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/index.ts b/index.ts index b2d6e6c..87c6e99 100644 --- a/index.ts +++ b/index.ts @@ -373,21 +373,22 @@ class DatabaseFake { let isDone = false; let continueCursor = null; - // Parse cursor to get the index key values if it's not a document ID + // Parse cursor to get the index key values let cursorIndexKeys: any[] | null = null; if (cursor && cursor !== "_end_cursor") { try { - // Try to parse as JSON array (index keys) + // Parse as JSON array (index keys) const parsed = JSON.parse(cursor); if (Array.isArray(parsed)) { cursorIndexKeys = parsed; } else { - // Not an array, treat as legacy cursor - cursorIndexKeys = null; + throw new Error("Invalid cursor format: expected JSON array"); } - } catch { - // If parsing fails, treat as legacy document ID cursor - cursorIndexKeys = null; + } catch (e) { + if (e instanceof Error && e.message.includes("expected JSON array")) { + throw e; + } + throw new Error("Invalid cursor format: must be a JSON array or null"); } } @@ -428,11 +429,6 @@ class DatabaseFake { isInPage = true; } } - } else { - // Legacy: exact document ID match - start from next doc after cursor - if (!isInPage && value!._id === cursor) { - isInPage = true; - } } // Then, if we're in the page, add the document @@ -482,8 +478,7 @@ class DatabaseFake { if (index) { fields = index.fields.concat(["_creationTime", "_id"]); } else { - // Fallback to document ID - return document._id as string; + throw new Error(`Unknown index "${indexName}" for table "${tableName}"`); } } @@ -503,8 +498,12 @@ class DatabaseFake { return JSON.stringify(indexValues); } - // For other query types, fallback to document ID - return document._id as string; + // For other query types, use creation time and ID + const fields = ["_creationTime", "_id"]; + const indexValues = fields.map((field) => + evaluateFieldPath(field, document), + ); + return JSON.stringify(indexValues); } private _shouldStartFromDocument( @@ -531,8 +530,7 @@ class DatabaseFake { if (index) { fields = index.fields.concat(["_creationTime", "_id"]); } else { - // Fallback to document ID comparison - return (document._id as string) > JSON.stringify(cursorIndexKeys); + throw new Error(`Unknown index "${indexName}" for table "${tableName}"`); } } @@ -570,8 +568,20 @@ class DatabaseFake { return comparison > 0; } - // For other query types, fallback to document ID comparison - return (document._id as string) > JSON.stringify(cursorIndexKeys); + // For other query types, use creation time and ID comparison + const fields = ["_creationTime", "_id"]; + const documentIndexValues = fields.map((field) => + evaluateFieldPath(field, document), + ); + + const order = "asc"; + const comparison = this._compareIndexValues( + documentIndexValues, + cursorIndexKeys, + order, + ); + + return comparison > 0; } private _compareIndexValues( From 26865ec542b6214b0fadae99b08fb6e032c5e9e4 Mon Sep 17 00:00:00 2001 From: Jordan Hunt Date: Tue, 15 Jul 2025 14:41:59 -0700 Subject: [PATCH 5/6] add composite index --- convex/pagination.test.ts | 69 +++++++++++++++++++++++++++++++++++---- convex/pagination.ts | 13 ++++++++ convex/schema.ts | 1 + 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/convex/pagination.test.ts b/convex/pagination.test.ts index 458c068..d97a542 100644 --- a/convex/pagination.test.ts +++ b/convex/pagination.test.ts @@ -101,10 +101,10 @@ test("paginate with deletes and indexes", async () => { const t = convexTest(schema); await t.run(async (ctx) => { await ctx.db.insert("messages", { author: "sarah", body: "hello1" }); - await ctx.db.insert("messages", { author: "jordan", body: "hello4" }); - await ctx.db.insert("messages", { author: "jordan", body: "hello3" }); - await ctx.db.insert("messages", { author: "jordan", body: "hello2" }); await ctx.db.insert("messages", { author: "jordan", body: "hello1" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello2" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello3" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello4" }); }); const { continueCursor, isDone, page } = await t.query( api.pagination.listWithIndex, @@ -116,7 +116,7 @@ test("paginate with deletes and indexes", async () => { }, }, ); - expect(page).toMatchObject([{ author: "jordan", body: "hello4" }]); + expect(page).toMatchObject([{ author: "jordan", body: "hello1" }]); expect(isDone).toEqual(false); await t.run(async (ctx) => { await ctx.db.delete(page[0]._id); @@ -133,8 +133,8 @@ test("paginate with deletes and indexes", async () => { }, }); expect(page2).toMatchObject([ - { author: "jordan", body: "hello3" }, { author: "jordan", body: "hello2" }, + { author: "jordan", body: "hello3" }, ]); expect(isDone2).toEqual(false); await t.run(async (ctx) => { @@ -150,6 +150,63 @@ test("paginate with deletes and indexes", async () => { }, }, ); - expect(page3).toMatchObject([{ author: "jordan", body: "hello1" }]); + expect(page3).toMatchObject([{ author: "jordan", body: "hello4" }]); + expect(isDone3).toEqual(true); +}); + +test("paginate with deletes and composite index", async () => { + const t = convexTest(schema); + await t.run(async (ctx) => { + await ctx.db.insert("messages", { author: "sarah", body: "hello1" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello2" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello4" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello3" }); + await ctx.db.insert("messages", { author: "jordan", body: "hello1" }); + }); + const { continueCursor, isDone, page } = await t.query( + api.pagination.listWithCompositeIndex, + { + author: "jordan", + paginationOptions: { + cursor: null, + numItems: 1, + }, + }, + ); + expect(page).toMatchObject([{ author: "jordan", body: "hello1" }]); + expect(isDone).toEqual(false); + await t.run(async (ctx) => { + await ctx.db.delete(page[0]._id); + }); + const { + isDone: isDone2, + page: page2, + continueCursor: continueCursor2, + } = await t.query(api.pagination.listWithCompositeIndex, { + author: "jordan", + paginationOptions: { + cursor: continueCursor, + numItems: 2, + }, + }); + expect(page2).toMatchObject([ + { author: "jordan", body: "hello2" }, + { author: "jordan", body: "hello3" }, + ]); + expect(isDone2).toEqual(false); + await t.run(async (ctx) => { + await ctx.db.delete(page2[1]._id); + }); + const { isDone: isDone3, page: page3 } = await t.query( + api.pagination.listWithCompositeIndex, + { + author: "jordan", + paginationOptions: { + cursor: continueCursor2, + numItems: 1, + }, + }, + ); + expect(page3).toMatchObject([{ author: "jordan", body: "hello4" }]); expect(isDone3).toEqual(true); }); diff --git a/convex/pagination.ts b/convex/pagination.ts index 51c39c9..730c378 100644 --- a/convex/pagination.ts +++ b/convex/pagination.ts @@ -29,3 +29,16 @@ export const listWithIndex = query({ .paginate(args.paginationOptions); }, }); + +export const listWithCompositeIndex = query({ + args: { + author: v.string(), + paginationOptions: paginationOptsValidator, + }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .withIndex("author_body", (q) => q.eq("author", args.author)) + .paginate(args.paginationOptions); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 636f68f..d9eeb75 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -9,6 +9,7 @@ export default defineSchema({ score: v.optional(v.number()), }) .index("author", ["author"]) + .index("author_body", ["author", "body"]) .searchIndex("body", { searchField: "body", filterFields: ["author"], From 5d3384e52cf900950408083ce9ae21dcf9ddb932 Mon Sep 17 00:00:00 2001 From: Jordan Hunt Date: Tue, 15 Jul 2025 14:46:13 -0700 Subject: [PATCH 6/6] formatting --- index.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index 87c6e99..7a678b1 100644 --- a/index.ts +++ b/index.ts @@ -478,7 +478,9 @@ class DatabaseFake { if (index) { fields = index.fields.concat(["_creationTime", "_id"]); } else { - throw new Error(`Unknown index "${indexName}" for table "${tableName}"`); + throw new Error( + `Unknown index "${indexName}" for table "${tableName}"`, + ); } } @@ -530,7 +532,9 @@ class DatabaseFake { if (index) { fields = index.fields.concat(["_creationTime", "_id"]); } else { - throw new Error(`Unknown index "${indexName}" for table "${tableName}"`); + throw new Error( + `Unknown index "${indexName}" for table "${tableName}"`, + ); } } @@ -573,14 +577,14 @@ class DatabaseFake { const documentIndexValues = fields.map((field) => evaluateFieldPath(field, document), ); - + const order = "asc"; const comparison = this._compareIndexValues( documentIndexValues, cursorIndexKeys, order, ); - + return comparison > 0; }