@atscript/db-mongo 0.1.146 → 0.1.148

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,6 +1,6 @@
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";
3
- import { MongoClient, MongoServerError, ObjectId } from "mongodb";
1
+ import { a as buildMongoQuery, c as planStages, d as orNull, f as queryNodeToExpr, i as buildMongoFilter, l as exprToMongo, n as distinctCountExpr, o as collationOfAdapter, p as correlate, r as REL_FILTER_TEMP_PREFIX, s as mongoFilterStages, t as buildAccumulator } from "./mongo-accumulator-BvJNCVoc.mjs";
2
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, andFilters, bucketTimeZoneUnavailable, computeInsights, containsRelationFilter, createFailureCollector, geoIndexNotFoundMessage, getDbFieldOp, getKeyProps, getPath, isAtscriptDbView, isFieldRef, relationStaticFilter, resolveDesignType, searchIndexNotFoundMessage, tableNameOf, translateQueryTree, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
3
+ import { MongoBulkWriteError, MongoClient, MongoServerError, ObjectId } from "mongodb";
4
4
  import { resolveAggregateSearch } from "@atscript/db/agg";
5
5
  //#region src/lib/path-utils.ts
6
6
  function joinPath(prefix, segment) {
@@ -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,16 @@ 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;
1805
+ /** Whether a bulk-write error carries a write-concern failure besides (or instead of) write errors. */
1806
+ function hasWriteConcernError(error) {
1807
+ const e = error;
1808
+ return Boolean(e.writeConcernError || e.result?.getWriteConcernError?.() || e.result?.writeConcernErrors?.length);
1809
+ }
1702
1810
  var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1703
1811
  db;
1704
1812
  client;
@@ -1742,6 +1850,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1742
1850
  return this.client ?? this.db;
1743
1851
  }
1744
1852
  /**
1853
+ * Same store = same client (or database handle) AND the same database: a
1854
+ * `$lookup` only reads collections of the pipeline's own database, and one
1855
+ * client may serve several (since 0.1.147).
1856
+ */
1857
+ sharesStoreWith(other) {
1858
+ return other instanceof MongoAdapter && other._transactionOwner() === this._transactionOwner() && other.db.databaseName === this.db.databaseName;
1859
+ }
1860
+ /**
1745
1861
  * Per-client cache: whether transactions are unavailable (standalone MongoDB).
1746
1862
  * Shared across all adapter instances for the same client so topology is probed once.
1747
1863
  */
@@ -1796,21 +1912,35 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1796
1912
  if (!this._collection) this._collection = this.db.collection(this.resolveTableName(false));
1797
1913
  return this._collection;
1798
1914
  }
