@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.mjs CHANGED
@@ -1,15 +1,8 @@
1
- import { BaseDbAdapter, DbSpace, getKeyProps, walkFilter } from "@atscript/utils-db";
2
- import { MongoClient, ObjectId } from "mongodb";
3
- import { defineAnnotatedType, isAnnotatedTypeOfPrimitive } from "@atscript/typescript/utils";
1
+ import { AtscriptDbView, BaseDbAdapter, DbError, DbSpace, getKeyProps, walkFilter } from "@atscript/utils-db";
2
+ import { MongoClient, MongoServerError, ObjectId } from "mongodb";
4
3
 
5
- //#region packages/mongo/src/lib/validate-plugins.ts
6
- const validateMongoIdPlugin = (ctx, def, value) => {
7
- if (ctx.path === "_id" && def.type.tags.has("objectId")) return ctx.validateAnnotatedType(def, value instanceof ObjectId ? value.toString() : value);
8
- };
9
-
10
- //#endregion
11
4
  //#region packages/mongo/src/lib/collection-patcher.ts
12
- function _define_property$2(obj, key, value) {
5
+ function _define_property$1(obj, key, value) {
13
6
  if (key in obj) Object.defineProperty(obj, key, {
14
7
  value,
15
8
  enumerable: true,
@@ -19,50 +12,7 @@ function _define_property$2(obj, key, value) {
19
12
  else obj[key] = value;
20
13
  return obj;
21
14
  }
22
- var CollectionPatcher = class CollectionPatcher {
23
- /**
24
- * Build a runtime *Validator* that understands the extended patch payload.
25
- *
26
- * * Adds per‑array *patch* wrappers (the `$replace`, `$insert`, … fields).
27
- * * Honors `db.patch.strategy === "merge"` metadata.
28
- *
29
- * @param collection Target collection wrapper
30
- * @returns Atscript Validator
31
- */ static prepareValidator(context) {
32
- return context.createValidator({
33
- plugins: [validateMongoIdPlugin],
34
- replace: (def, path) => {
35
- if (path === "" && def.type.kind === "object") {
36
- const obj = defineAnnotatedType("object").copyMetadata(def.metadata);
37
- for (const [prop, type] of def.type.props.entries()) obj.prop(prop, defineAnnotatedType().refTo(type).copyMetadata(type.metadata).optional(prop !== "_id").$type);
38
- return obj.$type;
39
- }
40
- if (def.type.kind === "array" && context.flatMap.get(path)?.metadata.get("db.mongo.__topLevelArray") && !def.metadata.has("db.mongo.__patchArrayValue")) {
41
- const defArray = def;
42
- const mergeStrategy = defArray.metadata.get("db.patch.strategy") === "merge";
43
- function getPatchType() {
44
- const isPrimitive = isAnnotatedTypeOfPrimitive(defArray.type.of);
45
- if (isPrimitive) return defineAnnotatedType().refTo(def).copyMetadata(def.metadata).annotate("db.mongo.__patchArrayValue").optional().$type;
46
- if (defArray.type.of.type.kind === "object") {
47
- const objType = defArray.type.of.type;
48
- const t = defineAnnotatedType("object").copyMetadata(defArray.type.of.metadata);
49
- const keyProps = CollectionPatcher.getKeyProps(defArray);
50
- for (const [key, val] of objType.props.entries()) if (keyProps.size > 0) if (keyProps.has(key)) t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).$type);
51
- else t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).optional().$type);
52
- else t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).optional(!!val.optional).$type);
53
- return defineAnnotatedType("array").of(t.$type).copyMetadata(def.metadata).annotate("db.mongo.__patchArrayValue").optional().$type;
54
- }
55
- return undefined;
56
- }
57
- const fullType = defineAnnotatedType().refTo(def).copyMetadata(def.metadata).annotate("db.mongo.__patchArrayValue").optional().$type;
58
- const patchType = getPatchType();
59
- return patchType ? defineAnnotatedType("object").prop("$replace", fullType).prop("$insert", fullType).prop("$upsert", fullType).prop("$update", mergeStrategy ? patchType : fullType).prop("$remove", patchType).optional().$type : defineAnnotatedType("object").prop("$replace", fullType).prop("$insert", fullType).optional().$type;
60
- }
61
- return def;
62
- },
63
- partial: (def, path) => path !== "" && def.metadata.get("db.patch.strategy") === "merge"
64
- });
65
- }
15
+ var CollectionPatcher = class {
66
16
  /**
67
17
  * Entry point – walk the payload, build `filter`, `update` and `options`.
68
18
  *
@@ -109,8 +59,8 @@ else t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).opt
109
59
  for (const [_key, value] of Object.entries(payload)) {
110
60
  const key = evalKey(_key);
111
61
  const flatType = this.collection.flatMap.get(key);
112
- const topLevelArray = flatType?.metadata?.get("db.mongo.__topLevelArray");
113
- if (typeof value === "object" && topLevelArray) this.parseArrayPatch(key, value, flatType);
62
+ const topLevelArray = flatType?.metadata?.get("db.__topLevelArray");
63
+ if (typeof value === "object" && !Array.isArray(value) && topLevelArray && !flatType?.metadata?.has("db.json")) this.parseArrayPatch(key, value, flatType);
114
64
  else if (typeof value === "object" && flatType?.metadata?.get("db.patch.strategy") === "merge") this.flattenPayload(value, key);
115
65
  else if (key !== "_id") this._set(key, value);
116
66
  }
@@ -174,22 +124,37 @@ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
174
124
  * `$upsert`
175
125
  * - keyed → remove existing matching by key(s) then append candidate
176
126
  * - unique → $setUnion (deep equality)
177
- */ _upsert(key, input, keys, _flatType) {
127
+ */ _upsert(key, input, keys, flatType) {
178
128
  if (!input?.length) return;
179
129
  if (keys.length > 0) {
130
+ const mergeStrategy = flatType.metadata?.get("db.patch.strategy") === "merge";
131
+ const vars = {
132
+ acc: "$$value",
133
+ cand: "$$this"
134
+ };
135
+ let appendExpr = "$$cand";
136
+ if (mergeStrategy) {
137
+ vars.existing = { $arrayElemAt: [{ $filter: {
138
+ input: "$$value",
139
+ as: "el",
140
+ cond: this._keysEqual(keys, "$$el", "$$this")
141
+ } }, 0] };
142
+ appendExpr = { $cond: [
143
+ { $ifNull: ["$$existing", false] },
144
+ { $mergeObjects: ["$$existing", "$$cand"] },
145
+ "$$cand"
146
+ ] };
147
+ }
180
148
  this._set(key, { $reduce: {
181
149
  input,
182
150
  initialValue: { $ifNull: [`$${key}`, []] },
183
151
  in: { $let: {
184
- vars: {
185
- acc: "$$value",
186
- cand: "$$this"
187
- },
152
+ vars,
188
153
  in: { $concatArrays: [{ $filter: {
189
154
  input: "$$acc",
190
155
  as: "el",
191
156
  cond: { $not: this._keysEqual(keys, "$$el", "$$cand") }
192
- } }, ["$$cand"]] }
157
+ } }, [appendExpr]] }
193
158
  } }
194
159
  } });
195
160
  return;
@@ -240,15 +205,15 @@ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
240
205
  else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
241
206
  }
