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