1799
- aggregatePipeline(pipeline) {
1800
- return this.collection.aggregate(pipeline, this._getSessionOpts());
1915
+ aggregatePipeline(pipeline, options) {
1916
+ return this.collection.aggregate(pipeline, {
1917
+ ...options,
1918
+ ...this._getSessionOpts()
1919
+ });
1801
1920
  }
1802
1921
  async aggregate(query) {
1803
- const { buildAggregatePipeline, buildCountPipeline } = await import("./agg.mjs");
1922
+ const { aggregateOptions, buildAggregatePipeline, buildCountPipeline, buildMatchedProbe, emptyGroupRow, emptyGroupStages } = await import("./agg.mjs");
1923
+ const emptyGroup = async (forCount) => {
1924
+ const row = emptyGroupRow(query);
1925
+ if (!row) return void 0;
1926
+ const probe = buildMatchedProbe(query, searchStage, this._predicateFilterOpts);
1927
+ if ((await wrapInvalidQuery(() => this.aggregatePipeline(probe).toArray())).length > 0) return;
1928
+ const stages = emptyGroupStages(query, row, forCount);
1929
+ if (!stages) return row;
1930
+ return (await wrapInvalidQuery(() => this.aggregatePipeline(stages).toArray()))[0];
1931
+ };
1804
1932
  const searchStage = buildAggregateSearchStage(this, query.controls);
1805
1933
  if (query.controls?.$count) {
1806
- const pipeline = buildCountPipeline(query, searchStage);
1934
+ const pipeline = buildCountPipeline(query, searchStage, this._predicateFilterOpts);
1807
1935
  this._log("aggregate (count)", pipeline);
1808
- const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1809
- return result.length > 0 ? result : [{ count: 0 }];
1936
+ const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
1937
+ return result.length > 0 ? result : [{ count: await emptyGroup(true) ? 1 : 0 }];
1810
1938
  }
1811
- const pipeline = buildAggregatePipeline(query, searchStage);
1939
+ const pipeline = buildAggregatePipeline(query, searchStage, this._predicateFilterOpts);
1812
1940
  this._log("aggregate", pipeline);
1813
- return wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1941
+ const rows = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
1942
+ const empty = rows.length === 0 ? await emptyGroup(false) : void 0;
1943
+ return empty ? [empty] : rows;
1814
1944
  }
1815
1945
  get idType() {
1816
1946
  const idProp = this._table.type.type.props.get("_id");
@@ -1846,14 +1976,22 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1846
1976
  supportsNativePatch() {
1847
1977
  return true;
1848
1978
  }
1849
- /** All five units, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
1979
+ /** Every unit, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
1850
1980
  calendarBucketUnits() {
1851
1981
  return ALL_BUCKET_UNITS;
1852
1982
  }
1853
- /** Every aggregate function, `countDistinct` included. */
1983
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
1854
1984
  aggregateFns() {
1855
1985
  return ALL_AGGREGATE_FNS;
1856
1986
  }
1987
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
1988
+ supportsAggregateExpressions() {
1989
+ return true;
1990
+ }
1991
+ /** Computed view columns and first-row joins. */
1992
+ viewCapabilities() {
1993
+ return ALL_VIEW_CAPABILITIES;
1994
+ }
1857
1995
  /**
1858
1996
  * See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
1859
1997
  * BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
@@ -1911,6 +2049,58 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1911
2049
  supportsNativeRelations() {
1912
2050
  return true;
1913
2051
  }
2052
+ /**
2053
+ * Relational predicates (`{ nav: { $some | $none: … } }`) render as
2054
+ * correlated `$lookup` stages, so a read whose filter holds one runs as an
2055
+ * aggregation pipeline (find / count / findManyWithCount / grouped
2056
+ * aggregate / search / geo); predicate-free reads keep their plain
2057
+ * `find` / `countDocuments` path.
2058
+ *
2059
+ * Writes (`updateMany` / `replaceMany` / `deleteMany` and the single-row
2060
+ * variants, whose scope may carry a predicate) first resolve the matching
2061
+ * `_id`s through that pipeline, then write by `_id` in batches of
2062
+ * {@link REL_WRITE_BATCH} — re-checking the predicate-free part of the
2063
+ * filter. Inside an active transaction both steps share its session and
2064
+ * are atomic; without one there is a window between resolving and writing
2065
+ * in which a related document can change, so a written document may no
2066
+ * longer satisfy the predicate (or a newly matching one is missed).
2067
+ *
2068
+ * Collation: such a pipeline runs without an operation-wide `collation`
2069
+ * (it would govern the join keys and every related field too); each
2070
+ * table's `'nocase'` fields are compared case-insensitively explicitly
2071
+ * ({@link TMongoFilterOptions.collation}) — on reads and on these writes.
2072
+ *
2073
+ * @since 0.1.147
2074
+ */
2075
+ supportsRelationFilters(_mode) {
2076
+ return true;
2077
+ }
2078
+ /**
2079
+ * The `@db.column.collate` of a field (physical or logical path), or
2080
+ * `undefined` for a byte-wise one — how predicate pipelines render this
2081
+ * table's `'nocase'` comparisons ({@link TMongoFilterOptions.collation}).
2082
+ *
2083
+ * @since 0.1.147
2084
+ */
2085
+ fieldCollation(field) {
2086
+ this._table.flatMap;
2087
+ return this._collateFields?.get(field);
2088
+ }
2089
+ /** Filter-rendering options of a pipeline with relational predicates on this table. */
2090
+ get _predicateFilterOpts() {
2091
+ return { collation: collationOfAdapter(this) };
2092
+ }
2093
+ /**
2094
+ * Operation options of a read: the request collation for a predicate-free
2095
+ * filter ({@link _getCollationOpts}); none with relational predicates —
2096
+ * those pipelines render collation per field ({@link _predicateFilterOpts}).
2097
+ */
2098
+ _readOpts(query) {
2099
+ return containsRelationFilter(query.filter) ? this._getSessionOpts() : {
2100
+ ...this._getCollationOpts(query),
2101
+ ...this._getSessionOpts()
2102
+ };
2103
+ }
1914
2104
  async loadRelations(rows, withRelations, relations, foreignKeys, tableResolver) {
1915
2105
  return loadRelationsImpl(this, rows, withRelations, relations, foreignKeys, tableResolver);
1916
2106
  }
@@ -1923,7 +2113,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1923
2113
  };
1924
2114
  }