242
207
  constructor(collection, payload) {
243
- _define_property$2(this, "collection", void 0);
244
- _define_property$2(this, "payload", void 0);
208
+ _define_property$1(this, "collection", void 0);
209
+ _define_property$1(this, "payload", void 0);
245
210
  /**
246
211
  * Internal accumulator: filter passed to `updateOne()`.
247
212
  * Filled only with the `_id` field right now.
248
- */ _define_property$2(this, "filterObj", void 0);
249
- /** MongoDB *update* document being built. */ _define_property$2(this, "updatePipeline", void 0);
250
- /** Current `$set` stage being populated. */ _define_property$2(this, "currentSetStage", void 0);
251
- /** Additional *options* (mainly `arrayFilters`). */ _define_property$2(this, "optionsObj", void 0);
213
+ */ _define_property$1(this, "filterObj", void 0);
214
+ /** MongoDB *update* document being built. */ _define_property$1(this, "updatePipeline", void 0);
215
+ /** Current `$set` stage being populated. */ _define_property$1(this, "currentSetStage", void 0);
216
+ /** Additional *options* (mainly `arrayFilters`). */ _define_property$1(this, "optionsObj", void 0);
252
217
  this.collection = collection;
253
218
  this.payload = payload;
254
219
  this.filterObj = {};
@@ -257,7 +222,7 @@ else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
257
222
  this.optionsObj = {};
258
223
  }
259
224
  };
260
- _define_property$2(CollectionPatcher, "getKeyProps", getKeyProps);
225
+ _define_property$1(CollectionPatcher, "getKeyProps", getKeyProps);
261
226
 
262
227
  //#endregion
263
228
  //#region packages/mongo/src/lib/mongo-filter.ts
@@ -283,12 +248,24 @@ const mongoVisitor = {
283
248
  };
284
249
  function buildMongoFilter(filter) {
285
250
  if (!filter || Object.keys(filter).length === 0) return EMPTY;
286
- return walkFilter(filter, mongoVisitor);
251
+ return walkFilter(filter, mongoVisitor) ?? EMPTY;
287
252
  }
288
253
 
254
+ //#endregion
255
+ //#region packages/mongo/src/lib/validate-plugins.ts
256
+ const validateMongoIdPlugin = (ctx, def, value) => {
257
+ if (def.type.tags?.has("objectId")) {
258
+ if (ctx.path === "_id" && (value === undefined || value === null)) {
259
+ const dbCtx = ctx.context;
260
+ if (dbCtx && (dbCtx.mode === "insert" || dbCtx.mode === "replace")) return true;
261
+ }
262
+ return ctx.validateAnnotatedType(def, value instanceof ObjectId ? value.toString() : value);
263
+ }
264
+ };
265
+
289
266
  //#endregion
290
267
  //#region packages/mongo/src/lib/mongo-adapter.ts
