From 38e185002d5c31e1fa539e980fe94b307865e842 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 07:45:51 +0000 Subject: [PATCH] Fix inclusive bounds handling in Position encoding Co-Authored-By: Ian Macartney --- src/client/index.test.ts | 227 +++++++++++++++++++++++++++++++++++++++ src/client/positions.ts | 15 ++- 2 files changed, 233 insertions(+), 9 deletions(-) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index fb68c0a..35eb6b6 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -497,4 +497,231 @@ describe("TableAggregate with namespace", () => { expect(vacationCount).toBe(1); }); }); + + describe("inclusive bounds behavior", () => { + let t: ConvexTest; + let aggregate: ReturnType["aggregate"]; + + beforeEach(() => { + t = setupTest(); + ({ aggregate } = createAggregates()); + }); + + test("should respect inclusive vs exclusive lower bounds", async () => { + await t.run(async (ctx) => { + const docs = [ + { name: "item1", value: 10 }, + { name: "item2", value: 20 }, + { name: "item3", 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!); + } + }); + + const countInclusiveLower = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { + lower: { key: 20, inclusive: true }, + }, + }); + }); + expect(countInclusiveLower).toBe(2); + + const countExclusiveLower = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { + lower: { key: 20, inclusive: false }, + }, + }); + }); + expect(countExclusiveLower).toBe(1); + }); + + test("should respect inclusive vs exclusive upper bounds", async () => { + await t.run(async (ctx) => { + const docs = [ + { name: "item1", value: 10 }, + { name: "item2", value: 20 }, + { name: "item3", 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!); + } + }); + + const countInclusiveUpper = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { + upper: { key: 20, inclusive: true }, + }, + }); + }); + expect(countInclusiveUpper).toBe(2); + + const countExclusiveUpper = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { + upper: { key: 20, inclusive: false }, + }, + }); + }); + expect(countExclusiveUpper).toBe(1); + }); + + test("should respect inclusive vs exclusive bounds with both lower and upper", async () => { + await t.run(async (ctx) => { + const docs = [ + { name: "item1", value: 10 }, + { name: "item2", value: 20 }, + { name: "item3", value: 30 }, + { name: "item4", value: 40 }, + ]; + + 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 countBothInclusive = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { + lower: { key: 20, inclusive: true }, + upper: { key: 30, inclusive: true }, + }, + }); + }); + expect(countBothInclusive).toBe(2); + + const countBothExclusive = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { + lower: { key: 20, inclusive: false }, + upper: { key: 30, inclusive: false }, + }, + }); + }); + expect(countBothExclusive).toBe(0); + + const countMixed1 = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { + lower: { key: 20, inclusive: true }, + upper: { key: 30, inclusive: false }, + }, + }); + }); + expect(countMixed1).toBe(1); + + const countMixed2 = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { + lower: { key: 20, inclusive: false }, + upper: { key: 30, inclusive: true }, + }, + }); + }); + expect(countMixed2).toBe(1); + }); + + test("should respect inclusive bounds with exact boundary matches", async () => { + await t.run(async (ctx) => { + const docs = [ + { name: "item1", value: 15 }, + { name: "item2", value: 20 }, + { name: "item3", value: 20 }, + { name: "item4", 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 countInclusiveLowerDupe = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { + lower: { key: 20, inclusive: true }, + }, + }); + }); + expect(countInclusiveLowerDupe).toBe(3); + + const countExclusiveLowerDupe = await t.run(async (ctx) => { + return await aggregate.count(ctx, { + bounds: { + lower: { key: 20, inclusive: false }, + }, + }); + }); + expect(countExclusiveLowerDupe).toBe(1); + }); + + test("should respect inclusive bounds with array keys", async () => { + const aggregateWithArrayKeys = new TableAggregate(components.aggregate, { + sortKey: (doc) => [doc.value, doc.name], + }); + + 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!); + } + }); + + const countInclusiveArrayLower = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { + lower: { key: [20, "b"], inclusive: true }, + }, + }); + }); + expect(countInclusiveArrayLower).toBe(3); + + const countExclusiveArrayLower = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { + lower: { key: [20, "b"], inclusive: false }, + }, + }); + }); + expect(countExclusiveArrayLower).toBe(2); + + const countInclusiveArrayUpper = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { + upper: { key: [20, "c"], inclusive: true }, + }, + }); + }); + expect(countInclusiveArrayUpper).toBe(3); + + const countExclusiveArrayUpper = await t.run(async (ctx) => { + return await aggregateWithArrayKeys.count(ctx, { + bounds: { + upper: { key: [20, "c"], inclusive: false }, + }, + }); + }); + expect(countExclusiveArrayUpper).toBe(2); + }); + }); }); diff --git a/src/client/positions.ts b/src/client/positions.ts index 286e5c7..721d504 100644 --- a/src/client/positions.ts +++ b/src/client/positions.ts @@ -38,8 +38,7 @@ const AFTER_ALL_IDS: never[] = []; // First a key, which is exploded with explodeKey. // Then the ID, or BEFORE_ALL_IDS or AFTER_ALL_IDS. -// Then a value to be inclusive or exclusive. -export type Position = [Key, string | null | never[], "" | null | never[]]; +export type Position = [Key, string | null | never[]]; function explodeKey(key: K): Key { if (Array.isArray(key)) { @@ -68,7 +67,7 @@ export function keyToPosition( key: K, id: ID ): Position { - return [explodeKey(key), id, ""]; + return [explodeKey(key), id]; } export function positionToKey( @@ -91,8 +90,8 @@ export function boundsToPositions( exploded.push(item); } return { - k1: [exploded.concat([BEFORE_ALL_IDS]), BEFORE_ALL_IDS, BEFORE_ALL_IDS], - k2: [exploded.concat([AFTER_ALL_IDS]), AFTER_ALL_IDS, AFTER_ALL_IDS], + k1: [exploded.concat([BEFORE_ALL_IDS]), BEFORE_ALL_IDS], + k2: [exploded.concat([AFTER_ALL_IDS]), AFTER_ALL_IDS], }; } return { @@ -120,14 +119,12 @@ export function boundToPosition( if (direction === "lower") { return [ explodeKey(bound.key), - bound.id ?? BEFORE_ALL_IDS, - bound.inclusive ? BEFORE_ALL_IDS : AFTER_ALL_IDS, + bound.id ?? (bound.inclusive ? BEFORE_ALL_IDS : AFTER_ALL_IDS), ]; } else { return [ explodeKey(bound.key), - bound.id ?? AFTER_ALL_IDS, - bound.inclusive ? AFTER_ALL_IDS : BEFORE_ALL_IDS, + bound.id ?? (bound.inclusive ? AFTER_ALL_IDS : BEFORE_ALL_IDS), ]; } }