From 0108711ca4bdad46050edfbb4a78ae0104083482 Mon Sep 17 00:00:00 2001 From: Ian Macartney Date: Wed, 29 Oct 2025 17:58:24 -0700 Subject: [PATCH 1/2] allow bounds of {eq: key} --- src/client/index.ts | 2 +- src/client/positions.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 71145d3..f173d13 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -34,7 +34,7 @@ export type Item = { sumValue: number; }; -export type { Key, Bound }; +export type { Key, Bound, Bounds }; /** * Write data to be aggregated, and read aggregated data. diff --git a/src/client/positions.ts b/src/client/positions.ts index 262e488..a998337 100644 --- a/src/client/positions.ts +++ b/src/client/positions.ts @@ -28,8 +28,11 @@ export type TuplePrefix< export type Bounds = | SideBounds + | (K extends unknown[] + ? { prefix: TuplePrefix> } + : never) | { - prefix: TuplePrefix>; + eq: K; }; // IDs are strings so in the Convex ordering, null < IDs < arrays. @@ -83,6 +86,12 @@ export function boundsToPositions( if (bounds === undefined) { return {}; } + if ("eq" in bounds) { + return { + k1: boundToPosition("lower", { key: bounds.eq, inclusive: true }), + k2: boundToPosition("upper", { key: bounds.eq, inclusive: true }), + }; + } if ("prefix" in bounds) { const prefix: Key[] = bounds.prefix; const exploded: Key = []; From 7be2c22a5f145daa1a6f91802dd4756a2d78b669 Mon Sep 17 00:00:00 2001 From: Ian Macartney Date: Thu, 8 Jan 2026 18:19:02 -0800 Subject: [PATCH 2/2] add tests --- src/client/index.test.ts | 330 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 1a21171..c3ca4c9 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -882,3 +882,333 @@ describe("TableAggregate with namespace", () => { }); }); }); + +describe("Bounds with eq", () => { + let t: ConvexTest; + let aggregate: ReturnType["aggregate"]; + + beforeEach(() => { + t = setupTest(); + ({ aggregate } = createAggregates()); + }); + + test("should count items with eq bound on non-array key", async () => { + await t.run(async (ctx) => { + const docs = [ + { name: "item1", value: 10 }, + { name: "item2", value: 20 }, + { name: "item3", value: 20 }, + { name: "item4", value: 30 }, + ]; + + for (const doc of docs) { + const id = await ctx.db.insert("testItems", doc); + const insertedDoc = await ctx.db.get(id); + await aggregate.insert(ctx, insertedDoc!); + } + }); + + // Test eq bound - should only count items with exact key value + const countEq20 = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { eq: 20 }, + }); + }); + expect(countEq20).toBe(2); // Two items with value 20 + + const countEq10 = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { eq: 10 }, + }); + }); + expect(countEq10).toBe(1); // One item with value 10 + + const countEq30 = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { eq: 30 }, + }); + }); + expect(countEq30).toBe(1); // One item with value 30 + + const countEq40 = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { eq: 40 }, + }); + }); + expect(countEq40).toBe(0); // No items with value 40 + }); + + test("should sum items with eq bound on non-array key", async () => { + await t.run(async (ctx) => { + const docs = [ + { name: "item1", value: 15 }, + { name: "item2", value: 15 }, + { name: "item3", value: 25 }, + ]; + + for (const doc of docs) { + const id = await ctx.db.insert("testItems", doc); + const insertedDoc = await ctx.db.get(id); + await aggregate.insert(ctx, insertedDoc!); + } + }); + + const sumEq15 = await t.run(async (ctx) => { + return await aggregate.sum(ctx, { + bounds: { eq: 15 }, + }); + }); + expect(sumEq15).toBe(30); // 15 + 15 = 30 + + const sumEq25 = await t.run(async (ctx) => { + return await aggregate.sum(ctx, { + bounds: { eq: 25 }, + }); + }); + expect(sumEq25).toBe(25); + + const sumEq100 = await t.run(async (ctx) => { + return await aggregate.sum(ctx, { + bounds: { eq: 100 }, + }); + }); + expect(sumEq100).toBe(0); // No items with value 100 + }); + + test("should work with eq bound on array keys", async () => { + const aggregateWithArrayKeys = new TableAggregate<{ + Key: [number, string]; + DataModel: DataModel; + TableName: "testItems"; + }>(components.aggregate, { + sortKey: (doc) => [doc.value, doc.name], + sumValue: (doc) => doc.value, + }); + + await t.run(async (ctx) => { + const docs = [ + { name: "a", value: 10 }, + { name: "b", value: 20 }, + { name: "c", value: 20 }, + { name: "d", value: 30 }, + ]; + + for (const doc of docs) { + const id = await ctx.db.insert("testItems", doc); + const insertedDoc = await ctx.db.get(id); + await aggregateWithArrayKeys.insert(ctx, insertedDoc!); + } + }); + + // Test eq bound with exact array match + const countEqArray = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { eq: [20, "b"] }, + }); + }); + expect(countEqArray).toBe(1); // Only one item with exact key [20, "b"] + + const countEqArray2 = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { eq: [20, "c"] }, + }); + }); + expect(countEqArray2).toBe(1); // Only one item with exact key [20, "c"] + + const countEqArrayNonExistent = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { eq: [20, "z"] }, + }); + }); + expect(countEqArrayNonExistent).toBe(0); // No items with key [20, "z"] + }); + + test("should work with eq bound and namespace", async () => { + const { aggregateWithNamespace } = createAggregates(); + + await t.run(async (ctx) => { + const docs = [ + { album: "vacation", url: "photo1.jpg", score: 10 }, + { album: "vacation", url: "photo2.jpg", score: 20 }, + { album: "vacation", url: "photo3.jpg", score: 20 }, + { album: "family", url: "photo4.jpg", score: 20 }, + ]; + + for (const doc of docs) { + const id = await ctx.db.insert("photos", doc); + const insertedDoc = await ctx.db.get(id); + await aggregateWithNamespace.insert(ctx, insertedDoc!); + } + }); + + // Test eq bound within vacation namespace + const vacationCountEq20 = await t.run(async (ctx) => { + return await aggregateWithNamespace.count(ctx, { + namespace: "vacation", + bounds: { eq: 20 }, + }); + }); + expect(vacationCountEq20).toBe(2); // Two vacation photos with score 20 + + // Test eq bound within family namespace + const familyCountEq20 = await t.run(async (ctx) => { + return await aggregateWithNamespace.count(ctx, { + namespace: "family", + bounds: { eq: 20 }, + }); + }); + expect(familyCountEq20).toBe(1); // One family photo with score 20 + + // Test eq bound with non-existent value in namespace + const vacationCountEq100 = await t.run(async (ctx) => { + return await aggregateWithNamespace.count(ctx, { + namespace: "vacation", + bounds: { eq: 100 }, + }); + }); + expect(vacationCountEq100).toBe(0); + }); + + test("should use eq bounds in batch operations", async () => { + await t.run(async (ctx) => { + const docs = [ + { name: "item1", value: 10 }, + { name: "item2", value: 20 }, + { name: "item3", value: 20 }, + { name: "item4", value: 30 }, + ]; + + for (const doc of docs) { + const id = await ctx.db.insert("testItems", doc); + const insertedDoc = await ctx.db.get(id); + await aggregate.insert(ctx, insertedDoc!); + } + }); + + // Test countBatch with multiple eq bounds + const counts = await t.run(async (ctx) => { + return await aggregate.countBatch(ctx, [ + { bounds: { eq: 10 } }, + { bounds: { eq: 20 } }, + { bounds: { eq: 30 } }, + { bounds: { eq: 40 } }, + ]); + }); + expect(counts).toEqual([1, 2, 1, 0]); + + // Test sumBatch with eq bounds + const sums = await t.run(async (ctx) => { + return await aggregate.sumBatch(ctx, [ + { bounds: { eq: 10 } }, + { bounds: { eq: 20 } }, + { bounds: { eq: 30 } }, + ]); + }); + expect(sums).toEqual([10, 40, 30]); + }); +}); + +describe("Bounds with prefix on array keys", () => { + let t: ConvexTest; + + beforeEach(() => { + t = setupTest(); + }); + + test("should still work with prefix bounds on array keys", async () => { + const aggregateWithArrayKeys = new TableAggregate<{ + Key: [number, string]; + DataModel: DataModel; + TableName: "testItems"; + }>(components.aggregate, { + sortKey: (doc) => [doc.value, doc.name], + sumValue: (doc) => doc.value, + }); + + await t.run(async (ctx) => { + const docs = [ + { name: "a", value: 10 }, + { name: "b", value: 10 }, + { name: "c", value: 20 }, + { name: "d", value: 20 }, + { name: "e", value: 30 }, + ]; + + for (const doc of docs) { + const id = await ctx.db.insert("testItems", doc); + const insertedDoc = await ctx.db.get(id); + await aggregateWithArrayKeys.insert(ctx, insertedDoc!); + } + }); + + // Test prefix bound - should match all items with value 10 + const countPrefix10 = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { prefix: [10] }, + }); + }); + expect(countPrefix10).toBe(2); // Two items with first element 10 + + const countPrefix20 = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { prefix: [20] }, + }); + }); + expect(countPrefix20).toBe(2); // Two items with first element 20 + + // Test empty prefix - should match all items + const countPrefixEmpty = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { prefix: [] }, + }); + }); + expect(countPrefixEmpty).toBe(5); // All items + + // Test full prefix - should match exact item + const countPrefixFull = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { prefix: [10, "a"] }, + }); + }); + expect(countPrefixFull).toBe(1); // Only [10, "a"] + }); + + test("should sum with prefix bounds on array keys", async () => { + const aggregateWithArrayKeys = new TableAggregate<{ + Key: [number, string]; + DataModel: DataModel; + TableName: "testItems"; + }>(components.aggregate, { + sortKey: (doc) => [doc.value, doc.name], + sumValue: (doc) => doc.value, + }); + + await t.run(async (ctx) => { + const docs = [ + { name: "a", value: 10 }, + { name: "b", value: 10 }, + { name: "c", value: 20 }, + ]; + + for (const doc of docs) { + const id = await ctx.db.insert("testItems", doc); + const insertedDoc = await ctx.db.get(id); + await aggregateWithArrayKeys.insert(ctx, insertedDoc!); + } + }); + + const sumPrefix10 = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.sum(ctx, { + bounds: { prefix: [10] }, + }); + }); + expect(sumPrefix10).toBe(20); // 10 + 10 = 20 + + const sumPrefix20 = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.sum(ctx, { + bounds: { prefix: [20] }, + }); + }); + expect(sumPrefix20).toBe(20); + }); +});