1925
2115
  async nativePatch(filter, patch, ops, expectedVersion) {
1926
- const mongoFilter = this._buildCasFilter(filter, expectedVersion, "nativePatch");
2116
+ const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "nativePatch");
2117
+ if (!mongoFilter) return {
2118
+ matchedCount: 0,
2119
+ modifiedCount: 0
2120
+ };
1927
2121
  const versionColumn = this._table.versionColumnPhysical;
1928
2122
  const effectiveOps = versionColumn !== void 0 ? {
1929
2123
  ...ops,
@@ -1961,10 +2155,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1961
2155
  }
1962
2156
  onFieldScanned(field, _type, metadata) {
1963
2157
  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
- }
2158
+ if (field !== "_id" && metadata.has("meta.id")) this._pendingUniqueFields.push(field);
1968
2159
  for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
1969
2160
  indexName: index.indexName,
1970
2161
  field,
@@ -2031,6 +2222,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2031
2222
  };
2032
2223
  }
2033
2224
  onAfterFlatten() {
2225
+ const meta = this._table.getMetadata();
2226
+ for (const field of this._pendingUniqueFields) this._addMongoIndexField("unique", "__pk", meta.documentPath(field));
2034
2227
  for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
2035
2228
  this._pendingSearchFields = [];
2036
2229
  for (const [key, value] of this._vectorFilters.entries()) {
@@ -2120,25 +2313,12 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2120
2313
  return geoSearchWithCountImpl(this, point, query, indexName);
2121
2314
  }
2122
2315
  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,
2316
+ const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), { $facet: {
2317
+ data: pageStages(query.controls),
2135
2318
  meta: [{ $count: "count" }]
2136
2319
  } }];
2137
2320
  this._log("aggregate (findManyWithCount)", pipeline);
