@atscript/mongo 0.0.17 → 0.0.18

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
@@ -106,6 +106,15 @@ const annotations = { mongo: {
106
106
  });
107
107
  }
108
108
  }),
109
+ autoIndexes: new __atscript_core.AnnotationSpec({
110
+ description: "Switch on/off the automatic index creation. Works with as-mongo moost controller.\n\nDefault: true",
111
+ nodeType: ["interface"],
112
+ argument: {
113
+ name: "type",
114
+ type: "boolean",
115
+ description: "On/Off the automatic index creation"
116
+ }
117
+ }),
109
118
  index: {
110
119
  plain: new __atscript_core.AnnotationSpec({
111
120
  description: "Defines a **standard MongoDB index** on a field.\n\n- Improves query performance on indexed fields.\n- Can be used for **single-field** or **compound** indexes.\n\n**Example:**\n```atscript\n@mongo.index.plain \"departmentIndex\"\ndepartment: string\n```\n",
@@ -278,6 +287,28 @@ const annotations = { mongo: {
278
287
  });
279
288
  return errors;
280
289
  }
290
+ }) },
291
+ array: { uniqueItems: new __atscript_core.AnnotationSpec({
292
+ description: "Marks an **array field** as containing *globally unique items* when handling **patch `$insert` operations**.\n\n- Forces the patcher to use **set-semantics** (`$setUnion`) instead of a plain append, so duplicates are silently skipped.\n- Has **no effect** on `$replace`, `$update`, or `$remove`.\n- If the array’s element type already defines one or more `@meta.isKey` properties, *uniqueness is implied* and this annotation is unnecessary (but harmless).\n\n**Example:**\n```atscript\n@mongo.array.uniqueItems\ntags: string[]\n\n// Later in a patch payload …\n{\n $insert: {\n tags: [\"mongo\", \"mongo\"] // second \"mongo\" is ignored\n }\n}\n```\n",
293
+ nodeType: ["prop"],
294
+ multiple: false,
295
+ validate(token, args, doc) {
296
+ const field = token.parentNode;
297
+ const errors = [];
298
+ const definition = field.getDefinition();
299
+ if (!definition) return errors;
300
+ let wrongType = false;
301
+ if ((0, __atscript_core.isRef)(definition)) {
302
+ const def = doc.unwindType(definition.id, definition.chain)?.def;
303
+ if (!(0, __atscript_core.isArray)(def)) wrongType = true;
304
+ } else if (!(0, __atscript_core.isArray)(definition)) wrongType = true;
305
+ if (wrongType) errors.push({
306
+ message: `[mongo] type of array expected when using @mongo.array.uniqueItems`,
307
+ severity: 1,
308
+ range: token.range
309
+ });
310
+ return errors;
311
+ }
281
312
  }) }
282
313
  } };
283
314
 
@@ -305,6 +336,304 @@ const NoopLogger = {
305
336
  debug: () => {}
306
337
  };
307
338
 
