@atscript/db-mongo 0.1.145 → 0.1.147

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/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { a as queryNodeToExpr, i as orNull, n as distinctCountExpr, r as buildMongoFilter, t as buildAccumulator } from "./mongo-accumulator-Dc0w4t0l.mjs";
2
- import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, BaseDbAdapter, DbError, DbSpace, UniquSelect, bucketTimeZoneUnavailable, computeInsights, createFailureCollector, geoIndexNotFoundMessage, getDbFieldOp, getKeyProps, isAtscriptDbView, isFieldRef, resolveDesignType, searchIndexNotFoundMessage, tableNameOf, translateQueryTree, vectorIndexNotFoundMessage } from "@atscript/db";
1
+ import { a as buildMongoQuery, c as planStages, d as queryNodeToExpr, f as correlate, i as buildMongoFilter, l as exprToMongo, n as distinctCountExpr, o as collationOfAdapter, r as REL_FILTER_TEMP_PREFIX, s as mongoFilterStages, t as buildAccumulator, u as orNull } from "./mongo-accumulator-Y_xVYo3C.mjs";
2
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, andFilters, bucketTimeZoneUnavailable, computeInsights, containsRelationFilter, createFailureCollector, geoIndexNotFoundMessage, getDbFieldOp, getKeyProps, isAtscriptDbView, isFieldRef, relationStaticFilter, resolveDesignType, searchIndexNotFoundMessage, tableNameOf, translateQueryTree, vectorIndexNotFoundMessage } from "@atscript/db";
3
3
  import { MongoClient, MongoServerError, ObjectId } from "mongodb";
4
4
  import { resolveAggregateSearch } from "@atscript/db/agg";
5
5
  //#region src/lib/path-utils.ts
@@ -373,47 +373,67 @@ function mongoIndexKey(type, name) {
373
373
  }
374
374
  //#endregion
375
375
  //#region src/lib/mongo-relations.ts