2138
- const result = await wrapInvalidQuery(() => this.collection.aggregate(pipeline, {
2139
- ...this._getCollationOpts(query),
2140
- ...this._getSessionOpts()
2141
- }).toArray());
2321
+ const result = await wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray());
2142
2322
  return {
2143
2323
  data: result[0]?.data || [],
2144
2324
  count: result[0]?.meta[0]?.count || 0
@@ -2168,14 +2348,94 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2168
2348
  * is supplied for a non-versioned table — fail loud at the adapter boundary
2169
2349
  * rather than silently dropping the CAS predicate.
2170
2350
  */
2171
- _buildCasFilter(filter, expectedVersion, op) {
2351
+ async _buildCasFilter(filter, expectedVersion, op) {
2172
2352
  const versionColumn = this._table.versionColumnPhysical;
2173
2353
  if (expectedVersion !== void 0 && versionColumn === void 0) throw new Error(`${op}: expectedVersion requires versionColumn`);
2174
- const mongoFilter = buildMongoFilter(filter);
2354
+ const mongoFilter = await this._writeFilterOne(filter);
2355
+ if (!mongoFilter) return;
2175
2356
  if (expectedVersion !== void 0 && versionColumn !== void 0) mongoFilter[versionColumn] = expectedVersion;
2176
2357
  return mongoFilter;
2177
2358
  }
2178
2359
  /**
2360
+ * The id-resolving pipeline of a write filter holding relational
2361
+ * predicates: the matching `_id`s (in the active transaction's session,
2362
+ * if any), each table's `'nocase'` fields compared like on reads
2363
+ * ({@link _predicateFilterOpts}). `pre` is the predicate-free part, which
2364
+ * every `_id`-based write re-checks — so a document that stopped matching
2365
+ * it in between is not written.
2366
+ */
2367
+ _predicateWritePlan(filter) {
2368
+ const plan = buildMongoQuery(filter, this._predicateFilterOpts);
2369
+ return {
2370
+ pipeline: planStages(plan),
2371
+ pre: plan.pre
2372
+ };
2373
+ }
2374
+ /** A write filter's `_id` filter (re-checking `pre`). */
2375
+ static _byIds(ids, pre) {
2376
+ const byId = ids.length === 1 ? { _id: ids[0] } : { _id: { $in: ids } };
2377
+ return pre ? { $and: [byId, pre] } : byId;
2378
+ }
2379
+ /** Single-document write over a predicate filter: the first match's `_id` filter (`undefined`: none). */
2380
+ async _predicateWriteFilterOne(filter) {
2381
+ const { pipeline, pre } = this._predicateWritePlan(filter);
2382
+ pipeline.push({ $limit: 1 }, { $project: { _id: 1 } });
2383
+ this._log("aggregate (write id)", pipeline);
2384
+ const [doc] = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
2385
+ return doc ? MongoAdapter._byIds([doc._id], pre) : void 0;
2386
+ }
2387
+ /**
2388
+ * Multi-document write over `filter`: a predicate-free filter is written
2389
+ * as is; one with predicates calls `write` with one `_id`-based filter per
2390
+ * batch of {@link REL_WRITE_BATCH} matching ids, streamed from the cursor
2391
+ * (never all ids in memory at once).
2392
+ *
2393
+ * The ids come sorted by `_id`, so writes made while the cursor is open
2394
+ * can never feed back into it (otherwise a document an update moves within
2395
+ * the index the cursor scans could be returned — and written — twice):
2396
+ * after the `$lookup`s that `$sort` consumes every match before the first
2397
+ * batch returns, and an `_id` order read from the `_id` index is stable
2398
+ * (`_id` never changes).
2399
+ */
2400
+ async _forEachMatching(filter, write) {
2401
+ if (!containsRelationFilter(filter)) return write(buildMongoFilter(filter));
2402
+ const { pipeline, pre } = this._predicateWritePlan(filter);
2403
+ pipeline.push({ $project: { _id: 1 } }, { $sort: { _id: 1 } });
2404
+ this._log("aggregate (write ids)", pipeline);
2405
+ const cursor = this.aggregatePipeline(pipeline, { allowDiskUse: true }).batchSize(REL_WRITE_BATCH);
2406
+ try {
2407
+ const next = () => wrapInvalidQuery(() => cursor.next());
2408
+ let ids = [];
2409
+ for (let doc = await next(); doc; doc = await next()) {
2410
+ ids.push(doc._id);
2411
+ if (ids.length === REL_WRITE_BATCH) {
2412
+ await write(MongoAdapter._byIds(ids, pre));
2413
+ ids = [];
2414
+ }
2415
+ }
2416
+ if (ids.length > 0) await write(MongoAdapter._byIds(ids, pre));
2417
+ } finally {
2418
+ await cursor.close();
2419
+ }
2420
+ }
2421
+ /** Runs an update-shaped write over `filter` ({@link _forEachMatching}), counts summed. */
2422
+ async _updateMatching(filter, write) {
2423
+ const total = {
2424
+ matchedCount: 0,
2425
+ modifiedCount: 0
2426
+ };
2427
+ await this._forEachMatching(filter, async (mongoFilter) => {
2428
+ const result = await write(mongoFilter);
2429
+ total.matchedCount += result.matchedCount;
2430
+ total.modifiedCount += result.modifiedCount;
2431
+ });
2432
+ return total;
2433
+ }
2434
+ /** The filter of a single-document write — its predicates resolved to one `_id`. */
2435
+ _writeFilterOne(filter) {
2436
+ return containsRelationFilter(filter) ? this._predicateWriteFilterOne(filter) : buildMongoFilter(filter);
2437
+ }
2438
+ /**
2179
2439
  * Wraps an async operation to catch MongoDB duplicate key errors
2180
2440
  * (code 11000) and rethrow as structured `DbError` — every write path
2181
2441
  * (insert, replace, update, patch, their `*Many` variants) goes through it,
@@ -2185,13 +2445,17 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2185
2445
  try {
2186
2446
  return await fn();
2187
2447
  } catch (error) {
2188
- if (error instanceof MongoServerError && error.code === 11e3) throw new DbError("CONFLICT", [{
2189
- path: error.keyPattern ? Object.keys(error.keyPattern)[0] ?? "" : "",
2190
- message: error.message
2191
- }]);
2192
- throw error;
2448
+ return this._mapConstraintError(error);
2193
2449
  }
2194
2450
  }
2451
+ /** Rethrows a duplicate-key (11000) server error as `CONFLICT`, anything else as is. */
2452
+ _mapConstraintError(error) {
2453
+ if (error instanceof MongoServerError && error.code === 11e3) throw new DbError("CONFLICT", [{
2454
+ path: error.keyPattern ? Object.keys(error.keyPattern)[0] ?? "" : "",
2455
+ message: error.message
2456
+ }]);
2457
+ throw error;
2458
+ }
2195
2459
  /** An update-shaped write under {@link _wrapDuplicateKeyError}, reduced to `TDbUpdateResult`. */
2196
2460
  async _wrapUpdate(fn) {
2197
2461
  const { matchedCount, modifiedCount } = await this._wrapDuplicateKeyError(fn);
@@ -2215,6 +2479,98 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2215
2479
  return { insertedId: objectIdFromStorage(this._resolveInsertedId(data, result.insertedId)) };
2216
2480
  }
2217
2481
  async insertMany(data) {
2482
+ await this._prepareInsertBatch(data);
2483
+ this._log("insertMany", `${data.length} docs`);
2484
+ const result = await this._wrapDuplicateKeyError(() => this.collection.insertMany(data, this._getSessionOpts()));
2485
+ return {
2486
+ insertedCount: result.insertedCount,
2487
+ insertedIds: data.map((item, i) => objectIdFromStorage(this._resolveInsertedId(item, result.insertedIds[i])))
2488
+ };
2489
+ }
2490
+ supportsInsertIgnore() {
2491
+ return true;
2492
+ }
2493
+ /**
2494
+ * Conflict-ignoring batch insert (the core already removed in-batch duplicates).
2495
+ *
2496
+ * Outside a transaction: `insertMany(…, { ordered: false })`; duplicate-key
2497
+ * write errors (11000) mark the skipped rows, every other write error
2498
+ * rethrows after the batch (rows already written stay — as for a plain
2499
+ * non-transactional `insertMany`).
2500
+ *
2501
+ * Inside a transaction a duplicate key would abort the transaction even with
2502
+ * `ordered: false`, so the stored keys are looked up first (inside the
2503
+ * session), the matches are skipped and the rest is inserted ordered. A
2504
+ * residual 11000 (e.g. a collation-equal value) throws `CONFLICT`.
2505
+ */
2506
+ async insertManyIgnore(data) {
2507
+ if (data.length === 0) return [];
2508
+ await this._prepareInsertBatch(data);
2509
+ this._log("insertManyIgnore", `${data.length} docs`);
2510
+ const idOf = (item) => objectIdFromStorage(this._resolveInsertedId(item, item._id));
2511
+ if (this._getTransactionState()) {
2512
+ const skipped = await this._findStoredKeyConflicts(data);
2513
+ const survivors = data.filter((_, i) => !skipped.has(i));
2514
+ if (survivors.length > 0) await this._wrapDuplicateKeyError(() => this.collection.insertMany(survivors, {
2515
+ ordered: true,
2516
+ ...this._getSessionOpts()
2517
+ }));
2518
+ return data.map((item, i) => skipped.has(i) ? null : { insertedId: idOf(item) });
2519
+ }
2520
+ const skipped = /* @__PURE__ */ new Set();
2521
+ try {
2522
+ await this.collection.insertMany(data, {
2523
+ ordered: false,
2524
+ ...this._getSessionOpts()
2525
+ });
2526
+ } catch (error) {
2527
+ if (!(error instanceof MongoBulkWriteError)) this._mapConstraintError(error);
2528
+ const writeErrors = [error.writeErrors].flat().filter(Boolean);
2529
+ if (writeErrors.length === 0 || hasWriteConcernError(error)) throw error;
2530
+ for (const writeError of writeErrors) {
2531
+ if (writeError.code !== 11e3) throw error;
2532
+ skipped.add(writeError.index);
2533
+ }
2534
+ }
2535
+ return data.map((item, i) => skipped.has(i) ? null : { insertedId: idOf(item) });
2536
+ }
2537
+ /**
2538
+ * Indices of `data` rows whose primary (`_id`) or unique-index key tuple is
2539
+ * already stored (rows with a null / missing key component never collide).
2540
+ * One `$or` query per chunk covers every key set.
2541
+ */
2542
+ async _findStoredKeyConflicts(data) {
2543
+ const keySets = [["_id"], ...this._table.uniqueKeySets.filter((f) => !(f.length === 1 && f[0] === "_id"))];
2544
+ const rowTuples = data.map((row) => keySets.map((fields) => uniqueKeyTuple(row, fields)));
2545
+ const projection = Object.fromEntries(keySets.flat().map((f) => [f, 1]));
2546
+ const stored = keySets.map(() => /* @__PURE__ */ new Set());
2547
+ const CHUNK = 1e3;
2548
+ for (let offset = 0; offset < data.length; offset += CHUNK) {
2549
+ const clauses = [];
2550
+ keySets.forEach((fields, k) => {
2551
+ const rows = data.slice(offset, offset + CHUNK).filter((_, i) => rowTuples[offset + i][k]);
2552
+ if (rows.length === 0) return;
2553
+ if (fields.length === 1) clauses.push({ [fields[0]]: { $in: rows.map((row) => getPath(row, fields[0])) } });
2554
+ else for (const row of rows) clauses.push(Object.fromEntries(fields.map((f) => [f, getPath(row, f)])));
2555
+ });
2556
+ if (clauses.length === 0) continue;
2557
+ const found = await this.collection.find({ $or: clauses }, {
2558
+ projection,
2559
+ ...this._getSessionOpts()
2560
+ }).toArray();
2561
+ for (const doc of found) keySets.forEach((fields, k) => {
2562
+ const tuple = uniqueKeyTuple(doc, fields);
2563
+ if (tuple !== void 0) stored[k].add(tuple);
2564
+ });
2565
+ }
2566
+ const skipped = /* @__PURE__ */ new Set();
2567
+ rowTuples.forEach((tuples, i) => {
2568
+ if (tuples.some((t, k) => t !== void 0 && stored[k].has(t))) skipped.add(i);
2569
+ });
2570
+ return skipped;
2571
+ }
2572
+ /** Version default + `@db.default.increment` allocation shared by every batch insert. */
2573
+ async _prepareInsertBatch(data) {
2218
2574
  const versionColumn = this._table.versionColumnPhysical;
2219
2575
  if (versionColumn !== void 0) {
2220
2576
  for (const item of data) if (!(versionColumn in item)) item[versionColumn] = 0;
@@ -2224,14 +2580,12 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2224
2580
  for (const item of data) for (const f of this._fieldsNeedingIncrement(item)) allFields.add(f);
2225
2581
  if (allFields.size > 0) await this._assignBatchIncrements(data, allFields);
2226
2582
  }
2227
- this._log("insertMany", `${data.length} docs`);
2228
- const result = await this._wrapDuplicateKeyError(() => this.collection.insertMany(data, this._getSessionOpts()));
2229
- return {
2230
- insertedCount: result.insertedCount,
2231
- insertedIds: data.map((item, i) => objectIdFromStorage(this._resolveInsertedId(item, result.insertedIds[i])))
2232
- };
2233
2583
  }
2234
2584
  async findOne(query) {
2585
+ if (containsRelationFilter(query.filter)) {
2586
+ const [row] = await this._aggregateFind(query, "findOne", 1);
2587
+ return row ?? null;
2588
+ }
2235
2589
  const filter = buildMongoFilter(query.filter);
2236
2590
  const opts = this._buildFindOptions(query.controls);
2237
2591
  this._log("findOne", filter, opts);
@@ -2242,6 +2596,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2242
2596
  }));