339
+ //#endregion
340
+ //#region packages/mongo/src/lib/validate-plugins.ts
341
+ const validateMongoIdPlugin = (ctx, def, value) => {
342
+ if (ctx.path === "_id" && def.type.tags.has("objectId")) return ctx.validateAnnotatedType(def, value instanceof mongodb.ObjectId ? value.toString() : value);
343
+ };
344
+ const validateMongoUniqueArrayItemsPlugin = (ctx, def, value) => {
345
+ if (def.metadata.has("mongo.array.uniqueItems") && def.type.kind === "array") {
346
+ if (Array.isArray(value)) {
347
+ const separator = "▼↩";
348
+ const seen = new Set();
349
+ const keyProps = CollectionPatcher.getKeyProps(def);
350
+ for (const item of value) {
351
+ let key = "";
352
+ if (keyProps.size) for (const prop of keyProps) key += JSON.stringify(item[prop]) + separator;
353
+ else key = JSON.stringify(item);
354
+ if (seen.has(key)) {
355
+ ctx.error(`Duplicate items are not allowed`);
356
+ return false;
357
+ }
358
+ seen.add(key);
359
+ }
360
+ }
361
+ }
362
+ return undefined;
363
+ };
364
+
365
+ //#endregion
366
+ //#region packages/mongo/src/lib/collection-patcher.ts
367
+ function _define_property$2(obj, key, value) {
368
+ if (key in obj) Object.defineProperty(obj, key, {
369
+ value,
370
+ enumerable: true,
371
+ configurable: true,
372
+ writable: true
373
+ });
374
+ else obj[key] = value;
375
+ return obj;
376
+ }
377
+ var CollectionPatcher = class CollectionPatcher {
378
+ /**
379
+ * Extract a set of *key properties* (annotated with `@meta.isKey`) from an
380
+ * array‐of‐objects type definition. These keys uniquely identify an element
381
+ * inside the array and are later used for `$update`, `$remove` and `$upsert`.
382
+ *
383
+ * @param def Atscript array type
384
+ * @returns Set of property names marked as keys; empty set if none
385
+ */ static getKeyProps(def) {
386
+ if (def.type.of.type.kind === "object") {
387
+ const objType = def.type.of.type;
388
+ const keyProps = new Set();
389
+ for (const [key, val] of objType.props.entries()) if (val.metadata.get("meta.isKey")) keyProps.add(key);
390
+ return keyProps;
391
+ }
392
+ return new Set();
393
+ }
394
+ /**
395
+ * Build a runtime *Validator* that understands the extended patch payload.
396
+ *
397
+ * * Adds per‑array *patch* wrappers (the `$replace`, `$insert`, … fields).
398
+ * * Honors `mongo.patch.strategy === "merge"` metadata.
399
+ *
400
+ * @param collection Target collection wrapper
401
+ * @returns Atscript Validator
402
+ */ static prepareValidator(collection) {
403
+ return collection.type.validator({
404
+ plugins: [validateMongoIdPlugin, validateMongoUniqueArrayItemsPlugin],
405
+ replace: (def, path) => {
406
+ if (path === "" && def.type.kind === "object") {
407
+ const obj = (0, __atscript_typescript.defineAnnotatedType)("object").copyMetadata(def.metadata);
408
+ for (const [prop, type] of def.type.props.entries()) obj.prop(prop, (0, __atscript_typescript.defineAnnotatedType)().refTo(type).copyMetadata(type.metadata).optional(prop !== "_id").$type);
409
+ return obj.$type;
410
+ }
411
+ if (def.type.kind === "array" && collection.flatMap.get(path)?.metadata.get("mongo.__topLevelArray") && !def.metadata.has("mongo.__patchArrayValue")) {
412
+ const defArray = def;
413
+ const mergeStrategy = defArray.metadata.get("mongo.patch.strategy") === "merge";
414
+ function getPatchType() {
415
+ const isPrimitive$1 = (0, __atscript_typescript.isAnnotatedTypeOfPrimitive)(defArray.type.of);
416
+ if (isPrimitive$1) return (0, __atscript_typescript.defineAnnotatedType)().refTo(def).copyMetadata(def.metadata).annotate("mongo.__patchArrayValue").optional().$type;
417
+ if (defArray.type.of.type.kind === "object") {
418
+ const objType = defArray.type.of.type;
419
+ const t = (0, __atscript_typescript.defineAnnotatedType)("object").copyMetadata(defArray.type.of.metadata);
420
+ const keyProps = CollectionPatcher.getKeyProps(defArray);
421
+ for (const [key, val] of objType.props.entries()) if (keyProps.size) if (keyProps.has(key)) t.prop(key, (0, __atscript_typescript.defineAnnotatedType)().refTo(val).copyMetadata(def.metadata).$type);
422
+ else t.prop(key, (0, __atscript_typescript.defineAnnotatedType)().refTo(val).copyMetadata(def.metadata).optional().$type);
423
+ else t.prop(key, (0, __atscript_typescript.defineAnnotatedType)().refTo(val).copyMetadata(def.metadata).optional(!!val.optional).$type);
424
+ return (0, __atscript_typescript.defineAnnotatedType)("array").of(t.$type).copyMetadata(def.metadata).annotate("mongo.__patchArrayValue").optional().$type;
425
+ }
426
+ return undefined;
427
+ }
428
+ const fullType = (0, __atscript_typescript.defineAnnotatedType)().refTo(def).copyMetadata(def.metadata).annotate("mongo.__patchArrayValue").optional().$type;
429
+ const patchType = getPatchType();
430
+ return patchType ? (0, __atscript_typescript.defineAnnotatedType)("object").prop("$replace", fullType).prop("$insert", fullType).prop("$upsert", fullType).prop("$update", mergeStrategy ? patchType : fullType).prop("$remove", patchType).optional().$type : (0, __atscript_typescript.defineAnnotatedType)("object").prop("$replace", fullType).prop("$insert", fullType).optional().$type;
431
+ }
432
+ return def;
433
+ },
434
+ partial: (def, path) => {
435
+ return path !== "" && def.metadata.get("mongo.patch.strategy") === "merge";
436
+ }
437
+ });
438
+ }
439
+ /**
440
+ * Entry point – walk the payload, build `filter`, `update` and `options`.
441
+ *
442
+ * @returns Helper object exposing both individual parts and
443
+ * a `.toArgs()` convenience callback.
444
+ */ preparePatch() {
445
+ this.filterObj = { _id: this.collection.prepareId(this.payload._id) };
446
+ this.flattenPayload(this.payload);
447
+ let updateFilter = this.updatePipeline;
448
+ return {
449
+ toArgs: () => [
450
+ this.filterObj,
451
+ updateFilter,
452
+ this.optionsObj
453
+ ],
454
+ filter: this.filterObj,
455
+ updateFilter,
456
+ updateOptions: this.optionsObj
457
+ };
458
+ }
459
+ /**
460
+ * Helper – lazily create `$set` section and assign *key* → *value*.
461
+ *
462
+ * @param key Fully‑qualified dotted path
463
+ * @param val Value to be written
464
+ * @private
465
+ */ _set(key, val) {
466
+ for (const pipe of this.updatePipeline) {
467
+ if (!pipe.$set) pipe.$set = {};
468
+ if (!pipe.$set[key]) {
469
+ pipe.$set[key] = val;
470
+ return;
471
+ }
472
+ }
473
+ this.updatePipeline.push({ $set: { [key]: val } });
474
+ }
475
+ /**
476
+ * Recursively walk through the patch *payload* and convert it into `$set`/…
477
+ * statements. Top‑level arrays are delegated to {@link parseArrayPatch}.
478
+ *
479
+ * @param payload Current payload chunk
480
+ * @param prefix Dotted path accumulated so far
481
+ * @private
482
+ */ flattenPayload(payload, prefix = "") {
483
+ const evalKey = (k) => prefix ? `${prefix}.${k}` : k;
484
+ for (const [_key, value] of Object.entries(payload)) {
485
+ const key = evalKey(_key);
486
+ const flatType = this.collection.flatMap.get(key);
487
+ const topLevelArray = flatType?.metadata?.get("mongo.__topLevelArray");
488
+ if (typeof value === "object" && topLevelArray) this.parseArrayPatch(key, value);
489
+ else if (typeof value === "object" && this.collection.flatMap.get(key)?.metadata?.get("mongo.patch.strategy") === "merge") this.flattenPayload(value, key);
490
+ else if (key !== "_id") this._set(key, value);
491
+ }
492
+ return this.updatePipeline;
493
+ }
494
+ /**
495
+ * Dispatch a *single* array patch. Exactly one of `$replace`, `$insert`,
496
+ * `$upsert`, `$update`, `$remove` must be present – otherwise we throw.
497
+ *
498
+ * @param key Dotted path to the array field
499
+ * @param value Payload slice for that field
500
+ * @private
501
+ */ parseArrayPatch(key, value) {
502
+ const flatType = this.collection.flatMap.get(key);
503
+ const toRemove = value.$remove;
504
+ const toReplace = value.$replace;
505
+ const toInsert = value.$insert;
506
+ const toUpsert = value.$upsert;
507
+ const toUpdate = value.$update;
508
+ const keyProps = flatType?.type.kind === "array" ? CollectionPatcher.getKeyProps(flatType) : new Set();
509
+ this._remove(key, toRemove, keyProps);
510
+ this._replace(key, toReplace);
511
+ this._insert(key, toInsert, keyProps);
512
+ this._upsert(key, toUpsert, keyProps);
513
+ this._update(key, toUpdate, keyProps);
514
+ }
515
+ /**
516
+ * Build an *aggregation‐expression* that checks equality by **all** keys in
517
+ * `keys`. Example output for keys `["id", "lang"]` and bases `a`, `b`:
518
+ * ```json
519
+ * { "$and": [ { "$eq": ["$$a.id", "$$b.id"] }, { "$eq": ["$$a.lang", "$$b.lang"] } ] }
520
+ * ```
521
+ *
522
+ * @param keys Ordered list of key property names
523
+ * @param left Base token for *left* expression (e.g. `"$$el"`)
524
+ * @param right Base token for *right* expression (e.g. `"$$this"`)
525
+ */ _keysEqual(keys, left, right) {
526
+ return keys.map((k) => ({ $eq: [`${left}.${k}`, `${right}.${k}`] })).reduce((acc, cur) => acc ? { $and: [acc, cur] } : cur);
527
+ }
528
+ /**
529
+ * `$replace` – overwrite the entire array with `input`.
530
+ *
531
+ * @param key Dotted path to the array
532
+ * @param input New array value (may be `undefined`)
533
+ * @private
534
+ */ _replace(key, input) {
535
+ if (input) this._set(key, input);
536
+ }
537
+ /**
538
+ * `$insert`
539
+ * - plain append → $concatArrays
540
+ * - unique / keyed → delegate to _upsert (insert-or-update)
541
+ */ _insert(key, input, keyProps) {
542
+ if (!input?.length) return;
543
+ const uniqueItems = this.collection.flatMap.get(key)?.metadata?.has("mongo.array.uniqueItems");
544
+ if (uniqueItems || keyProps.size > 0) this._upsert(key, input, keyProps);
545
+ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
546
+ }
547
+ /**
548
+ * `$upsert`
549
+ * - keyed → remove existing matching by key(s) then append candidate
550
+ * - unique → $setUnion (deep equality)
551
+ */ _upsert(key, input, keyProps) {
552
+ if (!input?.length) return;
553
+ if (keyProps.size) {
554
+ const keys = [...keyProps];
555
+ this._set(key, { $reduce: {
556
+ input,
557
+ initialValue: { $ifNull: [`$${key}`, []] },
558
+ in: { $let: {
559
+ vars: {
560
+ acc: "$$value",
561
+ cand: "$$this"
562
+ },
563
+ in: { $concatArrays: [{ $filter: {
564
+ input: "$$acc",
565
+ as: "el",
566
+ cond: { $not: this._keysEqual(keys, "$$el", "$$cand") }
567
+ } }, ["$$cand"]] }
568
+ } }
569
+ } });
570
+ return;
571
+ }
572
+ this._set(key, { $setUnion: [{ $ifNull: [`$${key}`, []] }, input] });
573
+ }
574
+ /**
575
+ * `$update`
576
+ * - keyed → map array and merge / replace matching element(s)
577
+ * - non-keyed → behave like `$addToSet` (insert only when not present)
578
+ */ _update(key, input, keyProps) {
579
+ if (!input?.length) return;
580
+ if (keyProps.size) {
581
+ const mergeStrategy = this.collection.flatMap.get(key)?.metadata?.get("mongo.patch.strategy") === "merge";
582
+ const keys = [...keyProps];
583
+ this._set(key, { $reduce: {
584
+ input,
585
+ initialValue: { $ifNull: [`$${key}`, []] },
586
+ in: { $map: {
587
+ input: "$$value",
588
+ as: "el",
589
+ in: { $cond: [
590
+ this._keysEqual(keys, "$$el", "$$this"),
591
+ mergeStrategy ? { $mergeObjects: ["$$el", "$$this"] } : "$$this",
592
+ "$$el"
593
+ ] }
594
+ } }
595
+ } });
596
+ } else this._set(key, { $setUnion: [{ $ifNull: [`$${key}`, []] }, input] });
597
+ }
598
+ /**
599
+ * `$remove`
600
+ * - keyed → filter out any element whose key set matches a payload item
601
+ * - non-keyed → deep equality remove (`$setDifference`)
602
+ */ _remove(key, input, keyProps) {
603
+ if (!input?.length) return;
604
+ if (keyProps.size) {
605
+ const keys = [...keyProps];
606
+ this._set(key, { $let: {
607
+ vars: { rem: input },
608
+ in: { $filter: {
609
+ input: { $ifNull: [`$${key}`, []] },
610
+ as: "el",
611
+ cond: { $not: { $anyElementTrue: { $map: {
612
+ input: "$$rem",
613
+ as: "r",
614
+ in: this._keysEqual(keys, "$$el", "$$r")
615
+ } } } }
616
+ } }
617
+ } });
618
+ } else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
619
+ }
620
+ constructor(collection, payload) {
621
+ _define_property$2(this, "collection", void 0);
622
+ _define_property$2(this, "payload", void 0);
623
+ /**
624
+ * Internal accumulator: filter passed to `updateOne()`.
625
+ * Filled only with the `_id` field right now.
626
+ */ _define_property$2(this, "filterObj", void 0);
627
+ /** MongoDB *update* document being built. */ _define_property$2(this, "updatePipeline", void 0);
628
+ /** Additional *options* (mainly `arrayFilters`). */ _define_property$2(this, "optionsObj", void 0);
629
+ this.collection = collection;
630
+ this.payload = payload;
631
+ this.filterObj = {};
632
+ this.updatePipeline = [];
633
+ this.optionsObj = {};
634
+ }
635
+ };
636
+
308
637
  //#endregion
