From 071ac5f5de6b7416494a229a29188a3ec67cbbce Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 5 Nov 2025 10:38:44 -0700 Subject: [PATCH 1/9] Enhance geospatial query capabilities by introducing filtering and sorting options. Update README to reflect new options for maxDistance and filtering logic. Modify API definitions and implementations to support must/should filter conditions and sorting intervals. Add tests for new filtering functionality in closest point queries. --- README.md | 7 ++- example/convex/_generated/api.d.ts | 8 +++ src/client/index.ts | 40 ++++++++++++-- src/component/_generated/api.d.ts | 8 +++ src/component/lib/pointQuery.ts | 74 +++++++++++++++++++++++++- src/component/query.ts | 6 +++ src/component/tests/pointQuery.test.ts | 67 +++++++++++++++++++++-- 7 files changed, 200 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9b2a36d..80ed834 100644 --- a/README.md +++ b/README.md @@ -251,14 +251,17 @@ const example = query({ ctx, { latitude: 40.7813, longitude: -73.9737 }, maxResults, - maxDistance, + { + maxDistance, + filter: (q) => q.eq("category", "coffee"), + }, ); return result; }, }); ``` -The `maxDistance` parameter is optional, but providing it can greatly speed up searching the index. +The fourth argument can either be a numeric `maxDistance` (for backwards compatibility) or an options object. When you pass an options object you can combine `maxDistance` with the same filter builder used by `query`, including `eq`, `in`, `gte`, and `lt` conditions. Filtering helps constrain the search space and can speed up lookups. ## Example diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index b4a9ac4..c19bded 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -172,6 +172,14 @@ export declare const components: { minLevel: number; nextCursor?: string; point: { latitude: number; longitude: number }; + filtering: Array<{ + filterKey: string; + filterValue: string | number | boolean | null | bigint; + occur: "should" | "must"; + }>; + sorting: { + interval: { endExclusive?: number; startInclusive?: number }; + }; }, Array<{ coordinates: { latitude: number; longitude: number }; diff --git a/src/client/index.ts b/src/client/index.ts index f3eaa85..c667b58 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -39,6 +39,13 @@ export type GeospatialDocument< sortKey: number; }; +export type QueryNearestOptions< + Doc extends GeospatialDocument = GeospatialDocument, +> = { + maxDistance?: number; + filter?: NonNullable["filter"]>; +}; + export interface GeospatialIndexOptions { /** * The minimum S2 cell level to use when querying. Defaults to 4. @@ -211,23 +218,50 @@ export class GeospatialIndex< * @param ctx - The Convex query context. * @param point - The point to query for. * @param maxResults - The maximum number of results to return. - * @param maxDistance - The maximum distance to return results within in meters. + * @param maxDistanceOrOptions - Either the maximum distance in meters or an options object containing filtering logic. + * @param maybeOptions - Additional options when the maximum distance is provided separately. * @returns - An array of objects with the key-coordinate pairs and their distance from the query point in meters. */ async queryNearest( ctx: QueryCtx, point: Point, maxResults: number, - maxDistance?: number, + maxDistanceOrOptions?: + | number + | QueryNearestOptions>, + maybeOptions?: QueryNearestOptions>, ) { + let options: + | QueryNearestOptions> + | undefined; + let maxDistance: number | undefined; + if ( + typeof maxDistanceOrOptions === "object" && + maxDistanceOrOptions !== null + ) { + options = maxDistanceOrOptions; + } else { + maxDistance = maxDistanceOrOptions; + options = maybeOptions; + } + + const filterBuilder = new FilterBuilderImpl< + GeospatialDocument + >(); + if (options?.filter) { + options.filter(filterBuilder); + } + const resp = await ctx.runQuery(this.component.query.nearestPoints, { point, - maxDistance, + maxDistance: options?.maxDistance ?? maxDistance, maxResults, minLevel: this.minLevel, maxLevel: this.maxLevel, levelMod: this.levelMod, logLevel: this.logLevel, + filtering: filterBuilder.filterConditions, + sorting: { interval: filterBuilder.interval ?? {} }, }); return resp as { key: Key; coordinates: Point; distance: number }[]; } diff --git a/src/component/_generated/api.d.ts b/src/component/_generated/api.d.ts index 4206775..36a6bf5 100644 --- a/src/component/_generated/api.d.ts +++ b/src/component/_generated/api.d.ts @@ -189,6 +189,14 @@ export type Mounts = { minLevel: number; nextCursor?: string; point: { latitude: number; longitude: number }; + filtering: Array<{ + filterKey: string; + filterValue: string | number | boolean | null | bigint; + occur: "should" | "must"; + }>; + sorting: { + interval: { endExclusive?: number; startInclusive?: number }; + }; }, Array<{ coordinates: { latitude: number; longitude: number }; diff --git a/src/component/lib/pointQuery.ts b/src/component/lib/pointQuery.ts index 568f181..d10a1e7 100644 --- a/src/component/lib/pointQuery.ts +++ b/src/component/lib/pointQuery.ts @@ -1,12 +1,20 @@ import { Heap } from "heap-js"; +import type { Primitive } from "../types.js"; import { ChordAngle, Meters, Point } from "../types.js"; -import { Id } from "../_generated/dataModel.js"; +import { Doc, Id } from "../_generated/dataModel.js"; import { S2Bindings } from "./s2Bindings.js"; import { QueryCtx } from "../_generated/server.js"; import * as approximateCounter from "./approximateCounter.js"; import { cellCounterKey } from "../streams/cellRange.js"; import { decodeTupleKey } from "./tupleKey.js"; import { Logger } from "./logging.js"; +import type { Interval } from "./interval.js"; + +type FilterCondition = { + filterKey: string; + filterValue: Primitive; + occur: "must" | "should"; +}; export class ClosestPointQuery { // Min-heap of cells to process. @@ -16,6 +24,9 @@ export class ClosestPointQuery { results: Heap; maxDistanceChordAngle?: ChordAngle; + private mustFilters: FilterCondition[]; + private shouldFilters: FilterCondition[]; + private sortInterval: Interval; constructor( private s2: S2Bindings, @@ -26,11 +37,18 @@ export class ClosestPointQuery { private minLevel: number, private maxLevel: number, private levelMod: number, + filtering: FilterCondition[] = [], + interval: Interval = {}, ) { this.toProcess = new Heap((a, b) => a.distance - b.distance); this.results = new Heap((a, b) => b.distance - a.distance); this.maxDistanceChordAngle = this.maxDistance && this.s2.metersToChordAngle(this.maxDistance); + this.mustFilters = filtering.filter((filter) => filter.occur === "must"); + this.shouldFilters = filtering.filter( + (filter) => filter.occur === "should", + ); + this.sortInterval = interval; for (const cellID of this.s2.initialCells(this.minLevel)) { const distance = this.s2.minDistanceToCell(this.point, cellID); @@ -85,7 +103,9 @@ export class ClosestPointQuery { if (!point) { throw new Error("Point not found"); } - this.addResult(point._id, point.coordinates); + if (this.matchesFilters(point)) { + this.addResult(point._id, point.coordinates); + } } } } @@ -99,6 +119,9 @@ export class ClosestPointQuery { if (!point) { throw new Error("Point not found"); } + if (!this.matchesFilters(point)) { + continue; + } results.push({ key: point.key, coordinates: point.coordinates, @@ -108,6 +131,53 @@ export class ClosestPointQuery { return results; } + private matchesFilters(point: Doc<"points">): boolean { + if ( + this.sortInterval.startInclusive !== undefined && + point.sortKey < this.sortInterval.startInclusive + ) { + return false; + } + if ( + this.sortInterval.endExclusive !== undefined && + point.sortKey >= this.sortInterval.endExclusive + ) { + return false; + } + + for (const filter of this.mustFilters) { + if (!this.pointMatchesCondition(point, filter)) { + return false; + } + } + + if (this.shouldFilters.length > 0) { + let anyMatch = false; + for (const filter of this.shouldFilters) { + if (this.pointMatchesCondition(point, filter)) { + anyMatch = true; + break; + } + } + if (!anyMatch) { + return false; + } + } + + return true; + } + + private pointMatchesCondition(point: Doc<"points">, filter: FilterCondition) { + const value = point.filterKeys[filter.filterKey]; + if (value === undefined) { + return false; + } + if (Array.isArray(value)) { + return value.some((candidate) => candidate === filter.filterValue); + } + return value === filter.filterValue; + } + addCandidate(cellID: bigint, level: number, distance: ChordAngle) { if (this.maxDistanceChordAngle && distance > this.maxDistanceChordAngle) { return; diff --git a/src/component/query.ts b/src/component/query.ts index 0b6c51c..b450686 100644 --- a/src/component/query.ts +++ b/src/component/query.ts @@ -274,6 +274,10 @@ export const nearestPoints = query({ maxLevel: v.number(), levelMod: v.number(), nextCursor: v.optional(v.string()), + filtering: v.array(equalityCondition), + sorting: v.object({ + interval, + }), logLevel, }, returns: v.array(queryResultWithDistance), @@ -292,6 +296,8 @@ export const nearestPoints = query({ args.minLevel, args.maxLevel, args.levelMod, + args.filtering, + args.sorting.interval, ); const results = await query.execute(ctx); return results; diff --git a/src/component/tests/pointQuery.test.ts b/src/component/tests/pointQuery.test.ts index fb41b7a..78611fb 100644 --- a/src/component/tests/pointQuery.test.ts +++ b/src/component/tests/pointQuery.test.ts @@ -27,19 +27,19 @@ test("closest point query - basic functionality", async () => { key: "point1", coordinates: { latitude: 0, longitude: 0 }, sortKey: 1, - filterKeys: {}, + filterKeys: { category: "coffee" }, }, { key: "point2", coordinates: { latitude: 1, longitude: 1 }, sortKey: 2, - filterKeys: {}, + filterKeys: { category: "tea" }, }, { key: "point3", coordinates: { latitude: -1, longitude: -1 }, sortKey: 3, - filterKeys: {}, + filterKeys: { category: "coffee" }, }, ]; @@ -99,6 +99,67 @@ test("closest point query - basic functionality", async () => { const result3 = await query3.execute(ctx); expect(result3.length).toBe(1); expect(result3[0].key).toBe("point1"); + + // Test must filter + const query4 = new ClosestPointQuery( + s2, + logger, + { latitude: 0, longitude: 0 }, + 10000000, + 3, + opts.minLevel, + opts.maxLevel, + opts.levelMod, + [ + { + occur: "must", + filterKey: "category", + filterValue: "coffee", + }, + ], + ); + const result4 = await query4.execute(ctx); + expect(result4.length).toBe(2); + expect(result4.map((r) => r.key).sort()).toEqual(["point1", "point3"]); + + // Test should filter (must match at least one) + const query5 = new ClosestPointQuery( + s2, + logger, + { latitude: 0, longitude: 0 }, + 10000000, + 3, + opts.minLevel, + opts.maxLevel, + opts.levelMod, + [ + { + occur: "should", + filterKey: "category", + filterValue: "tea", + }, + ], + ); + const result5 = await query5.execute(ctx); + expect(result5.length).toBe(1); + expect(result5[0].key).toBe("point2"); + + // Test sort key interval + const query6 = new ClosestPointQuery( + s2, + logger, + { latitude: 0, longitude: 0 }, + 10000000, + 3, + opts.minLevel, + opts.maxLevel, + opts.levelMod, + [], + { startInclusive: 3 }, + ); + const result6 = await query6.execute(ctx); + expect(result6.length).toBe(1); + expect(result6[0].key).toBe("point3"); }); }); From 0ac446e7c670201756a0dac95131206c7cb1411d Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Thu, 6 Nov 2025 09:24:41 -0700 Subject: [PATCH 2/9] fix: update build scripts to use echo -e for proper newline handling in package.json --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 6127689..b636eaf 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "scripts": { "build": "npm run build:esm && npm run build:cjs", "build:go": "cd src/s2-bindings && python build.py", - "build:esm": "tsc --project ./esm.json && echo '{\\n \"type\": \"module\"\\n}' > dist/esm/package.json", - "build:cjs": "tsc --project ./commonjs.json && echo '{\\n \"type\": \"commonjs\"\\n}' > dist/commonjs/package.json", + "build:esm": "tsc --project ./esm.json && echo -e '{\n \"type\": \"module\"\n}' > dist/esm/package.json", + "build:cjs": "tsc --project ./commonjs.json && echo -e '{\n \"type\": \"commonjs\"\n}' > dist/commonjs/package.json", "dev": "cd example; npm run dev", "typecheck": "tsc --noEmit", "prepare": "npm run build", @@ -94,4 +94,4 @@ "main": "./dist/commonjs/client/index.js", "types": "./dist/commonjs/client/index.d.ts", "module": "./dist/esm/client/index.js" -} +} \ No newline at end of file From eac67b4d77baebad9aa408ab8b18a6421255d27d Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Thu, 6 Nov 2025 09:51:50 -0700 Subject: [PATCH 3/9] fix: update build scripts to use node for JSON file generation in package.json --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b636eaf..4864597 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "scripts": { "build": "npm run build:esm && npm run build:cjs", "build:go": "cd src/s2-bindings && python build.py", - "build:esm": "tsc --project ./esm.json && echo -e '{\n \"type\": \"module\"\n}' > dist/esm/package.json", - "build:cjs": "tsc --project ./commonjs.json && echo -e '{\n \"type\": \"commonjs\"\n}' > dist/commonjs/package.json", + "build:esm": "tsc --project ./esm.json && node -e \"require('fs').writeFileSync('dist/esm/package.json', JSON.stringify({type:'module'}, null, 2) + '\\n')\"", + "build:cjs": "tsc --project ./commonjs.json && node -e \"require('fs').writeFileSync('dist/commonjs/package.json', JSON.stringify({type:'commonjs'}, null, 2) + '\\n')\"", "dev": "cd example; npm run dev", "typecheck": "tsc --noEmit", "prepare": "npm run build", From b9e431045126831e3c6ce8fe5c8479c753030fa4 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Thu, 6 Nov 2025 11:39:50 -0700 Subject: [PATCH 4/9] Refactor point query implementation to enhance filtering capabilities and improve performance. Introduce a new buildStreamForCell method to manage filter conditions and optimize data retrieval. Update README to clarify maxDistance usage and filtering logic. Add tests for multiple filter scenarios in closest point queries. --- README.md | 4 +- src/component/lib/pointQuery.ts | 94 ++++++++++++++++++++++---- src/component/query.ts | 3 +- src/component/streams/constants.ts | 2 + src/component/streams/intersection.ts | 2 +- src/component/tests/pointQuery.test.ts | 55 ++++++++++++--- 6 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 src/component/streams/constants.ts diff --git a/README.md b/README.md index 80ed834..ac91b4b 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ const example = query({ { latitude: 40.7813, longitude: -73.9737 }, maxResults, { - maxDistance, + maxDistance, filter: (q) => q.eq("category", "coffee"), }, ); @@ -261,7 +261,7 @@ const example = query({ }); ``` -The fourth argument can either be a numeric `maxDistance` (for backwards compatibility) or an options object. When you pass an options object you can combine `maxDistance` with the same filter builder used by `query`, including `eq`, `in`, `gte`, and `lt` conditions. Filtering helps constrain the search space and can speed up lookups. +The fourth argument can either be a numeric `maxDistance` (for backwards compatibility) or an options object. When you pass an options object you can combine `maxDistance` with the same filter builder used by `query`, including `eq`, `in`, `gte`, and `lt` conditions. These filters are enforced through the indexed `pointsByFilterKey` range before documents are loaded, so the database does the heavy lifting and the query avoids reading unrelated points. Pairing that with a sensible `maxDistance` further constrains the search space and can speed up lookups. ## Example diff --git a/src/component/lib/pointQuery.ts b/src/component/lib/pointQuery.ts index d10a1e7..fbb11ea 100644 --- a/src/component/lib/pointQuery.ts +++ b/src/component/lib/pointQuery.ts @@ -5,10 +5,15 @@ import { Doc, Id } from "../_generated/dataModel.js"; import { S2Bindings } from "./s2Bindings.js"; import { QueryCtx } from "../_generated/server.js"; import * as approximateCounter from "./approximateCounter.js"; -import { cellCounterKey } from "../streams/cellRange.js"; +import { CellRange, cellCounterKey } from "../streams/cellRange.js"; +import { FilterKeyRange } from "../streams/filterKeyRange.js"; +import { Intersection } from "../streams/intersection.js"; +import { Union } from "../streams/union.js"; import { decodeTupleKey } from "./tupleKey.js"; import { Logger } from "./logging.js"; import type { Interval } from "./interval.js"; +import type { PointSet, Stats } from "../streams/zigzag.js"; +import { PREFETCH_SIZE } from "../streams/constants.js"; type FilterCondition = { filterKey: string; @@ -87,25 +92,27 @@ export class ClosestPointQuery { this.addCandidate(cellID, nextLevel, distance); } } else { - // Query the current cell and add its results in. - const pointEntries = await ctx.db - .query("pointsByCell") - .withIndex("cell", (q) => q.eq("cell", cellIDToken)) - .collect(); - this.logger.debug( - `Found ${pointEntries.length} points in cell ${cellIDToken}`, - ); - const pointIds = pointEntries.map( - (entry) => decodeTupleKey(entry.tupleKey).pointId, - ); - const points = await Promise.all(pointIds.map((id) => ctx.db.get(id))); - for (const point of points) { + const stats: Stats = { + cells: 1, + queriesIssued: 0, + rowsRead: 0, + rowsPostFiltered: 0, + }; + const stream = this.buildStreamForCell(ctx, cellIDToken, stats); + let tuple = await stream.current(); + while (tuple !== null) { + const { pointId } = decodeTupleKey(tuple); + const point = await ctx.db.get(pointId); if (!point) { throw new Error("Point not found"); } if (this.matchesFilters(point)) { this.addResult(point._id, point.coordinates); + } else { + stats.rowsPostFiltered++; } + await stream.advance(); + tuple = await stream.current(); } } } @@ -131,6 +138,65 @@ export class ClosestPointQuery { return results; } + private buildStreamForCell( + ctx: QueryCtx, + cellIDToken: string, + stats: Stats, + ): PointSet { + const ranges: PointSet[] = [ + new CellRange( + ctx, + this.logger, + cellIDToken, + undefined, + this.sortInterval, + PREFETCH_SIZE, + stats, + ), + ]; + + for (const filter of this.mustFilters) { + ranges.push( + new FilterKeyRange( + ctx, + this.logger, + filter.filterKey, + filter.filterValue, + undefined, + this.sortInterval, + PREFETCH_SIZE, + stats, + ), + ); + } + + if (this.shouldFilters.length > 0) { + const shouldStreams = this.shouldFilters.map( + (filter) => + new FilterKeyRange( + ctx, + this.logger, + filter.filterKey, + filter.filterValue, + undefined, + this.sortInterval, + PREFETCH_SIZE, + stats, + ), + ); + if (shouldStreams.length === 1) { + ranges.push(shouldStreams[0]); + } else if (shouldStreams.length > 1) { + ranges.push(new Union(shouldStreams)); + } + } + + if (ranges.length === 1) { + return ranges[0]; + } + return new Intersection(ranges); + } + private matchesFilters(point: Doc<"points">): boolean { if ( this.sortInterval.startInclusive !== undefined && diff --git a/src/component/query.ts b/src/component/query.ts index b450686..2836c97 100644 --- a/src/component/query.ts +++ b/src/component/query.ts @@ -13,8 +13,9 @@ import { Doc } from "./_generated/dataModel.js"; import { createLogger, logLevel } from "./lib/logging.js"; import { S2Bindings } from "./lib/s2Bindings.js"; import { ClosestPointQuery } from "./lib/pointQuery.js"; +import { PREFETCH_SIZE } from "./streams/constants.js"; -export const PREFETCH_SIZE = 16; +export { PREFETCH_SIZE } from "./streams/constants.js"; const equalityCondition = v.object({ occur: v.union(v.literal("should"), v.literal("must")), diff --git a/src/component/streams/constants.ts b/src/component/streams/constants.ts new file mode 100644 index 0000000..97ae041 --- /dev/null +++ b/src/component/streams/constants.ts @@ -0,0 +1,2 @@ +export const PREFETCH_SIZE = 16; + diff --git a/src/component/streams/intersection.ts b/src/component/streams/intersection.ts index 1c2df22..bc1e907 100644 --- a/src/component/streams/intersection.ts +++ b/src/component/streams/intersection.ts @@ -1,5 +1,5 @@ import { TupleKey } from "../lib/tupleKey.js"; -import { PREFETCH_SIZE } from "../query.js"; +import { PREFETCH_SIZE } from "./constants.js"; import { PointSet } from "./zigzag.js"; export class Intersection implements PointSet { diff --git a/src/component/tests/pointQuery.test.ts b/src/component/tests/pointQuery.test.ts index 78611fb..91e56c3 100644 --- a/src/component/tests/pointQuery.test.ts +++ b/src/component/tests/pointQuery.test.ts @@ -16,8 +16,14 @@ const opts = { maxCells: 8, }; +const testSchema = schema as unknown as NonNullable< + Parameters[0] +>; +const testModules = modules as NonNullable[1]>; +type ClosestQueryCtx = Parameters[0]; + test("closest point query - basic functionality", async () => { - const t = convexTest(schema, modules); + const t = await convexTest(testSchema, testModules); const s2 = await S2Bindings.load(); const logger = createLogger("INFO"); @@ -52,6 +58,7 @@ test("closest point query - basic functionality", async () => { } await t.run(async (ctx) => { + const queryCtx = ctx as unknown as ClosestQueryCtx; // Test finding closest point to origin const query1 = new ClosestPointQuery( s2, @@ -63,7 +70,7 @@ test("closest point query - basic functionality", async () => { opts.maxLevel, opts.levelMod, ); - const result1 = await query1.execute(ctx); + const result1 = await query1.execute(queryCtx); expect(result1.length).toBe(1); expect(result1[0].key).toBe("point1"); expect(result1[0].distance).toBeLessThan(1); // Should be very close to 0 @@ -79,7 +86,7 @@ test("closest point query - basic functionality", async () => { opts.maxLevel, opts.levelMod, ); - const result2 = await query2.execute(ctx); + const result2 = await query2.execute(queryCtx); expect(result2.length).toBe(2); expect(result2[0].key).toBe("point2"); expect(result2[1].key).toBe("point1"); @@ -96,7 +103,7 @@ test("closest point query - basic functionality", async () => { opts.maxLevel, opts.levelMod, ); - const result3 = await query3.execute(ctx); + const result3 = await query3.execute(queryCtx); expect(result3.length).toBe(1); expect(result3[0].key).toBe("point1"); @@ -118,7 +125,7 @@ test("closest point query - basic functionality", async () => { }, ], ); - const result4 = await query4.execute(ctx); + const result4 = await query4.execute(queryCtx); expect(result4.length).toBe(2); expect(result4.map((r) => r.key).sort()).toEqual(["point1", "point3"]); @@ -140,7 +147,7 @@ test("closest point query - basic functionality", async () => { }, ], ); - const result5 = await query5.execute(ctx); + const result5 = await query5.execute(queryCtx); expect(result5.length).toBe(1); expect(result5[0].key).toBe("point2"); @@ -157,16 +164,45 @@ test("closest point query - basic functionality", async () => { [], { startInclusive: 3 }, ); - const result6 = await query6.execute(ctx); + const result6 = await query6.execute(queryCtx); expect(result6.length).toBe(1); expect(result6[0].key).toBe("point3"); + + // Test multiple should filters + const query7 = new ClosestPointQuery( + s2, + logger, + { latitude: 0, longitude: 0 }, + 10000000, + 3, + opts.minLevel, + opts.maxLevel, + opts.levelMod, + [ + { + occur: "should", + filterKey: "category", + filterValue: "tea", + }, + { + occur: "should", + filterKey: "category", + filterValue: "coffee", + }, + ], + ); + const result7 = await query7.execute(queryCtx); + expect(result7.length).toBe(3); + expect(new Set(result7.map((r) => r.key))).toEqual( + new Set(["point1", "point2", "point3"]), + ); }); }); fcTest.prop({ documents: arbitraryDocuments })( "closest point query - property based testing", async ({ documents }) => { - const t = convexTest(schema, modules); + const t = await convexTest(testSchema, testModules); const s2 = await S2Bindings.load(); const logger = createLogger("INFO"); @@ -179,6 +215,7 @@ fcTest.prop({ documents: arbitraryDocuments })( } await t.run(async (ctx) => { + const queryCtx = ctx as unknown as ClosestQueryCtx; const testPoint = { latitude: 0, longitude: 0 }; const query = new ClosestPointQuery( s2, @@ -190,7 +227,7 @@ fcTest.prop({ documents: arbitraryDocuments })( opts.maxLevel, opts.levelMod, ); - const results = await query.execute(ctx); + const results = await query.execute(queryCtx); // Verify results are ordered by distance for (let i = 1; i < results.length; i++) { From 7d6f6b20199ce65761911144c4afd9363c7ce503 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Thu, 6 Nov 2025 13:00:49 -0700 Subject: [PATCH 5/9] Refactor ClosestPointQuery to streamline filtering logic and improve performance. Replace the buildStreamForCell method with direct database queries for point retrieval, enhancing clarity and efficiency. Introduce new helper methods for filter checks and sorting interval validation. --- src/component/lib/pointQuery.ts | 156 +++++++++++++++++--------------- 1 file changed, 83 insertions(+), 73 deletions(-) diff --git a/src/component/lib/pointQuery.ts b/src/component/lib/pointQuery.ts index fbb11ea..60f3d37 100644 --- a/src/component/lib/pointQuery.ts +++ b/src/component/lib/pointQuery.ts @@ -5,15 +5,10 @@ import { Doc, Id } from "../_generated/dataModel.js"; import { S2Bindings } from "./s2Bindings.js"; import { QueryCtx } from "../_generated/server.js"; import * as approximateCounter from "./approximateCounter.js"; -import { CellRange, cellCounterKey } from "../streams/cellRange.js"; -import { FilterKeyRange } from "../streams/filterKeyRange.js"; -import { Intersection } from "../streams/intersection.js"; -import { Union } from "../streams/union.js"; -import { decodeTupleKey } from "./tupleKey.js"; +import { cellCounterKey } from "../streams/cellRange.js"; +import { decodeTupleKey, encodeBound } from "./tupleKey.js"; import { Logger } from "./logging.js"; import type { Interval } from "./interval.js"; -import type { PointSet, Stats } from "../streams/zigzag.js"; -import { PREFETCH_SIZE } from "../streams/constants.js"; type FilterCondition = { filterKey: string; @@ -92,27 +87,46 @@ export class ClosestPointQuery { this.addCandidate(cellID, nextLevel, distance); } } else { - const stats: Stats = { - cells: 1, - queriesIssued: 0, - rowsRead: 0, - rowsPostFiltered: 0, - }; - const stream = this.buildStreamForCell(ctx, cellIDToken, stats); - let tuple = await stream.current(); - while (tuple !== null) { - const { pointId } = decodeTupleKey(tuple); + const pointEntries = await ctx.db + .query("pointsByCell") + .withIndex("cell", (q) => { + const withCell = q.eq("cell", cellIDToken); + const withStart = + this.sortInterval.startInclusive !== undefined + ? withCell.gte( + "tupleKey", + encodeBound(this.sortInterval.startInclusive), + ) + : withCell; + const withEnd = + this.sortInterval.endExclusive !== undefined + ? withStart.lt( + "tupleKey", + encodeBound(this.sortInterval.endExclusive), + ) + : withStart; + return withEnd; + }) + .collect(); + this.logger.debug( + `Found ${pointEntries.length} points in cell ${cellIDToken}`, + ); + for (const entry of pointEntries) { + const { pointId, sortKey } = decodeTupleKey(entry.tupleKey); + if (!this.withinSortInterval(sortKey)) { + continue; + } + if (!(await this.passesFilterIndexes(ctx, entry.tupleKey))) { + continue; + } const point = await ctx.db.get(pointId); if (!point) { throw new Error("Point not found"); } - if (this.matchesFilters(point)) { - this.addResult(point._id, point.coordinates); - } else { - stats.rowsPostFiltered++; + if (!this.matchesFilters(point)) { + continue; } - await stream.advance(); - tuple = await stream.current(); + this.addResult(point._id, point.coordinates); } } } @@ -138,63 +152,59 @@ export class ClosestPointQuery { return results; } - private buildStreamForCell( - ctx: QueryCtx, - cellIDToken: string, - stats: Stats, - ): PointSet { - const ranges: PointSet[] = [ - new CellRange( - ctx, - this.logger, - cellIDToken, - undefined, - this.sortInterval, - PREFETCH_SIZE, - stats, - ), - ]; + private withinSortInterval(sortKey: number): boolean { + if ( + this.sortInterval.startInclusive !== undefined && + sortKey < this.sortInterval.startInclusive + ) { + return false; + } + if ( + this.sortInterval.endExclusive !== undefined && + sortKey >= this.sortInterval.endExclusive + ) { + return false; + } + return true; + } + private async passesFilterIndexes( + ctx: QueryCtx, + tupleKey: string, + ): Promise { for (const filter of this.mustFilters) { - ranges.push( - new FilterKeyRange( - ctx, - this.logger, - filter.filterKey, - filter.filterValue, - undefined, - this.sortInterval, - PREFETCH_SIZE, - stats, - ), - ); + if (!(await this.hasFilterEntry(ctx, filter, tupleKey))) { + return false; + } } - if (this.shouldFilters.length > 0) { - const shouldStreams = this.shouldFilters.map( - (filter) => - new FilterKeyRange( - ctx, - this.logger, - filter.filterKey, - filter.filterValue, - undefined, - this.sortInterval, - PREFETCH_SIZE, - stats, - ), - ); - if (shouldStreams.length === 1) { - ranges.push(shouldStreams[0]); - } else if (shouldStreams.length > 1) { - ranges.push(new Union(shouldStreams)); - } + if (this.shouldFilters.length === 0) { + return true; } - if (ranges.length === 1) { - return ranges[0]; + for (const filter of this.shouldFilters) { + if (await this.hasFilterEntry(ctx, filter, tupleKey)) { + return true; + } } - return new Intersection(ranges); + return false; + } + + private async hasFilterEntry( + ctx: QueryCtx, + filter: FilterCondition, + tupleKey: string, + ): Promise { + const match = await ctx.db + .query("pointsByFilterKey") + .withIndex("filterKey", (q) => + q + .eq("filterKey", filter.filterKey) + .eq("filterValue", filter.filterValue) + .eq("tupleKey", tupleKey), + ) + .unique(); + return match !== null; } private matchesFilters(point: Doc<"points">): boolean { From 135dc02cc4e4a3331598e9b4f9c3d8e04d429998 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Fri, 7 Nov 2025 09:18:17 -0700 Subject: [PATCH 6/9] Enhance ClosestPointQuery by introducing batch processing for point retrieval and improving filter checks. Add methods for managing cell processing and fetching point entries in batches, optimizing performance and clarity in the query logic. --- src/component/lib/pointQuery.ts | 125 +++++++++++++++++++++++--------- 1 file changed, 89 insertions(+), 36 deletions(-) diff --git a/src/component/lib/pointQuery.ts b/src/component/lib/pointQuery.ts index 60f3d37..018cc29 100644 --- a/src/component/lib/pointQuery.ts +++ b/src/component/lib/pointQuery.ts @@ -27,6 +27,8 @@ export class ClosestPointQuery { private mustFilters: FilterCondition[]; private shouldFilters: FilterCondition[]; private sortInterval: Interval; + private readonly checkFilters: boolean; + private static readonly CELL_BATCH_SIZE = 64; constructor( private s2: S2Bindings, @@ -49,6 +51,8 @@ export class ClosestPointQuery { (filter) => filter.occur === "should", ); this.sortInterval = interval; + this.checkFilters = + this.mustFilters.length > 0 || this.shouldFilters.length > 0; for (const cellID of this.s2.initialCells(this.minLevel)) { const distance = this.s2.minDistanceToCell(this.point, cellID); @@ -87,46 +91,49 @@ export class ClosestPointQuery { this.addCandidate(cellID, nextLevel, distance); } } else { - const pointEntries = await ctx.db - .query("pointsByCell") - .withIndex("cell", (q) => { - const withCell = q.eq("cell", cellIDToken); - const withStart = - this.sortInterval.startInclusive !== undefined - ? withCell.gte( - "tupleKey", - encodeBound(this.sortInterval.startInclusive), - ) - : withCell; - const withEnd = - this.sortInterval.endExclusive !== undefined - ? withStart.lt( - "tupleKey", - encodeBound(this.sortInterval.endExclusive), - ) - : withStart; - return withEnd; - }) - .collect(); - this.logger.debug( - `Found ${pointEntries.length} points in cell ${cellIDToken}`, - ); - for (const entry of pointEntries) { - const { pointId, sortKey } = decodeTupleKey(entry.tupleKey); - if (!this.withinSortInterval(sortKey)) { - continue; + let lastTupleKey: string | undefined = undefined; + while (true) { + if (this.shouldStopProcessingCell(candidate.distance)) { + break; } - if (!(await this.passesFilterIndexes(ctx, entry.tupleKey))) { - continue; + const pointEntries = await this.fetchCellBatch( + ctx, + cellIDToken, + lastTupleKey, + ); + if (pointEntries.length === 0) { + break; } - const point = await ctx.db.get(pointId); - if (!point) { - throw new Error("Point not found"); + this.logger.debug( + `Processing batch of ${pointEntries.length} points in cell ${cellIDToken}`, + ); + for (const entry of pointEntries) { + if (this.shouldStopProcessingCell(candidate.distance)) { + break; + } + const { pointId, sortKey } = decodeTupleKey(entry.tupleKey); + if (!this.withinSortInterval(sortKey)) { + continue; + } + if ( + this.checkFilters && + !(await this.passesFilterIndexes(ctx, entry.tupleKey)) + ) { + continue; + } + const point = await ctx.db.get(pointId); + if (!point) { + throw new Error("Point not found"); + } + if (!this.matchesFilters(point)) { + continue; + } + this.addResult(point._id, point.coordinates); } - if (!this.matchesFilters(point)) { - continue; + if (pointEntries.length < ClosestPointQuery.CELL_BATCH_SIZE) { + break; } - this.addResult(point._id, point.coordinates); + lastTupleKey = pointEntries[pointEntries.length - 1].tupleKey; } } } @@ -152,6 +159,17 @@ export class ClosestPointQuery { return results; } + private shouldStopProcessingCell(candidateDistance: ChordAngle): boolean { + if (this.results.size() < this.maxResults) { + return false; + } + const threshold = this.distanceThreshold(); + if (threshold === undefined) { + return false; + } + return threshold <= candidateDistance; + } + private withinSortInterval(sortKey: number): boolean { if ( this.sortInterval.startInclusive !== undefined && @@ -207,6 +225,41 @@ export class ClosestPointQuery { return match !== null; } + private async fetchCellBatch( + ctx: QueryCtx, + cellIDToken: string, + lastTupleKey: string | undefined, + ) { + const entries = await ctx.db + .query("pointsByCell") + .withIndex("cell", (q) => { + const withCell = q.eq("cell", cellIDToken); + let withStart; + if (lastTupleKey !== undefined) { + withStart = withCell.gt("tupleKey", lastTupleKey); + } else if (this.sortInterval.startInclusive !== undefined) { + withStart = withCell.gte( + "tupleKey", + encodeBound(this.sortInterval.startInclusive), + ); + } else { + withStart = withCell; + } + let withEnd; + if (this.sortInterval.endExclusive !== undefined) { + withEnd = withStart.lt( + "tupleKey", + encodeBound(this.sortInterval.endExclusive), + ); + } else { + withEnd = withStart; + } + return withEnd; + }) + .take(ClosestPointQuery.CELL_BATCH_SIZE); + return entries; + } + private matchesFilters(point: Doc<"points">): boolean { if ( this.sortInterval.startInclusive !== undefined && From 0f19373b7a664bee8f287152f8bcb84b42e3efe7 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Fri, 7 Nov 2025 09:49:14 -0700 Subject: [PATCH 7/9] Refactor ClosestPointQuery to improve subdivision logic and remove deprecated filter methods. Introduce FILTER_SUBDIVIDE_THRESHOLD for enhanced performance in point retrieval, streamlining the query process. --- src/component/lib/pointQuery.ts | 54 +++++---------------------------- 1 file changed, 8 insertions(+), 46 deletions(-) diff --git a/src/component/lib/pointQuery.ts b/src/component/lib/pointQuery.ts index 018cc29..271352c 100644 --- a/src/component/lib/pointQuery.ts +++ b/src/component/lib/pointQuery.ts @@ -29,6 +29,7 @@ export class ClosestPointQuery { private sortInterval: Interval; private readonly checkFilters: boolean; private static readonly CELL_BATCH_SIZE = 64; + private static readonly FILTER_SUBDIVIDE_THRESHOLD = 64; constructor( private s2: S2Bindings, @@ -77,7 +78,13 @@ export class ClosestPointQuery { ); this.logger.debug(`Size estimate for ${cellIDToken}: ${sizeEstimate}`); - if (canSubdivide && sizeEstimate >= approximateCounter.SAMPLING_RATE) { + const shouldSubdivide = + canSubdivide && + (sizeEstimate >= approximateCounter.SAMPLING_RATE || + (this.checkFilters && + sizeEstimate >= ClosestPointQuery.FILTER_SUBDIVIDE_THRESHOLD)); + + if (shouldSubdivide) { this.logger.debug(`Subdividing cell ${candidate.cellID}`); const nextLevel = Math.min( candidate.level + this.levelMod, @@ -115,12 +122,6 @@ export class ClosestPointQuery { if (!this.withinSortInterval(sortKey)) { continue; } - if ( - this.checkFilters && - !(await this.passesFilterIndexes(ctx, entry.tupleKey)) - ) { - continue; - } const point = await ctx.db.get(pointId); if (!point) { throw new Error("Point not found"); @@ -186,45 +187,6 @@ export class ClosestPointQuery { return true; } - private async passesFilterIndexes( - ctx: QueryCtx, - tupleKey: string, - ): Promise { - for (const filter of this.mustFilters) { - if (!(await this.hasFilterEntry(ctx, filter, tupleKey))) { - return false; - } - } - - if (this.shouldFilters.length === 0) { - return true; - } - - for (const filter of this.shouldFilters) { - if (await this.hasFilterEntry(ctx, filter, tupleKey)) { - return true; - } - } - return false; - } - - private async hasFilterEntry( - ctx: QueryCtx, - filter: FilterCondition, - tupleKey: string, - ): Promise { - const match = await ctx.db - .query("pointsByFilterKey") - .withIndex("filterKey", (q) => - q - .eq("filterKey", filter.filterKey) - .eq("filterValue", filter.filterValue) - .eq("tupleKey", tupleKey), - ) - .unique(); - return match !== null; - } - private async fetchCellBatch( ctx: QueryCtx, cellIDToken: string, From 1c765e8fa493ce1c80bb8b4fbedad175831c9067 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Sat, 8 Nov 2025 11:46:32 -0700 Subject: [PATCH 8/9] Refactor ClosestPointQuery to enhance cell stream management and improve point retrieval logic. Introduce CellStreamState for better state handling during processing, and optimize filter checks and batch processing for improved performance. --- src/component/lib/pointQuery.ts | 164 +++++++++++++++++++------------- 1 file changed, 99 insertions(+), 65 deletions(-) diff --git a/src/component/lib/pointQuery.ts b/src/component/lib/pointQuery.ts index 271352c..ce6e536 100644 --- a/src/component/lib/pointQuery.ts +++ b/src/component/lib/pointQuery.ts @@ -5,8 +5,13 @@ import { Doc, Id } from "../_generated/dataModel.js"; import { S2Bindings } from "./s2Bindings.js"; import { QueryCtx } from "../_generated/server.js"; import * as approximateCounter from "./approximateCounter.js"; -import { cellCounterKey } from "../streams/cellRange.js"; -import { decodeTupleKey, encodeBound } from "./tupleKey.js"; +import { cellCounterKey, CellRange } from "../streams/cellRange.js"; +import { FilterKeyRange } from "../streams/filterKeyRange.js"; +import { Union } from "../streams/union.js"; +import { Intersection } from "../streams/intersection.js"; +import type { PointSet, Stats } from "../streams/zigzag.js"; +import { PREFETCH_SIZE } from "../streams/constants.js"; +import { decodeTupleKey } from "./tupleKey.js"; import { Logger } from "./logging.js"; import type { Interval } from "./interval.js"; @@ -28,8 +33,8 @@ export class ClosestPointQuery { private shouldFilters: FilterCondition[]; private sortInterval: Interval; private readonly checkFilters: boolean; - private static readonly CELL_BATCH_SIZE = 64; - private static readonly FILTER_SUBDIVIDE_THRESHOLD = 64; + private static readonly FILTER_SUBDIVIDE_THRESHOLD = 8; + private cellStreams = new Map(); constructor( private s2: S2Bindings, @@ -78,11 +83,14 @@ export class ClosestPointQuery { ); this.logger.debug(`Size estimate for ${cellIDToken}: ${sizeEstimate}`); + const approxRows = Math.floor( + sizeEstimate / approximateCounter.SAMPLING_RATE, + ); const shouldSubdivide = canSubdivide && - (sizeEstimate >= approximateCounter.SAMPLING_RATE || + (approxRows >= 1 || (this.checkFilters && - sizeEstimate >= ClosestPointQuery.FILTER_SUBDIVIDE_THRESHOLD)); + approxRows >= ClosestPointQuery.FILTER_SUBDIVIDE_THRESHOLD)); if (shouldSubdivide) { this.logger.debug(`Subdividing cell ${candidate.cellID}`); @@ -98,43 +106,35 @@ export class ClosestPointQuery { this.addCandidate(cellID, nextLevel, distance); } } else { - let lastTupleKey: string | undefined = undefined; - while (true) { + const streamState = this.getOrCreateStreamForCell(ctx, cellIDToken); + while (!streamState.done) { if (this.shouldStopProcessingCell(candidate.distance)) { break; } - const pointEntries = await this.fetchCellBatch( - ctx, - cellIDToken, - lastTupleKey, - ); - if (pointEntries.length === 0) { + const tupleKey = await streamState.stream.current(); + if (tupleKey === null) { + streamState.done = true; break; } - this.logger.debug( - `Processing batch of ${pointEntries.length} points in cell ${cellIDToken}`, - ); - for (const entry of pointEntries) { - if (this.shouldStopProcessingCell(candidate.distance)) { - break; - } - const { pointId, sortKey } = decodeTupleKey(entry.tupleKey); - if (!this.withinSortInterval(sortKey)) { - continue; - } - const point = await ctx.db.get(pointId); - if (!point) { - throw new Error("Point not found"); - } - if (!this.matchesFilters(point)) { - continue; + const { pointId, sortKey } = decodeTupleKey(tupleKey); + if (!this.withinSortInterval(sortKey)) { + const next = await streamState.stream.advance(); + if (next === null) { + streamState.done = true; } + continue; + } + const point = await ctx.db.get(pointId); + if (!point) { + throw new Error("Point not found"); + } + if (this.matchesFilters(point)) { this.addResult(point._id, point.coordinates); } - if (pointEntries.length < ClosestPointQuery.CELL_BATCH_SIZE) { - break; + const nextTuple = await streamState.stream.advance(); + if (nextTuple === null) { + streamState.done = true; } - lastTupleKey = pointEntries[pointEntries.length - 1].tupleKey; } } } @@ -157,6 +157,7 @@ export class ClosestPointQuery { distance: this.s2.chordAngleToMeters(entries[i].distance), }); } + this.cellStreams.clear(); return results; } @@ -187,39 +188,67 @@ export class ClosestPointQuery { return true; } - private async fetchCellBatch( + private getOrCreateStreamForCell( ctx: QueryCtx, cellIDToken: string, - lastTupleKey: string | undefined, - ) { - const entries = await ctx.db - .query("pointsByCell") - .withIndex("cell", (q) => { - const withCell = q.eq("cell", cellIDToken); - let withStart; - if (lastTupleKey !== undefined) { - withStart = withCell.gt("tupleKey", lastTupleKey); - } else if (this.sortInterval.startInclusive !== undefined) { - withStart = withCell.gte( - "tupleKey", - encodeBound(this.sortInterval.startInclusive), - ); - } else { - withStart = withCell; - } - let withEnd; - if (this.sortInterval.endExclusive !== undefined) { - withEnd = withStart.lt( - "tupleKey", - encodeBound(this.sortInterval.endExclusive), - ); - } else { - withEnd = withStart; - } - return withEnd; - }) - .take(ClosestPointQuery.CELL_BATCH_SIZE); - return entries; + ): CellStreamState { + const existing = this.cellStreams.get(cellIDToken); + if (existing) { + return existing; + } + const stats: Stats = { + cells: 1, + queriesIssued: 0, + rowsRead: 0, + rowsPostFiltered: 0, + }; + const ranges: PointSet[] = [ + new CellRange( + ctx, + this.logger, + cellIDToken, + undefined, + this.sortInterval, + PREFETCH_SIZE, + stats, + ), + ]; + for (const filter of this.mustFilters) { + ranges.push( + new FilterKeyRange( + ctx, + this.logger, + filter.filterKey, + filter.filterValue, + undefined, + this.sortInterval, + PREFETCH_SIZE, + stats, + ), + ); + } + if (this.shouldFilters.length > 0) { + const shouldRanges = this.shouldFilters.map( + (filter) => + new FilterKeyRange( + ctx, + this.logger, + filter.filterKey, + filter.filterValue, + undefined, + this.sortInterval, + PREFETCH_SIZE, + stats, + ), + ); + ranges.push( + shouldRanges.length === 1 ? shouldRanges[0] : new Union(shouldRanges), + ); + } + const stream = ranges.length === 1 ? ranges[0] : new Intersection(ranges); + const state: CellStreamState = { stream, done: false }; + this.cellStreams.set(cellIDToken, state); + return state; } private matchesFilters(point: Doc<"points">): boolean { @@ -334,3 +363,8 @@ type Result = { pointID: Id<"points">; distance: ChordAngle; }; + +type CellStreamState = { + stream: PointSet; + done: boolean; +}; From 6f4b7ba1905defb1d0891ec787d81e2a0f9f61d2 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Tue, 11 Nov 2025 13:07:29 -0700 Subject: [PATCH 9/9] Refactor pointQuery tests to simplify context handling by removing unnecessary type assertions and directly using the context in query executions. This improves code clarity and maintains functionality. --- src/component/tests/pointQuery.test.ts | 28 +++++++++----------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/component/tests/pointQuery.test.ts b/src/component/tests/pointQuery.test.ts index 91e56c3..0f584db 100644 --- a/src/component/tests/pointQuery.test.ts +++ b/src/component/tests/pointQuery.test.ts @@ -16,14 +16,8 @@ const opts = { maxCells: 8, }; -const testSchema = schema as unknown as NonNullable< - Parameters[0] ->; -const testModules = modules as NonNullable[1]>; -type ClosestQueryCtx = Parameters[0]; - test("closest point query - basic functionality", async () => { - const t = await convexTest(testSchema, testModules); + const t = convexTest(schema, modules); const s2 = await S2Bindings.load(); const logger = createLogger("INFO"); @@ -58,7 +52,6 @@ test("closest point query - basic functionality", async () => { } await t.run(async (ctx) => { - const queryCtx = ctx as unknown as ClosestQueryCtx; // Test finding closest point to origin const query1 = new ClosestPointQuery( s2, @@ -70,7 +63,7 @@ test("closest point query - basic functionality", async () => { opts.maxLevel, opts.levelMod, ); - const result1 = await query1.execute(queryCtx); + const result1 = await query1.execute(ctx); expect(result1.length).toBe(1); expect(result1[0].key).toBe("point1"); expect(result1[0].distance).toBeLessThan(1); // Should be very close to 0 @@ -86,7 +79,7 @@ test("closest point query - basic functionality", async () => { opts.maxLevel, opts.levelMod, ); - const result2 = await query2.execute(queryCtx); + const result2 = await query2.execute(ctx); expect(result2.length).toBe(2); expect(result2[0].key).toBe("point2"); expect(result2[1].key).toBe("point1"); @@ -103,7 +96,7 @@ test("closest point query - basic functionality", async () => { opts.maxLevel, opts.levelMod, ); - const result3 = await query3.execute(queryCtx); + const result3 = await query3.execute(ctx); expect(result3.length).toBe(1); expect(result3[0].key).toBe("point1"); @@ -125,7 +118,7 @@ test("closest point query - basic functionality", async () => { }, ], ); - const result4 = await query4.execute(queryCtx); + const result4 = await query4.execute(ctx); expect(result4.length).toBe(2); expect(result4.map((r) => r.key).sort()).toEqual(["point1", "point3"]); @@ -147,7 +140,7 @@ test("closest point query - basic functionality", async () => { }, ], ); - const result5 = await query5.execute(queryCtx); + const result5 = await query5.execute(ctx); expect(result5.length).toBe(1); expect(result5[0].key).toBe("point2"); @@ -164,7 +157,7 @@ test("closest point query - basic functionality", async () => { [], { startInclusive: 3 }, ); - const result6 = await query6.execute(queryCtx); + const result6 = await query6.execute(ctx); expect(result6.length).toBe(1); expect(result6[0].key).toBe("point3"); @@ -191,7 +184,7 @@ test("closest point query - basic functionality", async () => { }, ], ); - const result7 = await query7.execute(queryCtx); + const result7 = await query7.execute(ctx); expect(result7.length).toBe(3); expect(new Set(result7.map((r) => r.key))).toEqual( new Set(["point1", "point2", "point3"]), @@ -202,7 +195,7 @@ test("closest point query - basic functionality", async () => { fcTest.prop({ documents: arbitraryDocuments })( "closest point query - property based testing", async ({ documents }) => { - const t = await convexTest(testSchema, testModules); + const t = convexTest(schema, modules); const s2 = await S2Bindings.load(); const logger = createLogger("INFO"); @@ -215,7 +208,6 @@ fcTest.prop({ documents: arbitraryDocuments })( } await t.run(async (ctx) => { - const queryCtx = ctx as unknown as ClosestQueryCtx; const testPoint = { latitude: 0, longitude: 0 }; const query = new ClosestPointQuery( s2, @@ -227,7 +219,7 @@ fcTest.prop({ documents: arbitraryDocuments })( opts.maxLevel, opts.levelMod, ); - const results = await query.execute(queryCtx); + const results = await query.execute(ctx); // Verify results are ordered by distance for (let i = 1; i < results.length; i++) {