@atscript/mongo 0.1.34 → 0.1.36

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
@@ -24,16 +24,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  //#endregion
25
25
  const __atscript_utils_db = __toESM(require("@atscript/utils-db"));
26
26
  const mongodb = __toESM(require("mongodb"));
27
- const __atscript_typescript_utils = __toESM(require("@atscript/typescript/utils"));
28
27
 
29
- //#region packages/mongo/src/lib/validate-plugins.ts
30
- const validateMongoIdPlugin = (ctx, def, value) => {
31
- if (ctx.path === "_id" && def.type.tags.has("objectId")) return ctx.validateAnnotatedType(def, value instanceof mongodb.ObjectId ? value.toString() : value);
32
- };
33
-
34
- //#endregion
35
28
  //#region packages/mongo/src/lib/collection-patcher.ts
36
- function _define_property$2(obj, key, value) {
29
+ function _define_property$1(obj, key, value) {
37
30
  if (key in obj) Object.defineProperty(obj, key, {
38
31
  value,
39
32
  enumerable: true,
@@ -43,50 +36,7 @@ function _define_property$2(obj, key, value) {
43
36
  else obj[key] = value;
44
37
  return obj;
45
38
  }
46
- var CollectionPatcher = class CollectionPatcher {
47
- /**
48
- * Build a runtime *Validator* that understands the extended patch payload.
49
- *
50
- * * Adds per‑array *patch* wrappers (the `$replace`, `$insert`, … fields).
51
- * * Honors `db.patch.strategy === "merge"` metadata.
52
- *
53
- * @param collection Target collection wrapper
54
- * @returns Atscript Validator
55
- */ static prepareValidator(context) {
56
- return context.createValidator({
57
- plugins: [validateMongoIdPlugin],
58
- replace: (def, path) => {
59
- if (path === "" && def.type.kind === "object") {
60
- const obj = (0, __atscript_typescript_utils.defineAnnotatedType)("object").copyMetadata(def.metadata);
61
- for (const [prop, type] of def.type.props.entries()) obj.prop(prop, (0, __atscript_typescript_utils.defineAnnotatedType)().refTo(type).copyMetadata(type.metadata).optional(prop !== "_id").$type);
62
- return obj.$type;
63
- }
64
- if (def.type.kind === "array" && context.flatMap.get(path)?.metadata.get("db.mongo.__topLevelArray") && !def.metadata.has("db.mongo.__patchArrayValue")) {
65
- const defArray = def;
66
- const mergeStrategy = defArray.metadata.get("db.patch.strategy") === "merge";
67
- function getPatchType() {
68
- const isPrimitive = (0, __atscript_typescript_utils.isAnnotatedTypeOfPrimitive)(defArray.type.of);
69
- if (isPrimitive) return (0, __atscript_typescript_utils.defineAnnotatedType)().refTo(def).copyMetadata(def.metadata).annotate("db.mongo.__patchArrayValue").optional().$type;
70
- if (defArray.type.of.type.kind === "object") {
71
- const objType = defArray.type.of.type;
72
- const t = (0, __atscript_typescript_utils.defineAnnotatedType)("object").copyMetadata(defArray.type.of.metadata);
73
- const keyProps = CollectionPatcher.getKeyProps(defArray);
74
- for (const [key, val] of objType.props.entries()) if (keyProps.size > 0) if (keyProps.has(key)) t.prop(key, (0, __atscript_typescript_utils.defineAnnotatedType)().refTo(val).copyMetadata(def.metadata).$type);
75
- else t.prop(key, (0, __atscript_typescript_utils.defineAnnotatedType)().refTo(val).copyMetadata(def.metadata).optional().$type);
76
- else t.prop(key, (0, __atscript_typescript_utils.defineAnnotatedType)().refTo(val).copyMetadata(def.metadata).optional(!!val.optional).$type);
77
- return (0, __atscript_typescript_utils.defineAnnotatedType)("array").of(t.$type).copyMetadata(def.metadata).annotate("db.mongo.__patchArrayValue").optional().$type;
78
- }
79
- return undefined;
80
- }
81
- const fullType = (0, __atscript_typescript_utils.defineAnnotatedType)().refTo(def).copyMetadata(def.metadata).annotate("db.mongo.__patchArrayValue").optional().$type;
82
- const patchType = getPatchType();
83
- return patchType ? (0, __atscript_typescript_utils.defineAnnotatedType)("object").prop("$replace", fullType).prop("$insert", fullType).prop("$upsert", fullType).prop("$update", mergeStrategy ? patchType : fullType).prop("$remove", patchType).optional().$type : (0, __atscript_typescript_utils.defineAnnotatedType)("object").prop("$replace", fullType).prop("$insert", fullType).optional().$type;
84
- }
85
- return def;
86
- },
87
- partial: (def, path) => path !== "" && def.metadata.get("db.patch.strategy") === "merge"
88
- });
89
- }
39
+ var CollectionPatcher = class {
90
40
  /**
91
41
  * Entry point – walk the payload, build `filter`, `update` and `options`.
92
42
  *
@@ -133,8 +83,8 @@ else t.prop(key, (0, __atscript_typescript_utils.defineAnnotatedType)().refTo(va
133
83
  for (const [_key, value] of Object.entries(payload)) {
134
84
  const key = evalKey(_key);
135
85
  const flatType = this.collection.flatMap.get(key);
136
- const topLevelArray = flatType?.metadata?.get("db.mongo.__topLevelArray");
137
- if (typeof value === "object" && topLevelArray) this.parseArrayPatch(key, value, flatType);
86
+ const topLevelArray = flatType?.metadata?.get("db.__topLevelArray");
87
+ if (typeof value === "object" && !Array.isArray(value) && topLevelArray && !flatType?.metadata?.has("db.json")) this.parseArrayPatch(key, value, flatType);
138
88
  else if (typeof value === "object" && flatType?.metadata?.get("db.patch.strategy") === "merge") this.flattenPayload(value, key);
139
89
  else if (key !== "_id") this._set(key, value);
140
90
  }
@@ -198,22 +148,37 @@ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
198
148
  * `$upsert`
199
149
  * - keyed → remove existing matching by key(s) then append candidate
200
150
  * - unique → $setUnion (deep equality)
201
- */ _upsert(key, input, keys, _flatType) {
151
+ */ _upsert(key, input, keys, flatType) {
202
152
  if (!input?.length) return;
203
153
  if (keys.length > 0) {
154
+ const mergeStrategy = flatType.metadata?.get("db.patch.strategy") === "merge";
155
+ const vars = {
156
+ acc: "$$value",
157
+ cand: "$$this"
158
+ };
159
+ let appendExpr = "$$cand";
160
+ if (mergeStrategy) {
161
+ vars.existing = { $arrayElemAt: [{ $filter: {
162
+ input: "$$value",
163
+ as: "el",
164
+ cond: this._keysEqual(keys, "$$el", "$$this")
165
+ } }, 0] };
166
+ appendExpr = { $cond: [
167
+ { $ifNull: ["$$existing", false] },
168
+ { $mergeObjects: ["$$existing", "$$cand"] },
169
+ "$$cand"
170
+ ] };
171
+ }
204
172
  this._set(key, { $reduce: {
205
173
  input,
206
174
  initialValue: { $ifNull: [`$${key}`, []] },
207
175
  in: { $let: {
208
- vars: {
209
- acc: "$$value",
210
- cand: "$$this"
211
- },
176
+ vars,
212
177
  in: { $concatArrays: [{ $filter: {
213
178
  input: "$$acc",
214
179
  as: "el",
215
180
  cond: { $not: this._keysEqual(keys, "$$el", "$$cand") }
216
- } }, ["$$cand"]] }
181
+ } }, [appendExpr]] }
217
182
  } }
218
183
  } });
219
184
  return;
@@ -264,15 +229,15 @@ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
264
229
  else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
265
230
  }
266
231
  constructor(collection, payload) {
267
- _define_property$2(this, "collection", void 0);
268
- _define_property$2(this, "payload", void 0);
232
+ _define_property$1(this, "collection", void 0);
233
+ _define_property$1(this, "payload", void 0);
269
234
  /**
270
235
  * Internal accumulator: filter passed to `updateOne()`.
271
236
  * Filled only with the `_id` field right now.
272
- */ _define_property$2(this, "filterObj", void 0);
273
- /** MongoDB *update* document being built. */ _define_property$2(this, "updatePipeline", void 0);
274
- /** Current `$set` stage being populated. */ _define_property$2(this, "currentSetStage", void 0);
275
- /** Additional *options* (mainly `arrayFilters`). */ _define_property$2(this, "optionsObj", void 0);
237
+ */ _define_property$1(this, "filterObj", void 0);
238
+ /** MongoDB *update* document being built. */ _define_property$1(this, "updatePipeline", void 0);
239
+ /** Current `$set` stage being populated. */ _define_property$1(this, "currentSetStage", void 0);
240
+ /** Additional *options* (mainly `arrayFilters`). */ _define_property$1(this, "optionsObj", void 0);
276
241
  this.collection = collection;
277
242
  this.payload = payload;
278
243
  this.filterObj = {};
@@ -281,7 +246,7 @@ else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
281
246
  this.optionsObj = {};
282
247
  }
283
248
  };
284
- _define_property$2(CollectionPatcher, "getKeyProps", __atscript_utils_db.getKeyProps);
249
+ _define_property$1(CollectionPatcher, "getKeyProps", __atscript_utils_db.getKeyProps);
285
250
 
286
251
  //#endregion
287
252
  //#region packages/mongo/src/lib/mongo-filter.ts
@@ -307,12 +272,24 @@ const mongoVisitor = {
307
272
  };
308
273
  function buildMongoFilter(filter) {
309
274
  if (!filter || Object.keys(filter).length === 0) return EMPTY;
310
- return (0, __atscript_utils_db.walkFilter)(filter, mongoVisitor);
275
+ return (0, __atscript_utils_db.walkFilter)(filter, mongoVisitor) ?? EMPTY;
311
276
  }
312
277
 
278
+ //#endregion
279
+ //#region packages/mongo/src/lib/validate-plugins.ts
280
+ const validateMongoIdPlugin = (ctx, def, value) => {
281
+ if (def.type.tags?.has("objectId")) {
282
+ if (ctx.path === "_id" && (value === undefined || value === null)) {
283
+ const dbCtx = ctx.context;
284
+ if (dbCtx && (dbCtx.mode === "insert" || dbCtx.mode === "replace")) return true;
285
+ }
286
+ return ctx.validateAnnotatedType(def, value instanceof mongodb.ObjectId ? value.toString() : value);
287
+ }
288
+ };
289
+
313
290
  //#endregion
314
291
  //#region packages/mongo/src/lib/mongo-adapter.ts
315
- function _define_property$1(obj, key, value) {
292
+ function _define_property(obj, key, value) {
316
293
  if (key in obj) Object.defineProperty(obj, key, {
317
294
  value,
318
295
  enumerable: true,
@@ -324,17 +301,63 @@ else obj[key] = value;
324
301
  }
325
302
  const INDEX_PREFIX = "atscript__";
326
303
  const DEFAULT_INDEX_NAME = "DEFAULT";
304
+ const JOINED_PREFIX = "__joined_";
327
305
  function mongoIndexKey(type, name) {
328
306
  const cleanName = name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 127 - INDEX_PREFIX.length - type.length - 2);
329
307
  return `${INDEX_PREFIX}${type}__${cleanName}`;
330
308
  }
331
- var MongoAdapter = class extends __atscript_utils_db.BaseDbAdapter {
309
+ var MongoAdapter = class MongoAdapter extends __atscript_utils_db.BaseDbAdapter {
310
+ get _client() {
311
+ return this.client;
312
+ }
313
+ async _beginTransaction() {
314
+ if (this._txDisabled || !this._client) return undefined;
315
+ try {
316
+ const topology = this._client.topology;
317
+ if (topology) {
318
+ const desc = topology.description ?? topology.s?.description;
319
+ const type = desc?.type;
320
+ if (type === "Single" || type === "Unknown") {
321
+ this._txDisabled = true;
322
+ return undefined;
323
+ }
324
+ }
325
+ const session = this._client.startSession();
326
+ session.startTransaction();
327
+ return session;
328
+ } catch {
329
+ this._txDisabled = true;
330
+ return undefined;
331
+ }
332
+ }
333
+ async _commitTransaction(state) {
334
+ if (!state) return;
335
+ const session = state;
336
+ try {
337
+ await session.commitTransaction();
338
+ } finally {
339
+ session.endSession();
340
+ }
341
+ }
342
+ async _rollbackTransaction(state) {
343
+ if (!state) return;
344
+ const session = state;
345
+ try {
346
+ await session.abortTransaction();
347
+ } finally {
348
+ session.endSession();
349
+ }
350
+ }
351
+ /** Returns `{ session }` opts if inside a transaction, empty object otherwise. */ _getSessionOpts() {
352
+ const session = this._getTransactionState();
353
+ return session ? { session } : MongoAdapter._noSession;
354
+ }
332
355
  get collection() {
333
356
  if (!this._collection) this._collection = this.db.collection(this.resolveTableName(false));
334
357
  return this._collection;
335
358
  }
336
359
  aggregate(pipeline) {
337
- return this.collection.aggregate(pipeline);
360
+ return this.collection.aggregate(pipeline, this._getSessionOpts());
338
361
  }
339
362
  get idType() {
340
363
  const idProp = this._table.type.type.props.get("_id");
@@ -372,30 +395,260 @@ var MongoAdapter = class extends __atscript_utils_db.BaseDbAdapter {
372
395
  getValidatorPlugins() {
373
396
  return [validateMongoIdPlugin];
374
397
  }
375
- getTopLevelArrayTag() {
376
- return "db.mongo.__topLevelArray";
377
- }
378
398
  getAdapterTableName(type) {
379
399
  return undefined;
380
400
  }
381
- buildInsertValidator(table) {
382
- return table.createValidator({
383
- plugins: this.getValidatorPlugins(),
384
- replace: (type, path) => {
385
- if (path === "_id" && type.type.tags?.has("objectId")) return {
386
- ...type,
387
- optional: true
388
- };
389
- if (table.defaults.has(path)) return {
390
- ...type,
391
- optional: true
392
- };
393
- return type;
401
+ supportsNativeRelations() {
402
+ return true;
403
+ }
404
+ async loadRelations(rows, withRelations, relations, foreignKeys, tableResolver) {
405
+ if (rows.length === 0 || withRelations.length === 0) return;
406
+ const primaryKeys = this._table.primaryKeys;
407
+ const relMeta = [];
408
+ for (const withRel of withRelations) {
409
+ if (withRel.name.includes(".")) continue;
410
+ const relation = relations.get(withRel.name);
411
+ if (!relation) throw new Error(`Unknown relation "${withRel.name}" in $with. Available relations: ${[...relations.keys()].join(", ") || "(none)"}`);
412
+ const lookupResult = this._buildRelationLookup(withRel, relation, foreignKeys, tableResolver);
413
+ if (!lookupResult) continue;
414
+ relMeta.push({
415
+ name: withRel.name,
416
+ isArray: lookupResult.isArray,
417
+ relation,
418
+ nestedWith: this._extractNestedWith(withRel),
419
+ stages: lookupResult.stages
420
+ });
421
+ }
422
+ if (relMeta.length === 0) return;
423
+ const pkMatchFilter = this._buildPKMatchFilter(rows, primaryKeys);
424
+ if (pkMatchFilter) {
425
+ const pipeline = [{ $match: pkMatchFilter }];
426
+ for (const meta of relMeta) pipeline.push(...meta.stages);
427
+ const results = await this.collection.aggregate(pipeline, this._getSessionOpts()).toArray();
428
+ this._mergeRelationResults(rows, results, primaryKeys, relMeta);
429
+ } else for (const row of rows) for (const meta of relMeta) row[meta.name] = meta.isArray ? [] : null;
430
+ await this._loadNestedRelations(rows, relMeta, tableResolver);
431
+ }
432
+ /** Builds a $match filter to re-select source rows by PK. */ _buildPKMatchFilter(rows, primaryKeys) {
433
+ if (primaryKeys.length === 1) {
434
+ const pk = primaryKeys[0];
435
+ const values = new Set();
436
+ for (const row of rows) {
437
+ const v = row[pk];
438
+ if (v !== null && v !== undefined) values.add(v);
394
439
  }
395
- });
440
+ if (values.size === 0) return undefined;
441
+ return { [pk]: { $in: [...values] } };
442
+ }
443
+ const seen = new Set();
444
+ const orFilters = [];
445
+ for (const row of rows) {
446
+ const key = primaryKeys.map((pk) => String(row[pk] ?? "")).join("\0");
447
+ if (seen.has(key)) continue;
448
+ seen.add(key);
449
+ const condition = {};
450
+ let valid = true;
451
+ for (const pk of primaryKeys) {
452
+ const val = row[pk];
453
+ if (val === null || val === undefined) {
454
+ valid = false;
455
+ break;
456
+ }
457
+ condition[pk] = val;
458
+ }
459
+ if (valid) orFilters.push(condition);
460
+ }
461
+ if (orFilters.length === 0) return undefined;
462
+ return orFilters.length === 1 ? orFilters[0] : { $or: orFilters };
463
+ }
464
+ /** Dispatches to the correct $lookup builder based on relation direction. */ _buildRelationLookup(withRel, relation, foreignKeys, tableResolver) {
465
+ switch (relation.direction) {
466
+ case "to": return this._buildToLookup(withRel, relation, foreignKeys);
467
+ case "from": return this._buildFromLookup(withRel, relation, tableResolver);
468
+ case "via": return this._buildViaLookup(withRel, relation, tableResolver);
469
+ default: return undefined;
470
+ }
396
471
  }
397
- buildPatchValidator(table) {
398
- return CollectionPatcher.prepareValidator(this.getPatcherContext());
472
+ /** Builds `let` variable bindings and the corresponding `$expr` match for `$lookup`. */ _buildLookupJoin(localFields, remoteFields, varPrefix) {
473
+ const letVars = Object.fromEntries(localFields.map((f, i) => [`${varPrefix}${i}`, `$${f}`]));
474
+ const exprMatch = remoteFields.length === 1 ? { $eq: [`$${remoteFields[0]}`, `$$${varPrefix}0`] } : { $and: remoteFields.map((rf, i) => ({ $eq: [`$${rf}`, `$$${varPrefix}${i}`] })) };
475
+ return {
476
+ letVars,
477
+ exprMatch
478
+ };
479
+ }
480
+ /** $lookup for TO relations (FK is on this table → target). Always single-valued. */ _buildToLookup(withRel, relation, foreignKeys) {
481
+ const fk = this._findFKForRelationLookup(relation, foreignKeys);
482
+ if (!fk) return undefined;
483
+ const innerPipeline = this._buildLookupInnerPipeline(withRel, fk.targetFields);
484
+ const { letVars, exprMatch } = this._buildLookupJoin(fk.localFields, fk.targetFields, "fk_");
485
+ const stages = [{ $lookup: {
486
+ from: fk.targetTable,
487
+ let: letVars,
488
+ pipeline: [{ $match: { $expr: exprMatch } }, ...innerPipeline],
489
+ as: withRel.name
490
+ } }, { $unwind: {
491
+ path: `$${withRel.name}`,
492
+ preserveNullAndEmptyArrays: true
493
+ } }];
494
+ return {
495
+ stages,
496
+ isArray: false
497
+ };
498
+ }
499
+ /** $lookup for FROM relations (FK is on target → this table). */ _buildFromLookup(withRel, relation, tableResolver) {
500
+ const targetType = relation.targetType();
501
+ if (!targetType || !tableResolver) return undefined;
502
+ const targetMeta = tableResolver(targetType);
503
+ if (!targetMeta) return undefined;
504
+ const remoteFK = this._findRemoteFKFromMeta(targetMeta, this._table.tableName, relation.alias);
505
+ if (!remoteFK) return undefined;
506
+ const targetTableName = this._resolveRelTargetTableName(relation);
507
+ const innerPipeline = this._buildLookupInnerPipeline(withRel, remoteFK.fields);
508
+ const { letVars, exprMatch } = this._buildLookupJoin(remoteFK.targetFields, remoteFK.fields, "pk_");
509
+ const stages = [{ $lookup: {
510
+ from: targetTableName,
511
+ let: letVars,
512
+ pipeline: [{ $match: { $expr: exprMatch } }, ...innerPipeline],
513
+ as: withRel.name
514
+ } }];
515
+ if (!relation.isArray) stages.push({ $unwind: {
516
+ path: `$${withRel.name}`,
517
+ preserveNullAndEmptyArrays: true
518
+ } });
519
+ return {
520
+ stages,
521
+ isArray: relation.isArray
522
+ };
523
+ }
524
+ /** $lookup for VIA relations (M:N through junction table). Always array. */ _buildViaLookup(withRel, relation, tableResolver) {
525
+ if (!relation.viaType || !tableResolver) return undefined;
526
+ const junctionType = relation.viaType();
527
+ if (!junctionType) return undefined;
528
+ const junctionMeta = tableResolver(junctionType);
529
+ if (!junctionMeta) return undefined;
530
+ const junctionTableName = junctionType.metadata?.get("db.table") || junctionType.id || "";
531
+ const targetTableName = this._resolveRelTargetTableName(relation);
532
+ const fkToThis = this._findRemoteFKFromMeta(junctionMeta, this._table.tableName);
533
+ if (!fkToThis) return undefined;
534
+ const fkToTarget = this._findRemoteFKFromMeta(junctionMeta, targetTableName);
535
+ if (!fkToTarget) return undefined;
536
+ const innerPipeline = this._buildLookupInnerPipeline(withRel, fkToTarget.targetFields);
537
+ const { letVars, exprMatch } = this._buildLookupJoin(fkToThis.targetFields, fkToThis.fields, "pk_");
538
+ const stages = [{ $lookup: {
539
+ from: junctionTableName,
540
+ let: letVars,
541
+ pipeline: [
542
+ { $match: { $expr: exprMatch } },
543
+ { $lookup: {
544
+ from: targetTableName,
545
+ localField: fkToTarget.fields[0],
546
+ foreignField: fkToTarget.targetFields[0],
547
+ pipeline: innerPipeline,
548
+ as: "__target"
549
+ } },
550
+ { $unwind: {
551
+ path: "$__target",
552
+ preserveNullAndEmptyArrays: false
553
+ } },
554
+ { $replaceRoot: { newRoot: "$__target" } }
555
+ ],
556
+ as: withRel.name
557
+ } }];
558
+ return {
559
+ stages,
560
+ isArray: true
561
+ };
562
+ }
563
+ /** Builds inner pipeline stages for relation controls ($sort, $limit, $skip, $select, filter). */ _buildLookupInnerPipeline(withRel, requiredFields) {
564
+ const pipeline = [];
565
+ const flatRel = withRel;
566
+ const nested = withRel.controls || {};
567
+ const filter = withRel.filter;
568
+ const sort = nested.$sort || flatRel.$sort;
569
+ const limit = nested.$limit ?? flatRel.$limit;
570
+ const skip = nested.$skip ?? flatRel.$skip;
571
+ const select = nested.$select || flatRel.$select;
572
+ if (filter && Object.keys(filter).length > 0) pipeline.push({ $match: buildMongoFilter(filter) });
573
+ if (sort) pipeline.push({ $sort: sort });
574
+ if (skip) pipeline.push({ $skip: skip });
575
+ if (limit !== null && limit !== undefined) pipeline.push({ $limit: limit });
576
+ if (select) {
577
+ const projection = {};
578
+ for (const f of select) projection[f] = 1;
579
+ for (const f of requiredFields) projection[f] = 1;
580
+ if (!select.includes("_id") && !requiredFields.includes("_id")) projection["_id"] = 0;
581
+ pipeline.push({ $project: projection });
582
+ }
583
+ return pipeline;
584
+ }
585
+ /** Extracts nested $with from a WithRelation's controls. */ _extractNestedWith(withRel) {
586
+ const flatRel = withRel;
587
+ const nested = withRel.controls || {};
588
+ const nestedWith = nested.$with || flatRel.$with;
589
+ return nestedWith && nestedWith.length > 0 ? nestedWith : undefined;
590
+ }
591
+ /** Post-processes nested $with by delegating to the target table's own relation loading. */ async _loadNestedRelations(rows, relMeta, tableResolver) {
592
+ if (!tableResolver) return;
593
+ const tasks = [];
594
+ for (const meta of relMeta) {
595
+ if (!meta.nestedWith || meta.nestedWith.length === 0) continue;
596
+ const targetType = meta.relation.targetType();
597
+ if (!targetType) continue;
598
+ const targetTable = tableResolver(targetType);
599
+ if (!targetTable) continue;
600
+ const subRows = [];
601
+ for (const row of rows) {
602
+ const val = row[meta.name];
603
+ if (meta.isArray && Array.isArray(val)) for (const item of val) subRows.push(item);
604
+ else if (val && typeof val === "object") subRows.push(val);
605
+ }
606
+ if (subRows.length === 0) continue;
607
+ tasks.push(targetTable.loadRelations(subRows, meta.nestedWith));
608
+ }
609
+ await Promise.all(tasks);
610
+ }
611
+ /** Merges aggregation results back onto the original rows by PK. */ _mergeRelationResults(rows, results, primaryKeys, relMeta) {
612
+ const resultIndex = new Map();
613
+ for (const doc of results) {
614
+ const key = primaryKeys.map((pk) => String(doc[pk] ?? "")).join("\0");
615
+ resultIndex.set(key, doc);
616
+ }
617
+ for (const row of rows) {
618
+ const key = primaryKeys.map((pk) => String(row[pk] ?? "")).join("\0");
619
+ const enriched = resultIndex.get(key);
620
+ for (const meta of relMeta) if (enriched) {
621
+ const value = enriched[meta.name];
622
+ if (!meta.isArray && Array.isArray(value)) row[meta.name] = value[0] ?? null;
623
+ else row[meta.name] = value ?? (meta.isArray ? [] : null);
624
+ } else row[meta.name] = meta.isArray ? [] : null;
625
+ }
626
+ }
627
+ /** Finds FK entry for a TO relation from this table's foreignKeys map. */ _findFKForRelationLookup(relation, foreignKeys) {
628
+ const targetTableName = this._resolveRelTargetTableName(relation);
629
+ for (const fk of foreignKeys.values()) if (relation.alias) {
630
+ if (fk.alias === relation.alias) return {
631
+ localFields: fk.fields,
632
+ targetFields: fk.targetFields,
633
+ targetTable: fk.targetTable
634
+ };
635
+ } else if (fk.targetTable === targetTableName) return {
636
+ localFields: fk.fields,
637
+ targetFields: fk.targetFields,
638
+ targetTable: fk.targetTable
639
+ };
640
+ return undefined;
641
+ }
642
+ /** Finds a FK on a remote table that points back to the given table name. */ _findRemoteFKFromMeta(target, thisTableName, alias) {
643
+ for (const fk of target.foreignKeys.values()) {
644
+ if (alias && fk.alias === alias && fk.targetTable === thisTableName) return fk;
645
+ if (!alias && fk.targetTable === thisTableName) return fk;
646
+ }
647
+ return undefined;
648
+ }
649
+ /** Resolves the target table/collection name from a relation's target type. */ _resolveRelTargetTableName(relation) {
650
+ const targetType = relation.targetType();
651
+ return targetType?.metadata?.get("db.table") || targetType?.id || "";
399
652
  }
400
653
  /** Returns the context object used by CollectionPatcher. */ getPatcherContext() {
401
654
  return {
@@ -408,7 +661,11 @@ var MongoAdapter = class extends __atscript_utils_db.BaseDbAdapter {
408
661
  const mongoFilter = buildMongoFilter(filter);
409
662
  const patcher = new CollectionPatcher(this.getPatcherContext(), patch);
410
663
  const { updateFilter, updateOptions } = patcher.preparePatch();
411
- const result = await this.collection.updateOne(mongoFilter, updateFilter, updateOptions);
664
+ this._log("updateOne (patch)", mongoFilter, updateFilter);
665
+ const result = await this.collection.updateOne(mongoFilter, updateFilter, {
666
+ ...updateOptions,
667
+ ...this._getSessionOpts()
668
+ });
412
669
  return {
413
670
  matchedCount: result.matchedCount,
414
671
  modifiedCount: result.modifiedCount
@@ -416,6 +673,11 @@ var MongoAdapter = class extends __atscript_utils_db.BaseDbAdapter {
416
673
  }
417
674
  onBeforeFlatten(type) {
418
675
  const typeMeta = type.metadata;
676
+ const capped = typeMeta.get("db.mongo.capped");
677
+ if (capped) this._cappedOptions = {
678
+ size: capped.size,
679
+ max: capped.max
680
+ };
419
681
  const dynamicText = typeMeta.get("db.mongo.search.dynamic");
420
682
  if (dynamicText) this._setSearchIndex("dynamic_text", "_", {
421
683
  mappings: { dynamic: true },
@@ -429,8 +691,8 @@ var MongoAdapter = class extends __atscript_utils_db.BaseDbAdapter {
429
691
  });
430
692
  }
431
693
  onFieldScanned(field, type, metadata) {
694
+ if (field === "_id") this._hasExplicitId = true;
432
695
  if (field !== "_id" && metadata.has("meta.id")) {
433
- this._table.removePrimaryKey(field);
434
696
  this._addMongoIndexField("unique", "__pk", field);
435
697
  this._table.addUniqueField(field);
436
698
  }
@@ -455,7 +717,28 @@ var MongoAdapter = class extends __atscript_utils_db.BaseDbAdapter {
455
717
  for (const index of metadata.get("db.mongo.search.filter") || []) this._vectorFilters.set(mongoIndexKey("vector", index.indexName), field);
456
718
  }
457
719
  onAfterFlatten() {
458
- this._table.addPrimaryKey("_id");
720
+ if (this._hasExplicitId) {
721
+ this._table.addPrimaryKey("_id");
722
+ for (const field of this._table.originalMetaIdFields) if (field !== "_id") this._table.removePrimaryKey(field);
723
+ } else {
724
+ this._table.flatMap.set("_id", {
725
+ __is_atscript_annotated_type: true,
726
+ type: {
727
+ kind: "",
728
+ designType: "string",
729
+ tags: new Set(["objectId", "mongo"])
730
+ },
731
+ metadata: new Map()
732
+ });
733
+ this._table.addUniqueField("_id");
734
+ }
735
+ if (this._table.navFields.size > 0) {
736
+ const isUnderNav = (path) => {
737
+ for (const nav of this._table.navFields) if (path.startsWith(`${nav}.`)) return true;
738
+ return false;
739
+ };
740
+ for (const field of this._incrementFields) if (isUnderNav(field)) this._incrementFields.delete(field);
741
+ }
459
742
  for (const [key, value] of this._vectorFilters.entries()) {
460
743
  const index = this._mongoIndexes.get(key);
461
744
  if (index && index.type === "vector") index.definition.fields?.push({
@@ -513,7 +796,7 @@ var MongoAdapter = class extends __atscript_utils_db.BaseDbAdapter {
513
796
  /**
514
797
  * Builds a MongoDB `$search` pipeline stage.
515
798
  * Override `buildVectorSearchStage` in subclasses to provide embeddings.
516
- */ buildSearchStage(text, indexName) {
799
+ */ async buildSearchStage(text, indexName) {
517
800
  const index = this.getMongoSearchIndex(indexName);
518
801
  if (!index) return undefined;
519
802
  if (index.type === "vector") return this.buildVectorSearchStage(text, index);
@@ -528,11 +811,11 @@ var MongoAdapter = class extends __atscript_utils_db.BaseDbAdapter {
528
811
  /**
529
812
  * Builds a vector search stage. Override in subclasses to generate embeddings.
530
813
  * Returns `undefined` by default (vector search requires custom implementation).
531
- */ buildVectorSearchStage(text, index) {
814
+ */ async buildVectorSearchStage(text, index) {
532
815
  return undefined;
533
816
  }
534
817
  async search(text, query, indexName) {
535
- const searchStage = this.buildSearchStage(text, indexName);
818
+ const searchStage = await this.buildSearchStage(text, indexName);
536
819
  if (!searchStage) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
537
820
  const filter = buildMongoFilter(query.filter);
538
821
  const controls = query.controls || {};
@@ -542,10 +825,11 @@ var MongoAdapter = class extends __atscript_utils_db.BaseDbAdapter {
542
825
  if (controls.$limit) pipeline.push({ $limit: controls.$limit });
543
826
  else pipeline.push({ $limit: 1e3 });
544
827
  if (controls.$select) pipeline.push({ $project: controls.$select.asProjection });
545
- return this.collection.aggregate(pipeline).toArray();
828
+ this._log("aggregate (search)", pipeline);
829
+ return this.collection.aggregate(pipeline, this._getSessionOpts()).toArray();
546
830
  }
547
831
  async searchWithCount(text, query, indexName) {
548
- const searchStage = this.buildSearchStage(text, indexName);
832
+ const searchStage = await this.buildSearchStage(text, indexName);
549
833
  if (!searchStage) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
550
834
  const filter = buildMongoFilter(query.filter);
551
835
  const controls = query.controls || {};
@@ -562,7 +846,8 @@ else pipeline.push({ $limit: 1e3 });
562
846
  meta: [{ $count: "count" }]
563
847
  } }
564
848
  ];
565
- const result = await this.collection.aggregate(pipeline).toArray();
849
+ this._log("aggregate (searchWithCount)", pipeline);
850
+ const result = await this.collection.aggregate(pipeline, this._getSessionOpts()).toArray();
566
851
  return {
567
852
  data: result[0]?.data || [],
568
853
  count: result[0]?.meta[0]?.count || 0
@@ -580,71 +865,101 @@ else pipeline.push({ $limit: 1e3 });
580
865
  ].filter(Boolean),
581
866
  meta: [{ $count: "count" }]
582
867
  } }];
583
- const result = await this.collection.aggregate(pipeline).toArray();
868
+ this._log("aggregate (findManyWithCount)", pipeline);
869
+ const result = await this.collection.aggregate(pipeline, this._getSessionOpts()).toArray();
584
870
  return {
585
871
  data: result[0]?.data || [],
586
872
  count: result[0]?.meta[0]?.count || 0
587
873
  };
588
874
  }
589
875
  async collectionExists() {
590
- if (this.asMongo) return this.asMongo.collectionExists(this._table.tableName);
591
876
  const cols = await this.db.listCollections({ name: this._table.tableName }).toArray();
592
877
  return cols.length > 0;
593
878
  }
594
879
  async ensureCollectionExists() {
595
880
  const exists = await this.collectionExists();
596
- if (!exists) await this.db.createCollection(this._table.tableName, { comment: "Created by Atscript Mongo Adapter" });
881
+ if (!exists) {
882
+ this._log("createCollection", this._table.tableName);
883
+ const opts = { comment: "Created by Atscript Mongo Adapter" };
884
+ if (this._cappedOptions) {
885
+ opts.capped = true;
886
+ opts.size = this._cappedOptions.size;
887
+ if (this._cappedOptions.max !== null && this._cappedOptions.max !== undefined) opts.max = this._cappedOptions.max;
888
+ }
889
+ await this.db.createCollection(this._table.tableName, opts);
890
+ }
891
+ }
892
+ /**
893
+ * Wraps an async operation to catch MongoDB duplicate key errors
894
+ * (code 11000) and rethrow as structured `DbError`.
895
+ */ async _wrapDuplicateKeyError(fn) {
896
+ try {
897
+ return await fn();
898
+ } catch (e) {
899
+ if (e instanceof mongodb.MongoServerError && e.code === 11e3) {
900
+ const field = e.keyPattern ? Object.keys(e.keyPattern)[0] ?? "" : "";
901
+ throw new __atscript_utils_db.DbError("CONFLICT", [{
902
+ path: field,
903
+ message: e.message
904
+ }]);
905
+ }
906
+ throw e;
907
+ }
597
908
  }
598
909
  async insertOne(data) {
599
910
  if (this._incrementFields.size > 0) {
600
911
  const fields = this._fieldsNeedingIncrement(data);
601
912
  if (fields.length > 0) {
602
- const maxValues = await this._getMaxValues(fields);
603
- for (const physical of fields) data[physical] = (maxValues.get(physical) ?? 0) + 1;
913
+ const nextValues = await this._allocateIncrementValues(fields, 1);
914
+ for (const physical of fields) data[physical] = nextValues.get(physical) ?? 1;
604
915
  }
605
916
  }
606
- const result = await this.collection.insertOne(data);
607
- return { insertedId: result.insertedId };
917
+ this._log("insertOne", data);
918
+ const result = await this._wrapDuplicateKeyError(() => this.collection.insertOne(data, this._getSessionOpts()));
919
+ const metaIdPhysical = this._getMetaIdPhysical();
920
+ return { insertedId: metaIdPhysical ? data[metaIdPhysical] ?? result.insertedId : result.insertedId };
608
921
  }
609
922
  async insertMany(data) {
610
923
  if (this._incrementFields.size > 0) {
611
924
  const allFields = new Set();
612
925
  for (const item of data) for (const f of this._fieldsNeedingIncrement(item)) allFields.add(f);
613
- if (allFields.size > 0) {
614
- const maxValues = await this._getMaxValues([...allFields]);
615
- for (const item of data) for (const physical of allFields) if (item[physical] === undefined || item[physical] === null) {
616
- const next = (maxValues.get(physical) ?? 0) + 1;
617
- item[physical] = next;
618
- maxValues.set(physical, next);
619
- } else if (typeof item[physical] === "number") {
620
- const current = maxValues.get(physical) ?? 0;
621
- if (item[physical] > current) maxValues.set(physical, item[physical]);
622
- }
623
- }
926
+ if (allFields.size > 0) await this._assignBatchIncrements(data, allFields);
624
927
  }
625
- const result = await this.collection.insertMany(data);
928
+ this._log("insertMany", `${data.length} docs`);
929
+ const result = await this._wrapDuplicateKeyError(() => this.collection.insertMany(data, this._getSessionOpts()));
930
+ const metaIdPhysical = this._getMetaIdPhysical();
626
931
  return {
627
932
  insertedCount: result.insertedCount,
628
- insertedIds: Object.values(result.insertedIds)
933
+ insertedIds: metaIdPhysical ? data.map((item, i) => item[metaIdPhysical] ?? result.insertedIds[i]) : Object.values(result.insertedIds)
629
934
  };
630
935
  }
631
936
  async findOne(query) {
632
937
  const filter = buildMongoFilter(query.filter);
633
938
  const opts = this._buildFindOptions(query.controls);
634
- return this.collection.findOne(filter, opts);
939
+ this._log("findOne", filter, opts);
940
+ return this.collection.findOne(filter, {
941
+ ...opts,
942
+ ...this._getSessionOpts()
943
+ });
635
944
  }
636
945
  async findMany(query) {
637
946
  const filter = buildMongoFilter(query.filter);
638
947
  const opts = this._buildFindOptions(query.controls);
639
- return this.collection.find(filter, opts).toArray();
948
+ this._log("findMany", filter, opts);
949
+ return this.collection.find(filter, {
950
+ ...opts,
951
+ ...this._getSessionOpts()
952
+ }).toArray();
640
953
  }
641
954
  async count(query) {
642
955
  const filter = buildMongoFilter(query.filter);
643
- return this.collection.countDocuments(filter);
956
+ this._log("countDocuments", filter);
957
+ return this.collection.countDocuments(filter, this._getSessionOpts());
644
958
  }
645
959
  async updateOne(filter, data) {
646
960
  const mongoFilter = buildMongoFilter(filter);
647
- const result = await this.collection.updateOne(mongoFilter, { $set: data });
961
+ this._log("updateOne", mongoFilter, { $set: data });
962
+ const result = await this.collection.updateOne(mongoFilter, { $set: data }, this._getSessionOpts());
648
963
  return {
649
964
  matchedCount: result.matchedCount,
650
965
  modifiedCount: result.modifiedCount
@@ -652,7 +967,8 @@ else pipeline.push({ $limit: 1e3 });
652
967
  }
653
968
  async replaceOne(filter, data) {
654
969
  const mongoFilter = buildMongoFilter(filter);
655
- const result = await this.collection.replaceOne(mongoFilter, data);
970
+ this._log("replaceOne", mongoFilter, data);
971
+ const result = await this._wrapDuplicateKeyError(() => this.collection.replaceOne(mongoFilter, data, this._getSessionOpts()));
656
972
  return {
657
973
  matchedCount: result.matchedCount,
658
974
  modifiedCount: result.modifiedCount
@@ -660,12 +976,14 @@ else pipeline.push({ $limit: 1e3 });
660
976
  }
661
977
  async deleteOne(filter) {
662
978
  const mongoFilter = buildMongoFilter(filter);
663
- const result = await this.collection.deleteOne(mongoFilter);
979
+ this._log("deleteOne", mongoFilter);
980
+ const result = await this.collection.deleteOne(mongoFilter, this._getSessionOpts());
664
981
  return { deletedCount: result.deletedCount };
665
982
  }
666
983
  async updateMany(filter, data) {
667
984
  const mongoFilter = buildMongoFilter(filter);
668
- const result = await this.collection.updateMany(mongoFilter, { $set: data });
985
+ this._log("updateMany", mongoFilter, { $set: data });
986
+ const result = await this.collection.updateMany(mongoFilter, { $set: data }, this._getSessionOpts());
669
987
  return {
670
988
  matchedCount: result.matchedCount,
671
989
  modifiedCount: result.modifiedCount
@@ -673,7 +991,8 @@ else pipeline.push({ $limit: 1e3 });
673
991
  }
674
992
  async replaceMany(filter, data) {
675
993
  const mongoFilter = buildMongoFilter(filter);
676
- const result = await this.collection.updateMany(mongoFilter, { $set: data });
994
+ this._log("replaceMany", mongoFilter, { $set: data });
995
+ const result = await this.collection.updateMany(mongoFilter, { $set: data }, this._getSessionOpts());
677
996
  return {
678
997
  matchedCount: result.matchedCount,
679
998
  modifiedCount: result.modifiedCount
@@ -681,12 +1000,184 @@ else pipeline.push({ $limit: 1e3 });
681
1000
  }
682
1001
  async deleteMany(filter) {
683
1002
  const mongoFilter = buildMongoFilter(filter);
684
- const result = await this.collection.deleteMany(mongoFilter);
1003
+ this._log("deleteMany", mongoFilter);
1004
+ const result = await this.collection.deleteMany(mongoFilter, this._getSessionOpts());
685
1005
  return { deletedCount: result.deletedCount };
686
1006
  }
1007
+ async tableExists() {
1008
+ return this.collectionExists();
1009
+ }
1010
+ async detectTableOptionDrift() {
1011
+ if (!this._cappedOptions) return false;
1012
+ const cols = await this.db.listCollections({ name: this._table.tableName }, { nameOnly: false }).toArray();
1013
+ if (cols.length === 0) return false;
1014
+ const opts = cols[0].options;
1015
+ if (!opts?.capped) return true;
1016
+ if (opts.size !== this._cappedOptions.size) return true;
1017
+ if ((opts.max ?? undefined) !== (this._cappedOptions.max ?? undefined)) return true;
1018
+ return false;
1019
+ }
687
1020
  async ensureTable() {
1021
+ if (this._table instanceof __atscript_utils_db.AtscriptDbView && !this._table.isExternal) return this._ensureView(this._table);
688
1022
  return this.ensureCollectionExists();
689
1023
  }
1024
+ /**
1025
+ * Creates a MongoDB view from the AtscriptDbView's view plan.
1026
+ * Translates joins → $lookup/$unwind, filter → $match, columns → $project.
1027
+ */ async _ensureView(view) {
1028
+ const exists = await this.collectionExists();
1029
+ if (exists) return;
1030
+ const plan = view.viewPlan;
1031
+ const columns = view.getViewColumnMappings();
1032
+ const pipeline = [];
1033
+ for (const join of plan.joins) {
1034
+ const { localField, foreignField } = this._resolveJoinFields(join.condition, plan.entryTable, join.targetTable);
1035
+ pipeline.push({ $lookup: {
1036
+ from: join.targetTable,
1037
+ localField,
1038
+ foreignField,
1039
+ as: `${JOINED_PREFIX}${join.targetTable}`
1040
+ } });
1041
+ pipeline.push({ $unwind: {
1042
+ path: `$__joined_${join.targetTable}`,
1043
+ preserveNullAndEmptyArrays: true
1044
+ } });
1045
+ }
1046
+ if (plan.filter) {
1047
+ const matchExpr = this._queryNodeToMatch(plan.filter, plan.entryTable);
1048
+ pipeline.push({ $match: matchExpr });
1049
+ }
1050
+ const project = { _id: 0 };
1051
+ for (const col of columns) if (col.sourceTable === plan.entryTable) project[col.viewColumn] = `$${col.sourceColumn}`;
1052
+ else project[col.viewColumn] = `$${JOINED_PREFIX}${col.sourceTable}.${col.sourceColumn}`;
1053
+ pipeline.push({ $project: project });
1054
+ this._log("createView", this._table.tableName, plan.entryTable, pipeline);
1055
+ await this.db.createCollection(this._table.tableName, {
1056
+ viewOn: plan.entryTable,
1057
+ pipeline
1058
+ });
1059
+ }
1060
+ /**
1061
+ * Extracts localField/foreignField from a join condition like `User.id = Task.assigneeId`.
1062
+ * The condition is a comparison node with two field refs.
1063
+ */ _resolveJoinFields(condition, entryTable, joinTable) {
1064
+ const comp = "$and" in condition ? condition.$and[0] : condition;
1065
+ const c = comp;
1066
+ const leftTable = c.left.type ? c.left.type()?.metadata?.get("db.table") || "" : entryTable;
1067
+ if (leftTable === joinTable) return {
1068
+ localField: c.right.field,
1069
+ foreignField: c.left.field
1070
+ };
1071
+ return {
1072
+ localField: c.left.field,
1073
+ foreignField: c.right.field
1074
+ };
1075
+ }
1076
+ /**
1077
+ * Translates an AtscriptQueryNode to a MongoDB $match expression.
1078
+ * Field refs are resolved to dot-path references (joined fields use JOINED_PREFIX).
1079
+ */ _queryNodeToMatch(node, entryTable) {
1080
+ if ("$and" in node) return { $and: node.$and.map((n) => this._queryNodeToMatch(n, entryTable)) };
1081
+ if ("$or" in node) return { $or: node.$or.map((n) => this._queryNodeToMatch(n, entryTable)) };
1082
+ if ("$not" in node) return { $not: this._queryNodeToMatch(node.$not, entryTable) };
1083
+ const comp = node;
1084
+ const fieldPath = this._resolveViewFieldPath(comp.left, entryTable);
1085
+ if (comp.right && typeof comp.right === "object" && "field" in comp.right) {
1086
+ const rightPath = this._resolveViewFieldPath(comp.right, entryTable);
1087
+ return { $expr: { [comp.op]: [`$${fieldPath}`, `$${rightPath}`] } };
1088
+ }
1089
+ if (comp.op === "$eq") return { [fieldPath]: comp.right };
1090
+ if (comp.op === "$ne") return { [fieldPath]: { $ne: comp.right } };
1091
+ return { [fieldPath]: { [comp.op]: comp.right } };
1092
+ }
1093
+ /**
1094
+ * Resolves a field ref to a MongoDB dot path for view pipeline expressions.
1095
+ */ _resolveViewFieldPath(ref, entryTable) {
1096
+ if (!ref.type) return ref.field;
1097
+ const table = ref.type()?.metadata?.get("db.table") || "";
1098
+ if (table === entryTable) return ref.field;
1099
+ return `${JOINED_PREFIX}${table}.${ref.field}`;
1100
+ }
1101
+ async dropTable() {
1102
+ this._log("drop", this._table.tableName);
1103
+ await this.collection.drop();
1104
+ this._collection = undefined;
1105
+ }
1106
+ async dropViewByName(viewName) {
1107
+ this._log("dropView", viewName);
1108
+ try {
1109
+ await this.db.collection(viewName).drop();
1110
+ } catch {}
1111
+ }
1112
+ async dropTableByName(tableName) {
1113
+ this._log("dropByName", tableName);
1114
+ try {
1115
+ await this.db.collection(tableName).drop();
1116
+ } catch {}
1117
+ }
1118
+ async recreateTable() {
1119
+ const tableName = this._table.tableName;
1120
+ this._log("recreateTable", tableName);
1121
+ const tempName = `${tableName}__tmp_${Date.now()}`;
1122
+ const source = this.db.collection(tableName);
1123
+ const count = await source.countDocuments();
1124
+ if (count > 0) await source.aggregate([{ $out: tempName }]).toArray();
1125
+ await this.collection.drop();
1126
+ this._collection = undefined;
1127
+ await this.ensureCollectionExists();
1128
+ if (count > 0) {
1129
+ const temp = this.db.collection(tempName);
1130
+ await temp.aggregate([{ $merge: { into: tableName } }]).toArray();
1131
+ await temp.drop();
1132
+ }
1133
+ }
1134
+ async syncColumns(diff) {
1135
+ const renamed = [];
1136
+ const added = [];
1137
+ const update = {};
1138
+ if (diff.renamed.length > 0) {
1139
+ const renameSpec = {};
1140
+ for (const r of diff.renamed) {
1141
+ renameSpec[r.oldName] = r.field.physicalName;
1142
+ renamed.push(r.field.physicalName);
1143
+ }
1144
+ update.$rename = renameSpec;
1145
+ }
1146
+ if (diff.added.length > 0) {
1147
+ const setSpec = {};
1148
+ for (const field of diff.added) {
1149
+ const defaultVal = this._resolveSyncDefault(field);
1150
+ if (defaultVal !== undefined) setSpec[field.physicalName] = defaultVal;
1151
+ added.push(field.physicalName);
1152
+ }
1153
+ if (Object.keys(setSpec).length > 0) update.$set = setSpec;
1154
+ }
1155
+ if (Object.keys(update).length > 0) await this.collection.updateMany({}, update, this._getSessionOpts());
1156
+ return {
1157
+ added,
1158
+ renamed
1159
+ };
1160
+ }
1161
+ async dropColumns(columns) {
1162
+ if (columns.length === 0) return;
1163
+ const unsetSpec = {};
1164
+ for (const col of columns) unsetSpec[col] = "";
1165
+ await this.collection.updateMany({}, { $unset: unsetSpec }, this._getSessionOpts());
1166
+ }
1167
+ async renameTable(oldName) {
1168
+ const newName = this.resolveTableName(false);
1169
+ this._log("renameTable", oldName, "→", newName);
1170
+ await this.db.renameCollection(oldName, newName);
1171
+ this._collection = undefined;
1172
+ }
1173
+ /**
1174
+ * Resolves a field's default value for bulk $set during column sync.
1175
+ * Returns `undefined` if no concrete default can be determined.
1176
+ */ _resolveSyncDefault(field) {
1177
+ if (!field.defaultValue) return field.optional ? null : undefined;
1178
+ if (field.defaultValue.kind === "value") return field.defaultValue.value;
1179
+ return undefined;
1180
+ }
690
1181
  async syncIndexes() {
691
1182
  await this.ensureCollectionExists();
692
1183
  const allIndexes = new Map();
@@ -730,21 +1221,29 @@ else pipeline.push({ $limit: 1e3 });
730
1221
  case "unique":
731
1222
  case "text": {
732
1223
  if ((local.type === "text" || objMatch(local.fields, remote.key)) && objMatch(local.weights || {}, remote.weights || {})) indexesToCreate.delete(remote.name);
733
- else await this.collection.dropIndex(remote.name);
1224
+ else {
1225
+ this._log("dropIndex", remote.name);
1226
+ await this.collection.dropIndex(remote.name);
1227
+ }
734
1228
  break;
735
1229
  }
736
1230
  default:
737
1231
  }
738
- } else await this.collection.dropIndex(remote.name);
1232
+ } else {
1233
+ this._log("dropIndex", remote.name);
1234
+ await this.collection.dropIndex(remote.name);
1235
+ }
739
1236
  }
740
1237
  for (const [key, value] of allIndexes.entries()) switch (value.type) {
741
1238
  case "plain": {
742
1239
  if (!indexesToCreate.has(key)) continue;
1240
+ this._log("createIndex", key, value.fields);
743
1241
  await this.collection.createIndex(value.fields, { name: key });
744
1242
  break;
745
1243
  }
746
1244
  case "unique": {
747
1245
  if (!indexesToCreate.has(key)) continue;
1246
+ this._log("createIndex (unique)", key, value.fields);
748
1247
  await this.collection.createIndex(value.fields, {
749
1248
  name: key,
750
1249
  unique: true
@@ -753,6 +1252,7 @@ else await this.collection.dropIndex(remote.name);
753
1252
  }
754
1253
  case "text": {
755
1254
  if (!indexesToCreate.has(key)) continue;
1255
+ this._log("createIndex (text)", key, value.fields);
756
1256
  await this.collection.createIndex(value.fields, {
757
1257
  weights: value.weights,
758
1258
  name: key
@@ -784,43 +1284,110 @@ else toUpdate.add(remote.name);
784
1284
  }
785
1285
  default:
786
1286
  }
787
- } else if (remote.status !== "DELETING") await this.collection.dropSearchIndex(remote.name);
1287
+ } else if (remote.status !== "DELETING") {
1288
+ this._log("dropSearchIndex", remote.name);
1289
+ await this.collection.dropSearchIndex(remote.name);
1290
+ }
788
1291
  }
789
1292
  for (const [key, value] of indexesToCreate.entries()) switch (value.type) {
790
1293
  case "dynamic_text":
791
1294
  case "search_text":
792
1295
  case "vector": {
793
- if (toUpdate.has(key)) await this.collection.updateSearchIndex(key, value.definition);
794
- else await this.collection.createSearchIndex({
795
- name: key,
796
- type: value.type === "vector" ? "vectorSearch" : "search",
797
- definition: value.definition
798
- });
1296
+ if (toUpdate.has(key)) {
1297
+ this._log("updateSearchIndex", key, value.definition);
1298
+ await this.collection.updateSearchIndex(key, value.definition);
1299
+ } else {
1300
+ this._log("createSearchIndex", key, value.type);
1301
+ await this.collection.createSearchIndex({
1302
+ name: key,
1303
+ type: value.type === "vector" ? "vectorSearch" : "search",
1304
+ definition: value.definition
1305
+ });
1306
+ }
799
1307
  break;
800
1308
  }
801
1309
  default:
802
1310
  }
803
1311
  } catch {}
804
1312
  }
1313
+ /**
1314
+ * Returns the physical column name of the single @meta.id field (if any).
1315
+ * Used to return the user's logical ID instead of MongoDB's _id on insert.
1316
+ */ _getMetaIdPhysical() {
1317
+ if (this._metaIdPhysical === undefined) {
1318
+ const fields = this._table.originalMetaIdFields;
1319
+ if (fields.length === 1) {
1320
+ const field = fields[0];
1321
+ this._metaIdPhysical = this._table.columnMap.get(field) ?? field;
1322
+ } else this._metaIdPhysical = null;
1323
+ }
1324
+ return this._metaIdPhysical;
1325
+ }
1326
+ /** Returns the counters collection used for atomic auto-increment. */ get _countersCollection() {
1327
+ return this.db.collection("__atscript_counters");
1328
+ }
805
1329
  /** Returns physical field names of increment fields that are undefined in the data. */ _fieldsNeedingIncrement(data) {
806
1330
  const result = [];
807
1331
  for (const physical of this._incrementFields) if (data[physical] === undefined || data[physical] === null) result.push(physical);
808
1332
  return result;
809
1333
  }
810
- /** Reads current max value for each field via $group aggregation. */ async _getMaxValues(physicalFields) {
811
- const aliases = physicalFields.map((f) => [`max__${f.replace(/\./g, "__")}`, f]);
812
- const group = { _id: null };
813
- for (const [alias, field] of aliases) group[alias] = { $max: `$${field}` };
814
- const result = await this.collection.aggregate([{ $group: group }]).toArray();
815
- const maxMap = new Map();
816
- if (result.length > 0) {
817
- const row = result[0];
818
- for (const [alias, field] of aliases) {
819
- const val = row[alias];
820
- maxMap.set(field, typeof val === "number" ? val : 0);
1334
+ /**
1335
+ * Atomically allocates `count` sequential values for each increment field
1336
+ * using a counter collection. Returns a map of field → first allocated value.
1337
+ */ async _allocateIncrementValues(physicalFields, count) {
1338
+ const counters = this._countersCollection;
1339
+ const collectionName = this._table.tableName;
1340
+ const result = new Map();
1341
+ for (const field of physicalFields) {
1342
+ const counterId = `${collectionName}.${field}`;
1343
+ const doc = await counters.findOneAndUpdate({ _id: counterId }, { $inc: { seq: count } }, {
1344
+ upsert: true,
1345
+ returnDocument: "after",
1346
+ ...this._getSessionOpts()
1347
+ });
1348
+ const seq = doc?.seq ?? count;
1349
+ if (seq === count) {
1350
+ const currentMax = await this._getCurrentFieldMax(field);
1351
+ if (currentMax >= seq) {
1352
+ const adjusted = currentMax + count;
1353
+ await counters.updateOne({ _id: counterId }, { $max: { seq: adjusted } }, this._getSessionOpts());
1354
+ result.set(field, currentMax + 1);
1355
+ continue;
1356
+ }
821
1357
  }
1358
+ result.set(field, seq - count + 1);
1359
+ }
1360
+ return result;
1361
+ }
1362
+ /** Reads current max value for a single field via $group aggregation. */ async _getCurrentFieldMax(field) {
1363
+ const alias = `max__${field.replace(/\./g, "__")}`;
1364
+ const agg = await this.collection.aggregate([{ $group: {
1365
+ _id: null,
1366
+ [alias]: { $max: `$${field}` }
1367
+ } }], this._getSessionOpts()).toArray();
1368
+ if (agg.length > 0) {
1369
+ const val = agg[0][alias];
1370
+ if (typeof val === "number") return val;
1371
+ }
1372
+ return 0;
1373
+ }
1374
+ /** Allocates increment values for a batch of items, assigning in order. */ async _assignBatchIncrements(data, allFields) {
1375
+ const fieldCounts = new Map();
1376
+ for (const physical of allFields) {
1377
+ let count = 0;
1378
+ for (const item of data) if (item[physical] === undefined || item[physical] === null) count++;
1379
+ if (count > 0) fieldCounts.set(physical, count);
1380
+ }
1381
+ const fieldCounters = new Map();
1382
+ for (const [physical, count] of fieldCounts) {
1383
+ const allocated = await this._allocateIncrementValues([physical], count);
1384
+ fieldCounters.set(physical, allocated.get(physical) ?? 1);
1385
+ }
1386
+ for (const item of data) for (const physical of allFields) if (item[physical] === undefined || item[physical] === null) {
1387
+ const next = fieldCounters.get(physical) ?? 1;
1388
+ item[physical] = next;
1389
+ fieldCounters.set(physical, next + 1);
822
1390
  }
823
- return maxMap;
824
1391
  }
825
1392
  _buildFindOptions(controls) {
826
1393
  const opts = {};
@@ -872,10 +1439,11 @@ else {
872
1439
  if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
873
1440
  }
874
1441
  }
875
- constructor(db, asMongo) {
876
- super(), _define_property$1(this, "db", void 0), _define_property$1(this, "asMongo", void 0), _define_property$1(this, "_collection", void 0), _define_property$1(this, "_mongoIndexes", void 0), _define_property$1(this, "_vectorFilters", void 0), _define_property$1(this, "_searchIndexesMap", void 0), _define_property$1(this, "_incrementFields", void 0), this.db = db, this.asMongo = asMongo, this._mongoIndexes = new Map(), this._vectorFilters = new Map(), this._incrementFields = new Set();
1442
+ constructor(db, client) {
1443
+ super(), _define_property(this, "db", void 0), _define_property(this, "client", void 0), _define_property(this, "_collection", void 0), _define_property(this, "_mongoIndexes", void 0), _define_property(this, "_vectorFilters", void 0), _define_property(this, "_searchIndexesMap", void 0), _define_property(this, "_incrementFields", void 0), _define_property(this, "_cappedOptions", void 0), _define_property(this, "_hasExplicitId", void 0), _define_property(this, "_txDisabled", void 0), _define_property(this, "_metaIdPhysical", void 0), this.db = db, this.client = client, this._mongoIndexes = new Map(), this._vectorFilters = new Map(), this._incrementFields = new Set(), this._hasExplicitId = false, this._txDisabled = false;
877
1444
  }
878
1445
  };
1446
+ _define_property(MongoAdapter, "_noSession", Object.freeze({}));
879
1447
  function objMatch(o1, o2) {
880
1448
  const keys1 = Object.keys(o1);
881
1449
  const keys2 = Object.keys(o2);
@@ -905,55 +1473,16 @@ function vectorFieldsMatch(left, right) {
905
1473
  }
906
1474
 
907
1475
  //#endregion
908
- //#region packages/mongo/src/lib/logger.ts
909
- const NoopLogger = {
910
- error: () => {},
911
- warn: () => {},
912
- log: () => {},
913
- info: () => {},
914
- debug: () => {}
915
- };
916
-
917
- //#endregion
918
- //#region packages/mongo/src/lib/as-mongo.ts
919
- function _define_property(obj, key, value) {
920
- if (key in obj) Object.defineProperty(obj, key, {
921
- value,
922
- enumerable: true,
923
- configurable: true,
924
- writable: true
925
- });
926
- else obj[key] = value;
927
- return obj;
1476
+ //#region packages/mongo/src/lib/index.ts
1477
+ function createAdapter(connection, _options) {
1478
+ const client = new mongodb.MongoClient(connection);
1479
+ const db = client.db();
1480
+ return new __atscript_utils_db.DbSpace(() => new MongoAdapter(db, client));
928
1481
  }
929
- var AsMongo = class extends __atscript_utils_db.DbSpace {
930
- get db() {
931
- return this.client.db();
932
- }
933
- getCollectionsList() {
934
- if (!this.collectionsList) this.collectionsList = this.db.listCollections().toArray().then((c) => new Set(c.map((c$1) => c$1.name)));
935
- return this.collectionsList;
936
- }
937
- async collectionExists(name) {
938
- const list = await this.getCollectionsList();
939
- return list.has(name);
940
- }
941
- /**
942
- * Returns the MongoAdapter for the given type.
943
- * Convenience accessor for Mongo-specific adapter operations.
944
- */ getAdapter(type) {
945
- return super.getAdapter(type);
946
- }
947
- constructor(client, logger = NoopLogger) {
948
- const resolvedClient = typeof client === "string" ? new mongodb.MongoClient(client) : client;
949
- super(() => new MongoAdapter(this.db, this), logger), _define_property(this, "client", void 0), _define_property(this, "collectionsList", void 0);
950
- this.client = resolvedClient;
951
- }
952
- };
953
1482
 
954
1483
  //#endregion
955
- exports.AsMongo = AsMongo
956
1484
  exports.CollectionPatcher = CollectionPatcher
957
1485
  exports.MongoAdapter = MongoAdapter
958
1486
  exports.buildMongoFilter = buildMongoFilter
1487
+ exports.createAdapter = createAdapter
959
1488
  exports.validateMongoIdPlugin = validateMongoIdPlugin