309
638
  //#region packages/mongo/src/lib/as-collection.ts
310
639
  function _define_property$1(obj, key, value) {
@@ -370,17 +699,24 @@ var AsCollection = class {
370
699
  */ getValidator(purpose) {
371
700
  if (!this.validators.has(purpose)) switch (purpose) {
372
701
  case "insert": {
373
- this.validators.set(purpose, this.type.validator(this.idType === "objectId" ? { skipList: new Set(["_id"]) } : {}));
702
+ this.validators.set(purpose, this.type.validator({
703
+ plugins: [validateMongoIdPlugin, validateMongoUniqueArrayItemsPlugin],
704
+ replace(type, path) {
705
+ if (path === "_id" && type.type.tags.has("objectId")) return {
706
+ ...type,
707
+ optional: true
708
+ };
709
+ return type;
710
+ }
711
+ }));
374
712
  break;
375
713
  }
376
714
  case "update": {
377
- this.validators.set(purpose, this.type.validator());
715
+ this.validators.set(purpose, this.type.validator({ plugins: [validateMongoIdPlugin] }));
378
716
  break;
379
717
  }
380
718
  case "patch": {
381
- this.validators.set(purpose, this.type.validator({ partial: (def, path) => {
382
- return path === "" || def.metadata.get("mongo.patch.strategy") === "merge";
383
- } }));
719
+ this.validators.set(purpose, CollectionPatcher.prepareValidator(this));
384
720
  break;
385
721
  }
386
722
  default: throw new Error(`Unknown validator purpose: ${purpose}`);
@@ -431,20 +767,24 @@ else {
431
767
  if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
432
768
  }
433
769
  }
434
- _flattenType(type, prefix) {
770
+ _flattenType(type, prefix = "", inComplexTypeOrArray = false) {
435
771
  switch (type.type.kind) {
436
772
  case "object":
437
773
  this._flatMap?.set(prefix || "", type);
438
774
  const items = Array.from(type.type.props.entries());
439
- for (const [key, value] of items) this._flattenType(value, prefix ? `${prefix}.${key}` : key);
775
+ for (const [key, value] of items) this._flattenType(value, prefix ? `${prefix}.${key}` : key, inComplexTypeOrArray);
440
776
  break;
441
777
  case "array":
442
- this._flatMap?.set(prefix || "", type);
443
- if (type.type.of.type.kind) this._flattenType(type.type.of, prefix);
778
+ let typeArray = type;
779
+ if (!inComplexTypeOrArray) {
780
+ typeArray = (0, __atscript_typescript.defineAnnotatedType)().refTo(type).copyMetadata(type.metadata).$type;
781
+ typeArray.metadata.set("mongo.__topLevelArray", true);
782
+ }
783
+ this._flatMap?.set(prefix || "", typeArray);
444
784
  break;
445
785
  case "intersection":
446
786
  case "tuple":
447
- case "union": for (const item of type.type.items) this._flattenType(item, prefix);
787
+ case "union": for (const item of type.type.items) this._flattenType(item, prefix, true);
448
788
  default:
449
789
  this._flatMap?.set(prefix || "", type);
450
790
  break;
@@ -465,6 +805,9 @@ else {
465
805
  text: { fuzzy: { maxEdits: textSearch.fuzzy || 0 } }
466
806
  });
467
807
  }
808
+ get uniqueProps() {
809
+ return this._uniqueProps;
810
+ }
468
811
  _finalizeIndexesForCollection() {
469
812
  for (const [key, value] of Array.from(this._vectorFilters.entries())) {
470
813
  const index = this._indexes.get(key);
@@ -473,6 +816,10 @@ else {
473
816
  path: value
474
817
  });
475
818
  }
819
+ for (const [, value] of Array.from(this._indexes.entries())) if (value.type === "unique") {
820
+ const keys = Object.keys(value.fields);
821
+ if (keys.length === 1) this._uniqueProps.add(keys[0]);
822
+ }
476
823
  }
477
824
  _prepareIndexesForField(fieldName, metadata) {
478
825
  for (const index of metadata.get("mongo.index.plain") || []) this._addIndexField("plain", index === true ? fieldName : index, fieldName);
@@ -588,39 +935,62 @@ else toUpdate.add(remote.name);
588
935
  default:
589
936
  }
590
937
  }
938
+ insert(payload, options) {
939
+ const toInsert = this.prepareInsert(payload);
940
+ return Array.isArray(toInsert) ? this.collection.insertMany(toInsert, options) : this.collection.insertOne(toInsert, options);
941
+ }
942
+ replace(payload, options) {
943
+ const [filter, replace, opts] = this.prepareReplace(payload).toArgs();
944
+ return this.collection.replaceOne(filter, replace, {
945
+ ...opts,
946
+ ...options
947
+ });
948
+ }
949
+ update(payload, options) {
950
+ const [filter, update, opts] = this.prepareUpdate(payload).toArgs();
951
+ return this.collection.updateOne(filter, update, {
952
+ ...opts,
953
+ ...options
954
+ });
955
+ }
591
956
  prepareInsert(payload) {
592
957
  const v = this.getValidator("insert");
593
- if (v.validate(payload)) {
594
- const data = { ...payload };
958
+ const arr = Array.isArray(payload) ? payload : [payload];
959
+ const prepared = [];
960
+ for (const item of arr) if (v.validate(item)) {
961
+ const data = { ...item };
595
962
  if (data._id) data._id = this.prepareId(data._id);
596
963
  else if (this.idType !== "objectId") throw new Error("Missing \"_id\" field");
597
- return data;
598
- }
599
- throw new Error("Invalid payload");
964
+ prepared.push(data);
965
+ } else throw new Error("Invalid payload");
966
+ return prepared.length === 1 ? prepared[0] : prepared;
600
967
  }
601
- prepareUpdate(payload) {
602
- const v = this.getValidator("insert");
968
+ prepareReplace(payload) {
969
+ const v = this.getValidator("update");
603
970
  if (v.validate(payload)) {
604
- const data = { ...payload };
605
- data._id = this.prepareId(data._id);
606
- return data;
971
+ const _id = this.prepareId(payload._id);
972
+ const data = {
973
+ ...payload,
974
+ _id
975
+ };
976
+ return {
977
+ toArgs: () => [
978
+ { _id },
979
+ data,
980
+ {}
981
+ ],
982
+ filter: { _id },
983
+ updateFilter: data,
984
+ updateOptions: {}
985
+ };
607
986
  }
608
987
  throw new Error("Invalid payload");
609
988
  }
610
- preparePatch(payload) {
989
+ prepareUpdate(payload) {
611
990
  const v = this.getValidator("patch");
612
- if (v.validate(payload)) return { $set: this._flattenPayload(payload) };
991
+ if (v.validate(payload)) return new CollectionPatcher(this, payload).preparePatch();
613
992
  throw new Error("Invalid payload");
614
993
  }
615
- _flattenPayload(payload, prefix = "", obj = {}) {
616
- const evalKey = (k) => prefix ? `${prefix}.${k}` : k;
617
- for (const [_key, value] of Object.entries(payload)) {
618
- const key = evalKey(_key);
619
- if (typeof value === "object" && this.flatMap.get(key)?.metadata?.get("mongo.patch.strategy") === "merge") this._flattenPayload(value, key, obj);
620
- else obj[key] = value;
621
- }
622
- return obj;
623
- }
624
994
  constructor(asMongo, _type, logger = NoopLogger) {
625
995
  _define_property$1(this, "asMongo", void 0);
626
996
  _define_property$1(this, "_type", void 0);
@@ -631,12 +1001,14 @@ else obj[key] = value;
631
1001
  _define_property$1(this, "_indexes", void 0);
632
1002
  _define_property$1(this, "_vectorFilters", void 0);
633
1003
  _define_property$1(this, "_flatMap", void 0);
1004
+ _define_property$1(this, "_uniqueProps", void 0);
634
1005
  this.asMongo = asMongo;
635
1006
  this._type = _type;
636
1007
  this.logger = logger;
637
1008
  this.validators = new Map();
638
1009
  this._indexes = new Map();
639
1010
  this._vectorFilters = new Map();
1011
+ this._uniqueProps = new Set();
640
1012
  if (!(0, __atscript_typescript.isAnnotatedType)(_type)) throw new Error("Atscript Annotated Type expected");
641
1013
  const name = _type.metadata.get("mongo.collection");
642
1014
  if (!name) throw new Error("@mongo.collection annotation expected with collection name");
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { TAtscriptPlugin } from '@atscript/core';
2
- import { TAtscriptAnnotatedTypeConstructor, TAtscriptAnnotatedType, TAtscriptTypeObject, TMetadataMap } from '@atscript/typescript';
3
2
  import * as mongodb from 'mongodb';
4
- import { MongoClient, Collection, ObjectId, WithId, UpdateFilter, MatchKeysAndValues } from 'mongodb';
3
+ import { MongoClient, Collection, ObjectId, InsertOneOptions, ReplaceOptions, UpdateOptions, OptionalUnlessRequiredId, Filter, WithoutId } from 'mongodb';
4
+ import { TAtscriptAnnotatedTypeConstructor, TAtscriptAnnotatedType, TAtscriptTypeObject, TMetadataMap } from '@atscript/typescript';
5
5
 
6
6
  declare const MongoPlugin: () => TAtscriptPlugin;
7
7
 
@@ -48,25 +48,74 @@ declare class AsCollection<T extends TAtscriptAnnotatedTypeConstructor> {
48
48
  constructor(asMongo: AsMongo, _type: T, logger?: TGenericLogger);
49
49
  exists(): Promise<boolean>;
50
50
  ensureExists(): Promise<void>;
51
+ /**
52
+ * Returns the a type definition of the "_id" prop.
53
+ */
51
54
  get idType(): 'string' | 'number' | 'objectId';
55
+ /**
56
+ * Transforms an "_id" value to the expected type (`ObjectId`, `number`, or `string`).
57
+ * Assumes input has already been validated.
58
+ *
59
+ * @param {string | number | ObjectId} id - The validated ID.
60
+ * @returns {string | number | ObjectId} - The transformed ID.
61
+ * @throws {Error} If the `_id` type is unknown.
62
+ */
52
63
  prepareId<D = string | number | ObjectId>(id: string | number | ObjectId): D;
64
+ /**
65
+ * Retrieves a validator for a given purpose. If the validator is not already cached,
66
+ * it creates and stores a new one based on the purpose.
67
+ *
68
+ * @param {TValidatorPurpose} purpose - The validation purpose (`input`, `update`, `patch`).
69
+ * @returns {Validator} The corresponding validator instance.
70
+ * @throws {Error} If an unknown purpose is provided.
71
+ */
53
72
  getValidator(purpose: TValidatorPurpose): any;
54
73
  get type(): TAtscriptAnnotatedType<TAtscriptTypeObject>;
55
74
  get indexes(): Map<string, TIndex>;
56
75
  protected _addIndexField(type: TPlainIndex['type'], name: string, field: string, weight?: number): void;
57
76
  protected _setSearchIndex(type: TSearchIndex['type'], name: string | undefined, definition: TMongoSearchIndexDefinition): void;
58
77
  protected _addFieldToSearchIndex(type: TSearchIndex['type'], _name: string | undefined, fieldName: string, analyzer?: string): void;
59
- protected _flattenType(type: TAtscriptAnnotatedType, prefix?: string): void;
78
+ protected _flattenType(type: TAtscriptAnnotatedType, prefix?: string, inComplexTypeOrArray?: boolean): void;
60
79
  protected _prepareIndexesForCollection(): void;
80
+ protected _uniqueProps: Set<string>;
81
+ get uniqueProps(): Set<string>;
61
82
  protected _finalizeIndexesForCollection(): void;
62
83
  protected _prepareIndexesForField(fieldName: string, metadata: TMetadataMap<AtscriptMetadata>): void;
63
84
  protected _flatten(): void;
64
85
  get flatMap(): Map<string, TAtscriptAnnotatedType>;
65
86
  syncIndexes(): Promise<void>;
66
- prepareInsert(payload: any): InstanceType<T>;
67
- prepareUpdate(payload: any): WithId<InstanceType<T>>;
68
- preparePatch(payload: any): UpdateFilter<InstanceType<T>>;
69
- protected _flattenPayload(payload: T, prefix?: string, obj?: MatchKeysAndValues<InstanceType<T>>): MatchKeysAndValues<InstanceType<T>>;
87
+ insert(payload: (Omit<InstanceType<T>, '_id'> & {
88
+ _id?: InstanceType<T>['_id'] | ObjectId;
89
+ }) | (Omit<InstanceType<T>, '_id'> & {
90
+ _id?: InstanceType<T>['_id'] | ObjectId;
91
+ })[], options?: InsertOneOptions): Promise<mongodb.InsertManyResult<InstanceType<T>>> | Promise<mongodb.InsertOneResult<InstanceType<T>>>;
92
+ replace(payload: Omit<InstanceType<T>, '_id'> & {
93
+ _id: InstanceType<T>['_id'] | ObjectId;
94
+ }, options?: ReplaceOptions): Promise<mongodb.UpdateResult<InstanceType<T>>>;
95
+ update(payload: AsMongoPatch<Omit<InstanceType<T>, '_id'>> & {
96
+ _id: InstanceType<T>['_id'] | ObjectId;
97
+ }, options?: UpdateOptions): Promise<mongodb.UpdateResult<InstanceType<T>>>;
98
+ prepareInsert(payload: (Omit<InstanceType<T>, '_id'> & {
99
+ _id?: InstanceType<T>['_id'] | ObjectId;
100
+ }) | (Omit<InstanceType<T>, '_id'> & {
101
+ _id?: InstanceType<T>['_id'] | ObjectId;
102
+ })[]): OptionalUnlessRequiredId<InstanceType<T>> | OptionalUnlessRequiredId<InstanceType<T>>[];
103
+ prepareReplace(payload: Omit<InstanceType<T>, '_id'> & {
104
+ _id: InstanceType<T>['_id'] | ObjectId;
105
+ }): {
106
+ toArgs: () => [Filter<InstanceType<T>>, WithoutId<InstanceType<T>>, ReplaceOptions];
107
+ filter: Filter<InstanceType<T>>;
108
+ updateFilter: WithoutId<InstanceType<T>>;
109
+ updateOptions: ReplaceOptions;
110
+ };
111
+ prepareUpdate(payload: AsMongoPatch<Omit<InstanceType<T>, '_id'>> & {
112
+ _id: InstanceType<T>['_id'] | ObjectId;
113
+ }): {
114
+ toArgs: () => [Filter<InstanceType<T>>, mongodb.Document[] | mongodb.UpdateFilter<InstanceType<T>>, UpdateOptions];
115
+ filter: Filter<InstanceType<T>>;
116
+ updateFilter: mongodb.Document[];
117
+ updateOptions: UpdateOptions;
118
+ };
70
119
  }
71
120
  type TVectorSimilarity = 'cosine' | 'euclidean' | 'dotProduct';
72
121
  type TMongoSearchIndexDefinition = {
@@ -90,5 +139,26 @@ type TMongoSearchIndexDefinition = {
90
139
  };
91
140
  };
92
141
  };
142
+ type TArrayPatch<A extends readonly unknown[]> = {
143
+ $replace?: A;
144
+ $insert?: A;
145
+ $upsert?: A;
146
+ $update?: Partial<TArrayElement<A>>[];
147
+ $remove?: Partial<TArrayElement<A>>[];
148
+ };
149
+ type TArrayElement<ArrayType extends readonly unknown[]> = ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
150
+ /**
151
+ * AsMongoPatch<T>
152
+ * ─────────────────
153
+ * - For every key K in T:
154
+ * • if T[K] is `X[]`, rewrite it to `TArrayPatch<X[]>`
155
+ * • otherwise omit the key (feel free to keep it if you want)
156
+ *
157
+ * The result is an *optional* property bag that matches a patch payload
158
+ * for array fields only.
159
+ */
160
+ type AsMongoPatch<T> = {
161
+ [K in keyof T]?: T[K] extends Array<infer _> ? TArrayPatch<T[K]> : Partial<T[K]>;
162
+ };
93
163
 
94
164
  export { AsCollection, AsMongo, MongoPlugin };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AnnotationSpec, isArray, isInterface, isPrimitive, isRef, isStructure } from "@atscript/core";
2
- import { isAnnotatedType } from "@atscript/typescript";
2
+ import { defineAnnotatedType, isAnnotatedType, isAnnotatedTypeOfPrimitive } from "@atscript/typescript";
3
3
  import { MongoClient, ObjectId } from "mongodb";
4
4
 
5
5
  //#region packages/mongo/src/plugin/primitives.ts
@@ -82,6 +82,15 @@ const annotations = { mongo: {
82
82
  });
83
83
  }
84
84
  }),
