@atscript/db-mongo 0.1.135 → 0.1.137

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_filter = require("./mongo-filter-Bf5rWV4P.cjs");
2
+ const require_mongo_accumulator = require("./mongo-accumulator-BalE9LkU.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");
@@ -498,7 +498,7 @@ function buildFromLookup(host, withRel, relation, tableResolver) {
498
498
  if (!targetMeta) return;
499
499
  const remoteFK = findRemoteFK(targetMeta, host._table.tableName, relation.alias);
500
500
  if (!remoteFK) return;
501
- const targetTableName = resolveRelTargetTableName(relation);
501
+ const targetTableName = (0, _atscript_db.tableNameOf)(relation.targetType());
502
502
  const innerPipeline = buildLookupInnerPipeline(withRel, remoteFK.fields);
503
503
  const { letVars, exprMatch } = buildLookupJoin(remoteFK.targetFields, remoteFK.fields, "pk_");
504
504
  const stages = [{ $lookup: {
@@ -523,8 +523,8 @@ function buildViaLookup(host, withRel, relation, tableResolver) {
523
523
  if (!junctionType) return;
524
524
  const junctionMeta = tableResolver(junctionType);
525
525
  if (!junctionMeta) return;
526
- const junctionTableName = junctionType.metadata?.get("db.table") || junctionType.id || "";
527
- const targetTableName = resolveRelTargetTableName(relation);
526
+ const junctionTableName = (0, _atscript_db.tableNameOf)(junctionType);
527
+ const targetTableName = (0, _atscript_db.tableNameOf)(relation.targetType());
528
528
  const fkToThis = findRemoteFK(junctionMeta, host._table.tableName);
529
529
  if (!fkToThis) return;
530
530
  const fkToTarget = findRemoteFK(junctionMeta, targetTableName);
@@ -565,7 +565,7 @@ function buildLookupInnerPipeline(withRel, requiredFields) {
565
565
  const limit = nested.$limit ?? flatRel.$limit;
566
566
  const skip = nested.$skip ?? flatRel.$skip;
567
567
  const select = nested.$select || flatRel.$select;
568
- if (filter && Object.keys(filter).length > 0) pipeline.push({ $match: require_mongo_filter.buildMongoFilter(filter) });
568
+ if (filter && Object.keys(filter).length > 0) pipeline.push({ $match: require_mongo_accumulator.buildMongoFilter(filter) });
569
569
  if (sort) pipeline.push({ $sort: sort });
570
570
  if (skip) pipeline.push({ $skip: skip });
571
571
  if (limit !== null && limit !== void 0) pipeline.push({ $limit: limit });
@@ -620,7 +620,7 @@ function mergeRelationResults(rows, results, primaryKeys, relMeta) {
620
620
  }
621
621
  /** Finds FK entry for a TO relation from this table's foreignKeys map. */
622
622
  function findFKForRelation(relation, foreignKeys) {
623
- const targetTableName = resolveRelTargetTableName(relation);
623
+ const targetTableName = (0, _atscript_db.tableNameOf)(relation.targetType());
624
624
  for (const fk of foreignKeys.values()) if (relation.alias) {
625
625
  if (fk.alias === relation.alias) return {
626
626
  localFields: fk.fields,
@@ -640,11 +640,6 @@ function findRemoteFK(target, thisTableName, alias) {
640
640
  if (!alias && fk.targetTable === thisTableName) return fk;
641
641
  }
642
642
  }
643
- /** Resolves the target table/collection name from a relation's target type. */
644
- function resolveRelTargetTableName(relation) {
645
- const targetType = relation.targetType();
646
- return targetType?.metadata?.get("db.table") || targetType?.id || "";
647
- }
648
643
  //#endregion
649
644
  //#region src/lib/mongo-search.ts
650
645
  /** Returns available search indexes as generic metadata for UI. */
@@ -752,7 +747,7 @@ function buildGeoNearStage(host, point, query, indexName) {
752
747
  distanceField: DISTANCE_FIELD,
753
748
  spherical: true,
754
749
  key: resolveGeoKeyPath(host, indexName),
755
- query: require_mongo_filter.buildMongoFilter(query.filter)
750
+ query: require_mongo_accumulator.buildMongoFilter(query.filter)
756
751
  };
757
752
  if (typeof controls.$maxDistance === "number") geoNear.maxDistance = controls.$maxDistance;
758
753
  if (typeof controls.$minDistance === "number") geoNear.minDistance = controls.$minDistance;
@@ -965,7 +960,7 @@ function buildVectorSearchStage(host, vector, indexName, limit) {
965
960
  }
966
961
  /** Runs a search/vector pipeline and returns results. Shared by search + vectorSearch. */
967
962
  async function runSearchPipeline(host, stage, query, label, threshold, classicText = false) {
968
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
963
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
969
964
  const controls = query.controls || {};
970
965
  const pipeline = [stage];
971
966
  if (threshold !== void 0) {
@@ -987,7 +982,7 @@ async function runSearchPipeline(host, stage, query, label, threshold, classicTe
987
982
  }
988
983
  /** Runs a search/vector pipeline with $facet for count. Shared by searchWithCount + vectorSearchWithCount. */
989
984
  async function runSearchWithCountPipeline(host, stage, query, label, threshold, classicText = false) {
990
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
985
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
991
986
  const controls = query.controls || {};
992
987
  const preStages = [];
993
988
  if (threshold !== void 0) {
@@ -1020,6 +1015,175 @@ async function runSearchWithCountPipeline(host, stage, query, label, threshold,
1020
1015
  };
1021
1016
  }
1022
1017
  //#endregion
1018
+ //#region src/lib/mongo-view-pipeline.ts
1019
+ /** Where a joined table's document lands (`$lookup.as`, then unwound). */
1020
+ function joinedAs(table) {
1021
+ return `${JOINED_PREFIX}${table}`;
1022
+ }
1023
+ /**
1024
+ * Builds the aggregation pipeline of a managed MongoDB view (created with
1025
+ * `viewOn: <entry collection>`):
1026
+ *
1027
+ * 1. per join, in declaration order: `$lookup` + `$unwind` (inner joins drop
1028
+ * unmatched documents, left joins keep them — `preserveNullAndEmptyArrays`);
1029
+ * 2. `@db.view.filter` → `$match`;
1030
+ * 3. aggregates → `$group` (+ `$addFields` turning `countDistinct` sets into
1031
+ * their sizes, + `@db.view.having` → `$match`) + `$project`, otherwise a
1032
+ * flat `$project`. A conditional aggregate's predicate reads the same
1033
+ * pipeline paths (SQL null semantics — `queryNodeToExpr`).
1034
+ *
1035
+ * All field paths are PHYSICAL document paths (`@db.column` renames), joined
1036
+ * tables live under `__joined_<table>`. A column whose source may be missing
1037
+ * (`nullable` mapping) reads as null; every other column is a plain path, so
1038
+ * a `$match` / `$sort` on the view can still push down to an index.
1039
+ * @since 0.1.136
1040
+ */
1041
+ function buildViewPipeline(view) {
1042
+ const plan = view.viewPlan;
1043
+ const columns = view.getViewColumnMappings();
1044
+ const pipeline = [];
1045
+ const prefixes = new Map([[plan.entryTable, ""]]);
1046
+ const pathOf = (ref) => {
1047
+ const { table, source } = view.resolveRefSource(ref);
1048
+ const prefix = prefixes.get(table);
1049
+ if (prefix === void 0) throw new Error(`View "${view.tableName}": "${table}" is not joined before it is referenced`);
1050
+ return prefix + source.column;
1051
+ };
1052
+ for (const join of plan.joins) {
1053
+ pipeline.push(buildLookup(view, join, prefixes));
1054
+ pipeline.push({ $unwind: {
1055
+ path: `$${joinedAs(join.targetTable)}`,
1056
+ preserveNullAndEmptyArrays: join.kind === "left"
1057
+ } });
1058
+ prefixes.set(join.targetTable, `${joinedAs(join.targetTable)}.`);
1059
+ }
1060
+ if (plan.filter) pipeline.push({ $match: viewMatch(plan.filter, pathOf) });
1061
+ /** A column's source field as an aggregation operand. */
1062
+ const colSourceField = (col) => `$${prefixes.get(col.sourceTable) ?? ""}${col.sourceColumn}`;
1063
+ const colValue = (col) => col.nullable ? require_mongo_accumulator.orNull(colSourceField(col)) : colSourceField(col);
1064
+ if (columns.some((c) => c.aggFn)) {
1065
+ const group = { _id: {} };
1066
+ const project = { _id: 0 };
1067
+ const distinctSizes = {};
1068
+ for (const col of columns) if (col.aggFn) {
1069
+ const predicate = col.aggFilter ? require_mongo_accumulator.queryNodeToExpr(col.aggFilter, (ref) => `$${pathOf(ref)}`) : void 0;
1070
+ group[col.viewColumn] = require_mongo_accumulator.buildAccumulator(col.aggFn, col.aggField === "*" ? "*" : colSourceField(col), predicate, col.viewColumn);
1071
+ project[col.viewColumn] = `$${col.viewColumn}`;
1072
+ if (col.aggFn === "countDistinct") distinctSizes[col.viewColumn] = require_mongo_accumulator.distinctCountExpr(`$${col.viewColumn}`);
1073
+ } else {
1074
+ group._id[col.viewColumn] = colValue(col);
1075
+ project[col.viewColumn] = `$_id.${col.viewColumn}`;
1076
+ }
1077
+ pipeline.push({ $group: group });
1078
+ if (Object.keys(distinctSizes).length > 0) pipeline.push({ $addFields: distinctSizes });
1079
+ if (plan.having) pipeline.push({ $match: havingMatch(plan.having, columns) });
1080
+ pipeline.push({ $project: project });
1081
+ } else {
1082
+ const project = { _id: 0 };
1083
+ for (const col of columns) project[col.viewColumn] = colValue(col);
1084
+ pipeline.push({ $project: project });
1085
+ }
1086
+ return pipeline;
1087
+ }
1088
+ /**
1089
+ * One join's `$lookup`.
1090
+ *
1091
+ * The simple `localField` / `foreignField` form is used whenever it is exact:
1092
+ * a single `=` between a field of the joined table and a field of an outer
1093
+ * table (the entry or an earlier join), with the joined field REQUIRED — the
1094
+ * simple form matches a null / missing local value to null / missing foreign
1095
+ * values, which a required foreign field can never hold. It is kept (rather
1096
+ * than always using the pipeline form) because it can use an index on the
1097
+ * foreign field on every MongoDB version; the pipeline form's `$expr` match
1098
+ * can't before MongoDB 5.0. Everything else uses the pipeline form (`let` +
1099
+ * `$match: { $expr }`, SQL null semantics).
1100
+ */
1101
+ function buildLookup(view, join, outer) {
1102
+ const as = joinedAs(join.targetTable);
1103
+ const simple = simpleJoinFields(view, join, outer);
1104
+ if (simple) return { $lookup: {
1105
+ from: join.targetTable,
1106
+ ...simple,
1107
+ as
1108
+ } };
1109
+ const letVars = {};
1110
+ const varByPath = /* @__PURE__ */ new Map();
1111
+ const pathOf = (ref) => {
1112
+ const { table, source } = view.resolveRefSource(ref);
1113
+ if (table === join.targetTable) return `$${source.column}`;
1114
+ const prefix = outer.get(table);
1115
+ if (prefix === void 0) throw new Error(`View "${view.tableName}": the join on "${join.targetTable}" references "${table}", which is not joined before it`);
1116
+ const outerPath = prefix + source.column;
1117
+ let varName = varByPath.get(outerPath);
1118
+ if (!varName) {
1119
+ varName = `v${varByPath.size}`;
1120
+ varByPath.set(outerPath, varName);
1121
+ letVars[varName] = `$${outerPath}`;
1122
+ }
1123
+ return `$$${varName}`;
1124
+ };
1125
+ const expr = require_mongo_accumulator.queryNodeToExpr(join.condition, pathOf);
1126
+ return { $lookup: {
1127
+ from: join.targetTable,
1128
+ ...varByPath.size > 0 ? { let: letVars } : {},
1129
+ pipeline: [{ $match: { $expr: expr } }],
1130
+ as
1131
+ } };
1132
+ }
1133
+ /** `{ localField, foreignField }` when the simple `$lookup` form is exact, else `undefined`. */
1134
+ function simpleJoinFields(view, join, outer) {
1135
+ let node = join.condition;
1136
+ if ("$and" in node) {
1137
+ const children = node.$and;
1138
+ if (children.length !== 1) return void 0;
1139
+ node = children[0];
1140
+ }
1141
+ if ("$or" in node || "$not" in node || "$and" in node) return void 0;
1142
+ const comp = node;
1143
+ if (comp.op !== "$eq" || !(0, _atscript_db.isFieldRef)(comp.right)) return void 0;
1144
+ const left = view.resolveRefSource(comp.left);
1145
+ const right = view.resolveRefSource(comp.right);
1146
+ let target;
1147
+ let local;
1148
+ if (left.table === join.targetTable && right.table !== join.targetTable) {
1149
+ target = left;
1150
+ local = right;
1151
+ } else if (right.table === join.targetTable && left.table !== join.targetTable) {
1152
+ target = right;
1153
+ local = left;
1154
+ } else return;
1155
+ const localPrefix = outer.get(local.table);
1156
+ if (localPrefix === void 0) return void 0;
1157
+ const foreign = target.source;
1158
+ if (!(!foreign.optional && foreign.designType !== "union" && foreign.designType !== "unknown")) return void 0;
1159
+ return {
1160
+ localField: localPrefix + local.source.column,
1161
+ foreignField: foreign.column
1162
+ };
1163
+ }
1164
+ /**
1165
+ * A view predicate (`@db.view.filter`, `@db.view.having`) as a `$match`
1166
+ * over pipeline paths: the shared query translation (`translateQueryTree` →
1167
+ * `buildMongoFilter`), so operators mean what they mean in queries (`exists`
1168
+ * = holds a value, `matches` takes `/re/flags`); a field-to-field comparison
1169
+ * becomes `$expr`.
1170
+ */
1171
+ function viewMatch(node, pathOf) {
1172
+ return require_mongo_accumulator.buildMongoFilter((0, _atscript_db.translateQueryTree)(node, pathOf), { fieldOperands: true });
1173
+ }
1174
+ /**
1175
+ * `@db.view.having` as a `$match` after `$group`: refs name view fields —
1176
+ * aggregates are top-level, dimensions under `_id`.
1177
+ */
1178
+ function havingMatch(node, columns) {
1179
+ const colMap = new Map(columns.map((c) => [c.viewPath, c]));
1180
+ return viewMatch(node, (ref) => {
1181
+ const col = ref.type ? void 0 : colMap.get(ref.field);
1182
+ if (!col) return ref.field;
1183
+ return col.aggFn ? col.viewColumn : `_id.${col.viewColumn}`;
1184
+ });
1185
+ }
1186
+ //#endregion
1023
1187
  //#region src/lib/mongo-schema-sync.ts
1024
1188
  const DESTRUCTIVE_OPTION_KEYS = new Set([
1025
1189
  "capped",
@@ -1080,134 +1244,17 @@ async function getObjectKindImpl(host, name) {
1080
1244
  if (type === void 0) return;
1081
1245
  return type === "view" ? "view" : "table";
1082
1246
  }
1083
- /** Creates a MongoDB view from the AtscriptDbView's view plan. */
1247
+ /** Creates a MongoDB view from the AtscriptDbView's view plan (pipeline: {@link buildViewPipeline}). */
1084
1248
  async function ensureView(host, view) {
1085
1249
  if (await host.collectionExists()) return;
1086
1250
  const plan = view.viewPlan;
1087
- const columns = view.getViewColumnMappings();
1088
- const pipeline = [];
1089
- for (const join of plan.joins) {
1090
- const { localField, foreignField } = resolveJoinFields(join.condition, plan.entryTable, join.targetTable);
1091
- pipeline.push({ $lookup: {
1092
- from: join.targetTable,
1093
- localField,
1094
- foreignField,
1095
- as: `${JOINED_PREFIX}${join.targetTable}`
1096
- } });
1097
- pipeline.push({ $unwind: {
1098
- path: `$__joined_${join.targetTable}`,
1099
- preserveNullAndEmptyArrays: true
1100
- } });
1101
- }
1102
- if (plan.filter) {
1103
- const matchExpr = queryNodeToMatch(plan.filter, plan.entryTable);
1104
- pipeline.push({ $match: matchExpr });
1105
- }
1106
- const hasAggregates = columns.some((c) => c.aggFn);
1107
- /** Resolves a column to its MongoDB source field path. */
1108
- const colSourceField = (col) => col.sourceTable === plan.entryTable ? `$${col.sourceColumn}` : `$${JOINED_PREFIX}${col.sourceTable}.${col.sourceColumn}`;
1109
- if (hasAggregates) {
1110
- const group = { _id: {} };
1111
- const project = { _id: 0 };
1112
- for (const col of columns) if (col.aggFn) {
1113
- if (col.aggFn === "count" && col.aggField === "*") group[col.viewColumn] = { $sum: 1 };
1114
- else group[col.viewColumn] = { [`$${col.aggFn}`]: colSourceField(col) };
1115
- project[col.viewColumn] = `$${col.viewColumn}`;
1116
- } else {
1117
- group._id[col.viewColumn] = colSourceField(col);
1118
- project[col.viewColumn] = `$_id.${col.viewColumn}`;
1119
- }
1120
- pipeline.push({ $group: group });
1121
- if (plan.having) {
1122
- const havingMatch = queryNodeToHaving(plan.having, columns);
1123
- pipeline.push({ $match: havingMatch });
1124
- }
1125
- pipeline.push({ $project: project });
1126
- } else {
1127
- const project = { _id: 0 };
1128
- for (const col of columns) project[col.viewColumn] = colSourceField(col);
1129
- pipeline.push({ $project: project });
1130
- }
1251
+ const pipeline = buildViewPipeline(view);
1131
1252
  host._log("createView", host._table.tableName, plan.entryTable, pipeline);
1132
1253
  await host.db.createCollection(host._table.tableName, {
1133
1254
  viewOn: plan.entryTable,
1134
1255
  pipeline
1135
1256
  });
1136
1257
  }
1137
- /** Extracts localField/foreignField from a join condition. */
1138
- function resolveJoinFields(condition, entryTable, joinTable) {
1139
- const c = "$and" in condition ? condition.$and[0] : condition;
1140
- if ((c.left.type ? c.left.type()?.metadata?.get("db.table") || "" : entryTable) === joinTable) return {
1141
- localField: c.right.field,
1142
- foreignField: c.left.field
1143
- };
1144
- return {
1145
- localField: c.left.field,
1146
- foreignField: c.right.field
1147
- };
1148
- }
1149
- /** Translates an AtscriptQueryNode to a MongoDB $match expression. */
1150
- function queryNodeToMatch(node, entryTable) {
1151
- if ("$and" in node) {
1152
- const items = node.$and;
1153
- const result = [];
1154
- for (const item of items) result.push(queryNodeToMatch(item, entryTable));
1155
- return { $and: result };
1156
- }
1157
- if ("$or" in node) {
1158
- const items = node.$or;
1159
- const result = [];
1160
- for (const item of items) result.push(queryNodeToMatch(item, entryTable));
1161
- return { $or: result };
1162
- }
1163
- if ("$not" in node) return { $not: queryNodeToMatch(node.$not, entryTable) };
1164
- const comp = node;
1165
- const fieldPath = resolveViewFieldPath(comp.left, entryTable);
1166
- if (comp.right && typeof comp.right === "object" && "field" in comp.right) {
1167
- const rightPath = resolveViewFieldPath(comp.right, entryTable);
1168
- return { $expr: { [comp.op]: [`$${fieldPath}`, `$${rightPath}`] } };
1169
- }
1170
- if (comp.op === "$eq") return { [fieldPath]: comp.right };
1171
- if (comp.op === "$ne") return { [fieldPath]: { $ne: comp.right } };
1172
- return { [fieldPath]: { [comp.op]: comp.right } };
1173
- }
1174
- /** Resolves a field ref to a MongoDB dot path for view pipeline expressions. */
1175
- function resolveViewFieldPath(ref, entryTable) {
1176
- if (!ref.type) return ref.field;
1177
- const table = ref.type()?.metadata?.get("db.table") || "";
1178
- if (table === entryTable) return ref.field;
1179
- return `${JOINED_PREFIX}${table}.${ref.field}`;
1180
- }
1181
- /**
1182
- * Translates an AtscriptQueryNode to a MongoDB $match for use after $group (HAVING).
1183
- * After $group, aggregate fields are top-level and dimension fields are under _id.
1184
- */
1185
- function queryNodeToHaving(node, columns) {
1186
- const colMap = new Map(columns.map((c) => [c.viewColumn, c]));
1187
- const resolveHavingField = (ref) => {
1188
- if (!ref.type) {
1189
- const col = colMap.get(ref.field);
1190
- if (col?.aggFn) return ref.field;
1191
- if (col) return `_id.${ref.field}`;
1192
- }
1193
- return ref.field;
1194
- };
1195
- return queryNodeToHavingInner(node, resolveHavingField);
1196
- }
1197
- function queryNodeToHavingInner(node, resolveField) {
1198
- if ("$and" in node) return { $and: node.$and.map((n) => queryNodeToHavingInner(n, resolveField)) };
1199
- if ("$or" in node) return { $or: node.$or.map((n) => queryNodeToHavingInner(n, resolveField)) };
1200
- if ("$not" in node) return { $not: queryNodeToHavingInner(node.$not, resolveField) };
1201
- const comp = node;
1202
- const fieldPath = resolveField(comp.left);
1203
- if (comp.right && typeof comp.right === "object" && "field" in comp.right) {
1204
- const rightPath = resolveField(comp.right);
1205
- return { $expr: { [comp.op]: [`$${fieldPath}`, `$${rightPath}`] } };
1206
- }
1207
- if (comp.op === "$eq") return { [fieldPath]: comp.right };
1208
- if (comp.op === "$ne") return { [fieldPath]: { $ne: comp.right } };
1209
- return { [fieldPath]: { [comp.op]: comp.right } };
1210
- }
1211
1258
  async function dropTableImpl(host) {
1212
1259
  host._log("drop", host._table.tableName);
1213
1260
  await host.collection.drop();
@@ -1250,65 +1297,75 @@ async function renameTableImpl(host, oldName) {
1250
1297
  async function syncColumnsImpl(host, diff) {
1251
1298
  const renamed = [];
1252
1299
  const added = [];
1253
- const update = {};
1254
- if (diff.renamed.length > 0) {
1255
- const renameSpec = {};
1256
- for (const r of diff.renamed) {
1257
- if (pathCrossesArray(host, r.field.path)) {
1258
- host._log("syncColumns: skipping $rename for array-element field", r.oldName, "→", r.field.physicalName, "(Mongo $rename cannot traverse arrays)");
1259
- continue;
1260
- }
1261
- renameSpec[r.oldName] = r.field.physicalName;
1262
- renamed.push(r.field.physicalName);
1263
- }
1264
- if (Object.keys(renameSpec).length > 0) update.$rename = renameSpec;
1265
- }
1266
- if (diff.added.length > 0) {
1267
- const setSpec = {};
1268
- for (const field of diff.added) {
1269
- const defaultVal = resolveSyncDefault(field);
1270
- if (defaultVal !== void 0) setSpec[arraySafePath(host, field.physicalName, field.path)] = defaultVal;
1271
- added.push(field.physicalName);
1300
+ const renameSpec = {};
1301
+ for (const r of diff.renamed) {
1302
+ if (pathCrossesArray(host, r.field.path)) {
1303
+ host._log("syncColumns: skipping $rename for array-element field", r.oldName, "→", r.field.physicalName, "(Mongo $rename cannot traverse arrays)");
1304
+ continue;
1272
1305
  }
1273
- if (Object.keys(setSpec).length > 0) update.$set = setSpec;
1306
+ renameSpec[r.oldName] = r.field.physicalName;
1307
+ renamed.push(r.field.physicalName);
1308
+ }
1309
+ if (renamed.length > 0) await host.collection.updateMany({}, { $rename: renameSpec }, host._getSessionOpts());
1310
+ for (const field of diff.added) {
1311
+ const value = resolveSyncDefault(field);
1312
+ if (value !== void 0) await defaultBackfill(host, field.path, value);
1313
+ added.push(field.physicalName);
1274
1314
  }
1275
- if (Object.keys(update).length > 0) await host.collection.updateMany({}, update, host._getSessionOpts());
1276
1315
  return {
1277
1316
  added,
1278
1317
  renamed
1279
1318
  };
1280
1319
  }
1320
+ /**
1321
+ * Backfills an added field's `@db.default` literal where the field is
1322
+ * missing — a stored value is never overwritten. Optional fields without a
1323
+ * default get no backfill (absent = "optional" in Mongo). A path crossing an
1324
+ * array narrows the innermost `$[]` to an `arrayFilters` element missing the
1325
+ * sub-path; outer arrays keep `$[]`, so empty arrays are a no-op (a bare
1326
+ * dotted path into an empty array is rejected with code 28).
1327
+ */
1328
+ async function defaultBackfill(host, logicalPath, value) {
1329
+ const segments = positionalSegments(host, logicalPath);
1330
+ const inner = segments.lastIndexOf("$[]");
1331
+ const opts = host._getSessionOpts();
1332
+ if (inner === -1) {
1333
+ const path = segments.join(".");
1334
+ await host.collection.updateMany({ [path]: { $exists: false } }, { $set: { [path]: value } }, opts);
1335
+ return;
1336
+ }
1337
+ segments[inner] = "$[backfill]";
1338
+ const rest = segments.slice(inner + 1).join(".");
1339
+ await host.collection.updateMany({}, { $set: { [segments.join(".")]: value } }, {
1340
+ ...opts,
1341
+ arrayFilters: [{ [`backfill.${rest}`]: { $exists: false } }]
1342
+ });
1343
+ }
1281
1344
  async function dropColumnsImpl(host, columns) {
1282
1345
  if (columns.length === 0) return;
1283
1346
  const dropped = new Set(columns);
1284
1347
  const minimal = columns.filter((col) => !hasAncestorIn(col, dropped));
1285
1348
  const unsetSpec = {};
1286
- for (const col of minimal) unsetSpec[arraySafePath(host, col, col)] = "";
1349
+ for (const col of minimal) unsetSpec[positionalSegments(host, col, col).join(".")] = "";
1287
1350
  await host.collection.updateMany({}, { $unset: unsetSpec }, host._getSessionOpts());
1288
1351
  }
1289
1352
  /**
1290
- * Rewrites a dotted physical path to use Mongo's all-positional $[] operator
1291
- * at every segment that's typed as an array in the table's flatMap. Returns
1292
- * the input unchanged when no segment crosses an array boundary.
1293
- *
1294
- * `logicalPath` drives the array-boundary walk (flatMap is keyed by logical
1295
- * path); the leaf of `physicalPath` is preserved so any `@db.column` rename
1296
- * on the leaf still applies.
1353
+ * The segments of a field's physical document path with `$[]` after every
1354
+ * segment whose logical prefix is an array in the flatMap — Mongo's
1355
+ * all-positional operator walks every element. `physicalPath` defaults to
1356
+ * the logical path's document path (same segment count).
1297
1357
  */
1298
- function arraySafePath(host, physicalPath, logicalPath) {
1299
- const logicalSegments = logicalPath.split(".");
1300
- if (logicalSegments.length < 2) return physicalPath;
1301
- const physicalSegments = physicalPath.split(".");
1302
- const physicalLeaf = physicalSegments[physicalSegments.length - 1];
1358
+ function positionalSegments(host, logicalPath, physicalPath = host._table.getMetadata().documentPath(logicalPath)) {
1359
+ const logical = logicalPath.split(".");
1360
+ const physical = physicalPath.split(".");
1303
1361
  const out = [];
1304
1362
  let prefix = "";
1305
- for (let i = 0; i < logicalSegments.length; i++) {
1306
- const isLeaf = i === logicalSegments.length - 1;
1307
- out.push(isLeaf ? physicalLeaf : logicalSegments[i]);
1308
- prefix = joinPath(prefix, logicalSegments[i]);
1309
- if (!isLeaf && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
1363
+ for (let i = 0; i < logical.length; i++) {
1364
+ out.push(physical[i]);
1365
+ prefix = joinPath(prefix, logical[i]);
1366
+ if (i < logical.length - 1 && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
1310
1367
  }
1311
- return out.join(".");
1368
+ return out;
1312
1369
  }
1313
1370
  /** Returns true if any non-leaf segment of the path is typed as an array. */
1314
1371
  function pathCrossesArray(host, logicalPath) {
@@ -1767,6 +1824,17 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1767
1824
  calendarBucketUnits() {
1768
1825
  return _atscript_db.ALL_BUCKET_UNITS;
1769
1826
  }
1827
+ /** Every aggregate function, `countDistinct` included. */
1828
+ aggregateFns() {
1829
+ return _atscript_db.ALL_AGGREGATE_FNS;
1830
+ }
1831
+ /**
1832
+ * See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
1833
+ * BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
1834
+ */
1835
+ viewRenderRevision() {
1836
+ return "2";
1837
+ }
1770
1838
  getValidatorPlugins() {
1771
1839
  return [validateMongoIdPlugin];
1772
1840
  }
@@ -1875,11 +1943,6 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1875
1943
  this._addMongoIndexField("unique", "__pk", field);
1876
1944
  this._pendingUniqueFields.push(field);
1877
1945
  }
1878
- if (metadata.has("db.default.increment")) {
1879
- const physicalName = metadata.get("db.column") ?? field;
1880
- const startValue = metadata.get("db.default.increment");
1881
- this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
1882
- }
1883
1946
  for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
1884
1947
  indexName: index.indexName,
1885
1948
  field,
@@ -1955,9 +2018,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1955
2018
  path: value
1956
2019
  });
1957
2020
  }
1958
- for (const fd of this._table.fieldDescriptors) if (fd.collate && fd.collate !== "binary") {
1959
- if (!this._collateFields) this._collateFields = /* @__PURE__ */ new Map();
1960
- this._collateFields.set(fd.physicalName, fd.collate);
2021
+ for (const fd of this._table.fieldDescriptors) {
2022
+ if (fd.collate && fd.collate !== "binary") {
2023
+ this._collateFields ??= /* @__PURE__ */ new Map();
2024
+ this._collateFields.set(fd.path, fd.collate);
2025
+ this._collateFields.set(fd.physicalName, fd.collate);
2026
+ }
2027
+ const def = fd.defaultValue;
2028
+ if (def?.kind === "fn" && def.fn === "increment") this._incrementFields.set(fd.physicalName, def.start);
1961
2029
  }
1962
2030
  }
1963
2031
  /** Returns MongoDB-specific search index map (internal). */
@@ -2030,7 +2098,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2030
2098
  return geoSearchWithCountImpl(this, point, query, indexName);
2031
2099
  }
2032
2100
  async findManyWithCount(query) {
2033
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
2101
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
2034
2102
  const controls = query.controls || {};
2035
2103
  const dataStages = [];
2036
2104
  if (controls.$sort) dataStages.push({ $sort: controls.$sort });
@@ -2077,7 +2145,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2077
2145
  _buildCasFilter(filter, expectedVersion, op) {
2078
2146
  const versionColumn = this._table.versionColumn;
2079
2147
  if (expectedVersion !== void 0 && versionColumn === void 0) throw new Error(`${op}: expectedVersion requires versionColumn`);
2080
- const mongoFilter = require_mongo_filter.buildMongoFilter(filter);
2148
+ const mongoFilter = require_mongo_accumulator.buildMongoFilter(filter);
2081
2149
  if (expectedVersion !== void 0 && versionColumn !== void 0) mongoFilter[versionColumn] = expectedVersion;
2082
2150
  return mongoFilter;
2083
2151
  }
@@ -2128,7 +2196,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2128
2196
  };
2129
2197
  }
2130
2198
  async findOne(query) {
2131
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
2199
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
2132
2200
  const opts = this._buildFindOptions(query.controls);
2133
2201
  this._log("findOne", filter, opts);
2134
2202
  return wrapInvalidQuery(() => this.collection.findOne(filter, {
@@ -2138,7 +2206,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2138
2206
  }));
2139
2207
  }
2140
2208
  async findMany(query) {
2141
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
2209
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
2142
2210
  const opts = this._buildFindOptions(query.controls);
2143
2211
  this._log("findMany", filter, opts);
2144
2212
  return wrapInvalidQuery(() => this.collection.find(filter, {
@@ -2148,7 +2216,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2148
2216
  }).toArray());
2149
2217
  }
2150
2218
  async count(query) {
2151
- const filter = require_mongo_filter.buildMongoFilter(query.filter);
2219
+ const filter = require_mongo_accumulator.buildMongoFilter(query.filter);
2152
2220
  this._log("countDocuments", filter);
2153
2221
  return this.collection.countDocuments(filter, {
2154
2222
  ...this._getCollationOpts(query),
@@ -2183,13 +2251,13 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2183
2251
  };
2184
2252
  }
2185
2253
  async deleteOne(filter) {
2186
- const mongoFilter = require_mongo_filter.buildMongoFilter(filter);
2254
+ const mongoFilter = require_mongo_accumulator.buildMongoFilter(filter);
2187
2255
  this._log("deleteOne", mongoFilter);
2188
2256
  return { deletedCount: (await this.collection.deleteOne(mongoFilter, this._getSessionOpts())).deletedCount };
2189
2257
  }
2190
2258
  async updateMany(filter, data, ops) {
2191
2259
  const versionColumn = this._table.versionColumn;
2192
- const mongoFilter = require_mongo_filter.buildMongoFilter(filter);
2260
+ const mongoFilter = require_mongo_accumulator.buildMongoFilter(filter);
2193
2261
  const updateDoc = buildMongoUpdateDoc(data, ops, versionColumn);
2194
2262
  this._log("updateMany", mongoFilter, updateDoc);
2195
2263
  const result = await this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts());
@@ -2199,7 +2267,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2199
2267
  };
2200
2268
  }
2201
2269
  async replaceMany(filter, data) {
2202
- const mongoFilter = require_mongo_filter.buildMongoFilter(filter);
2270
+ const mongoFilter = require_mongo_accumulator.buildMongoFilter(filter);
2203
2271
  const updateDoc = buildMongoUpdateDoc(data, void 0, this._table.versionColumn);
2204
2272
  this._log("replaceMany", mongoFilter, updateDoc);
2205
2273
  const result = await this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts());
@@ -2209,7 +2277,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2209
2277
  };
2210
2278
  }
2211
2279
  async deleteMany(filter) {
2212
- const mongoFilter = require_mongo_filter.buildMongoFilter(filter);
2280
+ const mongoFilter = require_mongo_accumulator.buildMongoFilter(filter);
2213
2281
  this._log("deleteMany", mongoFilter);
2214
2282
  return { deletedCount: (await this.collection.deleteMany(mongoFilter, this._getSessionOpts())).deletedCount };
2215
2283
  }
@@ -2519,7 +2587,7 @@ function createAdapter(connection, _options) {
2519
2587
  exports.CollectionPatcher = CollectionPatcher;
2520
2588
  exports.INDEX_PREFIX = INDEX_PREFIX;
2521
2589
  exports.MongoAdapter = MongoAdapter;
2522
- exports.buildMongoFilter = require_mongo_filter.buildMongoFilter;
2590
+ exports.buildMongoFilter = require_mongo_accumulator.buildMongoFilter;
2523
2591
  exports.createAdapter = createAdapter;
2524
2592
  exports.mongoIndexKey = mongoIndexKey;
2525
2593
  exports.validateMongoIdPlugin = validateMongoIdPlugin;