2243
2597
  }
2244
2598
  async findMany(query) {
2599
+ if (containsRelationFilter(query.filter)) return this._aggregateFind(query, "findMany");
2245
2600
  const filter = buildMongoFilter(query.filter);
2246
2601
  const opts = this._buildFindOptions(query.controls);
2247
2602
  this._log("findMany", filter, opts);
@@ -2252,6 +2607,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2252
2607
  }).toArray());
2253
2608
  }
2254
2609
  async count(query) {
2610
+ if (containsRelationFilter(query.filter)) {
2611
+ const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), { $count: "count" }];
2612
+ this._log("aggregate (count)", pipeline);
2613
+ return (await wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray()))[0]?.count ?? 0;
2614
+ }
2255
2615
  const filter = buildMongoFilter(query.filter);
2256
2616
  this._log("countDocuments", filter);
2257
2617
  return this.collection.countDocuments(filter, {
@@ -2260,13 +2620,21 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2260
2620
  });
2261
2621
  }
2262
2622
  async updateOne(filter, data, ops, expectedVersion) {
2263
- const mongoFilter = this._buildCasFilter(filter, expectedVersion, "updateOne");
2623
+ const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "updateOne");
2624
+ if (!mongoFilter) return {
2625
+ matchedCount: 0,
2626
+ modifiedCount: 0
2627
+ };
2264
2628
  const updateDoc = buildMongoUpdateDoc(data, ops, this._table.versionColumnPhysical);