85
+ autoIndexes: new AnnotationSpec({
86
+ description: "Switch on/off the automatic index creation. Works with as-mongo moost controller.\n\nDefault: true",
87
+ nodeType: ["interface"],
88
+ argument: {
89
+ name: "type",
90
+ type: "boolean",
91
+ description: "On/Off the automatic index creation"
92
+ }
93
+ }),
85
94
  index: {
86
95
  plain: new AnnotationSpec({
87
96
  description: "Defines a **standard MongoDB index** on a field.\n\n- Improves query performance on indexed fields.\n- Can be used for **single-field** or **compound** indexes.\n\n**Example:**\n```atscript\n@mongo.index.plain \"departmentIndex\"\ndepartment: string\n```\n",
@@ -254,6 +263,28 @@ const annotations = { mongo: {
254
263
  });
255
264
  return errors;
256
265
  }
266
+ }) },
267
+ array: { uniqueItems: new AnnotationSpec({
268
+ description: "Marks an **array field** as containing *globally unique items* when handling **patch `$insert` operations**.\n\n- Forces the patcher to use **set-semantics** (`$setUnion`) instead of a plain append, so duplicates are silently skipped.\n- Has **no effect** on `$replace`, `$update`, or `$remove`.\n- If the array’s element type already defines one or more `@meta.isKey` properties, *uniqueness is implied* and this annotation is unnecessary (but harmless).\n\n**Example:**\n```atscript\n@mongo.array.uniqueItems\ntags: string[]\n\n// Later in a patch payload …\n{\n $insert: {\n tags: [\"mongo\", \"mongo\"] // second \"mongo\" is ignored\n }\n}\n```\n",
269
+ nodeType: ["prop"],
270
+ multiple: false,
271
+ validate(token, args, doc) {
272
+ const field = token.parentNode;
273
+ const errors = [];
274
+ const definition = field.getDefinition();
275
+ if (!definition) return errors;
276
+ let wrongType = false;
277
+ if (isRef(definition)) {
278
+ const def = doc.unwindType(definition.id, definition.chain)?.def;
279
+ if (!isArray(def)) wrongType = true;
280
+ } else if (!isArray(definition)) wrongType = true;
281
+ if (wrongType) errors.push({
282
+ message: `[mongo] type of array expected when using @mongo.array.uniqueItems`,
283
+ severity: 1,
284
+ range: token.range
285
+ });
286
+ return errors;
287
+ }
257
288
  }) }
