@atscript/db-mongo 0.1.103 → 0.1.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agg.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_filter = require("./mongo-filter-1EpqdD-T.cjs");
2
+ const require_mongo_filter = require("./mongo-filter-z_hLPMyv.cjs");
3
3
  let _atscript_db_agg = require("@atscript/db/agg");
4
4
  //#region src/agg.ts
5
5
  /** Simple accumulators that map directly to `{ $<fn>: '$field' }`. */
package/dist/agg.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as buildMongoFilter } from "./mongo-filter-DBYaF9aH.mjs";
1
+ import { t as buildMongoFilter } from "./mongo-filter-DceAGI-S.mjs";
2
2
  import { resolveAlias } from "@atscript/db/agg";
3
3
  //#region src/agg.ts
4
4
  /** Simple accumulators that map directly to `{ $<fn>: '$field' }`. */
package/dist/index.cjs CHANGED
@@ -1,6 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_filter = require("./mongo-filter-1EpqdD-T.cjs");
3
- const require_plugin = require("./plugin-Bq6hZMBA.cjs");
2
+ const require_mongo_filter = require("./mongo-filter-z_hLPMyv.cjs");
4
3
  let _atscript_db = require("@atscript/db");
5
4
  let mongodb = require("mongodb");
6
5
  //#region src/lib/projection-dedupe.ts
@@ -654,6 +653,83 @@ async function vectorSearchWithCountImpl(host, vector, query, indexName) {
654
653
  const controls = query.controls || {};
655
654
  return runSearchWithCountPipeline(host, buildVectorSearchStage(host, vector, indexName, controls.$limit), query, "vectorSearchWithCount", resolveThreshold(host, controls, indexName));
656
655
  }
656
+ /**
657
+ * Field name `$geoNear` writes the computed distance into. Renamed to the
658
+ * public `$distance` pseudo-field after fetch — Mongo field paths cannot
659
+ * start with `$`, so the public name can't be used as `distanceField`.
660
+ */
661
+ const DISTANCE_FIELD = "__atscript_distance";
662
+ /** Distance-ranked geo search via a leading `$geoNear` aggregation stage. */
663
+ async function geoSearchImpl(host, point, query, indexName) {
664
+ const controls = query.controls || {};
665
+ const pipeline = [buildGeoNearStage(host, point, query, indexName)];
666
+ if (controls.$skip) pipeline.push({ $skip: controls.$skip });
667
+ pipeline.push({ $limit: controls.$limit || 1e3 });
668
+ pushGeoProjection(pipeline, query.controls);
669
+ host._log("aggregate (geoSearch)", pipeline);
670
+ return (await wrapInvalidQuery(() => host.collection.aggregate(pipeline, host._getSessionOpts()).toArray())).map((row) => renameDistance(row));
671
+ }
672
+ /** Geo search with faceted count (rows within the distance window). */
673
+ async function geoSearchWithCountImpl(host, point, query, indexName) {
674
+ const controls = query.controls || {};
675
+ const dataStages = [];
676
+ if (controls.$skip) dataStages.push({ $skip: controls.$skip });
677
+ if (controls.$limit) dataStages.push({ $limit: controls.$limit });
678
+ pushGeoProjection(dataStages, query.controls);
679
+ const pipeline = [buildGeoNearStage(host, point, query, indexName), { $facet: {
680
+ data: dataStages,
681
+ meta: [{ $count: "count" }]
682
+ } }];
683
+ host._log("aggregate (geoSearchWithCount)", pipeline);
684
+ const result = await wrapInvalidQuery(() => host.collection.aggregate(pipeline, host._getSessionOpts()).toArray());
685
+ return {
686
+ data: (result[0]?.data || []).map((row) => renameDistance(row)),
687
+ count: result[0]?.meta?.[0]?.count || 0
688
+ };
689
+ }
690
+ /** Builds the leading `$geoNear` stage; the filter rides in its `query` option. */
691
+ function buildGeoNearStage(host, point, query, indexName) {
692
+ const controls = query.controls || {};
693
+ const geoNear = {
694
+ near: {
695
+ type: "Point",
696
+ coordinates: point
697
+ },
698
+ distanceField: DISTANCE_FIELD,
699
+ spherical: true,
700
+ key: resolveGeoKeyPath(host, indexName),
701
+ query: require_mongo_filter.buildMongoFilter(query.filter)
702
+ };
703
+ if (typeof controls.$maxDistance === "number") geoNear.maxDistance = controls.$maxDistance;
704
+ if (typeof controls.$minDistance === "number") geoNear.minDistance = controls.$minDistance;
705
+ return { $geoNear: geoNear };
706
+ }
707
+ /** Resolves the physical field path of the targeted geo index. */
708
+ function resolveGeoKeyPath(host, indexName) {
709
+ const geoIndexes = [...host._table.indexes.values()].filter((index) => index.type === "geo");
710
+ const field = (indexName ? geoIndexes.find((candidate) => candidate.name === indexName) : geoIndexes[0])?.fields[0]?.name;
711
+ if (!field) throw new _atscript_db.DbError("GEO_INDEX_MISSING", [{
712
+ path: indexName ?? "",
713
+ message: `No geo index${indexName ? ` "${indexName}"` : ""} on "${host._table.tableName}"`
714
+ }]);
715
+ return field;
716
+ }
717
+ /** Appends a `$project` stage, keeping the computed distance in inclusion mode. */
718
+ function pushGeoProjection(stages, controls) {
719
+ const projection = controls?.$select?.asProjection;
720
+ if (!projection) return;
721
+ const deduped = dedupeProjection(projection);
722
+ if (Object.values(deduped).some((v) => v === 1)) deduped[DISTANCE_FIELD] = 1;
723
+ stages.push({ $project: deduped });
724
+ }
725
+ /** Renames the internal distance field to the public `$distance` pseudo-field. */
726
+ function renameDistance(row) {
727
+ if (DISTANCE_FIELD in row) {
728
+ row.$distance = row[DISTANCE_FIELD];
729
+ delete row[DISTANCE_FIELD];
730
+ }
731
+ return row;
732
+ }
657
733
  /** Resolves the effective threshold: query-time $threshold > schema-level @db.search.vector.threshold. */