2265
2629
  this._log("updateOne", mongoFilter, updateDoc);
2266
2630
  return this._wrapUpdate(() => this.collection.updateOne(mongoFilter, updateDoc, this._getSessionOpts()));
2267
2631
  }
2268
2632
  async replaceOne(filter, data, expectedVersion) {
2269
- const mongoFilter = this._buildCasFilter(filter, expectedVersion, "replaceOne");
2633
+ const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "replaceOne");
2634
+ if (!mongoFilter) return {
2635
+ matchedCount: 0,
2636
+ modifiedCount: 0
2637
+ };
2270
2638
  const versionColumn = this._table.versionColumnPhysical;
2271
2639
  this._log("replaceOne", mongoFilter, data);
2272
2640
  if (versionColumn !== void 0) {
@@ -2279,27 +2647,34 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2279
2647
  return this._wrapUpdate(() => this.collection.replaceOne(mongoFilter, data, this._getSessionOpts()));
2280
2648
  }
2281
2649
  async deleteOne(filter) {
2282
- const mongoFilter = buildMongoFilter(filter);
2650
+ const mongoFilter = await this._writeFilterOne(filter);
2651
+ if (!mongoFilter) return { deletedCount: 0 };
2283
2652
  this._log("deleteOne", mongoFilter);
2284
2653
  return { deletedCount: (await this.collection.deleteOne(mongoFilter, this._getSessionOpts())).deletedCount };
2285
2654
  }