291
- function _define_property$1(obj, key, value) {
268
+ function _define_property(obj, key, value) {
292
269
  if (key in obj) Object.defineProperty(obj, key, {
293
270
  value,
294
271
  enumerable: true,
@@ -300,17 +277,63 @@ else obj[key] = value;
300
277
  }
301
278
  const INDEX_PREFIX = "atscript__";
302
279
  const DEFAULT_INDEX_NAME = "DEFAULT";
280
+ const JOINED_PREFIX = "__joined_";
303
281
  function mongoIndexKey(type, name) {
304
282
  const cleanName = name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 127 - INDEX_PREFIX.length - type.length - 2);
305
283
  return `${INDEX_PREFIX}${type}__${cleanName}`;
306
284
  }
307
- var MongoAdapter = class extends BaseDbAdapter {
285
+ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
286
+ get _client() {
287
+ return this.client;
288
+ }
289
+ async _beginTransaction() {
290
+ if (this._txDisabled || !this._client) return undefined;
291
+ try {
292
+ const topology = this._client.topology;
293
+ if (topology) {
294
+ const desc = topology.description ?? topology.s?.description;
295
+ const type = desc?.type;
296
+ if (type === "Single" || type === "Unknown") {
297
+ this._txDisabled = true;
298
+ return undefined;
299
+ }
300
+ }
301
+ const session = this._client.startSession();
302
+ session.startTransaction();
303
+ return session;
304
+ } catch {
305
+ this._txDisabled = true;
306
+ return undefined;
307
+ }
308
+ }
309
+ async _commitTransaction(state) {
310
+ if (!state) return;
311
+ const session = state;
312
+ try {
313
+ await session.commitTransaction();
314
+ } finally {
315
+ session.endSession();
316
+ }
317
+ }
318
+ async _rollbackTransaction(state) {
319
+ if (!state) return;
320
+ const session = state;
321
+ try {
322
+ await session.abortTransaction();
323
+ } finally {
324
+ session.endSession();
325
+ }
326
+ }
327
+ /** Returns `{ session }` opts if inside a transaction, empty object otherwise. */ _getSessionOpts() {
328
+ const session = this._getTransactionState();
329
+ return session ? { session } : MongoAdapter._noSession;
330
+ }
308
331
  get collection() {
309
332
  if (!this._collection) this._collection = this.db.collection(this.resolveTableName(false));
310
333
  return this._collection;
311
334
  }
312
335
  aggregate(pipeline) {
313
- return this.collection.aggregate(pipeline);
336
+ return this.collection.aggregate(pipeline, this._getSessionOpts());
314
337
  }
315
338
  get idType() {
316
339
  const idProp = this._table.type.type.props.get("_id");
@@ -348,30 +371,260 @@ var MongoAdapter = class extends BaseDbAdapter {
348
371
  getValidatorPlugins() {
349
372
  return [validateMongoIdPlugin];
350
373
  }
351
- getTopLevelArrayTag() {
352
- return "db.mongo.__topLevelArray";
353
- }
354
374
  getAdapterTableName(type) {
355
375
  return undefined;
356
376
  }
357
- buildInsertValidator(table) {
358
- return table.createValidator({
359
- plugins: this.getValidatorPlugins(),
360
- replace: (type, path) => {
361
- if (path === "_id" && type.type.tags?.has("objectId")) return {
362
- ...type,
363
- optional: true
364
- };
365
- if (table.defaults.has(path)) return {
366
- ...type,
367
- optional: true
368
- };
369
- return type;
377
+ supportsNativeRelations() {
378
+ return true;
379
+ }
380
+ async loadRelations(rows, withRelations, relations, foreignKeys, tableResolver) {
381
+ if (rows.length === 0 || withRelations.length === 0) return;
382
+ const primaryKeys = this._table.primaryKeys;
383
+ const relMeta = [];
384
+ for (const withRel of withRelations) {
385
+ if (withRel.name.includes(".")) continue;
386
+ const relation = relations.get(withRel.name);
387
+ if (!relation) throw new Error(`Unknown relation "${withRel.name}" in $with. Available relations: ${[...relations.keys()].join(", ") || "(none)"}`);
388
+ const lookupResult = this._buildRelationLookup(withRel, relation, foreignKeys, tableResolver);
389
+ if (!lookupResult) continue;
390
+ relMeta.push({
391
+ name: withRel.name,
392
+ isArray: lookupResult.isArray,
393
+ relation,
394
+ nestedWith: this._extractNestedWith(withRel),
395
+ stages: lookupResult.stages
396
+ });
397
+ }
398
+ if (relMeta.length === 0) return;
399
+ const pkMatchFilter = this._buildPKMatchFilter(rows, primaryKeys);
400
+ if (pkMatchFilter) {
401
+ const pipeline = [{ $match: pkMatchFilter }];
402
+ for (const meta of relMeta) pipeline.push(...meta.stages);
403
+ const results = await this.collection.aggregate(pipeline, this._getSessionOpts()).toArray();
404
+ this._mergeRelationResults(rows, results, primaryKeys, relMeta);
405
+ } else for (const row of rows) for (const meta of relMeta) row[meta.name] = meta.isArray ? [] : null;
406
+ await this._loadNestedRelations(rows, relMeta, tableResolver);
407
+ }
408
+ /** Builds a $match filter to re-select source rows by PK. */ _buildPKMatchFilter(rows, primaryKeys) {
409
+ if (primaryKeys.length === 1) {
410
+ const pk = primaryKeys[0];
411
+ const values = new Set();
412
+ for (const row of rows) {
413
+ const v = row[pk];
414
+ if (v !== null && v !== undefined) values.add(v);
370
415
  }
371
- });
416
+ if (values.size === 0) return undefined;
417
+ return { [pk]: { $in: [...values] } };
418
+ }
419
+ const seen = new Set();
420
+ const orFilters = [];
421
+ for (const row of rows) {
422
+ const key = primaryKeys.map((pk) => String(row[pk] ?? "")).join("\0");
423
+ if (seen.has(key)) continue;
424
+ seen.add(key);
425
+ const condition = {};
426
+ let valid = true;
427
+ for (const pk of primaryKeys) {
428
+ const val = row[pk];
429
+ if (val === null || val === undefined) {
430
+ valid = false;
431
+ break;
432
+ }
433
+ condition[pk] = val;
434
+ }
435
+ if (valid) orFilters.push(condition);
436
+ }
437
+ if (orFilters.length === 0) return undefined;
438
+ return orFilters.length === 1 ? orFilters[0] : { $or: orFilters };
439
+ }
440
+ /** Dispatches to the correct $lookup builder based on relation direction. */ _buildRelationLookup(withRel, relation, foreignKeys, tableResolver) {
441
+ switch (relation.direction) {
442
+ case "to": return this._buildToLookup(withRel, relation, foreignKeys);
443
+ case "from": return this._buildFromLookup(withRel, relation, tableResolver);
444
+ case "via": return this._buildViaLookup(withRel, relation, tableResolver);
445
+ default: return undefined;
446
+ }
372
447
  }
373
- buildPatchValidator(table) {
374
- return CollectionPatcher.prepareValidator(this.getPatcherContext());
448
+ /** Builds `let` variable bindings and the corresponding `$expr` match for `$lookup`. */ _buildLookupJoin(localFields, remoteFields, varPrefix) {
449
+ const letVars = Object.fromEntries(localFields.map((f, i) => [`${varPrefix}${i}`, `$${f}`]));
450
+ const exprMatch = remoteFields.length === 1 ? { $eq: [`$${remoteFields[0]}`, `$$${varPrefix}0`] } : { $and: remoteFields.map((rf, i) => ({ $eq: [`$${rf}`, `$$${varPrefix}${i}`] })) };
451
+ return {
452
+ letVars,
453
+ exprMatch
454
+ };
455
+ }
456
+ /** $lookup for TO relations (FK is on this table → target). Always single-valued. */ _buildToLookup(withRel, relation, foreignKeys) {
457
+ const fk = this._findFKForRelationLookup(relation, foreignKeys);
458
+ if (!fk) return undefined;
459
+ const innerPipeline = this._buildLookupInnerPipeline(withRel, fk.targetFields);
460
+ const { letVars, exprMatch } = this._buildLookupJoin(fk.localFields, fk.targetFields, "fk_");
461
+ const stages = [{ $lookup: {
462
+ from: fk.targetTable,
463
+ let: letVars,
464
+ pipeline: [{ $match: { $expr: exprMatch } }, ...innerPipeline],
465
+ as: withRel.name
466
+ } }, { $unwind: {
467
+ path: `$${withRel.name}`,
468
+ preserveNullAndEmptyArrays: true
469
+ } }];
470
+ return {
471
+ stages,
472
+ isArray: false
473
+ };
474
+ }
475
+ /** $lookup for FROM relations (FK is on target → this table). */ _buildFromLookup(withRel, relation, tableResolver) {
476
+ const targetType = relation.targetType();
477
+ if (!targetType || !tableResolver) return undefined;
478
+ const targetMeta = tableResolver(targetType);
479
+ if (!targetMeta) return undefined;
480
+ const remoteFK = this._findRemoteFKFromMeta(targetMeta, this._table.tableName, relation.alias);
481
+ if (!remoteFK) return undefined;
482
+ const targetTableName = this._resolveRelTargetTableName(relation);
483
+ const innerPipeline = this._buildLookupInnerPipeline(withRel, remoteFK.fields);
484
+ const { letVars, exprMatch } = this._buildLookupJoin(remoteFK.targetFields, remoteFK.fields, "pk_");
485
+ const stages = [{ $lookup: {
486
+ from: targetTableName,
487
+ let: letVars,
488
+ pipeline: [{ $match: { $expr: exprMatch } }, ...innerPipeline],
489
+ as: withRel.name
490
+ } }];
491
+ if (!relation.isArray) stages.push({ $unwind: {
492
+ path: `$${withRel.name}`,
493
+ preserveNullAndEmptyArrays: true
494
+ } });
495
+ return {
496
+ stages,
497
+ isArray: relation.isArray
498
+ };
499
+ }
500
+ /** $lookup for VIA relations (M:N through junction table). Always array. */ _buildViaLookup(withRel, relation, tableResolver) {
501
+ if (!relation.viaType || !tableResolver) return undefined;
502
+ const junctionType = relation.viaType();
503
+ if (!junctionType) return undefined;
504
+ const junctionMeta = tableResolver(junctionType);
505
+ if (!junctionMeta) return undefined;
506
+ const junctionTableName = junctionType.metadata?.get("db.table") || junctionType.id || "";
507
+ const targetTableName = this._resolveRelTargetTableName(relation);
508
+ const fkToThis = this._findRemoteFKFromMeta(junctionMeta, this._table.tableName);
509
+ if (!fkToThis) return undefined;
510
+ const fkToTarget = this._findRemoteFKFromMeta(junctionMeta, targetTableName);
511
+ if (!fkToTarget) return undefined;
512
+ const innerPipeline = this._buildLookupInnerPipeline(withRel, fkToTarget.targetFields);
513
+ const { letVars, exprMatch } = this._buildLookupJoin(fkToThis.targetFields, fkToThis.fields, "pk_");
514
+ const stages = [{ $lookup: {
515
+ from: junctionTableName,
516
+ let: letVars,
517
+ pipeline: [
518
+ { $match: { $expr: exprMatch } },
519
+ { $lookup: {
520
+ from: targetTableName,
521
+ localField: fkToTarget.fields[0],
522
+ foreignField: fkToTarget.targetFields[0],
523
+ pipeline: innerPipeline,
524
+ as: "__target"
525
+ } },
526
+ { $unwind: {
527
+ path: "$__target",
528
+ preserveNullAndEmptyArrays: false
529
+ } },
530
+ { $replaceRoot: { newRoot: "$__target" } }
531
+ ],
532
+ as: withRel.name
533
+ } }];
534
+ return {
535
+ stages,
536
+ isArray: true
537
+ };
538
+ }
539
+ /** Builds inner pipeline stages for relation controls ($sort, $limit, $skip, $select, filter). */ _buildLookupInnerPipeline(withRel, requiredFields) {
540
+ const pipeline = [];
541
+ const flatRel = withRel;
542
+ const nested = withRel.controls || {};
543
+ const filter = withRel.filter;
544
+ const sort = nested.$sort || flatRel.$sort;
545
+ const limit = nested.$limit ?? flatRel.$limit;
546
+ const skip = nested.$skip ?? flatRel.$skip;
547
+ const select = nested.$select || flatRel.$select;
548
+ if (filter && Object.keys(filter).length > 0) pipeline.push({ $match: buildMongoFilter(filter) });
549
+ if (sort) pipeline.push({ $sort: sort });
550
+ if (skip) pipeline.push({ $skip: skip });
551
+ if (limit !== null && limit !== undefined) pipeline.push({ $limit: limit });
552
+ if (select) {
553
+ const projection = {};
554
+ for (const f of select) projection[f] = 1;
555
+ for (const f of requiredFields) projection[f] = 1;
556
+ if (!select.includes("_id") && !requiredFields.includes("_id")) projection["_id"] = 0;
557
+ pipeline.push({ $project: projection });
558
+ }
559
+ return pipeline;
560
+ }
561
+ /** Extracts nested $with from a WithRelation's controls. */ _extractNestedWith(withRel) {
562
+ const flatRel = withRel;
563
+ const nested = withRel.controls || {};
564
+ const nestedWith = nested.$with || flatRel.$with;
565
+ return nestedWith && nestedWith.length > 0 ? nestedWith : undefined;
566
+ }
567
+ /** Post-processes nested $with by delegating to the target table's own relation loading. */ async _loadNestedRelations(rows, relMeta, tableResolver) {
568
+ if (!tableResolver) return;
569
+ const tasks = [];
570
+ for (const meta of relMeta) {
571
+ if (!meta.nestedWith || meta.nestedWith.length === 0) continue;
572
+ const targetType = meta.relation.targetType();
573
+ if (!targetType) continue;
574
+ const targetTable = tableResolver(targetType);
575
+ if (!targetTable) continue;
576
+ const subRows = [];
577
+ for (const row of rows) {
578
+ const val = row[meta.name];
579
+ if (meta.isArray && Array.isArray(val)) for (const item of val) subRows.push(item);
580
+ else if (val && typeof val === "object") subRows.push(val);
581
+ }
582
+ if (subRows.length === 0) continue;
583
+ tasks.push(targetTable.loadRelations(subRows, meta.nestedWith));
584
+ }
585
+ await Promise.all(tasks);
586
+ }
587
+ /** Merges aggregation results back onto the original rows by PK. */ _mergeRelationResults(rows, results, primaryKeys, relMeta) {
588
+ const resultIndex = new Map();
589
+ for (const doc of results) {
590
+ const key = primaryKeys.map((pk) => String(doc[pk] ?? "")).join("\0");
591
+ resultIndex.set(key, doc);
592
+ }
593
+ for (const row of rows) {
594
+ const key = primaryKeys.map((pk) => String(row[pk] ?? "")).join("\0");
595
+ const enriched = resultIndex.get(key);
596
+ for (const meta of relMeta) if (enriched) {
597
+ const value = enriched[meta.name];
598
+ if (!meta.isArray && Array.isArray(value)) row[meta.name] = value[0] ?? null;
599
+ else row[meta.name] = value ?? (meta.isArray ? [] : null);
600
+ } else row[meta.name] = meta.isArray ? [] : null;
601
+ }
602
+ }
603
+ /** Finds FK entry for a TO relation from this table's foreignKeys map. */ _findFKForRelationLookup(relation, foreignKeys) {
604
+ const targetTableName = this._resolveRelTargetTableName(relation);
605
+ for (const fk of foreignKeys.values()) if (relation.alias) {
606
+ if (fk.alias === relation.alias) return {
607
+ localFields: fk.fields,
608
+ targetFields: fk.targetFields,
609
+ targetTable: fk.targetTable
610
+ };
611
+ } else if (fk.targetTable === targetTableName) return {
612
+ localFields: fk.fields,
613
+ targetFields: fk.targetFields,
614
+ targetTable: fk.targetTable
615
+ };
616
+ return undefined;
617
+ }
618
+ /** Finds a FK on a remote table that points back to the given table name. */ _findRemoteFKFromMeta(target, thisTableName, alias) {
619
+ for (const fk of target.foreignKeys.values()) {
620
+ if (alias && fk.alias === alias && fk.targetTable === thisTableName) return fk;
621
+ if (!alias && fk.targetTable === thisTableName) return fk;
622
+ }
623
+ return undefined;
624
+ }
625
+ /** Resolves the target table/collection name from a relation's target type. */ _resolveRelTargetTableName(relation) {
626
+ const targetType = relation.targetType();
627
+ return targetType?.metadata?.get("db.table") || targetType?.id || "";
375
628
  }
376
629
  /** Returns the context object used by CollectionPatcher. */ getPatcherContext() {
377
630
  return {
@@ -384,7 +637,11 @@ var MongoAdapter = class extends BaseDbAdapter {
384
637
  const mongoFilter = buildMongoFilter(filter);
385
638
  const patcher = new CollectionPatcher(this.getPatcherContext(), patch);
386
639
  const { updateFilter, updateOptions } = patcher.preparePatch();
387
- const result = await this.collection.updateOne(mongoFilter, updateFilter, updateOptions);
640
+ this._log("updateOne (patch)", mongoFilter, updateFilter);
641
+ const result = await this.collection.updateOne(mongoFilter, updateFilter, {
642
+ ...updateOptions,
643
+ ...this._getSessionOpts()
644
+ });
388
645
  return {
389
646
  matchedCount: result.matchedCount,
390
647
  modifiedCount: result.modifiedCount
@@ -392,6 +649,11 @@ var MongoAdapter = class extends BaseDbAdapter {
392
649
  }
393
650
  onBeforeFlatten(type) {
394
651
  const typeMeta = type.metadata;
652
+ const capped = typeMeta.get("db.mongo.capped");
653
+ if (capped) this._cappedOptions = {
654
+ size: capped.size,
655
+ max: capped.max
656
+ };
395
657
  const dynamicText = typeMeta.get("db.mongo.search.dynamic");
396
658
  if (dynamicText) this._setSearchIndex("dynamic_text", "_", {
397
659
  mappings: { dynamic: true },
@@ -405,8 +667,8 @@ var MongoAdapter = class extends BaseDbAdapter {
405
667
  });
406
668
  }
407
669
  onFieldScanned(field, type, metadata) {
670
+ if (field === "_id") this._hasExplicitId = true;
408
671
  if (field !== "_id" && metadata.has("meta.id")) {
409
- this._table.removePrimaryKey(field);
410
672
  this._addMongoIndexField("unique", "__pk", field);
411
673
  this._table.addUniqueField(field);
412
674
  }
@@ -431,7 +693,28 @@ var MongoAdapter = class extends BaseDbAdapter {
431
693
  for (const index of metadata.get("db.mongo.search.filter") || []) this._vectorFilters.set(mongoIndexKey("vector", index.indexName), field);
432
694
  }
433
695
  onAfterFlatten() {
434
- this._table.addPrimaryKey("_id");
696
+ if (this._hasExplicitId) {
697
+ this._table.addPrimaryKey("_id");
698
+ for (const field of this._table.originalMetaIdFields) if (field !== "_id") this._table.removePrimaryKey(field);
699
+ } else {
700
+ this._table.flatMap.set("_id", {
701
+ __is_atscript_annotated_type: true,
702
+ type: {
703
+ kind: "",
704
+ designType: "string",
705
+ tags: new Set(["objectId", "mongo"])
706
+ },
707
+ metadata: new Map()
708
+ });
709
+ this._table.addUniqueField("_id");
710
+ }
711
+ if (this._table.navFields.size > 0) {
712
+ const isUnderNav = (path) => {
713
+ for (const nav of this._table.navFields) if (path.startsWith(`${nav}.`)) return true;
714
+ return false;
715
+ };
716
+ for (const field of this._incrementFields) if (isUnderNav(field)) this._incrementFields.delete(field);
717
+ }
435
718
  for (const [key, value] of this._vectorFilters.entries()) {
436
719
  const index = this._mongoIndexes.get(key);
437
720
  if (index && index.type === "vector") index.definition.fields?.push({
@@ -489,7 +772,7 @@ var MongoAdapter = class extends BaseDbAdapter {
489
772
  /**
490
773
  * Builds a MongoDB `$search` pipeline stage.
491
774
  * Override `buildVectorSearchStage` in subclasses to provide embeddings.
492
- */ buildSearchStage(text, indexName) {
775
+ */ async buildSearchStage(text, indexName) {
493
776
  const index = this.getMongoSearchIndex(indexName);
494
777
  if (!index) return undefined;
495
778
  if (index.type === "vector") return this.buildVectorSearchStage(text, index);
@@ -504,11 +787,11 @@ var MongoAdapter = class extends BaseDbAdapter {
504
787
  /**
505
788
  * Builds a vector search stage. Override in subclasses to generate embeddings.
506
789
  * Returns `undefined` by default (vector search requires custom implementation).
507
- */ buildVectorSearchStage(text, index) {
790
+ */ async buildVectorSearchStage(text, index) {
508
791
  return undefined;
509
792
  }
510
793
  async search(text, query, indexName) {
511
- const searchStage = this.buildSearchStage(text, indexName);
794
+ const searchStage = await this.buildSearchStage(text, indexName);
512
795
  if (!searchStage) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
513
796
  const filter = buildMongoFilter(query.filter);
514
797
  const controls = query.controls || {};
@@ -518,10 +801,11 @@ var MongoAdapter = class extends BaseDbAdapter {
518
801
  if (controls.$limit) pipeline.push({ $limit: controls.$limit });
519
802
  else pipeline.push({ $limit: 1e3 });
520
803
  if (controls.$select) pipeline.push({ $project: controls.$select.asProjection });
521
- return this.collection.aggregate(pipeline).toArray();
804
+ this._log("aggregate (search)", pipeline);
805
+ return this.collection.aggregate(pipeline, this._getSessionOpts()).toArray();
522
806
  }
523
807
  async searchWithCount(text, query, indexName) {
524
- const searchStage = this.buildSearchStage(text, indexName);
808
+ const searchStage = await this.buildSearchStage(text, indexName);
525
809
  if (!searchStage) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
526
810
  const filter = buildMongoFilter(query.filter);
527
811
  const controls = query.controls || {};
@@ -538,7 +822,8 @@ else pipeline.push({ $limit: 1e3 });
538
822
  meta: [{ $count: "count" }]
539
823
  } }
540
824
  ];
541
- const result = await this.collection.aggregate(pipeline).toArray();
825
+ this._log("aggregate (searchWithCount)", pipeline);
826
+ const result = await this.collection.aggregate(pipeline, this._getSessionOpts()).toArray();
542
827
  return {
543
828
  data: result[0]?.data || [],
544
829
  count: result[0]?.meta[0]?.count || 0
@@ -556,71 +841,101 @@ else pipeline.push({ $limit: 1e3 });
556
841
  ].filter(Boolean),
557
842
  meta: [{ $count: "count" }]
558
843
  } }];
559
- const result = await this.collection.aggregate(pipeline).toArray();
844
+ this._log("aggregate (findManyWithCount)", pipeline);
845
+ const result = await this.collection.aggregate(pipeline, this._getSessionOpts()).toArray();
560
846
  return {
561
847
  data: result[0]?.data || [],
562
848
  count: result[0]?.meta[0]?.count || 0
563
849
  };
564
850
  }
565
851
  async collectionExists() {
566
- if (this.asMongo) return this.asMongo.collectionExists(this._table.tableName);
567
852
  const cols = await this.db.listCollections({ name: this._table.tableName }).toArray();
568
853
  return cols.length > 0;
569
854
  }
570
855
  async ensureCollectionExists() {
571
856
  const exists = await this.collectionExists();
572
- if (!exists) await this.db.createCollection(this._table.tableName, { comment: "Created by Atscript Mongo Adapter" });
857
+ if (!exists) {
858
+ this._log("createCollection", this._table.tableName);
859
+ const opts = { comment: "Created by Atscript Mongo Adapter" };
860
+ if (this._cappedOptions) {
861
+ opts.capped = true;
862
+ opts.size = this._cappedOptions.size;
863
+ if (this._cappedOptions.max !== null && this._cappedOptions.max !== undefined) opts.max = this._cappedOptions.max;
864
+ }
865
+ await this.db.createCollection(this._table.tableName, opts);
866
+ }
867
+ }
868
+ /**
869
+ * Wraps an async operation to catch MongoDB duplicate key errors
870
+ * (code 11000) and rethrow as structured `DbError`.
871
+ */ async _wrapDuplicateKeyError(fn) {
872
+ try {
873
+ return await fn();
874
+ } catch (e) {
875
+ if (e instanceof MongoServerError && e.code === 11e3) {
876
+ const field = e.keyPattern ? Object.keys(e.keyPattern)[0] ?? "" : "";
877
+ throw new DbError("CONFLICT", [{
878
+ path: field,
879
+ message: e.message
880
+ }]);
881
+ }
882
+ throw e;
883
+ }
573
884
  }
574
885
  async insertOne(data) {
575
886
  if (this._incrementFields.size > 0) {
576
887
  const fields = this._fieldsNeedingIncrement(data);
577
888
  if (fields.length > 0) {
578
- const maxValues = await this._getMaxValues(fields);
579
- for (const physical of fields) data[physical] = (maxValues.get(physical) ?? 0) + 1;
889
+ const nextValues = await this._allocateIncrementValues(fields, 1);
890
+ for (const physical of fields) data[physical] = nextValues.get(physical) ?? 1;
580
891
  }
581
892
  }
582
- const result = await this.collection.insertOne(data);
583
- return { insertedId: result.insertedId };
893
+ this._log("insertOne", data);
894
+ const result = await this._wrapDuplicateKeyError(() => this.collection.insertOne(data, this._getSessionOpts()));
895
+ const metaIdPhysical = this._getMetaIdPhysical();
896
+ return { insertedId: metaIdPhysical ? data[metaIdPhysical] ?? result.insertedId : result.insertedId };
584
897
  }
585
898
  async insertMany(data) {
586
899
  if (this._incrementFields.size > 0) {
587
900
  const allFields = new Set();
588
901
  for (const item of data) for (const f of this._fieldsNeedingIncrement(item)) allFields.add(f);
589
- if (allFields.size > 0) {
590
- const maxValues = await this._getMaxValues([...allFields]);
591
- for (const item of data) for (const physical of allFields) if (item[physical] === undefined || item[physical] === null) {
592
- const next = (maxValues.get(physical) ?? 0) + 1;
593
- item[physical] = next;
594
- maxValues.set(physical, next);
595
- } else if (typeof item[physical] === "number") {
596
- const current = maxValues.get(physical) ?? 0;
597
- if (item[physical] > current) maxValues.set(physical, item[physical]);
598
- }
599
- }
902
+ if (allFields.size > 0) await this._assignBatchIncrements(data, allFields);
600
903
  }
601
- const result = await this.collection.insertMany(data);
904
+ this._log("insertMany", `${data.length} docs`);
905
+ const result = await this._wrapDuplicateKeyError(() => this.collection.insertMany(data, this._getSessionOpts()));
906
+ const metaIdPhysical = this._getMetaIdPhysical();
602
907
  return {
603
908
  insertedCount: result.insertedCount,
604
- insertedIds: Object.values(result.insertedIds)
909
+ insertedIds: metaIdPhysical ? data.map((item, i) => item[metaIdPhysical] ?? result.insertedIds[i]) : Object.values(result.insertedIds)
605
910
  };
606
911
  }
607
912
  async findOne(query) {
608
913
  const filter = buildMongoFilter(query.filter);
609
914
  const opts = this._buildFindOptions(query.controls);
610
- return this.collection.findOne(filter, opts);
915
+ this._log("findOne", filter, opts);
916
+ return this.collection.findOne(filter, {
917
+ ...opts,
918
+ ...this._getSessionOpts()
919
+ });
611
920
  }
612
921
  async findMany(query) {
613
922
  const filter = buildMongoFilter(query.filter);
614
923
  const opts = this._buildFindOptions(query.controls);
615
- return this.collection.find(filter, opts).toArray();
924
+ this._log("findMany", filter, opts);
925
+ return this.collection.find(filter, {
926
+ ...opts,
927
+ ...this._getSessionOpts()
928
+ }).toArray();
616
929
  }
617
930
  async count(query) {
618
931
  const filter = buildMongoFilter(query.filter);
619
- return this.collection.countDocuments(filter);
932
+ this._log("countDocuments", filter);
933
+ return this.collection.countDocuments(filter, this._getSessionOpts());
620
934
  }
621
935
  async updateOne(filter, data) {
622
936
  const mongoFilter = buildMongoFilter(filter);
623
- const result = await this.collection.updateOne(mongoFilter, { $set: data });
937
+ this._log("updateOne", mongoFilter, { $set: data });
938
+ const result = await this.collection.updateOne(mongoFilter, { $set: data }, this._getSessionOpts());
624
939
  return {
625
940
  matchedCount: result.matchedCount,
626
941
  modifiedCount: result.modifiedCount
@@ -628,7 +943,8 @@ else pipeline.push({ $limit: 1e3 });
628
943
  }
629
944
  async replaceOne(filter, data) {
630
945
  const mongoFilter = buildMongoFilter(filter);
631
- const result = await this.collection.replaceOne(mongoFilter, data);
946
+ this._log("replaceOne", mongoFilter, data);
947
+ const result = await this._wrapDuplicateKeyError(() => this.collection.replaceOne(mongoFilter, data, this._getSessionOpts()));
632
948
  return {
633
949
  matchedCount: result.matchedCount,
634
950
  modifiedCount: result.modifiedCount
@@ -636,12 +952,14 @@ else pipeline.push({ $limit: 1e3 });
636
952
  }
637
953
  async deleteOne(filter) {
638
954
  const mongoFilter = buildMongoFilter(filter);
639
- const result = await this.collection.deleteOne(mongoFilter);
955
+ this._log("deleteOne", mongoFilter);
956
+ const result = await this.collection.deleteOne(mongoFilter, this._getSessionOpts());
640
957
  return { deletedCount: result.deletedCount };
641
958
  }
642
959
  async updateMany(filter, data) {
643
960
  const mongoFilter = buildMongoFilter(filter);
644
- const result = await this.collection.updateMany(mongoFilter, { $set: data });
961
+ this._log("updateMany", mongoFilter, { $set: data });
962
+ const result = await this.collection.updateMany(mongoFilter, { $set: data }, this._getSessionOpts());
645
963
  return {
646
964
  matchedCount: result.matchedCount,
647
965
  modifiedCount: result.modifiedCount
@@ -649,7 +967,8 @@ else pipeline.push({ $limit: 1e3 });
649
967
  }
650
968
  async replaceMany(filter, data) {
651
969
  const mongoFilter = buildMongoFilter(filter);
652
- const result = await this.collection.updateMany(mongoFilter, { $set: data });
970
+ this._log("replaceMany", mongoFilter, { $set: data });
971
+ const result = await this.collection.updateMany(mongoFilter, { $set: data }, this._getSessionOpts());
653
972
  return {
654
973
  matchedCount: result.matchedCount,
655
974
  modifiedCount: result.modifiedCount
@@ -657,12 +976,184 @@ else pipeline.push({ $limit: 1e3 });
657
976
  }
658
977
  async deleteMany(filter) {
659
978
  const mongoFilter = buildMongoFilter(filter);
660
- const result = await this.collection.deleteMany(mongoFilter);
979
+ this._log("deleteMany", mongoFilter);
980
+ const result = await this.collection.deleteMany(mongoFilter, this._getSessionOpts());
661
981
  return { deletedCount: result.deletedCount };
662
982
  }
983
+ async tableExists() {
984
+ return this.collectionExists();
985
+ }
986
+ async detectTableOptionDrift() {
987
+ if (!this._cappedOptions) return false;
988
+ const cols = await this.db.listCollections({ name: this._table.tableName }, { nameOnly: false }).toArray();
989
+ if (cols.length === 0) return false;
990
+ const opts = cols[0].options;
991
+ if (!opts?.capped) return true;
992
+ if (opts.size !== this._cappedOptions.size) return true;
993
+ if ((opts.max ?? undefined) !== (this._cappedOptions.max ?? undefined)) return true;
994
+ return false;
995
+ }
663
996
  async ensureTable() {
997
+ if (this._table instanceof AtscriptDbView && !this._table.isExternal) return this._ensureView(this._table);
664
998
  return this.ensureCollectionExists();
665
999
  }
1000
+ /**
1001
+ * Creates a MongoDB view from the AtscriptDbView's view plan.
1002
+ * Translates joins → $lookup/$unwind, filter → $match, columns → $project.
1003
+ */ async _ensureView(view) {
1004
+ const exists = await this.collectionExists();
1005
+ if (exists) return;
1006
+ const plan = view.viewPlan;
1007
+ const columns = view.getViewColumnMappings();
1008
+ const pipeline = [];
1009
+ for (const join of plan.joins) {
1010
+ const { localField, foreignField } = this._resolveJoinFields(join.condition, plan.entryTable, join.targetTable);
1011
+ pipeline.push({ $lookup: {
1012
+ from: join.targetTable,
1013
+ localField,
1014
+ foreignField,
1015
+ as: `${JOINED_PREFIX}${join.targetTable}`
1016
+ } });
1017
+ pipeline.push({ $unwind: {
1018
+ path: `$__joined_${join.targetTable}`,
1019
+ preserveNullAndEmptyArrays: true
1020
+ } });
1021
+ }
1022
+ if (plan.filter) {
1023
+ const matchExpr = this._queryNodeToMatch(plan.filter, plan.entryTable);
1024
+ pipeline.push({ $match: matchExpr });
1025
+ }
1026
+ const project = { _id: 0 };
1027
+ for (const col of columns) if (col.sourceTable === plan.entryTable) project[col.viewColumn] = `$${col.sourceColumn}`;
1028
+ else project[col.viewColumn] = `$${JOINED_PREFIX}${col.sourceTable}.${col.sourceColumn}`;
1029
+ pipeline.push({ $project: project });
1030
+ this._log("createView", this._table.tableName, plan.entryTable, pipeline);
1031
+ await this.db.createCollection(this._table.tableName, {
1032
+ viewOn: plan.entryTable,
1033
+ pipeline
1034
+ });
1035
+ }
1036
+ /**
1037
+ * Extracts localField/foreignField from a join condition like `User.id = Task.assigneeId`.
1038
+ * The condition is a comparison node with two field refs.
1039
+ */ _resolveJoinFields(condition, entryTable, joinTable) {
1040
+ const comp = "$and" in condition ? condition.$and[0] : condition;
1041
+ const c = comp;
1042
+ const leftTable = c.left.type ? c.left.type()?.metadata?.get("db.table") || "" : entryTable;
1043
+ if (leftTable === joinTable) return {
1044
+ localField: c.right.field,
1045
+ foreignField: c.left.field
1046
+ };
1047
+ return {
1048
+ localField: c.left.field,
1049
+ foreignField: c.right.field
1050
+ };
1051
+ }
1052
+ /**
1053
+ * Translates an AtscriptQueryNode to a MongoDB $match expression.
1054
+ * Field refs are resolved to dot-path references (joined fields use JOINED_PREFIX).
1055
+ */ _queryNodeToMatch(node, entryTable) {
1056
+ if ("$and" in node) return { $and: node.$and.map((n) => this._queryNodeToMatch(n, entryTable)) };
1057
+ if ("$or" in node) return { $or: node.$or.map((n) => this._queryNodeToMatch(n, entryTable)) };
1058
+ if ("$not" in node) return { $not: this._queryNodeToMatch(node.$not, entryTable) };
1059
+ const comp = node;
1060
+ const fieldPath = this._resolveViewFieldPath(comp.left, entryTable);
1061
+ if (comp.right && typeof comp.right === "object" && "field" in comp.right) {
1062
+ const rightPath = this._resolveViewFieldPath(comp.right, entryTable);
1063
+ return { $expr: { [comp.op]: [`$${fieldPath}`, `$${rightPath}`] } };
1064
+ }
1065
+ if (comp.op === "$eq") return { [fieldPath]: comp.right };
1066
+ if (comp.op === "$ne") return { [fieldPath]: { $ne: comp.right } };
1067
+ return { [fieldPath]: { [comp.op]: comp.right } };
1068
+ }
1069
+ /**
1070
+ * Resolves a field ref to a MongoDB dot path for view pipeline expressions.
1071
+ */ _resolveViewFieldPath(ref, entryTable) {
1072
+ if (!ref.type) return ref.field;
1073
+ const table = ref.type()?.metadata?.get("db.table") || "";
1074
+ if (table === entryTable) return ref.field;
1075
+ return `${JOINED_PREFIX}${table}.${ref.field}`;
1076
+ }
1077
+ async dropTable() {
1078
+ this._log("drop", this._table.tableName);
1079
+ await this.collection.drop();
1080
+ this._collection = undefined;
1081
+ }
1082
+ async dropViewByName(viewName) {
1083
+ this._log("dropView", viewName);
1084
+ try {
1085
+ await this.db.collection(viewName).drop();
1086
+ } catch {}
1087
+ }
1088
+ async dropTableByName(tableName) {
1089
+ this._log("dropByName", tableName);
1090
+ try {
1091
+ await this.db.collection(tableName).drop();
1092
+ } catch {}
1093
+ }
1094
+ async recreateTable() {
1095
+ const tableName = this._table.tableName;
1096
+ this._log("recreateTable", tableName);
1097
+ const tempName = `${tableName}__tmp_${Date.now()}`;
1098
+ const source = this.db.collection(tableName);
1099
+ const count = await source.countDocuments();
1100
+ if (count > 0) await source.aggregate([{ $out: tempName }]).toArray();
1101
+ await this.collection.drop();
1102
+ this._collection = undefined;
1103
+ await this.ensureCollectionExists();
1104
+ if (count > 0) {
1105
+ const temp = this.db.collection(tempName);
1106
+ await temp.aggregate([{ $merge: { into: tableName } }]).toArray();
1107
+ await temp.drop();
1108
+ }
1109
+ }
1110
+ async syncColumns(diff) {
1111
+ const renamed = [];
1112
+ const added = [];
1113
+ const update = {};
1114
+ if (diff.renamed.length > 0) {
1115
+ const renameSpec = {};
1116
+ for (const r of diff.renamed) {
1117
+ renameSpec[r.oldName] = r.field.physicalName;
1118
+ renamed.push(r.field.physicalName);
1119
+ }
1120
+ update.$rename = renameSpec;
1121
+ }
1122
+ if (diff.added.length > 0) {
1123
+ const setSpec = {};
1124
+ for (const field of diff.added) {
1125
+ const defaultVal = this._resolveSyncDefault(field);
1126
+ if (defaultVal !== undefined) setSpec[field.physicalName] = defaultVal;
1127
+ added.push(field.physicalName);
1128
+ }
1129
+ if (Object.keys(setSpec).length > 0) update.$set = setSpec;
1130
+ }
1131
+ if (Object.keys(update).length > 0) await this.collection.updateMany({}, update, this._getSessionOpts());
1132
+ return {
1133
+ added,
1134
+ renamed
1135
+ };
1136
+ }
1137
+ async dropColumns(columns) {
1138
+ if (columns.length === 0) return;
1139
+ const unsetSpec = {};
1140
+ for (const col of columns) unsetSpec[col] = "";
1141
+ await this.collection.updateMany({}, { $unset: unsetSpec }, this._getSessionOpts());
1142
+ }
1143
+ async renameTable(oldName) {
1144
+ const newName = this.resolveTableName(false);
1145
+ this._log("renameTable", oldName, "→", newName);
1146
+ await this.db.renameCollection(oldName, newName);
1147
+ this._collection = undefined;
1148
+ }
1149
+ /**
1150
+ * Resolves a field's default value for bulk $set during column sync.
1151
+ * Returns `undefined` if no concrete default can be determined.
1152
+ */ _resolveSyncDefault(field) {
1153
+ if (!field.defaultValue) return field.optional ? null : undefined;
1154
+ if (field.defaultValue.kind === "value") return field.defaultValue.value;
1155
+ return undefined;
1156
+ }
666
1157
  async syncIndexes() {
667
1158
  await this.ensureCollectionExists();
668
1159
  const allIndexes = new Map();
@@ -706,21 +1197,29 @@ else pipeline.push({ $limit: 1e3 });
706
1197
  case "unique":
707
1198
  case "text": {
708
1199
  if ((local.type === "text" || objMatch(local.fields, remote.key)) && objMatch(local.weights || {}, remote.weights || {})) indexesToCreate.delete(remote.name);
709
- else await this.collection.dropIndex(remote.name);
1200
+ else {
1201
+ this._log("dropIndex", remote.name);
1202
+ await this.collection.dropIndex(remote.name);
1203
+ }
710
1204
  break;
711
1205
  }
712
1206
  default:
713
1207
  }
714
- } else await this.collection.dropIndex(remote.name);
1208
+ } else {
1209
+ this._log("dropIndex", remote.name);
1210
+ await this.collection.dropIndex(remote.name);
1211
+ }
715
1212
  }
716
1213
  for (const [key, value] of allIndexes.entries()) switch (value.type) {
717
1214
  case "plain": {
718
1215
  if (!indexesToCreate.has(key)) continue;
1216
+ this._log("createIndex", key, value.fields);
719
1217
  await this.collection.createIndex(value.fields, { name: key });
720
1218
  break;
721
1219
  }
722
1220
  case "unique": {
723
1221
  if (!indexesToCreate.has(key)) continue;
1222
+ this._log("createIndex (unique)", key, value.fields);
724
1223
  await this.collection.createIndex(value.fields, {
725
1224
  name: key,
726
1225
  unique: true
@@ -729,6 +1228,7 @@ else await this.collection.dropIndex(remote.name);
729
1228
  }
730
1229
  case "text": {
731
1230
  if (!indexesToCreate.has(key)) continue;
1231
+ this._log("createIndex (text)", key, value.fields);
732
1232
  await this.collection.createIndex(value.fields, {
733
1233
  weights: value.weights,
734
1234
  name: key
@@ -760,43 +1260,110 @@ else toUpdate.add(remote.name);
760
1260
  }
761
1261
  default:
762
1262
  }
763
- } else if (remote.status !== "DELETING") await this.collection.dropSearchIndex(remote.name);
1263
+ } else if (remote.status !== "DELETING") {
1264
+ this._log("dropSearchIndex", remote.name);
1265
+ await this.collection.dropSearchIndex(remote.name);
1266
+ }
764
1267
  }
765
1268
  for (const [key, value] of indexesToCreate.entries()) switch (value.type) {
766
1269
  case "dynamic_text":
767
1270
  case "search_text":
768
1271
  case "vector": {
769
- if (toUpdate.has(key)) await this.collection.updateSearchIndex(key, value.definition);
770
- else await this.collection.createSearchIndex({
771
- name: key,
772
- type: value.type === "vector" ? "vectorSearch" : "search",
773
- definition: value.definition
774
- });
1272
+ if (toUpdate.has(key)) {
1273
+ this._log("updateSearchIndex", key, value.definition);
1274
+ await this.collection.updateSearchIndex(key, value.definition);
1275
+ } else {
1276
+ this._log("createSearchIndex", key, value.type);
1277
+ await this.collection.createSearchIndex({
1278
+ name: key,
1279
+ type: value.type === "vector" ? "vectorSearch" : "search",
1280
+ definition: value.definition
1281
+ });
1282
+ }
775
1283
  break;
776
1284
  }
777
1285
  default:
778
1286
  }
779
1287
  } catch {}
780
1288
  }
1289
+ /**
1290
+ * Returns the physical column name of the single @meta.id field (if any).
1291
+ * Used to return the user's logical ID instead of MongoDB's _id on insert.
1292
+ */ _getMetaIdPhysical() {
1293
+ if (this._metaIdPhysical === undefined) {
1294
+ const fields = this._table.originalMetaIdFields;
1295
+ if (fields.length === 1) {
1296
+ const field = fields[0];
1297
+ this._metaIdPhysical = this._table.columnMap.get(field) ?? field;
1298
+ } else this._metaIdPhysical = null;
1299
+ }
1300
+ return this._metaIdPhysical;
1301
+ }
1302
+ /** Returns the counters collection used for atomic auto-increment. */ get _countersCollection() {
1303
+ return this.db.collection("__atscript_counters");
1304
+ }
781
1305
  /** Returns physical field names of increment fields that are undefined in the data. */ _fieldsNeedingIncrement(data) {
782
1306
  const result = [];
783
1307
  for (const physical of this._incrementFields) if (data[physical] === undefined || data[physical] === null) result.push(physical);
784
1308
  return result;
785
1309
  }
786
- /** Reads current max value for each field via $group aggregation. */ async _getMaxValues(physicalFields) {
787
- const aliases = physicalFields.map((f) => [`max__${f.replace(/\./g, "__")}`, f]);
788
- const group = { _id: null };
789
- for (const [alias, field] of aliases) group[alias] = { $max: `$${field}` };
790
- const result = await this.collection.aggregate([{ $group: group }]).toArray();
791
- const maxMap = new Map();
792
- if (result.length > 0) {
793
- const row = result[0];
794
- for (const [alias, field] of aliases) {
795
- const val = row[alias];
796
- maxMap.set(field, typeof val === "number" ? val : 0);
1310
+ /**
1311
+ * Atomically allocates `count` sequential values for each increment field
1312
+ * using a counter collection. Returns a map of field → first allocated value.
1313
+ */ async _allocateIncrementValues(physicalFields, count) {
1314
+ const counters = this._countersCollection;
1315
+ const collectionName = this._table.tableName;
1316
+ const result = new Map();
1317
+ for (const field of physicalFields) {
1318
+ const counterId = `${collectionName}.${field}`;
1319
+ const doc = await counters.findOneAndUpdate({ _id: counterId }, { $inc: { seq: count } }, {
1320
+ upsert: true,
1321
+ returnDocument: "after",
1322
+ ...this._getSessionOpts()
1323
+ });
1324
+ const seq = doc?.seq ?? count;
1325
+ if (seq === count) {
1326
+ const currentMax = await this._getCurrentFieldMax(field);
1327
+ if (currentMax >= seq) {
1328
+ const adjusted = currentMax + count;
1329
+ await counters.updateOne({ _id: counterId }, { $max: { seq: adjusted } }, this._getSessionOpts());
1330
+ result.set(field, currentMax + 1);
1331
+ continue;
1332
+ }
797
1333
  }
1334
+ result.set(field, seq - count + 1);
1335
+ }
1336
+ return result;
1337
+ }
1338
+ /** Reads current max value for a single field via $group aggregation. */ async _getCurrentFieldMax(field) {
1339
+ const alias = `max__${field.replace(/\./g, "__")}`;
1340
+ const agg = await this.collection.aggregate([{ $group: {
1341
+ _id: null,
1342
+ [alias]: { $max: `$${field}` }
1343
+ } }], this._getSessionOpts()).toArray();
1344
+ if (agg.length > 0) {
1345
+ const val = agg[0][alias];
1346
+ if (typeof val === "number") return val;
1347
+ }
1348
+ return 0;
1349
+ }
1350
+ /** Allocates increment values for a batch of items, assigning in order. */ async _assignBatchIncrements(data, allFields) {
1351
+ const fieldCounts = new Map();
1352
+ for (const physical of allFields) {
1353
+ let count = 0;
1354
+ for (const item of data) if (item[physical] === undefined || item[physical] === null) count++;
1355
+ if (count > 0) fieldCounts.set(physical, count);
1356
+ }
1357
+ const fieldCounters = new Map();
1358
+ for (const [physical, count] of fieldCounts) {
1359
+ const allocated = await this._allocateIncrementValues([physical], count);
1360
+ fieldCounters.set(physical, allocated.get(physical) ?? 1);
1361
+ }
1362
+ for (const item of data) for (const physical of allFields) if (item[physical] === undefined || item[physical] === null) {
1363
+ const next = fieldCounters.get(physical) ?? 1;
1364
+ item[physical] = next;
1365
+ fieldCounters.set(physical, next + 1);
798
1366
  }
799
- return maxMap;
800
1367
  }
801
1368
  _buildFindOptions(controls) {
802
1369
  const opts = {};
@@ -848,10 +1415,11 @@ else {
848
1415
  if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
849
1416
  }
850
1417
  }
851
- constructor(db, asMongo) {
852
- 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();
1418
+ constructor(db, client) {
1419
+ 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;
853
1420
  }
854
1421
  };
1422
+ _define_property(MongoAdapter, "_noSession", Object.freeze({}));
855
1423
  function objMatch(o1, o2) {
856
1424
  const keys1 = Object.keys(o1);
857
1425
  const keys2 = Object.keys(o2);
@@ -881,51 +1449,12 @@ function vectorFieldsMatch(left, right) {
881
1449
  }
882
1450
 
883
1451
  //#endregion
884
- //#region packages/mongo/src/lib/logger.ts
885
- const NoopLogger = {
886
- error: () => {},
887
- warn: () => {},
888
- log: () => {},
889
- info: () => {},
890
- debug: () => {}
891
- };
892
-
893
- //#endregion
894
- //#region packages/mongo/src/lib/as-mongo.ts
895
- function _define_property(obj, key, value) {
896
- if (key in obj) Object.defineProperty(obj, key, {
897
- value,
898
- enumerable: true,
899
- configurable: true,
900
- writable: true
901
- });
902
- else obj[key] = value;
903
- return obj;
1452
+ //#region packages/mongo/src/lib/index.ts
1453
+ function createAdapter(connection, _options) {
1454
+ const client = new MongoClient(connection);
1455
+ const db = client.db();
1456
+ return new DbSpace(() => new MongoAdapter(db, client));
904
1457
  }
905
- var AsMongo = class extends DbSpace {
906
- get db() {
907
- return this.client.db();
908
- }
909
- getCollectionsList() {
910
- if (!this.collectionsList) this.collectionsList = this.db.listCollections().toArray().then((c) => new Set(c.map((c$1) => c$1.name)));
911
- return this.collectionsList;
912
- }
913
- async collectionExists(name) {
914
- const list = await this.getCollectionsList();
915
- return list.has(name);
916
- }
917
- /**
918
- * Returns the MongoAdapter for the given type.
919
- * Convenience accessor for Mongo-specific adapter operations.
920
- */ getAdapter(type) {
921
- return super.getAdapter(type);
922
- }
923
- constructor(client, logger = NoopLogger) {
924
- const resolvedClient = typeof client === "string" ? new MongoClient(client) : client;
925
- super(() => new MongoAdapter(this.db, this), logger), _define_property(this, "client", void 0), _define_property(this, "collectionsList", void 0);
926
- this.client = resolvedClient;
927
- }
928
- };
929
1458
 
930
1459
  //#endregion
931
- export { AsMongo, CollectionPatcher, MongoAdapter, buildMongoFilter, validateMongoIdPlugin };
1460
+ export { CollectionPatcher, MongoAdapter, buildMongoFilter, createAdapter, validateMongoIdPlugin };