258
289
  } };
259
290
 
@@ -281,6 +312,304 @@ const NoopLogger = {
281
312
  debug: () => {}
282
313
  };
283
314
 
315
+ //#endregion
316
+ //#region packages/mongo/src/lib/validate-plugins.ts
317
+ const validateMongoIdPlugin = (ctx, def, value) => {
318
+ if (ctx.path === "_id" && def.type.tags.has("objectId")) return ctx.validateAnnotatedType(def, value instanceof ObjectId ? value.toString() : value);
319
+ };
320
+ const validateMongoUniqueArrayItemsPlugin = (ctx, def, value) => {
321
+ if (def.metadata.has("mongo.array.uniqueItems") && def.type.kind === "array") {
322
+ if (Array.isArray(value)) {
323
+ const separator = "▼↩";
324
+ const seen = new Set();
325
+ const keyProps = CollectionPatcher.getKeyProps(def);
326
+ for (const item of value) {
327
+ let key = "";
328
+ if (keyProps.size) for (const prop of keyProps) key += JSON.stringify(item[prop]) + separator;
329
+ else key = JSON.stringify(item);
330
+ if (seen.has(key)) {
331
+ ctx.error(`Duplicate items are not allowed`);
332
+ return false;
333
+ }
334
+ seen.add(key);
335
+ }
336
+ }
337
+ }
338
+ return undefined;
339
+ };
340
+
341
+ //#endregion
342
+ //#region packages/mongo/src/lib/collection-patcher.ts
343
+ function _define_property$2(obj, key, value) {
344
+ if (key in obj) Object.defineProperty(obj, key, {
345
+ value,
346
+ enumerable: true,
347
+ configurable: true,
348
+ writable: true
349
+ });
350
+ else obj[key] = value;
351
+ return obj;
352
+ }
353
+ var CollectionPatcher = class CollectionPatcher {
354
+ /**
355
+ * Extract a set of *key properties* (annotated with `@meta.isKey`) from an
356
+ * array‐of‐objects type definition. These keys uniquely identify an element
357
+ * inside the array and are later used for `$update`, `$remove` and `$upsert`.
358
+ *
359
+ * @param def Atscript array type
360
+ * @returns Set of property names marked as keys; empty set if none
361
+ */ static getKeyProps(def) {
362
+ if (def.type.of.type.kind === "object") {
363
+ const objType = def.type.of.type;
364
+ const keyProps = new Set();
365
+ for (const [key, val] of objType.props.entries()) if (val.metadata.get("meta.isKey")) keyProps.add(key);
366
+ return keyProps;
367
+ }
368
+ return new Set();
369
+ }
370
+ /**
371
+ * Build a runtime *Validator* that understands the extended patch payload.
372
+ *
373
+ * * Adds per‑array *patch* wrappers (the `$replace`, `$insert`, … fields).
374
+ * * Honors `mongo.patch.strategy === "merge"` metadata.
375
+ *
376
+ * @param collection Target collection wrapper
377
+ * @returns Atscript Validator
378
+ */ static prepareValidator(collection) {
379
+ return collection.type.validator({
380
+ plugins: [validateMongoIdPlugin, validateMongoUniqueArrayItemsPlugin],
381
+ replace: (def, path) => {
382
+ if (path === "" && def.type.kind === "object") {
383
+ const obj = defineAnnotatedType("object").copyMetadata(def.metadata);
384
+ for (const [prop, type] of def.type.props.entries()) obj.prop(prop, defineAnnotatedType().refTo(type).copyMetadata(type.metadata).optional(prop !== "_id").$type);
385
+ return obj.$type;
386
+ }
387
+ if (def.type.kind === "array" && collection.flatMap.get(path)?.metadata.get("mongo.__topLevelArray") && !def.metadata.has("mongo.__patchArrayValue")) {
388
+ const defArray = def;
389
+ const mergeStrategy = defArray.metadata.get("mongo.patch.strategy") === "merge";
390
+ function getPatchType() {
391
+ const isPrimitive$1 = isAnnotatedTypeOfPrimitive(defArray.type.of);
392
+ if (isPrimitive$1) return defineAnnotatedType().refTo(def).copyMetadata(def.metadata).annotate("mongo.__patchArrayValue").optional().$type;
393
+ if (defArray.type.of.type.kind === "object") {
394
+ const objType = defArray.type.of.type;
395
+ const t = defineAnnotatedType("object").copyMetadata(defArray.type.of.metadata);
396
+ const keyProps = CollectionPatcher.getKeyProps(defArray);
397
+ for (const [key, val] of objType.props.entries()) if (keyProps.size) if (keyProps.has(key)) t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).$type);
398
+ else t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).optional().$type);
399
+ else t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).optional(!!val.optional).$type);
400
+ return defineAnnotatedType("array").of(t.$type).copyMetadata(def.metadata).annotate("mongo.__patchArrayValue").optional().$type;
401
+ }
402
+ return undefined;
403
+ }
404
+ const fullType = defineAnnotatedType().refTo(def).copyMetadata(def.metadata).annotate("mongo.__patchArrayValue").optional().$type;
405
+ const patchType = getPatchType();
406
+ 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;
407
+ }
408
+ return def;
409
+ },
410
+ partial: (def, path) => {
411
+ return path !== "" && def.metadata.get("mongo.patch.strategy") === "merge";
412
+ }
413
+ });
414
+ }
415
+ /**
416
+ * Entry point – walk the payload, build `filter`, `update` and `options`.
417
+ *
418
+ * @returns Helper object exposing both individual parts and
419
+ * a `.toArgs()` convenience callback.
420
+ */ preparePatch() {
421
+ this.filterObj = { _id: this.collection.prepareId(this.payload._id) };
422
+ this.flattenPayload(this.payload);
423
+ let updateFilter = this.updatePipeline;
424
+ return {
425
+ toArgs: () => [
426
+ this.filterObj,
427
+ updateFilter,
428
+ this.optionsObj
429
+ ],
430
+ filter: this.filterObj,
431
+ updateFilter,
432
+ updateOptions: this.optionsObj
433
+ };
434
+ }
435
+ /**
436
+ * Helper – lazily create `$set` section and assign *key* → *value*.
437
+ *
438
+ * @param key Fully‑qualified dotted path
439
+ * @param val Value to be written
440
+ * @private
441
+ */ _set(key, val) {
442
+ for (const pipe of this.updatePipeline) {
443
+ if (!pipe.$set) pipe.$set = {};
444
+ if (!pipe.$set[key]) {
445
+ pipe.$set[key] = val;
446
+ return;
447
+ }
448
+ }
449
+ this.updatePipeline.push({ $set: { [key]: val } });
450
+ }
451
+ /**
452
+ * Recursively walk through the patch *payload* and convert it into `$set`/…
453
+ * statements. Top‑level arrays are delegated to {@link parseArrayPatch}.
454
+ *
455
+ * @param payload Current payload chunk
456
+ * @param prefix Dotted path accumulated so far
457
+ * @private
458
+ */ flattenPayload(payload, prefix = "") {
459
+ const evalKey = (k) => prefix ? `${prefix}.${k}` : k;
460
+ for (const [_key, value] of Object.entries(payload)) {
461
+ const key = evalKey(_key);
462
+ const flatType = this.collection.flatMap.get(key);
463
+ const topLevelArray = flatType?.metadata?.get("mongo.__topLevelArray");
464
+ if (typeof value === "object" && topLevelArray) this.parseArrayPatch(key, value);
465
+ else if (typeof value === "object" && this.collection.flatMap.get(key)?.metadata?.get("mongo.patch.strategy") === "merge") this.flattenPayload(value, key);
466
+ else if (key !== "_id") this._set(key, value);
467
+ }
468
+ return this.updatePipeline;
469
+ }
470
+ /**
471
+ * Dispatch a *single* array patch. Exactly one of `$replace`, `$insert`,
472
+ * `$upsert`, `$update`, `$remove` must be present – otherwise we throw.
473
+ *
474
+ * @param key Dotted path to the array field
475
+ * @param value Payload slice for that field
476
+ * @private
477
+ */ parseArrayPatch(key, value) {
478
+ const flatType = this.collection.flatMap.get(key);
479
+ const toRemove = value.$remove;
480
+ const toReplace = value.$replace;
481
+ const toInsert = value.$insert;
482
+ const toUpsert = value.$upsert;
483
+ const toUpdate = value.$update;
484
+ const keyProps = flatType?.type.kind === "array" ? CollectionPatcher.getKeyProps(flatType) : new Set();
485
+ this._remove(key, toRemove, keyProps);
486
+ this._replace(key, toReplace);
487
+ this._insert(key, toInsert, keyProps);
488
+ this._upsert(key, toUpsert, keyProps);
489
+ this._update(key, toUpdate, keyProps);
490
+ }
491
+ /**
492
+ * Build an *aggregation‐expression* that checks equality by **all** keys in
493
+ * `keys`. Example output for keys `["id", "lang"]` and bases `a`, `b`:
494
+ * ```json
495
+ * { "$and": [ { "$eq": ["$$a.id", "$$b.id"] }, { "$eq": ["$$a.lang", "$$b.lang"] } ] }
496
+ * ```
497
+ *
498
+ * @param keys Ordered list of key property names
499
+ * @param left Base token for *left* expression (e.g. `"$$el"`)
500
+ * @param right Base token for *right* expression (e.g. `"$$this"`)
501
+ */ _keysEqual(keys, left, right) {
502
+ return keys.map((k) => ({ $eq: [`${left}.${k}`, `${right}.${k}`] })).reduce((acc, cur) => acc ? { $and: [acc, cur] } : cur);
503
+ }
504
+ /**
505
+ * `$replace` – overwrite the entire array with `input`.
506
+ *
507
+ * @param key Dotted path to the array
508
+ * @param input New array value (may be `undefined`)
509
+ * @private
510
+ */ _replace(key, input) {
511
+ if (input) this._set(key, input);
512
+ }
513
+ /**
514
+ * `$insert`
515
+ * - plain append → $concatArrays
516
+ * - unique / keyed → delegate to _upsert (insert-or-update)
517
+ */ _insert(key, input, keyProps) {
518
+ if (!input?.length) return;
519
+ const uniqueItems = this.collection.flatMap.get(key)?.metadata?.has("mongo.array.uniqueItems");
520
+ if (uniqueItems || keyProps.size > 0) this._upsert(key, input, keyProps);
521
+ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
522
+ }
523
+ /**
524
+ * `$upsert`
525
+ * - keyed → remove existing matching by key(s) then append candidate
526
+ * - unique → $setUnion (deep equality)
527
+ */ _upsert(key, input, keyProps) {
528
+ if (!input?.length) return;
529
+ if (keyProps.size) {
530
+ const keys = [...keyProps];
531
+ this._set(key, { $reduce: {
532
+ input,
533
+ initialValue: { $ifNull: [`$${key}`, []] },
534
+ in: { $let: {
535
+ vars: {
536
+ acc: "$$value",
537
+ cand: "$$this"
538
+ },
539
+ in: { $concatArrays: [{ $filter: {
540
+ input: "$$acc",
541
+ as: "el",
542
+ cond: { $not: this._keysEqual(keys, "$$el", "$$cand") }
543
+ } }, ["$$cand"]] }
544
+ } }
545
+ } });
546
+ return;
547
+ }
548
+ this._set(key, { $setUnion: [{ $ifNull: [`$${key}`, []] }, input] });
549
+ }
550
+ /**
551
+ * `$update`
552
+ * - keyed → map array and merge / replace matching element(s)
553
+ * - non-keyed → behave like `$addToSet` (insert only when not present)
554
+ */ _update(key, input, keyProps) {
555
+ if (!input?.length) return;
556
+ if (keyProps.size) {
557
+ const mergeStrategy = this.collection.flatMap.get(key)?.metadata?.get("mongo.patch.strategy") === "merge";
558
+ const keys = [...keyProps];
559
+ this._set(key, { $reduce: {
560
+ input,
561
+ initialValue: { $ifNull: [`$${key}`, []] },
562
+ in: { $map: {
563
+ input: "$$value",
564
+ as: "el",
565
+ in: { $cond: [
566
+ this._keysEqual(keys, "$$el", "$$this"),
567
+ mergeStrategy ? { $mergeObjects: ["$$el", "$$this"] } : "$$this",
568
+ "$$el"
569
+ ] }
570
+ } }
571
+ } });
572
+ } else this._set(key, { $setUnion: [{ $ifNull: [`$${key}`, []] }, input] });
573
+ }
574
+ /**
575
+ * `$remove`
576
+ * - keyed → filter out any element whose key set matches a payload item
577
+ * - non-keyed → deep equality remove (`$setDifference`)
578
+ */ _remove(key, input, keyProps) {
579
+ if (!input?.length) return;
580
+ if (keyProps.size) {
581
+ const keys = [...keyProps];
582
+ this._set(key, { $let: {
583
+ vars: { rem: input },
584
+ in: { $filter: {
585
+ input: { $ifNull: [`$${key}`, []] },
586
+ as: "el",
587
+ cond: { $not: { $anyElementTrue: { $map: {
588
+ input: "$$rem",
589
+ as: "r",
590
+ in: this._keysEqual(keys, "$$el", "$$r")
591
+ } } } }
592
+ } }
593
+ } });
594
+ } else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
595
+ }
596
+ constructor(collection, payload) {
597
+ _define_property$2(this, "collection", void 0);
598
+ _define_property$2(this, "payload", void 0);
599
+ /**
600
+ * Internal accumulator: filter passed to `updateOne()`.
601
+ * Filled only with the `_id` field right now.
602
+ */ _define_property$2(this, "filterObj", void 0);
603
+ /** MongoDB *update* document being built. */ _define_property$2(this, "updatePipeline", void 0);
604
+ /** Additional *options* (mainly `arrayFilters`). */ _define_property$2(this, "optionsObj", void 0);
605
+ this.collection = collection;
606
+ this.payload = payload;
607
+ this.filterObj = {};
608
+ this.updatePipeline = [];
609
+ this.optionsObj = {};
610
+ }
611
+ };
612
+
284
613
  //#endregion
