@atscript/db-mongo 0.1.102 → 0.1.104

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