376
- function buildPKKey(primaryKeys, doc) {
377
- if (primaryKeys.length === 1) {
378
- const val = doc[primaryKeys[0]];
379
- return val == null ? "" : `${val}`;
380
- }
376
+ /** Join key of a row's primary key values (`fields` = the row's own names for them). */
377
+ function buildPKKey(fields, doc) {
381
378
  let key = "";
382
- for (let i = 0; i < primaryKeys.length; i++) {
379
+ for (let i = 0; i < fields.length; i++) {
383
380
  if (i > 0) key += "\0";
384
- const val = doc[primaryKeys[i]];
381
+ const val = doc[fields[i]];
385
382
  key += val == null ? "" : `${val}`;
386
383
  }
387
384
  return key;
388
385
  }
386
+ function resolveReadable(tableResolver, type) {
387
+ if (!type || !tableResolver) return;
388
+ return tableResolver(type);
389
+ }
390
+ /** The collection a readable reads (a view's name for a `@db.view`). */
391
+ function collectionOf(readable) {
392
+ return readable.getAdapter().resolveTableName(false);
393
+ }
394
+ /**
395
+ * Native `$with` loading: one aggregation over the source collection
396
+ * (re-selected by primary key) with a `$lookup` per relation.
397
+ *
398
+ * Every name is physical (since 0.1.147): join fields go through each
399
+ * side's `physicalPath`, and the `$with` sub-query (filter — relational
400
+ * predicates included —, `$sort`, `$select`) through the related table's
401
+ * own translation. The relation's `@db.rel.filter` is part of the lookup
402
+ * (target conditions ANDed into the sub-filter, junction conditions on the
403
+ * junction rows). Loaded documents come back as logical rows (renames,
404
+ * value formatters, decryption) before nested `$with` loads on them.
405
+ */
389
406
  async function loadRelationsImpl(host, rows, withRelations, relations, foreignKeys, tableResolver) {
390
407
  if (rows.length === 0 || withRelations.length === 0) return;
391
- const primaryKeys = host._table.primaryKeys;
408
+ const source = host._table;
409
+ const primaryKeys = source.primaryKeys;
392
410
  const relMeta = [];
393
411
  for (const withRel of withRelations) {
394
412
  if (withRel.name.includes(".")) continue;
395
413
  const relation = relations.get(withRel.name);
396
414
  if (!relation) throw new Error(`Unknown relation "${withRel.name}" in $with. Available relations: ${[...relations.keys()].join(", ") || "(none)"}`);
397
- const lookupResult = buildRelationLookup(host, withRel, relation, foreignKeys, tableResolver);
398
- if (!lookupResult) continue;
399
- relMeta.push({
400
- name: withRel.name,
401
- isArray: lookupResult.isArray,
402
- relation,
403
- nestedWith: extractNestedWith(withRel),
404
- stages: lookupResult.stages
405
- });
415
+ const lookup = buildRelationLookup(source, withRel, relation, foreignKeys, tableResolver);
416
+ if (lookup) relMeta.push(lookup);
406
417
  }
407
418
  if (relMeta.length === 0) return;
408
419
  const pkMatchFilter = buildPKMatchFilter(rows, primaryKeys);
409
420
  if (pkMatchFilter) {
410
- const pipeline = [{ $match: pkMatchFilter }];
421
+ const { filter } = source._translateForAdapter({ filter: pkMatchFilter });
422
+ const sourceMeta = source.getMetadata();
423
+ const physicalKeys = primaryKeys.map((pk) => sourceMeta.physicalPath(pk));
424
+ const project = {};
425
+ for (const key of physicalKeys) project[key] = 1;
426
+ for (const meta of relMeta) project[meta.name] = 1;
427
+ const pipeline = [{ $match: buildMongoFilter(filter) }];
411
428
  for (const meta of relMeta) pipeline.push(...meta.stages);
412
- mergeRelationResults(rows, await host.collection.aggregate(pipeline, host._getSessionOpts()).toArray(), primaryKeys, relMeta);
429
+ pipeline.push({ $project: project });
430
+ const results = await host.collection.aggregate(pipeline, host._getSessionOpts()).toArray();
431
+ await Promise.all(relMeta.map((meta) => toLogicalRows(results, meta)));
432
+ mergeRelationResults(rows, results, primaryKeys, physicalKeys, relMeta);
413
433
  } else for (const row of rows) for (const meta of relMeta) row[meta.name] = meta.isArray ? [] : null;
414
- await loadNestedRelations(rows, relMeta, tableResolver);
434
+ await loadNestedRelations(rows, relMeta);
415
435
  }
416
- /** Builds a $match filter to re-select source rows by PK. */
436
+ /** Builds a $match filter to re-select source rows by PK (logical names and values). */
417
437
  function buildPKMatchFilter(rows, primaryKeys) {
418
438
  if (primaryKeys.length === 1) {
419
439
  const pk = primaryKeys[0];
@@ -447,137 +467,164 @@ function buildPKMatchFilter(rows, primaryKeys) {
447
467
  return orFilters.length === 1 ? orFilters[0] : { $or: orFilters };
448
468
  }
449
469
  /** Dispatches to the correct $lookup builder based on relation direction. */
450
- function buildRelationLookup(host, withRel, relation, foreignKeys, tableResolver) {
470
+ function buildRelationLookup(source, withRel, relation, foreignKeys, tableResolver) {
471
+ const target = resolveReadable(tableResolver, relation.targetType());
472
+ if (!target) return;
473
+ let built;
451
474
  switch (relation.direction) {
452
- case "to": return buildToLookup(withRel, relation, foreignKeys);
453
- case "from": return buildFromLookup(host, withRel, relation, tableResolver);
454
- case "via": return buildViaLookup(host, withRel, relation, tableResolver);
475
+ case "to":
476
+ built = buildToLookup(source, target, withRel, relation, foreignKeys);
477
+ break;
478
+ case "from":
479
+ built = buildFromLookup(source, target, withRel, relation);
480
+ break;
481
+ case "via":
482
+ built = buildViaLookup(source, target, withRel, relation, tableResolver);
483
+ break;
455
484
  default: return;
456
485
  }
457
- }
458
- /** Builds `let` variable bindings and the corresponding `$expr` match for `$lookup`. */
459
- function buildLookupJoin(localFields, remoteFields, varPrefix) {
460
- const letVars = {};
461
- for (let i = 0; i < localFields.length; i++) letVars[`${varPrefix}${i}`] = `$${localFields[i]}`;
462
- if (remoteFields.length === 1) return {
463
- letVars,
464
- exprMatch: { $eq: [`$${remoteFields[0]}`, `$$${varPrefix}0`] }
465
- };
466
- const andClauses = [];
467
- for (let i = 0; i < remoteFields.length; i++) andClauses.push({ $eq: [`$${remoteFields[i]}`, `$$${varPrefix}${i}`] });
468
- return {
469
- letVars,
470
- exprMatch: { $and: andClauses }
486
+ return built && {
487
+ name: withRel.name,
488
+ isArray: built.isArray,
489
+ target,
490
+ readControls: built.readControls,
491
+ nestedWith: extractNestedWith(withRel),
492
+ stages: built.stages
471
493
  };
472
494
  }
495
+ /** Pairs of physical join fields: `outer` on the looking-up side, `inner` on the looked-up one. */
496
+ function joinPairs(outerMeta, outerFields, innerMeta, innerFields) {
497
+ return outerFields.map((field, i) => ({
498
+ outer: outerMeta.physicalPath(field),
499
+ inner: innerMeta.physicalPath(innerFields[i])
500
+ }));
501
+ }
473
502
  /** $lookup for TO relations (FK is on this table → target). Always single-valued. */
474
- function buildToLookup(withRel, relation, foreignKeys) {
503
+ function buildToLookup(source, target, withRel, relation, foreignKeys) {
475
504
  const fk = findFKForRelation(relation, foreignKeys);
476
- if (!fk) return;
477
- const innerPipeline = buildLookupInnerPipeline(withRel, fk.targetFields);
478
- const { letVars, exprMatch } = buildLookupJoin(fk.localFields, fk.targetFields, "fk_");
479
- return {
480
- stages: [{ $lookup: {
481
- from: fk.targetTable,
482
- let: letVars,
483
- pipeline: [{ $match: { $expr: exprMatch } }, ...innerPipeline],
484
- as: withRel.name
485
- } }, { $unwind: {
486
- path: `$${withRel.name}`,
487
- preserveNullAndEmptyArrays: true
488
- } }],
489
- isArray: false
490
- };
505
+ return fk ? directLookup(source, target, withRel, relation, fk.localFields, fk.targetFields, "fk_", false) : void 0;
491
506
  }
492
507
  /** $lookup for FROM relations (FK is on target → this table). */
493
- function buildFromLookup(host, withRel, relation, tableResolver) {
494
- const targetType = relation.targetType();
495
- if (!targetType || !tableResolver) return;
496
- const targetMeta = tableResolver(targetType);
497
- if (!targetMeta) return;
498
- const remoteFK = findRemoteFK(targetMeta, host._table.tableName, relation.alias);
499
- if (!remoteFK) return;
500
- const targetTableName = tableNameOf(relation.targetType());
501
- const innerPipeline = buildLookupInnerPipeline(withRel, remoteFK.fields);
502
- const { letVars, exprMatch } = buildLookupJoin(remoteFK.targetFields, remoteFK.fields, "pk_");
508
+ function buildFromLookup(source, target, withRel, relation) {
509
+ const remoteFK = findRemoteFK(target, source.tableName, relation.alias);
510
+ return remoteFK ? directLookup(source, target, withRel, relation, remoteFK.targetFields, remoteFK.fields, "pk_", relation.isArray) : void 0;
511
+ }
512
+ /**
513
+ * The correlated `$lookup` of a TO / FROM relation: `sourceFields` of this
514
+ * table paired with `targetFields` of the target, unwound when single-valued.
515
+ */
516
+ function directLookup(source, target, withRel, relation, sourceFields, targetFields, prefix, isArray) {
517
+ const pairs = joinPairs(source.getMetadata(), sourceFields, target.getMetadata(), targetFields);
518
+ const inner = buildLookupInnerPipeline(target, withRel, relation, pairs.map((p) => p.inner));
519
+ const join = correlate(prefix, pairs);
503
520
  const stages = [{ $lookup: {
504
- from: targetTableName,
505
- let: letVars,
506
- pipeline: [{ $match: { $expr: exprMatch } }, ...innerPipeline],
521
+ from: collectionOf(target),
522
+ let: join.let,
523
+ pipeline: [
524
+ ...join.stages,
525
+ ...inner.filter,
526
+ ...inner.page
527
+ ],
507
528
  as: withRel.name
508
529
  } }];
509
- if (!relation.isArray) stages.push({ $unwind: {
530
+ if (!isArray) stages.push({ $unwind: {
510
531
  path: `$${withRel.name}`,
511
532
  preserveNullAndEmptyArrays: true
512
533
  } });
513
534
  return {
514
535
  stages,
515
- isArray: relation.isArray
536
+ isArray,
537
+ readControls: inner.readControls
516
538
  };
517
539
  }
518
540
  /** $lookup for VIA relations (M:N through junction table). Always array. */
519
- function buildViaLookup(host, withRel, relation, tableResolver) {
520
- if (!relation.viaType || !tableResolver) return;
521
- const junctionType = relation.viaType();
522
- if (!junctionType) return;
523
- const junctionMeta = tableResolver(junctionType);
524
- if (!junctionMeta) return;
525
- const junctionTableName = tableNameOf(junctionType);
526
- const targetTableName = tableNameOf(relation.targetType());
527
- const fkToThis = findRemoteFK(junctionMeta, host._table.tableName);
541
+ function buildViaLookup(source, target, withRel, relation, tableResolver) {
542
+ const junction = resolveReadable(tableResolver, relation.viaType?.());
543
+ if (!junction) return;
544
+ const fkToThis = findRemoteFK(junction, source.tableName);
528
545
  if (!fkToThis) return;
529
- const fkToTarget = findRemoteFK(junctionMeta, targetTableName);
546
+ const fkToTarget = findRemoteFK(junction, tableNameOf(relation.targetType()));
530
547
  if (!fkToTarget) return;
531
- const innerPipeline = buildLookupInnerPipeline(withRel, fkToTarget.targetFields);
532
- const { letVars, exprMatch } = buildLookupJoin(fkToThis.targetFields, fkToThis.fields, "pk_");
548
+ const sourceMeta = source.getMetadata();
549
+ const junctionMeta = junction.getMetadata();
550
+ const targetMeta = target.getMetadata();
551
+ const toSource = correlate("pk_", joinPairs(sourceMeta, fkToThis.targetFields, junctionMeta, fkToThis.fields));
552
+ const toTargetPairs = joinPairs(junctionMeta, fkToTarget.fields, targetMeta, fkToTarget.targetFields);
553
+ const toTarget = correlate("fk_", toTargetPairs);
554
+ const inner = buildLookupInnerPipeline(target, withRel, relation, toTargetPairs.map((p) => p.inner));
555
+ const junctionFilter = relationStaticFilter(relation, withRel.name).junction;
556
+ const junctionStages = junctionFilter ? filterStages(junction, junction._translateForAdapter({ filter: junctionFilter }).filter) : [];
533
557
  return {
534
558
  stages: [{ $lookup: {
535
- from: junctionTableName,
536
- let: letVars,
559
+ from: collectionOf(junction),
560
+ let: toSource.let,
537
561
  pipeline: [
538
- { $match: { $expr: exprMatch } },
562
+ ...toSource.stages,
563
+ ...junctionStages,
539
564
  { $lookup: {
540
- from: targetTableName,
541
- localField: fkToTarget.fields[0],
542
- foreignField: fkToTarget.targetFields[0],
543
- pipeline: innerPipeline,
565
+ from: collectionOf(target),
566
+ let: toTarget.let,
567
+ pipeline: [...toTarget.stages, ...inner.filter],
544
568
  as: "__target"
545
569
  } },
546
570
  { $unwind: {
547
571
  path: "$__target",
548
572
  preserveNullAndEmptyArrays: false
549
573
  } },
550
- { $replaceRoot: { newRoot: "$__target" } }
574
+ { $replaceRoot: { newRoot: "$__target" } },
575
+ ...inner.page
551
576
  ],
552
577
  as: withRel.name
553
578
  } }],
554
- isArray: true
579
+ isArray: true,
580
+ readControls: inner.readControls
555
581
  };
556
582
  }
557
- /** Builds inner pipeline stages for relation controls ($sort, $limit, $skip, $select, filter). */
558
- function buildLookupInnerPipeline(withRel, requiredFields) {
559
- const pipeline = [];
583
+ /** Stages applying a translated filter on `readable`'s collection (none when empty). */
584
+ function filterStages(readable, filter) {
585
+ if (!filter || Object.keys(filter).length === 0) return [];
586
+ return mongoFilterStages(filter, { collation: collationOfAdapter(readable.getAdapter()) });
587
+ }
588
+ /**
589
+ * The `$with` sub-query on the related table: its filter AND the target part
590
+ * of the relation's `@db.rel.filter`, plus `$sort` / `$skip` / `$limit` /
591
+ * `$select` — translated by the TARGET readable (physical names, value
592
+ * formatters, relational predicates resolved). `requiredFields` (physical
593
+ * join keys) are always projected.
594
+ */
595
+ function buildLookupInnerPipeline(target, withRel, relation, requiredFields) {
560
596
  const flatRel = withRel;
561
597
  const nested = withRel.controls || {};
562
- const filter = withRel.filter;
563
598
  const sort = nested.$sort || flatRel.$sort;
564
599
  const limit = nested.$limit ?? flatRel.$limit;
565
600
  const skip = nested.$skip ?? flatRel.$skip;
566
601
  const select = nested.$select || flatRel.$select;
567
- if (filter && Object.keys(filter).length > 0) pipeline.push({ $match: buildMongoFilter(filter) });
568
- if (sort) pipeline.push({ $sort: sort });
569
- if (skip) pipeline.push({ $skip: skip });
570
- if (limit !== null && limit !== void 0) pipeline.push({ $limit: limit });
571
- const projection = select ? new UniquSelect(select).asProjection : void 0;
602
+ const statics = relationStaticFilter(relation, withRel.name);
603
+ const controls = {};
604
+ if (sort) controls.$sort = sort;
605
+ if (select) controls.$select = select;
606
+ const translated = target._translateForAdapter({
607
+ filter: andFilters(statics.target, withRel.filter),
608
+ controls
609
+ });
610
+ const page = [];
611
+ if (translated.controls?.$sort) page.push({ $sort: translated.controls.$sort });
612
+ if (skip) page.push({ $skip: skip });
613
+ if (limit !== null && limit !== void 0) page.push({ $limit: limit });
614
+ const projection = translated.controls?.$select?.asProjection;
572
615
  if (projection) {
573
616
  const project = { ...projection };
574
617
  if (Object.values(project).some((flag) => flag === 1 || flag === true)) {
575
618
  for (const f of requiredFields) project[f] = 1;
576
619
  if (!("_id" in projection) && !requiredFields.includes("_id")) project._id = 0;
577
620
  } else for (const f of requiredFields) delete project[f];
578
- if (Object.keys(project).length > 0) pipeline.push({ $project: dedupeProjection(project) });
621
+ if (Object.keys(project).length > 0) page.push({ $project: dedupeProjection(project) });
579
622
  }
580
- return pipeline;
623
+ return {
624
+ filter: filterStages(target, translated.filter),
625
+ page,
626
+ readControls: select ? { $select: select } : {}
627
+ };
581
628
  }
582
629
  /** Extracts nested $with from a WithRelation's controls. */
583
630
  function extractNestedWith(withRel) {
@@ -586,15 +633,10 @@ function extractNestedWith(withRel) {
586
633
  return nestedWith && nestedWith.length > 0 ? nestedWith : void 0;
587
634
  }
588
635
  /** Post-processes nested $with by delegating to the target table's own relation loading. */
589
- async function loadNestedRelations(rows, relMeta, tableResolver) {
590
- if (!tableResolver) return;
636
+ async function loadNestedRelations(rows, relMeta) {
591
637
  const tasks = [];
592
638
  for (const meta of relMeta) {
593
639
  if (!meta.nestedWith || meta.nestedWith.length === 0) continue;
594
- const targetType = meta.relation.targetType();
595
- if (!targetType) continue;
596
- const targetTable = tableResolver(targetType);
597
- if (!targetTable) continue;
598
640
  const subRows = [];
599
641
  for (const row of rows) {
600
642
  const val = row[meta.name];
@@ -602,21 +644,44 @@ async function loadNestedRelations(rows, relMeta, tableResolver) {
602
644
  else if (val && typeof val === "object") subRows.push(val);
603
645
  }
604
646
  if (subRows.length === 0) continue;
605
- tasks.push(targetTable.loadRelations(subRows, meta.nestedWith));
647
+ tasks.push(meta.target.loadRelations(subRows, meta.nestedWith));
606
648
  }
607
649
  await Promise.all(tasks);
608
650
  }
609
- /** Merges aggregation results back onto the original rows by PK. */
610
- function mergeRelationResults(rows, results, primaryKeys, relMeta) {
651
+ /**
652
+ * Replaces one relation's loaded documents on every aggregation result with
653
+ * the related table's LOGICAL rows (one conversion call for all of them).
654
+ */
655
+ async function toLogicalRows(results, meta) {
656
+ const physical = [];
657
+ const spans = [];
658
+ for (const doc of results) {
659
+ const value = doc[meta.name];
660
+ const list = Array.isArray(value) ? value : value && typeof value === "object" ? [value] : [];
661
+ spans.push([
662
+ doc,
663
+ physical.length,
664
+ list.length
665
+ ]);
666
+ physical.push(...list);
667
+ }
668
+ const logical = physical.length > 0 ? await meta.target._rowsFromAdapter(physical, meta.readControls) : [];
669
+ for (const [doc, start, length] of spans) doc[meta.name] = meta.isArray ? logical.slice(start, start + length) : length > 0 ? logical[start] : null;
670
+ }
671
+ /**
672
+ * Merges aggregation results back onto the original rows by PK — the
673
+ * results carry the PHYSICAL key fields (`physicalKeys`), the rows the
674
+ * logical ones (`primaryKeys`).
675
+ */
676
+ function mergeRelationResults(rows, results, primaryKeys, physicalKeys, relMeta) {
611
677
  const resultIndex = /* @__PURE__ */ new Map();
612
- for (const doc of results) resultIndex.set(buildPKKey(primaryKeys, doc), doc);
678
+ for (const doc of results) resultIndex.set(buildPKKey(physicalKeys, doc), doc);
613
679
  for (const row of rows) {
614
680
  const enriched = resultIndex.get(buildPKKey(primaryKeys, row));
615
- for (const meta of relMeta) if (enriched) {
616
- const value = enriched[meta.name];
617
- if (!meta.isArray && Array.isArray(value)) row[meta.name] = value[0] ?? null;
618
- else row[meta.name] = value ?? (meta.isArray ? [] : null);
619
- } else row[meta.name] = meta.isArray ? [] : null;
681
+ for (const meta of relMeta) {
682
+ const value = enriched?.[meta.name];
683
+ row[meta.name] = value ?? (meta.isArray ? [] : null);
684
+ }
620
685
  }
621
686
  }
622
687
  /** Finds FK entry for a TO relation from this table's foreignKeys map. */
@@ -643,6 +708,10 @@ function findRemoteFK(target, thisTableName, alias) {
643
708
  }
644
709
  //#endregion
645
710
  //#region src/lib/mongo-search.ts
711
+ /** Renders a host's filter with relational predicates — its 'nocase' fields per field. */
712
+ function filterOptionsOf(host) {
713
+ return { collation: collationOfAdapter(host) };
714
+ }
646
715
  /**
647
716
  * Returns available search indexes as generic metadata for UI. The text
648
717
  * default is the `DEFAULT` entry (an alias of the index `search()` answers
@@ -735,7 +804,7 @@ const DISTANCE_FIELD = "__atscript_distance";
735
804
  /** Distance-ranked geo search via a leading `$geoNear` aggregation stage. */
736
805
  async function geoSearchImpl(host, point, query, indexName) {
737
806
  const controls = query.controls || {};
738
- const pipeline = [buildGeoNearStage(host, point, query, indexName)];
807
+ const pipeline = buildGeoNearStages(host, point, query, indexName);
739
808
  if (controls.$skip) pipeline.push({ $skip: controls.$skip });
740
809
  pipeline.push({ $limit: controls.$limit || 1e3 });
741
810
  pushGeoProjection(pipeline, query.controls);
@@ -749,7 +818,7 @@ async function geoSearchWithCountImpl(host, point, query, indexName) {
749
818
  if (controls.$skip) dataStages.push({ $skip: controls.$skip });
750
819
  if (controls.$limit) dataStages.push({ $limit: controls.$limit });
751
820
  pushGeoProjection(dataStages, query.controls);
752
- const pipeline = [buildGeoNearStage(host, point, query, indexName), { $facet: {
821
+ const pipeline = [...buildGeoNearStages(host, point, query, indexName), { $facet: {
753
822
  data: dataStages,
754
823
  meta: [{ $count: "count" }]
755
824
  } }];
@@ -760,9 +829,15 @@ async function geoSearchWithCountImpl(host, point, query, indexName) {
760
829
  count: result[0]?.meta?.[0]?.count || 0
761
830
  };
762
831
  }
763
- /** Builds the leading `$geoNear` stage; the filter rides in its `query` option. */
764
- function buildGeoNearStage(host, point, query, indexName) {
832
+ /**
833
+ * Builds the leading `$geoNear` stage; the filter rides in its `query`
834
+ * option. A filter with relational predicates keeps its predicate-free
835
+ * conjuncts there and applies the rest (predicate `$lookup`s + `$match`)
836
+ * right after — exact, since `$geoNear` has no result cut-off of its own.
837
+ */
838
+ function buildGeoNearStages(host, point, query, indexName) {
765
839
  const controls = query.controls || {};
840
+ const plan = containsRelationFilter(query.filter) ? buildMongoQuery(query.filter, filterOptionsOf(host)) : void 0;
766
841
  const geoNear = {
767
842
  near: {
768
843
  type: "Point",
@@ -771,11 +846,14 @@ function buildGeoNearStage(host, point, query, indexName) {
771
846
  distanceField: DISTANCE_FIELD,
772
847
  spherical: true,
773
848
  key: resolveGeoKeyPath(host, indexName),
774
- query: buildMongoFilter(query.filter)
849
+ query: plan ? plan.pre ?? {} : buildMongoFilter(query.filter)
775
850
  };
776
851
  if (typeof controls.$maxDistance === "number") geoNear.maxDistance = controls.$maxDistance;
777
852
  if (typeof controls.$minDistance === "number") geoNear.minDistance = controls.$minDistance;
778
- return { $geoNear: geoNear };
853
+ return plan ? [{ $geoNear: geoNear }, ...planStages({
854
+ ...plan,
855
+ pre: void 0
856
+ })] : [{ $geoNear: geoNear }];
779
857
  }
780
858
  /** Resolves the physical field path of the targeted geo index. */
781
859
  function resolveGeoKeyPath(host, indexName) {
@@ -984,14 +1062,13 @@ function buildVectorSearchStage(host, vector, indexName, limit) {
984
1062
  }
985
1063
  /** Runs a search/vector pipeline and returns results. Shared by search + vectorSearch. */
986
1064
  async function runSearchPipeline(host, stage, query, label, threshold, classicText = false) {
987
- const filter = buildMongoFilter(query.filter);
988
1065
  const controls = query.controls || {};
989
1066
  const pipeline = [stage];
990
1067
  if (threshold !== void 0) {
991
1068
  pipeline.push({ $addFields: { _score: { $meta: "vectorSearchScore" } } });
992
1069
  pipeline.push({ $match: { _score: { $gte: threshold } } });
993
1070
  } else if (classicText) pipeline.push({ $addFields: { _score: { $meta: "textScore" } } });
994
- pipeline.push({ $match: filter });
1071
+ pipeline.push(...mongoFilterStages(query.filter, filterOptionsOf(host)));
995
1072
  if (controls.$sort) pipeline.push({ $sort: controls.$sort });
996
1073
  else if (classicText) pipeline.push({ $sort: { _score: -1 } });
997
1074
  if (controls.$skip) pipeline.push({ $skip: controls.$skip });
@@ -1006,7 +1083,6 @@ async function runSearchPipeline(host, stage, query, label, threshold, classicTe
1006
1083
  }
1007
1084
  /** Runs a search/vector pipeline with $facet for count. Shared by searchWithCount + vectorSearchWithCount. */
1008
1085
  async function runSearchWithCountPipeline(host, stage, query, label, threshold, classicText = false) {
1009
- const filter = buildMongoFilter(query.filter);
1010
1086
  const controls = query.controls || {};
1011
1087
  const preStages = [];
1012
1088
  if (threshold !== void 0) {
@@ -1025,7 +1101,7 @@ async function runSearchWithCountPipeline(host, stage, query, label, threshold,
1025
1101
  const pipeline = [
1026
1102
  stage,
1027
1103
  ...preStages,
1028
- { $match: filter },
1104
+ ...mongoFilterStages(query.filter, filterOptionsOf(host)),
1029
1105
  { $facet: {
1030
1106
  data: dataStages,
1031
1107
  meta: [{ $count: "count" }]
@@ -1088,11 +1164,28 @@ function buildViewPipeline(view) {
1088
1164
  /** A column's source field as an aggregation operand. */
1089
1165
  const colSourceField = (col) => `$${prefixes.get(col.sourceTable) ?? ""}${col.sourceColumn}`;
1090
1166
  const colValue = (col) => col.nullable ? orNull(colSourceField(col)) : colSourceField(col);
1167
+ const byPath = new Map(columns.map((c) => [c.viewPath, c]));
1168
+ const rendered = /* @__PURE__ */ new Map();
1169
+ const computedExpr = (col, leaf) => {
1170
+ let out = rendered.get(col.viewPath);
1171
+ if (out === void 0) {
1172
+ out = exprToMongo(col.expr, (path) => {
1173
+ const c = byPath.get(path);
1174
+ return c.expr === void 0 ? { $toDouble: leaf(c) } : computedExpr(c, leaf);
1175
+ });
1176
+ rendered.set(col.viewPath, out);
1177
+ }
1178
+ return out;
1179
+ };
1091
1180
  if (columns.some((c) => c.aggFn)) {
1092
1181
  const group = { _id: {} };
1093
1182
  const project = { _id: 0 };
1094
1183
  const distinctSizes = {};
1095
- for (const col of columns) if (col.aggFn) {
1184
+ const computed = {};
1185
+ for (const col of columns) if (col.expr !== void 0) {
1186
+ computed[col.viewColumn] = computedExpr(col, (c) => c.aggFn ? `$${c.viewColumn}` : `$_id.${c.viewColumn}`);
1187
+ project[col.viewColumn] = `$${col.viewColumn}`;
1188
+ } else if (col.aggFn) {
1096
1189
  const predicate = col.aggFilter ? queryNodeToExpr(col.aggFilter, (ref) => `$${pathOf(ref)}`) : void 0;
1097
1190
  group[col.viewColumn] = buildAccumulator(col.aggFn, col.aggField === "*" ? "*" : colSourceField(col), predicate, col.viewColumn);
1098
1191
  project[col.viewColumn] = `$${col.viewColumn}`;
@@ -1103,11 +1196,12 @@ function buildViewPipeline(view) {
1103
1196
  }
1104
1197
  pipeline.push({ $group: group });
1105
1198
  if (Object.keys(distinctSizes).length > 0) pipeline.push({ $addFields: distinctSizes });
1106
- if (plan.having) pipeline.push({ $match: havingMatch(plan.having, columns) });
1199
+ if (Object.keys(computed).length > 0) pipeline.push({ $addFields: computed });
1200
+ if (plan.having) pipeline.push({ $match: havingMatch(plan.having, byPath) });
1107
1201
  pipeline.push({ $project: project });
1108
1202
  } else {
1109
1203
  const project = { _id: 0 };
1110
- for (const col of columns) project[col.viewColumn] = colValue(col);
1204
+ for (const col of columns) project[col.viewColumn] = col.expr === void 0 ? colValue(col) : computedExpr(col, colValue);
1111
1205
  pipeline.push({ $project: project });
1112
1206
  }
1113
1207
  return pipeline;
@@ -1128,7 +1222,7 @@ function buildViewPipeline(view) {
1128
1222
  function buildLookup(view, join, outer) {
1129
1223
  const scope = join.scope;
1130
1224
  const as = joinedAs(scope);
1131
- const simple = simpleJoinFields(view, join, outer);
1225
+ const simple = join.first ? void 0 : simpleJoinFields(view, join, outer);
1132
1226
  if (simple) return { $lookup: {
1133
1227
  from: join.targetTable,
1134
1228
  ...simple,
@@ -1150,11 +1244,16 @@ function buildLookup(view, join, outer) {
1150
1244
  }
1151
1245
  return `$$${varName}`;
1152
1246
  };
1153
- const expr = queryNodeToExpr(join.condition, pathOf);
1247
+ const pipeline = [{ $match: { $expr: queryNodeToExpr(join.condition, pathOf) } }];
1248
+ if (join.first) {
1249
+ const sort = {};
1250
+ for (const { ref, desc } of join.first.order) sort[view.resolveRefSource(ref).source.column] = desc ? -1 : 1;
1251
+ pipeline.push({ $sort: sort }, { $limit: 1 });
1252
+ }
1154
1253
  return { $lookup: {
1155
1254
  from: join.targetTable,
1156
1255
  ...varByPath.size > 0 ? { let: letVars } : {},
1157
- pipeline: [{ $match: { $expr: expr } }],
1256
+ pipeline,
1158
1257
  as
1159
1258
  } };
1160
1259
  }
@@ -1204,12 +1303,11 @@ function viewMatch(node, pathOf) {
1204
1303
  * `@db.view.having` as a `$match` after `$group`: refs name view fields —
1205
1304
  * aggregates are top-level, dimensions under `_id`.
1206
1305
  */
1207
- function havingMatch(node, columns) {
1208
- const colMap = new Map(columns.map((c) => [c.viewPath, c]));
1306
+ function havingMatch(node, colMap) {
1209
1307
  return viewMatch(node, (ref) => {
1210
1308
  const col = ref.type ? void 0 : colMap.get(ref.field);
1211
1309
  if (!col) return ref.field;
1212
- return col.aggFn ? col.viewColumn : `_id.${col.viewColumn}`;
1310
+ return col.aggFn || col.expr !== void 0 ? col.viewColumn : `_id.${col.viewColumn}`;
1213
1311
  });
1214
1312
  }
1215
1313
  //#endregion
@@ -1699,6 +1797,11 @@ function objectIdFromStorage(value) {
1699
1797
  if (Array.isArray(value)) return value.map(objectIdFromStorage);
1700
1798
  return value;
1701
1799
  }
1800
+ /**
1801
+ * Documents per `_id $in` write when a mutation filter holds relational
1802
+ * predicates (see {@link MongoAdapter.supportsRelationFilters}).
1803
+ */
1804
+ const REL_WRITE_BATCH = 1e3;
1702
1805
  var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1703
1806
  db;
1704
1807
  client;
@@ -1742,6 +1845,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1742
1845
  return this.client ?? this.db;
1743
1846
  }
1744
1847
  /**
1848
+ * Same store = same client (or database handle) AND the same database: a
1849
+ * `$lookup` only reads collections of the pipeline's own database, and one
1850
+ * client may serve several (since 0.1.147).
1851
+ */
1852
+ sharesStoreWith(other) {
1853
+ return other instanceof MongoAdapter && other._transactionOwner() === this._transactionOwner() && other.db.databaseName === this.db.databaseName;
1854
+ }
1855
+ /**
1745
1856
  * Per-client cache: whether transactions are unavailable (standalone MongoDB).
1746
1857
  * Shared across all adapter instances for the same client so topology is probed once.
1747
1858
  */
@@ -1796,19 +1907,22 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1796
1907
  if (!this._collection) this._collection = this.db.collection(this.resolveTableName(false));
1797
1908
  return this._collection;
1798
1909
  }
1799
- aggregatePipeline(pipeline) {
1800
- return this.collection.aggregate(pipeline, this._getSessionOpts());
1910
+ aggregatePipeline(pipeline, options) {
1911
+ return this.collection.aggregate(pipeline, {
1912
+ ...options,
1913
+ ...this._getSessionOpts()
1914
+ });
1801
1915
  }
1802
1916
  async aggregate(query) {
1803
1917
  const { buildAggregatePipeline, buildCountPipeline } = await import("./agg.mjs");
1804
1918
  const searchStage = buildAggregateSearchStage(this, query.controls);
1805
1919
  if (query.controls?.$count) {
1806
- const pipeline = buildCountPipeline(query, searchStage);
1920
+ const pipeline = buildCountPipeline(query, searchStage, this._predicateFilterOpts);
1807
1921
  this._log("aggregate (count)", pipeline);
1808
1922
  const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1809
1923
  return result.length > 0 ? result : [{ count: 0 }];
1810
1924
  }
1811
- const pipeline = buildAggregatePipeline(query, searchStage);
1925
+ const pipeline = buildAggregatePipeline(query, searchStage, this._predicateFilterOpts);
1812
1926
  this._log("aggregate", pipeline);
1813
1927
  return wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1814
1928
  }
@@ -1846,7 +1960,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1846
1960
  supportsNativePatch() {
1847
1961
  return true;
1848
1962
  }
1849
- /** All five units, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
1963
+ /** Every unit, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
1850
1964
  calendarBucketUnits() {
1851
1965
  return ALL_BUCKET_UNITS;
1852
1966
  }
@@ -1854,6 +1968,10 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1854
1968
  aggregateFns() {
1855
1969
  return ALL_AGGREGATE_FNS;
1856
1970
  }
1971
+ /** Computed view columns and first-row joins. */
1972
+ viewCapabilities() {
1973
+ return ALL_VIEW_CAPABILITIES;
1974
+ }
1857
1975
  /**
1858
1976
  * See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
1859
1977
  * BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
@@ -1911,6 +2029,58 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1911
2029
  supportsNativeRelations() {
1912
2030
  return true;
1913
2031
  }
2032
+ /**
2033
+ * Relational predicates (`{ nav: { $some | $none: … } }`) render as
2034
+ * correlated `$lookup` stages, so a read whose filter holds one runs as an
2035
+ * aggregation pipeline (find / count / findManyWithCount / grouped
2036
+ * aggregate / search / geo); predicate-free reads keep their plain
2037
+ * `find` / `countDocuments` path.
2038
+ *
2039
+ * Writes (`updateMany` / `replaceMany` / `deleteMany` and the single-row
2040
+ * variants, whose scope may carry a predicate) first resolve the matching
2041
+ * `_id`s through that pipeline, then write by `_id` in batches of
2042
+ * {@link REL_WRITE_BATCH} — re-checking the predicate-free part of the
2043
+ * filter. Inside an active transaction both steps share its session and
2044
+ * are atomic; without one there is a window between resolving and writing
2045
+ * in which a related document can change, so a written document may no
2046
+ * longer satisfy the predicate (or a newly matching one is missed).
2047
+ *
2048
+ * Collation: such a pipeline runs without an operation-wide `collation`
2049
+ * (it would govern the join keys and every related field too); each
2050
+ * table's `'nocase'` fields are compared case-insensitively explicitly
2051
+ * ({@link TMongoFilterOptions.collation}) — on reads and on these writes.
2052
+ *
2053
+ * @since 0.1.147
2054
+ */
2055
+ supportsRelationFilters(_mode) {
2056
+ return true;
2057
+ }
2058
+ /**
2059
+ * The `@db.column.collate` of a field (physical or logical path), or
2060
+ * `undefined` for a byte-wise one — how predicate pipelines render this
2061
+ * table's `'nocase'` comparisons ({@link TMongoFilterOptions.collation}).
2062
+ *
2063
+ * @since 0.1.147
2064
+ */
2065
+ fieldCollation(field) {
2066
+ this._table.flatMap;
2067
+ return this._collateFields?.get(field);
2068
+ }
2069
+ /** Filter-rendering options of a pipeline with relational predicates on this table. */
2070
+ get _predicateFilterOpts() {
2071
+ return { collation: collationOfAdapter(this) };
2072
+ }
2073
+ /**
2074
+ * Operation options of a read: the request collation for a predicate-free
2075
+ * filter ({@link _getCollationOpts}); none with relational predicates —
2076
+ * those pipelines render collation per field ({@link _predicateFilterOpts}).
2077
+ */
2078
+ _readOpts(query) {
2079
+ return containsRelationFilter(query.filter) ? this._getSessionOpts() : {
2080
+ ...this._getCollationOpts(query),
2081
+ ...this._getSessionOpts()
2082
+ };
2083
+ }
1914
2084
  async loadRelations(rows, withRelations, relations, foreignKeys, tableResolver) {
1915
2085
  return loadRelationsImpl(this, rows, withRelations, relations, foreignKeys, tableResolver);
1916
2086
  }
@@ -1923,7 +2093,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1923
2093
  };
1924
2094
  }
1925
2095
  async nativePatch(filter, patch, ops, expectedVersion) {
1926
- const mongoFilter = this._buildCasFilter(filter, expectedVersion, "nativePatch");
2096
+ const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "nativePatch");
2097
+ if (!mongoFilter) return {
2098
+ matchedCount: 0,
2099
+ modifiedCount: 0
2100
+ };
1927
2101
  const versionColumn = this._table.versionColumnPhysical;
1928
2102
  const effectiveOps = versionColumn !== void 0 ? {
1929
2103
  ...ops,
@@ -1961,10 +2135,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1961
2135
  }
1962
2136
  onFieldScanned(field, _type, metadata) {
1963
2137
  if (field === "_id") this._hasExplicitId = true;
1964
- if (field !== "_id" && metadata.has("meta.id")) {
1965
- this._addMongoIndexField("unique", "__pk", field);
1966
- this._pendingUniqueFields.push(field);
1967
- }
2138
+ if (field !== "_id" && metadata.has("meta.id")) this._pendingUniqueFields.push(field);
1968
2139
  for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
1969
2140
  indexName: index.indexName,
1970
2141
  field,
@@ -2031,6 +2202,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2031
2202
  };
2032
2203
  }
2033
2204
  onAfterFlatten() {
2205
+ const meta = this._table.getMetadata();
2206
+ for (const field of this._pendingUniqueFields) this._addMongoIndexField("unique", "__pk", meta.documentPath(field));
2034
2207
  for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
2035
2208
  this._pendingSearchFields = [];
2036
2209
  for (const [key, value] of this._vectorFilters.entries()) {
@@ -2120,25 +2293,12 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2120
2293
  return geoSearchWithCountImpl(this, point, query, indexName);
2121
2294
  }
2122
2295
  async findManyWithCount(query) {
2123
- const filter = buildMongoFilter(query.filter);
2124
- const controls = query.controls || {};
2125
- const dataStages = [];
2126
- if (controls.$sort) dataStages.push({ $sort: controls.$sort });
2127
- if (controls.$skip) dataStages.push({ $skip: controls.$skip });
2128
- if (controls.$limit) dataStages.push({ $limit: controls.$limit });
2129
- if (controls.$select) {
2130
- const projection = controls.$select.asProjection;
2131
- if (projection) dataStages.push({ $project: dedupeProjection(projection) });
2132
- }
2133
- const pipeline = [{ $match: filter }, { $facet: {
2134
- data: dataStages,
2296
+ const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), { $facet: {
2297
+ data: pageStages(query.controls),
2135
2298
  meta: [{ $count: "count" }]
2136
2299
  } }];
2137
2300
  this._log("aggregate (findManyWithCount)", pipeline);
2138
- const result = await wrapInvalidQuery(() => this.collection.aggregate(pipeline, {
2139
- ...this._getCollationOpts(query),
2140
- ...this._getSessionOpts()
2141
- }).toArray());
2301
+ const result = await wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray());
2142
2302
  return {
2143
2303
  data: result[0]?.data || [],
2144
2304
  count: result[0]?.meta[0]?.count || 0
@@ -2168,14 +2328,94 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2168
2328
  * is supplied for a non-versioned table — fail loud at the adapter boundary
2169
2329
  * rather than silently dropping the CAS predicate.
2170
2330
  */
2171
- _buildCasFilter(filter, expectedVersion, op) {
2331
+ async _buildCasFilter(filter, expectedVersion, op) {
2172
2332
  const versionColumn = this._table.versionColumnPhysical;
2173
2333
  if (expectedVersion !== void 0 && versionColumn === void 0) throw new Error(`${op}: expectedVersion requires versionColumn`);
2174
- const mongoFilter = buildMongoFilter(filter);
2334
+ const mongoFilter = await this._writeFilterOne(filter);
2335
+ if (!mongoFilter) return;
2175
2336
  if (expectedVersion !== void 0 && versionColumn !== void 0) mongoFilter[versionColumn] = expectedVersion;
2176
2337
  return mongoFilter;
2177
2338
  }
2178
2339
  /**
2340
+ * The id-resolving pipeline of a write filter holding relational
2341
+ * predicates: the matching `_id`s (in the active transaction's session,
2342
+ * if any), each table's `'nocase'` fields compared like on reads
2343
+ * ({@link _predicateFilterOpts}). `pre` is the predicate-free part, which
2344
+ * every `_id`-based write re-checks — so a document that stopped matching
2345
+ * it in between is not written.
2346
+ */
2347
+ _predicateWritePlan(filter) {
2348
+ const plan = buildMongoQuery(filter, this._predicateFilterOpts);
2349
+ return {
2350
+ pipeline: planStages(plan),
2351
+ pre: plan.pre
2352
+ };
2353
+ }
2354
+ /** A write filter's `_id` filter (re-checking `pre`). */
2355
+ static _byIds(ids, pre) {
2356
+ const byId = ids.length === 1 ? { _id: ids[0] } : { _id: { $in: ids } };
2357
+ return pre ? { $and: [byId, pre] } : byId;
2358
+ }
2359
+ /** Single-document write over a predicate filter: the first match's `_id` filter (`undefined`: none). */
2360
+ async _predicateWriteFilterOne(filter) {
2361
+ const { pipeline, pre } = this._predicateWritePlan(filter);
2362
+ pipeline.push({ $limit: 1 }, { $project: { _id: 1 } });
2363
+ this._log("aggregate (write id)", pipeline);
2364
+ const [doc] = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
2365
+ return doc ? MongoAdapter._byIds([doc._id], pre) : void 0;
2366
+ }
2367
+ /**
2368
+ * Multi-document write over `filter`: a predicate-free filter is written
2369
+ * as is; one with predicates calls `write` with one `_id`-based filter per
2370
+ * batch of {@link REL_WRITE_BATCH} matching ids, streamed from the cursor
2371
+ * (never all ids in memory at once).
2372
+ *
2373
+ * The ids come sorted by `_id`, so writes made while the cursor is open
2374
+ * can never feed back into it (otherwise a document an update moves within
2375
+ * the index the cursor scans could be returned — and written — twice):
2376
+ * after the `$lookup`s that `$sort` consumes every match before the first
2377
+ * batch returns, and an `_id` order read from the `_id` index is stable
2378
+ * (`_id` never changes).
2379
+ */
2380
+ async _forEachMatching(filter, write) {
2381
+ if (!containsRelationFilter(filter)) return write(buildMongoFilter(filter));
2382
+ const { pipeline, pre } = this._predicateWritePlan(filter);
2383
+ pipeline.push({ $project: { _id: 1 } }, { $sort: { _id: 1 } });
2384
+ this._log("aggregate (write ids)", pipeline);
2385
+ const cursor = this.aggregatePipeline(pipeline, { allowDiskUse: true }).batchSize(REL_WRITE_BATCH);
2386
+ try {
2387
+ const next = () => wrapInvalidQuery(() => cursor.next());
2388
+ let ids = [];
2389
+ for (let doc = await next(); doc; doc = await next()) {
2390
+ ids.push(doc._id);
2391
+ if (ids.length === REL_WRITE_BATCH) {
2392
+ await write(MongoAdapter._byIds(ids, pre));
2393
+ ids = [];
2394
+ }
2395
+ }
2396
+ if (ids.length > 0) await write(MongoAdapter._byIds(ids, pre));
2397
+ } finally {
2398
+ await cursor.close();
2399
+ }
2400
+ }
2401
+ /** Runs an update-shaped write over `filter` ({@link _forEachMatching}), counts summed. */
2402
+ async _updateMatching(filter, write) {
2403
+ const total = {
2404
+ matchedCount: 0,
2405
+ modifiedCount: 0
2406
+ };
2407
+ await this._forEachMatching(filter, async (mongoFilter) => {
2408
+ const result = await write(mongoFilter);
2409
+ total.matchedCount += result.matchedCount;
2410
+ total.modifiedCount += result.modifiedCount;
2411
+ });
2412
+ return total;
2413
+ }
2414
+ /** The filter of a single-document write — its predicates resolved to one `_id`. */
2415
+ _writeFilterOne(filter) {
2416
+ return containsRelationFilter(filter) ? this._predicateWriteFilterOne(filter) : buildMongoFilter(filter);
2417
+ }
2418
+ /**
2179
2419
  * Wraps an async operation to catch MongoDB duplicate key errors
2180
2420
  * (code 11000) and rethrow as structured `DbError` — every write path
2181
2421
  * (insert, replace, update, patch, their `*Many` variants) goes through it,
@@ -2232,6 +2472,10 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2232
2472
  };
2233
2473
  }
2234
2474
  async findOne(query) {
2475
+ if (containsRelationFilter(query.filter)) {
2476
+ const [row] = await this._aggregateFind(query, "findOne", 1);
2477
+ return row ?? null;
2478
+ }
2235
2479
  const filter = buildMongoFilter(query.filter);
2236
2480
  const opts = this._buildFindOptions(query.controls);
2237
2481
  this._log("findOne", filter, opts);
@@ -2242,6 +2486,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2242
2486
  }));
2243
2487
  }
2244
2488
  async findMany(query) {
2489
+ if (containsRelationFilter(query.filter)) return this._aggregateFind(query, "findMany");
2245
2490
  const filter = buildMongoFilter(query.filter);
2246
2491
  const opts = this._buildFindOptions(query.controls);
2247
2492
  this._log("findMany", filter, opts);
@@ -2252,6 +2497,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2252
2497
  }).toArray());
2253
2498
  }
2254
2499
  async count(query) {
2500
+ if (containsRelationFilter(query.filter)) {
2501
+ const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), { $count: "count" }];
2502
+ this._log("aggregate (count)", pipeline);
2503
+ return (await wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray()))[0]?.count ?? 0;
2504
+ }
2255
2505
  const filter = buildMongoFilter(query.filter);
2256
2506
  this._log("countDocuments", filter);
2257
2507
  return this.collection.countDocuments(filter, {
@@ -2260,13 +2510,21 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2260
2510
  });
2261
2511
  }
2262
2512
  async updateOne(filter, data, ops, expectedVersion) {
2263
- const mongoFilter = this._buildCasFilter(filter, expectedVersion, "updateOne");
2513
+ const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "updateOne");
2514
+ if (!mongoFilter) return {
2515
+ matchedCount: 0,
2516
+ modifiedCount: 0
2517
+ };
2264
2518
  const updateDoc = buildMongoUpdateDoc(data, ops, this._table.versionColumnPhysical);
2265
2519
  this._log("updateOne", mongoFilter, updateDoc);
2266
2520
  return this._wrapUpdate(() => this.collection.updateOne(mongoFilter, updateDoc, this._getSessionOpts()));
2267
2521
  }
2268
2522
  async replaceOne(filter, data, expectedVersion) {
2269
- const mongoFilter = this._buildCasFilter(filter, expectedVersion, "replaceOne");
2523
+ const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "replaceOne");
2524
+ if (!mongoFilter) return {
2525
+ matchedCount: 0,
2526
+ modifiedCount: 0
2527
+ };
2270
2528
  const versionColumn = this._table.versionColumnPhysical;
2271
2529
  this._log("replaceOne", mongoFilter, data);
2272
2530
  if (versionColumn !== void 0) {
@@ -2279,27 +2537,34 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2279
2537
  return this._wrapUpdate(() => this.collection.replaceOne(mongoFilter, data, this._getSessionOpts()));
2280
2538
  }
2281
2539
  async deleteOne(filter) {
2282
- const mongoFilter = buildMongoFilter(filter);
2540
+ const mongoFilter = await this._writeFilterOne(filter);
2541
+ if (!mongoFilter) return { deletedCount: 0 };
2283
2542
  this._log("deleteOne", mongoFilter);
2284
2543
  return { deletedCount: (await this.collection.deleteOne(mongoFilter, this._getSessionOpts())).deletedCount };
2285
2544
  }
2286
2545
  async updateMany(filter, data, ops) {
2287
2546
  const versionColumn = this._table.versionColumnPhysical;
2288
- const mongoFilter = buildMongoFilter(filter);
2289
2547
  const updateDoc = buildMongoUpdateDoc(data, ops, versionColumn);
2290
- this._log("updateMany", mongoFilter, updateDoc);
2291
- return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
2548
+ return this._updateMatching(filter, (mongoFilter) => {
2549
+ this._log("updateMany", mongoFilter, updateDoc);
2550
+ return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
2551
+ });
2292
2552
  }
2293
2553
  async replaceMany(filter, data) {
2294
- const mongoFilter = buildMongoFilter(filter);
2295
2554
  const updateDoc = buildMongoUpdateDoc(data, void 0, this._table.versionColumnPhysical);
2296
- this._log("replaceMany", mongoFilter, updateDoc);
2297
- return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
2555
+ return this._updateMatching(filter, (mongoFilter) => {
2556
+ this._log("replaceMany", mongoFilter, updateDoc);
2557
+ return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
2558
+ });
2298
2559
  }
2299
2560
  async deleteMany(filter) {
2300
- const mongoFilter = buildMongoFilter(filter);
2301
- this._log("deleteMany", mongoFilter);
2302
- return { deletedCount: (await this.collection.deleteMany(mongoFilter, this._getSessionOpts())).deletedCount };
2561
+ let deletedCount = 0;
2562
+ await this._forEachMatching(filter, async (mongoFilter) => {
2563
+ this._log("deleteMany", mongoFilter);
2564
+ const result = await this.collection.deleteMany(mongoFilter, this._getSessionOpts());
2565
+ deletedCount += result.deletedCount;
2566
+ });
2567
+ return { deletedCount };
2303
2568
  }
2304
2569
  clearCollectionCache() {
2305
2570
  this._collection = void 0;
@@ -2426,6 +2691,15 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2426
2691
  fieldCounters.set(physical, next + 1);
2427
2692
  }
2428
2693
  }
2694
+ /** A find whose filter holds relational predicates, as an aggregation pipeline. */
2695
+ async _aggregateFind(query, label, limit) {
2696
+ const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), ...pageStages(limit ? {
2697
+ ...query.controls,
2698
+ $limit: limit
2699
+ } : query.controls)];
2700
+ this._log(`aggregate (${label})`, pipeline);
2701
+ return wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray());
2702
+ }
2429
2703
  _buildFindOptions(controls) {
2430
2704
  const opts = {};
2431
2705
  if (!controls) return opts;
@@ -2442,6 +2716,9 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2442
2716
  * Returns MongoDB collation options if any filter field has a non-binary collation.
2443
2717
  * Uses pre-computed insights when available, falls back to computing them on demand.
2444
2718
  * Maps: nocase → strength 2 (case-insensitive), unicode → strength 1 (case+accent-insensitive).
2719
+ * Predicate-free filters only — a filter with relational predicates never gets an
2720
+ * operation-wide collation ({@link _readOpts}), so insights are never computed over
2721
+ * resolved predicate operands.
2445
2722
  */
2446
2723
  _getCollationOpts(query) {
2447
2724
  if (!this._collateFields) return;
@@ -2544,6 +2821,19 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2544
2821
  return node.fields;
2545
2822
  }
2546
2823
  };
2824
+ /** `$sort` → `$skip` → `$limit` → `$project` stages of a read's controls. */
2825
+ function pageStages(controls) {
2826
+ const stages = [];
2827
+ if (!controls) return stages;
2828
+ if (controls.$sort) stages.push({ $sort: controls.$sort });
2829
+ if (controls.$skip) stages.push({ $skip: controls.$skip });
2830
+ if (controls.$limit) stages.push({ $limit: controls.$limit });
2831
+ if (controls.$select) {
2832
+ const projection = controls.$select.asProjection;
2833
+ if (projection) stages.push({ $project: dedupeProjection(projection) });
2834
+ }
2835
+ return stages;
2836
+ }
2547
2837
  /**
2548
2838
  * Normalizes a declared `fuzzy` arg (`0-2`) into the query-time metadata Atlas
2549
2839
  * accepts. Atlas only honors an edit distance of `1` or `2`; `0`/undefined means
@@ -2606,4 +2896,4 @@ function createAdapter(connection, _options) {
2606
2896
  return new DbSpace(() => new MongoAdapter(db, client));
2607
2897
  }
2608
2898
  //#endregion
2609
- export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, buildMongoFilter, createAdapter, mongoIndexKey, validateMongoIdPlugin };
2899
+ export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, REL_FILTER_TEMP_PREFIX, buildMongoFilter, buildMongoQuery, collationOfAdapter, createAdapter, mongoFilterStages, mongoIndexKey, planStages, validateMongoIdPlugin };