2286
2655
  async updateMany(filter, data, ops) {
2287
2656
  const versionColumn = this._table.versionColumnPhysical;
2288
- const mongoFilter = buildMongoFilter(filter);
2289
2657
  const updateDoc = buildMongoUpdateDoc(data, ops, versionColumn);
2290
- this._log("updateMany", mongoFilter, updateDoc);
2291
- return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
2658
+ return this._updateMatching(filter, (mongoFilter) => {
2659
+ this._log("updateMany", mongoFilter, updateDoc);
2660
+ return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
2661
+ });
2292
2662
  }
2293
2663
  async replaceMany(filter, data) {
2294
- const mongoFilter = buildMongoFilter(filter);
2295
2664
  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()));
2665
+ return this._updateMatching(filter, (mongoFilter) => {
2666
+ this._log("replaceMany", mongoFilter, updateDoc);
2667
+ return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
2668
+ });
2298
2669
  }
2299
2670
  async deleteMany(filter) {
2300
- const mongoFilter = buildMongoFilter(filter);
2301
- this._log("deleteMany", mongoFilter);
2302
- return { deletedCount: (await this.collection.deleteMany(mongoFilter, this._getSessionOpts())).deletedCount };
2671
+ let deletedCount = 0;
2672
+ await this._forEachMatching(filter, async (mongoFilter) => {
2673
+ this._log("deleteMany", mongoFilter);
2674
+ const result = await this.collection.deleteMany(mongoFilter, this._getSessionOpts());
2675
+ deletedCount += result.deletedCount;
2676
+ });
2677
+ return { deletedCount };
2303
2678
  }
