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