285
614
  //#region packages/mongo/src/lib/as-collection.ts
286
615
  function _define_property$1(obj, key, value) {
@@ -346,17 +675,24 @@ var AsCollection = class {
346
675
  */ getValidator(purpose) {
347
676
  if (!this.validators.has(purpose)) switch (purpose) {
348
677
  case "insert": {
349
- this.validators.set(purpose, this.type.validator(this.idType === "objectId" ? { skipList: new Set(["_id"]) } : {}));
678
+ this.validators.set(purpose, this.type.validator({
679
+ plugins: [validateMongoIdPlugin, validateMongoUniqueArrayItemsPlugin],
680
+ replace(type, path) {
681
+ if (path === "_id" && type.type.tags.has("objectId")) return {
682
+ ...type,
683
+ optional: true
684
+ };
685
+ return type;
686
+ }
687
+ }));
350
688
  break;
351
689
  }
352
690
  case "update": {
353
- this.validators.set(purpose, this.type.validator());
691
+ this.validators.set(purpose, this.type.validator({ plugins: [validateMongoIdPlugin] }));
354
692
  break;
355
693
  }
356
694
  case "patch": {
357
- this.validators.set(purpose, this.type.validator({ partial: (def, path) => {
358
- return path === "" || def.metadata.get("mongo.patch.strategy") === "merge";
359
- } }));
695
+ this.validators.set(purpose, CollectionPatcher.prepareValidator(this));
360
696
  break;
361
697
  }
362
698
  default: throw new Error(`Unknown validator purpose: ${purpose}`);
@@ -407,20 +743,24 @@ else {
407
743
  if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
408
744
  }
409
745
  }
410
- _flattenType(type, prefix) {
746
+ _flattenType(type, prefix = "", inComplexTypeOrArray = false) {
411
747
  switch (type.type.kind) {
412
748
  case "object":
413
749
  this._flatMap?.set(prefix || "", type);
414
750
  const items = Array.from(type.type.props.entries());
415
- for (const [key, value] of items) this._flattenType(value, prefix ? `${prefix}.${key}` : key);
751
+ for (const [key, value] of items) this._flattenType(value, prefix ? `${prefix}.${key}` : key, inComplexTypeOrArray);
416
752
  break;
417
753
  case "array":
418
- this._flatMap?.set(prefix || "", type);
419
- if (type.type.of.type.kind) this._flattenType(type.type.of, prefix);
754
+ let typeArray = type;
755
+ if (!inComplexTypeOrArray) {
756
+ typeArray = defineAnnotatedType().refTo(type).copyMetadata(type.metadata).$type;
757
+ typeArray.metadata.set("mongo.__topLevelArray", true);
758
+ }
759
+ this._flatMap?.set(prefix || "", typeArray);
420
760
  break;
421
761
  case "intersection":
422
762
  case "tuple":
423
- case "union": for (const item of type.type.items) this._flattenType(item, prefix);
763
+ case "union": for (const item of type.type.items) this._flattenType(item, prefix, true);
424
764
  default:
425
765
  this._flatMap?.set(prefix || "", type);
426
766
  break;
@@ -441,6 +781,9 @@ else {
441
781
  text: { fuzzy: { maxEdits: textSearch.fuzzy || 0 } }
442
782
  });
443
783
  }
784
+ get uniqueProps() {
785
+ return this._uniqueProps;
786
+ }
444
787
  _finalizeIndexesForCollection() {
445
788
  for (const [key, value] of Array.from(this._vectorFilters.entries())) {
446
789
  const index = this._indexes.get(key);
@@ -449,6 +792,10 @@ else {
449
792
  path: value
450
793
  });
451
794
  }
795
+ for (const [, value] of Array.from(this._indexes.entries())) if (value.type === "unique") {
796
+ const keys = Object.keys(value.fields);
797
+ if (keys.length === 1) this._uniqueProps.add(keys[0]);
798
+ }
452
799
  }
453
800
  _prepareIndexesForField(fieldName, metadata) {
454
801
  for (const index of metadata.get("mongo.index.plain") || []) this._addIndexField("plain", index === true ? fieldName : index, fieldName);
@@ -564,39 +911,62 @@ else toUpdate.add(remote.name);
564
911
  default:
565
912
  }
566
913
  }
914
+ insert(payload, options) {
915
+ const toInsert = this.prepareInsert(payload);
916
+ return Array.isArray(toInsert) ? this.collection.insertMany(toInsert, options) : this.collection.insertOne(toInsert, options);
917
+ }
918
+ replace(payload, options) {
919
+ const [filter, replace, opts] = this.prepareReplace(payload).toArgs();
920
+ return this.collection.replaceOne(filter, replace, {
921
+ ...opts,
922
+ ...options
923
+ });
924
+ }
925
+ update(payload, options) {
926
+ const [filter, update, opts] = this.prepareUpdate(payload).toArgs();
927
+ return this.collection.updateOne(filter, update, {
928
+ ...opts,
929
+ ...options
930
+ });
931
+ }
567
932
  prepareInsert(payload) {
568
933
  const v = this.getValidator("insert");
569
- if (v.validate(payload)) {
570
- const data = { ...payload };
934
+ const arr = Array.isArray(payload) ? payload : [payload];
935
+ const prepared = [];
936
+ for (const item of arr) if (v.validate(item)) {
937
+ const data = { ...item };
571
938
  if (data._id) data._id = this.prepareId(data._id);
572
939
  else if (this.idType !== "objectId") throw new Error("Missing \"_id\" field");
573
- return data;
574
- }
575
- throw new Error("Invalid payload");
940
+ prepared.push(data);
941
+ } else throw new Error("Invalid payload");
942
+ return prepared.length === 1 ? prepared[0] : prepared;
576
943
  }
577
- prepareUpdate(payload) {
578
- const v = this.getValidator("insert");
944
+ prepareReplace(payload) {
945
+ const v = this.getValidator("update");
579
946
  if (v.validate(payload)) {
580
- const data = { ...payload };
581
- data._id = this.prepareId(data._id);
582
- return data;
947
+ const _id = this.prepareId(payload._id);
948
+ const data = {
949
+ ...payload,
950
+ _id
951
+ };
952
+ return {
953
+ toArgs: () => [
954
+ { _id },
955
+ data,
956
+ {}
957
+ ],
958
+ filter: { _id },
959
+ updateFilter: data,
960
+ updateOptions: {}
961
+ };
583
962
  }
584
963
  throw new Error("Invalid payload");
585
964
  }
586
- preparePatch(payload) {
965
+ prepareUpdate(payload) {
587
966
  const v = this.getValidator("patch");
588
- if (v.validate(payload)) return { $set: this._flattenPayload(payload) };
967
+ if (v.validate(payload)) return new CollectionPatcher(this, payload).preparePatch();
589
968
  throw new Error("Invalid payload");
590
969
  }
591
- _flattenPayload(payload, prefix = "", obj = {}) {
592
- const evalKey = (k) => prefix ? `${prefix}.${k}` : k;
593
- for (const [_key, value] of Object.entries(payload)) {
594
- const key = evalKey(_key);
595
- if (typeof value === "object" && this.flatMap.get(key)?.metadata?.get("mongo.patch.strategy") === "merge") this._flattenPayload(value, key, obj);
596
- else obj[key] = value;
597
- }
598
- return obj;
599
- }
600
970
  constructor(asMongo, _type, logger = NoopLogger) {
601
971
  _define_property$1(this, "asMongo", void 0);
602
972
  _define_property$1(this, "_type", void 0);
@@ -607,12 +977,14 @@ else obj[key] = value;
607
977
  _define_property$1(this, "_indexes", void 0);
608
978
  _define_property$1(this, "_vectorFilters", void 0);
609
979
  _define_property$1(this, "_flatMap", void 0);
980
+ _define_property$1(this, "_uniqueProps", void 0);
610
981
  this.asMongo = asMongo;
611
982
  this._type = _type;
612
983
  this.logger = logger;
613
984
  this.validators = new Map();
614
985
  this._indexes = new Map();
615
986
  this._vectorFilters = new Map();
987
+ this._uniqueProps = new Set();
616
988
  if (!isAnnotatedType(_type)) throw new Error("Atscript Annotated Type expected");
617
989
  const name = _type.metadata.get("mongo.collection");
618
990
  if (!name) throw new Error("@mongo.collection annotation expected with collection name");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/mongo",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "description": "Mongodb plugin for atscript.",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -14,8 +14,7 @@
14
14
  "./package.json": "./package.json"
15
15
  },
16
16
  "files": [
17
- "dist",
18
- "cli.cjs"
17
+ "dist"
19
18
  ],
20
19
  "keywords": [
21
20
  "atscript",
@@ -33,13 +32,13 @@
33
32
  },
34
33
  "homepage": "https://github.com/moostjs/atscript/tree/main/packages/mongo#readme",
35
34
  "license": "ISC",
36
- "dependencies": {
37
- "mongodb": "^6.13.0",
38
- "@atscript/core": "^0.0.17",
39
- "@atscript/typescript": "^0.0.17"
35
+ "peerDependencies": {
36
+ "mongodb": "^6.17.0",
37
+ "@atscript/core": "^0.0.18",
38
+ "@atscript/typescript": "^0.0.18"
40
39
  },
41
40
  "devDependencies": {
42
- "vitest": "^3.0.0"
41
+ "vitest": "3.2.4"
43
42
  },
44
43
  "scripts": {
45
44
  "pub": "pnpm publish --access public",