658
734
  function resolveThreshold(host, controls, indexName) {
659
735
  const queryThreshold = controls.$threshold;
@@ -1129,6 +1205,9 @@ async function syncIndexesImpl(host) {
1129
1205
  fields[f.name] = "text";
1130
1206
  weights[f.name] = f.weight ?? 1;
1131
1207
  }
1208
+ } else if (index.type === "geo") {
1209
+ mongoType = "2dsphere";
1210
+ for (const f of index.fields) fields[f.name] = "2dsphere";
1132
1211
  } else {
1133
1212
  mongoType = index.type;
1134
1213
  for (const f of index.fields) fields[f.name] = 1;
@@ -1159,7 +1238,8 @@ async function syncIndexesImpl(host) {
1159
1238
  switch (local.type) {
1160
1239
  case "plain":
1161
1240
  case "unique":
1162
- case "text": {
1241
+ case "text":
1242
+ case "2dsphere": {
1163
1243
  const fieldsMatch = local.type === "text" || objMatch(local.fields, remote.key);
1164
1244
  const weightsMatch = objMatch(local.weights || {}, remote.weights || {});
1165
1245
  const optionsMatch = local.type === "text" || local.type === "unique" === (remote.unique === true) && partialFilterEqual(local.partialFilterExpression, remote.partialFilterExpression);
@@ -1200,6 +1280,11 @@ async function syncIndexesImpl(host) {
1200
1280
  name: key
1201
1281
  });
1202
1282
  break;
1283
+ case "2dsphere":
1284
+ if (!indexesToCreate.has(key)) continue;
1285
+ host._log("createIndex (2dsphere)", key, value.fields);
1286
+ await host.collection.createIndex(value.fields, { name: key });
1287
+ break;
1203
1288
  default:
1204
1289
  }
1205
1290
  try {
@@ -1521,9 +1606,31 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1521
1606
  * filterability blocker the way it is for SQL adapters.
1522
1607
  * `canSortField` keeps the conservative default (no sort on JSON storage):
1523
1608
  * sort-by-min/max-element on arrays is a footgun for generic UI sort headers.
1609
+ *
1610
+ * The `@db.encrypted` veto is core-supplied and absolute: ciphertext
1611
+ * envelopes cannot be filtered, no matter how permissive Mongo is.
1524
1612
  */
1525
- canFilterField(_fd) {
1526
- return true;
1613
+ canFilterField(fd) {
1614
+ return !fd.encrypted;
1615
+ }
1616
+ /**
1617
+ * Converts `db.geoPoint` tuples to/from GeoJSON Point storage:
1618
+ * write `[lng, lat]` → `{ type: 'Point', coordinates: [lng, lat] }`,
1619
+ * unwrap back to the tuple on read. Non-tuple values (e.g. `$geoWithin`
1620
+ * operator objects flowing through filter translation) pass through.
1621
+ */
1622
+ formatValue(field) {
1623
+ if (!field.isGeoPoint) return;
1624
+ return {
1625
+ toStorage: (value) => Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number" ? {
1626
+ type: "Point",
1627
+ coordinates: value
1628
+ } : value,
1629
+ fromStorage: (value) => {
1630
+ const v = value;
1631
+ return v && typeof v === "object" && v.type === "Point" && Array.isArray(v.coordinates) ? v.coordinates : value;
1632
+ }
1633
+ };
1527
1634
  }
1528
1635
  getAdapterTableName(_type) {}
1529
1636
  supportsNativeRelations() {
@@ -1722,6 +1829,15 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1722
1829
  async vectorSearchWithCount(vector, query, indexName) {
1723
1830
  return vectorSearchWithCountImpl(this, vector, query, indexName);
1724
1831
  }
1832
+ isGeoSearchable() {
1833
+ return true;
1834
+ }
1835
+ async geoSearch(point, query, indexName) {
1836
+ return geoSearchImpl(this, point, query, indexName);
1837
+ }
1838
+ async geoSearchWithCount(point, query, indexName) {
1839
+ return geoSearchWithCountImpl(this, point, query, indexName);
1840
+ }
1725
1841
  async findManyWithCount(query) {
1726
1842
  const filter = require_mongo_filter.buildMongoFilter(query.filter);
1727
1843
  const controls = query.controls || {};
@@ -2164,7 +2280,6 @@ function createAdapter(connection, _options) {
2164
2280
  //#endregion
2165
2281
  exports.CollectionPatcher = CollectionPatcher;
2166
2282
  exports.MongoAdapter = MongoAdapter;
2167
- exports.MongoPlugin = require_plugin.MongoPlugin;
2168
2283
  exports.buildMongoFilter = require_mongo_filter.buildMongoFilter;
2169
2284
  exports.createAdapter = createAdapter;
2170
2285
  exports.validateMongoIdPlugin = validateMongoIdPlugin;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,3 @@
1
- import { t as MongoPlugin } from "./index-Bl_O47fp.cjs";
2
1
  import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertManyResult, TDbInsertResult, TDbRelation, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TSearchIndexInfo, TSyncColumnResult, TTableResolver, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
3
2
  import { AggregationCursor, ClientSession, Collection, Db, Document, Filter, MongoClient, ObjectId, UpdateFilter, UpdateOptions } from "mongodb";
4
3
  import { TAtscriptAnnotatedType, TMetadataMap, TValidatorOptions, TValidatorPlugin, Validator } from "@atscript/typescript/utils";
@@ -149,8 +148,8 @@ declare class CollectionPatcher {
149
148
  interface TPlainIndex {
150
149
  key: string;
151
150
  name: string;
152
- type: "plain" | "unique" | "text";
153
- fields: Record<string, 1 | "text">;
151
+ type: "plain" | "unique" | "text" | "2dsphere";
152
+ fields: Record<string, 1 | "text" | "2dsphere">;
154
153
  weights: Record<string, number>;
155
154
  /**
156
155
  * For "present-only" unique indexes on optional fields: a MongoDB
@@ -280,8 +279,21 @@ declare class MongoAdapter extends BaseDbAdapter {
280
279
  * filterability blocker the way it is for SQL adapters.
281
280
  * `canSortField` keeps the conservative default (no sort on JSON storage):
282
281
  * sort-by-min/max-element on arrays is a footgun for generic UI sort headers.
282
+ *
283
+ * The `@db.encrypted` veto is core-supplied and absolute: ciphertext
284
+ * envelopes cannot be filtered, no matter how permissive Mongo is.
285
+ */
286
+ canFilterField(fd: TDbFieldMeta): boolean;
287
+ /**
288
+ * Converts `db.geoPoint` tuples to/from GeoJSON Point storage:
289
+ * write `[lng, lat]` → `{ type: 'Point', coordinates: [lng, lat] }`,
290
+ * unwrap back to the tuple on read. Non-tuple values (e.g. `$geoWithin`
291
+ * operator objects flowing through filter translation) pass through.
283
292
  */
284
- canFilterField(_fd: TDbFieldMeta): boolean;
293
+ formatValue(field: TDbFieldMeta): {
294
+ toStorage: (value: unknown) => unknown;
295
+ fromStorage: (value: unknown) => unknown;
296
+ } | undefined;
285
297
  getAdapterTableName(_type: unknown): string | undefined;
286
298
  supportsNativeRelations(): boolean;
287
299
  loadRelations(rows: Array<Record<string, unknown>>, withRelations: WithRelation[], relations: ReadonlyMap<string, TDbRelation>, foreignKeys: ReadonlyMap<string, TDbForeignKey>, tableResolver?: TTableResolver): Promise<void>;
@@ -310,6 +322,12 @@ declare class MongoAdapter extends BaseDbAdapter {
310
322
  data: Array<Record<string, unknown>>;
311
323
  count: number;
312
324
  }>;
325
+ isGeoSearchable(): boolean;
326
+ geoSearch(point: [number, number], query: DbQuery, indexName?: string): Promise<Record<string, unknown>[]>;
327
+ geoSearchWithCount(point: [number, number], query: DbQuery, indexName?: string): Promise<{
328
+ data: Array<Record<string, unknown>>;
329
+ count: number;
330
+ }>;
313
331
  findManyWithCount(query: DbQuery): Promise<{
314
332
  data: Array<Record<string, unknown>>;
315
333
  count: number;
@@ -408,4 +426,4 @@ declare const validateMongoIdPlugin: TValidatorPlugin;
408
426
  //#region src/lib/index.d.ts
409
427
  declare function createAdapter(connection: string, _options?: Record<string, unknown>): DbSpace;
410
428
  //#endregion
411
- export { CollectionPatcher, MongoAdapter, MongoPlugin, TCollectionPatcherContext, type TMongoIndex, type TMongoSearchIndexDefinition, type TPlainIndex, type TSearchIndex, buildMongoFilter, createAdapter, validateMongoIdPlugin };
429
+ export { CollectionPatcher, MongoAdapter, TCollectionPatcherContext, type TMongoIndex, type TMongoSearchIndexDefinition, type TPlainIndex, type TSearchIndex, buildMongoFilter, createAdapter, validateMongoIdPlugin };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,3 @@
1
- import { t as MongoPlugin } from "./index-Bl_O47fp.mjs";
2
1
  import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertManyResult, TDbInsertResult, TDbRelation, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TSearchIndexInfo, TSyncColumnResult, TTableResolver, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
3
2
  import { AggregationCursor, ClientSession, Collection, Db, Document, Filter, MongoClient, ObjectId, UpdateFilter, UpdateOptions } from "mongodb";
4
3
  import { TAtscriptAnnotatedType, TMetadataMap, TValidatorOptions, TValidatorPlugin, Validator } from "@atscript/typescript/utils";
@@ -149,8 +148,8 @@ declare class CollectionPatcher {
149
148
  interface TPlainIndex {
150
149
  key: string;
151
150
  name: string;
152
- type: "plain" | "unique" | "text";
153
- fields: Record<string, 1 | "text">;
151
+ type: "plain" | "unique" | "text" | "2dsphere";
152
+ fields: Record<string, 1 | "text" | "2dsphere">;
154
153
  weights: Record<string, number>;
155
154
  /**
156
155
  * For "present-only" unique indexes on optional fields: a MongoDB
@@ -280,8 +279,21 @@ declare class MongoAdapter extends BaseDbAdapter {
280
279
  * filterability blocker the way it is for SQL adapters.
281
280
  * `canSortField` keeps the conservative default (no sort on JSON storage):
282
281
  * sort-by-min/max-element on arrays is a footgun for generic UI sort headers.
282
+ *
283
+ * The `@db.encrypted` veto is core-supplied and absolute: ciphertext
284
+ * envelopes cannot be filtered, no matter how permissive Mongo is.
285
+ */
286
+ canFilterField(fd: TDbFieldMeta): boolean;
287
+ /**
288
+ * Converts `db.geoPoint` tuples to/from GeoJSON Point storage:
289
+ * write `[lng, lat]` → `{ type: 'Point', coordinates: [lng, lat] }`,
290
+ * unwrap back to the tuple on read. Non-tuple values (e.g. `$geoWithin`
291
+ * operator objects flowing through filter translation) pass through.
283
292
  */
284
- canFilterField(_fd: TDbFieldMeta): boolean;
293
+ formatValue(field: TDbFieldMeta): {
294
+ toStorage: (value: unknown) => unknown;
295
+ fromStorage: (value: unknown) => unknown;
296
+ } | undefined;
285
297
  getAdapterTableName(_type: unknown): string | undefined;
286
298
  supportsNativeRelations(): boolean;
287
299
  loadRelations(rows: Array<Record<string, unknown>>, withRelations: WithRelation[], relations: ReadonlyMap<string, TDbRelation>, foreignKeys: ReadonlyMap<string, TDbForeignKey>, tableResolver?: TTableResolver): Promise<void>;
@@ -310,6 +322,12 @@ declare class MongoAdapter extends BaseDbAdapter {
310
322
  data: Array<Record<string, unknown>>;
311
323
  count: number;
312
324
  }>;
325
+ isGeoSearchable(): boolean;
326
+ geoSearch(point: [number, number], query: DbQuery, indexName?: string): Promise<Record<string, unknown>[]>;
327
+ geoSearchWithCount(point: [number, number], query: DbQuery, indexName?: string): Promise<{
328
+ data: Array<Record<string, unknown>>;
329
+ count: number;
330
+ }>;
313
331
  findManyWithCount(query: DbQuery): Promise<{
314
332
  data: Array<Record<string, unknown>>;
315
333
  count: number;
@@ -408,4 +426,4 @@ declare const validateMongoIdPlugin: TValidatorPlugin;
408
426
  //#region src/lib/index.d.ts
409
427
  declare function createAdapter(connection: string, _options?: Record<string, unknown>): DbSpace;
410
428
  //#endregion
411
- export { CollectionPatcher, MongoAdapter, MongoPlugin, TCollectionPatcherContext, type TMongoIndex, type TMongoSearchIndexDefinition, type TPlainIndex, type TSearchIndex, buildMongoFilter, createAdapter, validateMongoIdPlugin };
429
+ export { CollectionPatcher, MongoAdapter, TCollectionPatcherContext, type TMongoIndex, type TMongoSearchIndexDefinition, type TPlainIndex, type TSearchIndex, buildMongoFilter, createAdapter, validateMongoIdPlugin };
package/dist/index.mjs CHANGED
@@ -1,5 +1,4 @@
1
- import { t as buildMongoFilter } from "./mongo-filter-DBYaF9aH.mjs";
2
- import { t as MongoPlugin } from "./plugin-KVFAwoGw.mjs";
1
+ import { t as buildMongoFilter } from "./mongo-filter-DceAGI-S.mjs";
3
2
  import { AtscriptDbView, BaseDbAdapter, DbError, DbSpace, computeInsights, getDbFieldOp, getKeyProps, resolveDesignType } from "@atscript/db";
4
3
  import { MongoClient, MongoServerError, ObjectId } from "mongodb";
5
4
  //#region src/lib/projection-dedupe.ts
@@ -653,6 +652,83 @@ async function vectorSearchWithCountImpl(host, vector, query, indexName) {
653
652
  const controls = query.controls || {};
654
653
  return runSearchWithCountPipeline(host, buildVectorSearchStage(host, vector, indexName, controls.$limit), query, "vectorSearchWithCount", resolveThreshold(host, controls, indexName));
655
654
  }
655
+ /**
656
+ * Field name `$geoNear` writes the computed distance into. Renamed to the
657
+ * public `$distance` pseudo-field after fetch — Mongo field paths cannot
658
+ * start with `$`, so the public name can't be used as `distanceField`.
659
+ */
660
+ const DISTANCE_FIELD = "__atscript_distance";
661
+ /** Distance-ranked geo search via a leading `$geoNear` aggregation stage. */
662
+ async function geoSearchImpl(host, point, query, indexName) {
663
+ const controls = query.controls || {};
664
+ const pipeline = [buildGeoNearStage(host, point, query, indexName)];
665
+ if (controls.$skip) pipeline.push({ $skip: controls.$skip });
666
+ pipeline.push({ $limit: controls.$limit || 1e3 });
667
+ pushGeoProjection(pipeline, query.controls);
668
+ host._log("aggregate (geoSearch)", pipeline);
669
+ return (await wrapInvalidQuery(() => host.collection.aggregate(pipeline, host._getSessionOpts()).toArray())).map((row) => renameDistance(row));
670
+ }
671
+ /** Geo search with faceted count (rows within the distance window). */
672
+ async function geoSearchWithCountImpl(host, point, query, indexName) {
673
+ const controls = query.controls || {};
674
+ const dataStages = [];
675
+ if (controls.$skip) dataStages.push({ $skip: controls.$skip });
676
+ if (controls.$limit) dataStages.push({ $limit: controls.$limit });
677
+ pushGeoProjection(dataStages, query.controls);
678
+ const pipeline = [buildGeoNearStage(host, point, query, indexName), { $facet: {
679
+ data: dataStages,
680
+ meta: [{ $count: "count" }]
681
+ } }];
682
+ host._log("aggregate (geoSearchWithCount)", pipeline);
683
+ const result = await wrapInvalidQuery(() => host.collection.aggregate(pipeline, host._getSessionOpts()).toArray());
684
+ return {
685
+ data: (result[0]?.data || []).map((row) => renameDistance(row)),
686
+ count: result[0]?.meta?.[0]?.count || 0
687
+ };
688
+ }
689
+ /** Builds the leading `$geoNear` stage; the filter rides in its `query` option. */
690
+ function buildGeoNearStage(host, point, query, indexName) {
691
+ const controls = query.controls || {};
692
+ const geoNear = {
693
+ near: {
694
+ type: "Point",
695
+ coordinates: point
696
+ },
697
+ distanceField: DISTANCE_FIELD,
698
+ spherical: true,
699
+ key: resolveGeoKeyPath(host, indexName),
700
+ query: buildMongoFilter(query.filter)
701
+ };
702
+ if (typeof controls.$maxDistance === "number") geoNear.maxDistance = controls.$maxDistance;
703
+ if (typeof controls.$minDistance === "number") geoNear.minDistance = controls.$minDistance;
704
+ return { $geoNear: geoNear };
705
+ }
706
+ /** Resolves the physical field path of the targeted geo index. */
707
+ function resolveGeoKeyPath(host, indexName) {
708
+ const geoIndexes = [...host._table.indexes.values()].filter((index) => index.type === "geo");
709
+ const field = (indexName ? geoIndexes.find((candidate) => candidate.name === indexName) : geoIndexes[0])?.fields[0]?.name;
710
+ if (!field) throw new DbError("GEO_INDEX_MISSING", [{
711
+ path: indexName ?? "",
712
+ message: `No geo index${indexName ? ` "${indexName}"` : ""} on "${host._table.tableName}"`
713
+ }]);
714
+ return field;
715
+ }
716
+ /** Appends a `$project` stage, keeping the computed distance in inclusion mode. */
717
+ function pushGeoProjection(stages, controls) {
718
+ const projection = controls?.$select?.asProjection;
719
+ if (!projection) return;
720
+ const deduped = dedupeProjection(projection);
721
+ if (Object.values(deduped).some((v) => v === 1)) deduped[DISTANCE_FIELD] = 1;
722
+ stages.push({ $project: deduped });
723
+ }
724
+ /** Renames the internal distance field to the public `$distance` pseudo-field. */
725
+ function renameDistance(row) {
726
+ if (DISTANCE_FIELD in row) {
727
+ row.$distance = row[DISTANCE_FIELD];
728
+ delete row[DISTANCE_FIELD];
729
+ }
730
+ return row;
731
+ }
656
732
  /** Resolves the effective threshold: query-time $threshold > schema-level @db.search.vector.threshold. */
657
733
  function resolveThreshold(host, controls, indexName) {
658
734
  const queryThreshold = controls.$threshold;
@@ -1128,6 +1204,9 @@ async function syncIndexesImpl(host) {
1128
1204
  fields[f.name] = "text";
1129
1205
  weights[f.name] = f.weight ?? 1;
1130
1206
  }
1207
+ } else if (index.type === "geo") {
1208
+ mongoType = "2dsphere";
1209
+ for (const f of index.fields) fields[f.name] = "2dsphere";
1131
1210
  } else {
1132
1211
  mongoType = index.type;
1133
1212
  for (const f of index.fields) fields[f.name] = 1;
@@ -1158,7 +1237,8 @@ async function syncIndexesImpl(host) {
1158
1237
  switch (local.type) {
1159
1238
  case "plain":
1160
1239
  case "unique":
1161
- case "text": {
1240
+ case "text":
1241
+ case "2dsphere": {
1162
1242
  const fieldsMatch = local.type === "text" || objMatch(local.fields, remote.key);
1163
1243
  const weightsMatch = objMatch(local.weights || {}, remote.weights || {});
1164
1244
  const optionsMatch = local.type === "text" || local.type === "unique" === (remote.unique === true) && partialFilterEqual(local.partialFilterExpression, remote.partialFilterExpression);
@@ -1199,6 +1279,11 @@ async function syncIndexesImpl(host) {
1199
1279
  name: key
1200
1280
  });
1201
1281
  break;
1282
+ case "2dsphere":
1283
+ if (!indexesToCreate.has(key)) continue;
1284
+ host._log("createIndex (2dsphere)", key, value.fields);
1285
+ await host.collection.createIndex(value.fields, { name: key });
1286
+ break;
1202
1287
  default:
1203
1288
  }
1204
1289
  try {
@@ -1520,9 +1605,31 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1520
1605
  * filterability blocker the way it is for SQL adapters.
1521
1606
  * `canSortField` keeps the conservative default (no sort on JSON storage):
1522
1607
  * sort-by-min/max-element on arrays is a footgun for generic UI sort headers.
1608
+ *
1609
+ * The `@db.encrypted` veto is core-supplied and absolute: ciphertext
1610
+ * envelopes cannot be filtered, no matter how permissive Mongo is.
1523
1611
  */
1524
- canFilterField(_fd) {
1525
- return true;
1612
+ canFilterField(fd) {
1613
+ return !fd.encrypted;
1614
+ }
1615
+ /**
1616
+ * Converts `db.geoPoint` tuples to/from GeoJSON Point storage:
1617
+ * write `[lng, lat]` → `{ type: 'Point', coordinates: [lng, lat] }`,
1618
+ * unwrap back to the tuple on read. Non-tuple values (e.g. `$geoWithin`
1619
+ * operator objects flowing through filter translation) pass through.
1620
+ */
1621
+ formatValue(field) {
1622
+ if (!field.isGeoPoint) return;
1623
+ return {
1624
+ toStorage: (value) => Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number" ? {
1625
+ type: "Point",
1626
+ coordinates: value
1627
+ } : value,
1628
+ fromStorage: (value) => {
1629
+ const v = value;
1630
+ return v && typeof v === "object" && v.type === "Point" && Array.isArray(v.coordinates) ? v.coordinates : value;
1631
+ }
1632
+ };
1526
1633
  }
1527
1634
  getAdapterTableName(_type) {}
1528
1635
  supportsNativeRelations() {
@@ -1721,6 +1828,15 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1721
1828
  async vectorSearchWithCount(vector, query, indexName) {
1722
1829
  return vectorSearchWithCountImpl(this, vector, query, indexName);
1723
1830
  }
1831
+ isGeoSearchable() {
1832
+ return true;
1833
+ }
1834
+ async geoSearch(point, query, indexName) {
1835
+ return geoSearchImpl(this, point, query, indexName);
1836
+ }
1837
+ async geoSearchWithCount(point, query, indexName) {
1838
+ return geoSearchWithCountImpl(this, point, query, indexName);
1839
+ }
1724
1840
  async findManyWithCount(query) {
1725
1841
  const filter = buildMongoFilter(query.filter);
1726
1842
  const controls = query.controls || {};
@@ -2161,4 +2277,4 @@ function createAdapter(connection, _options) {
2161
2277
  return new DbSpace(() => new MongoAdapter(db, client));
2162
2278
  }
2163
2279
  //#endregion
2164
- export { CollectionPatcher, MongoAdapter, MongoPlugin, buildMongoFilter, createAdapter, validateMongoIdPlugin };
2280
+ export { CollectionPatcher, MongoAdapter, buildMongoFilter, createAdapter, validateMongoIdPlugin };
@@ -17,6 +17,11 @@ function parseRegexString(value) {
17
17
  flags: ""
18
18
  };
19
19
  }
20
+ /**
21
+ * Earth radius in meters used by MongoDB's `$centerSphere` radians conversion
22
+ * (Mongo documents dividing by 6378.1 km).
23
+ */
24
+ const EARTH_RADIUS_M = 6378100;
20
25
  const mongoVisitor = {
21
26
  comparison(field, op, value) {
22
27
  if (op === "$eq") return { [field]: value };
@@ -27,6 +32,10 @@ const mongoVisitor = {
27
32
  $options: flags
28
33
  } } : { [field]: { $regex: pattern } };
29
34
  }
35
+ if (op === "$geoWithin") {
36
+ const { center, radius } = value;
37
+ return { [field]: { $geoWithin: { $centerSphere: [center, radius / EARTH_RADIUS_M] } } };
38
+ }
30
39
  return { [field]: { [op]: value } };
31
40
  },
32
41
  and(children) {
@@ -17,6 +17,11 @@ function parseRegexString(value) {
17
17
  flags: ""
18
18
  };
19
19
  }
20
+ /**
21
+ * Earth radius in meters used by MongoDB's `$centerSphere` radians conversion
22
+ * (Mongo documents dividing by 6378.1 km).
23
+ */
24
+ const EARTH_RADIUS_M = 6378100;
20
25
  const mongoVisitor = {
21
26
  comparison(field, op, value) {
22
27
  if (op === "$eq") return { [field]: value };
@@ -27,6 +32,10 @@ const mongoVisitor = {
27
32
  $options: flags
28
33
  } } : { [field]: { $regex: pattern } };
29
34
  }
35
+ if (op === "$geoWithin") {
36
+ const { center, radius } = value;
37
+ return { [field]: { $geoWithin: { $centerSphere: [center, radius / EARTH_RADIUS_M] } } };
38
+ }
30
39
  return { [field]: { [op]: value } };
31
40
  },
32
41
  and(children) {