2304
2679
  clearCollectionCache() {
2305
2680
  this._collection = void 0;
@@ -2426,6 +2801,15 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2426
2801
  fieldCounters.set(physical, next + 1);
2427
2802
  }
2428
2803
  }
2804
+ /** A find whose filter holds relational predicates, as an aggregation pipeline. */
2805
+ async _aggregateFind(query, label, limit) {
2806
+ const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), ...pageStages(limit ? {
2807
+ ...query.controls,
2808
+ $limit: limit
2809
+ } : query.controls)];
2810
+ this._log(`aggregate (${label})`, pipeline);
2811
+ return wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray());
2812
+ }
2429
2813
  _buildFindOptions(controls) {
2430
2814
  const opts = {};
2431
2815
  if (!controls) return opts;
@@ -2442,6 +2826,9 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2442
2826
  * Returns MongoDB collation options if any filter field has a non-binary collation.
2443
2827
  * Uses pre-computed insights when available, falls back to computing them on demand.
2444
2828
  * Maps: nocase → strength 2 (case-insensitive), unicode → strength 1 (case+accent-insensitive).
2829
+ * Predicate-free filters only — a filter with relational predicates never gets an
2830
+ * operation-wide collation ({@link _readOpts}), so insights are never computed over
2831
+ * resolved predicate operands.
2445
2832
  */
2446
2833
  _getCollationOpts(query) {
2447
2834
  if (!this._collateFields) return;
@@ -2544,6 +2931,19 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2544
2931
  return node.fields;
2545
2932
  }
2546
2933
  };
2934
+ /** `$sort` → `$skip` → `$limit` → `$project` stages of a read's controls. */
2935
+ function pageStages(controls) {
2936
+ const stages = [];
2937
+ if (!controls) return stages;
2938
+ if (controls.$sort) stages.push({ $sort: controls.$sort });
2939
+ if (controls.$skip) stages.push({ $skip: controls.$skip });
2940
+ if (controls.$limit) stages.push({ $limit: controls.$limit });
2941
+ if (controls.$select) {
2942
+ const projection = controls.$select.asProjection;
2943
+ if (projection) stages.push({ $project: dedupeProjection(projection) });
2944
+ }
2945
+ return stages;
2946
+ }
2547
2947
  /**
2548
2948
  * Normalizes a declared `fuzzy` arg (`0-2`) into the query-time metadata Atlas
2549
2949
  * accepts. Atlas only honors an edit distance of `1` or `2`; `0`/undefined means
@@ -2603,7 +3003,7 @@ function buildMongoUpdateDoc(data, ops, versionColumn) {
2603
3003
  function createAdapter(connection, _options) {
2604
3004
  const client = new MongoClient(connection);
2605
3005
  const db = client.db();
2606
- return new DbSpace(() => new MongoAdapter(db, client));
3006
+ return new DbSpace(() => new MongoAdapter(db, client), { onClose: () => client.close() });
2607
3007
  }
2608
3008
  //#endregion
2609
- export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, buildMongoFilter, createAdapter, mongoIndexKey, validateMongoIdPlugin };
3009
+ export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, REL_FILTER_TEMP_PREFIX, buildMongoFilter, buildMongoQuery, collationOfAdapter, createAdapter, mongoFilterStages, mongoIndexKey, planStages, validateMongoIdPlugin };