@atscript/mongo 0.0.16 → 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",
@@ -250,7 +259,57 @@ const annotations = { mongo: {
250
259
  description: "The **name of the vector search index** this field should be used as a filter for."
251
260
  }]
252
261
  })
253
- }
262
+ },
263
+ patch: { strategy: new __atscript_core.AnnotationSpec({
264
+ description: "Defines the **patching strategy** for updating MongoDB documents.\n\n- **\"replace\"** → The field or object will be **fully replaced**.\n- **\"merge\"** → The field or object will be **merged recursively** (applies only to objects, not arrays).\n\n**Example:**\n```atscript\n@mongo.patch.strategy \"merge\"\nsettings: {\n notifications: boolean\n preferences: {\n theme: string\n }\n}\n```\n",
265
+ nodeType: ["prop"],
266
+ multiple: false,
267
+ argument: {
268
+ name: "strategy",
269
+ type: "string",
270
+ description: "The **patch strategy** for this field: `\"replace\"` (default) or `\"merge\"`.",
271
+ values: ["replace", "merge"]
272
+ },
273
+ validate(token, args, doc) {
274
+ const field = token.parentNode;
275
+ const errors = [];
276
+ const definition = field.getDefinition();
277
+ if (!definition) return errors;
278
+ let wrongType = false;
279
+ if ((0, __atscript_core.isRef)(definition)) {
280
+ const def = doc.unwindType(definition.id, definition.chain)?.def;
281
+ if (!(0, __atscript_core.isStructure)(def) && !(0, __atscript_core.isInterface)(def) && !(0, __atscript_core.isArray)(def)) wrongType = true;
282
+ } else if (!(0, __atscript_core.isStructure)(definition) && !(0, __atscript_core.isInterface)(definition) && !(0, __atscript_core.isArray)(definition)) wrongType = true;
283
+ if (wrongType) errors.push({
284
+ message: `[mongo] type of object or array expected when using @mongo.patch.strategy`,
285
+ severity: 1,
286
+ range: token.range
287
+ });
288
+ return errors;
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
+ }
312
+ }) }
254
313
  } };
255
314
 
256
315
  //#endregion
@@ -277,6 +336,304 @@ const NoopLogger = {
277
336
  debug: () => {}
278
337
  };
279
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
+
280
637
  //#endregion
281
638
  //#region packages/mongo/src/lib/as-collection.ts
282
639
  function _define_property$1(obj, key, value) {
@@ -289,9 +646,14 @@ function _define_property$1(obj, key, value) {
289
646
  else obj[key] = value;
290
647
  return obj;
291
648
  }
292
- const INDEX_PREFIX = "anscript__";
649
+ const INDEX_PREFIX = "atscript__";
293
650
  const DEFAULT_INDEX_NAME = "DEFAULT";
294
- function indexKey(type, name) {
651
+ /**
652
+ * Generates a key for mongo index
653
+ * @param type index type
654
+ * @param name index name
655
+ * @returns index key
656
+ */ function indexKey(type, name) {
295
657
  const cleanName = name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 127 - INDEX_PREFIX.length - type.length - 2);
296
658
  return `${INDEX_PREFIX}${type}__${cleanName}`;
297
659
  }
@@ -303,6 +665,64 @@ var AsCollection = class {
303
665
  const exists = await this.exists();
304
666
  if (!exists) await this.asMongo.db.createCollection(this.name, { comment: "Created by Atscript Mongo Collection" });
305
667
  }
668
+ /**
669
+ * Returns the a type definition of the "_id" prop.
670
+ */ get idType() {
671
+ const idProp = this.type.type.props.get("_id");
672
+ const idTags = idProp?.type.tags;
673
+ if (idTags?.has("objectId") && idTags?.has("mongo")) return "objectId";
674
+ if (idProp?.type.kind === "") return idProp.type.designType;
675
+ return "objectId";
676
+ }
677
+ /**
678
+ * Transforms an "_id" value to the expected type (`ObjectId`, `number`, or `string`).
679
+ * Assumes input has already been validated.
680
+ *
681
+ * @param {string | number | ObjectId} id - The validated ID.
682
+ * @returns {string | number | ObjectId} - The transformed ID.
683
+ * @throws {Error} If the `_id` type is unknown.
684
+ */ prepareId(id) {
685
+ switch (this.idType) {
686
+ case "objectId": return id instanceof mongodb.ObjectId ? id : new mongodb.ObjectId(id);
687
+ case "number": return Number(id);
688
+ case "string": return String(id);
689
+ default: throw new Error("Unknown \"_id\" type");
690
+ }
691
+ }
692
+ /**
693
+ * Retrieves a validator for a given purpose. If the validator is not already cached,
694
+ * it creates and stores a new one based on the purpose.
695
+ *
696
+ * @param {TValidatorPurpose} purpose - The validation purpose (`input`, `update`, `patch`).
697
+ * @returns {Validator} The corresponding validator instance.
698
+ * @throws {Error} If an unknown purpose is provided.
699
+ */ getValidator(purpose) {
700
+ if (!this.validators.has(purpose)) switch (purpose) {
701
+ case "insert": {
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
+ }));
712
+ break;
713
+ }
714
+ case "update": {
715
+ this.validators.set(purpose, this.type.validator({ plugins: [validateMongoIdPlugin] }));
716
+ break;
717
+ }
718
+ case "patch": {
719
+ this.validators.set(purpose, CollectionPatcher.prepareValidator(this));
720
+ break;
721
+ }
722
+ default: throw new Error(`Unknown validator purpose: ${purpose}`);
723
+ }
724
+ return this.validators.get(purpose);
725
+ }
306
726
  get type() {
307
727
  return this._type;
308
728
  }
@@ -347,18 +767,24 @@ else {
347
767
  if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
348
768
  }
349
769
  }
350
- _flattenType(type, prefix) {
770
+ _flattenType(type, prefix = "", inComplexTypeOrArray = false) {
351
771
  switch (type.type.kind) {
352
772
  case "object":
773
+ this._flatMap?.set(prefix || "", type);
353
774
  const items = Array.from(type.type.props.entries());
354
- 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);
355
776
  break;
356
777
  case "array":
357
- 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);
358
784
  break;
359
785
  case "intersection":
360
786
  case "tuple":
361
- 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);
362
788
  default:
363
789
  this._flatMap?.set(prefix || "", type);
364
790
  break;
@@ -379,6 +805,9 @@ else {
379
805
  text: { fuzzy: { maxEdits: textSearch.fuzzy || 0 } }
380
806
  });
381
807
  }
808
+ get uniqueProps() {
809
+ return this._uniqueProps;
810
+ }
382
811
  _finalizeIndexesForCollection() {
383
812
  for (const [key, value] of Array.from(this._vectorFilters.entries())) {
384
813
  const index = this._indexes.get(key);
@@ -387,6 +816,10 @@ else {
387
816
  path: value
388
817
  });
389
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
+ }
390
823
  }
391
824
  _prepareIndexesForField(fieldName, metadata) {
392
825
  for (const index of metadata.get("mongo.index.plain") || []) this._addIndexField("plain", index === true ? fieldName : index, fieldName);
@@ -502,20 +935,80 @@ else toUpdate.add(remote.name);
502
935
  default:
503
936
  }
504
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
+ }
956
+ prepareInsert(payload) {
957
+ const v = this.getValidator("insert");
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 };
962
+ if (data._id) data._id = this.prepareId(data._id);
963
+ else if (this.idType !== "objectId") throw new Error("Missing \"_id\" field");
964
+ prepared.push(data);
965
+ } else throw new Error("Invalid payload");
966
+ return prepared.length === 1 ? prepared[0] : prepared;
967
+ }
968
+ prepareReplace(payload) {
969
+ const v = this.getValidator("update");
970
+ if (v.validate(payload)) {
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
+ };
986
+ }
987
+ throw new Error("Invalid payload");
988
+ }
989
+ prepareUpdate(payload) {
990
+ const v = this.getValidator("patch");
991
+ if (v.validate(payload)) return new CollectionPatcher(this, payload).preparePatch();
992
+ throw new Error("Invalid payload");
993
+ }
505
994
  constructor(asMongo, _type, logger = NoopLogger) {
506
995
  _define_property$1(this, "asMongo", void 0);
507
996
  _define_property$1(this, "_type", void 0);
508
997
  _define_property$1(this, "logger", void 0);
509
998
  _define_property$1(this, "name", void 0);
510
999
  _define_property$1(this, "collection", void 0);
1000
+ _define_property$1(this, "validators", void 0);
511
1001
  _define_property$1(this, "_indexes", void 0);
512
1002
  _define_property$1(this, "_vectorFilters", void 0);
513
1003
  _define_property$1(this, "_flatMap", void 0);
1004
+ _define_property$1(this, "_uniqueProps", void 0);
514
1005
  this.asMongo = asMongo;
515
1006
  this._type = _type;
516
1007
  this.logger = logger;
1008
+ this.validators = new Map();
517
1009
  this._indexes = new Map();
518
1010
  this._vectorFilters = new Map();
1011
+ this._uniqueProps = new Set();
519
1012
  if (!(0, __atscript_typescript.isAnnotatedType)(_type)) throw new Error("Atscript Annotated Type expected");
520
1013
  const name = _type.metadata.get("mongo.collection");
521
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 } 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
 
@@ -34,30 +34,88 @@ type TSearchIndex = {
34
34
  definition: TMongoSearchIndexDefinition;
35
35
  };
36
36
  type TIndex = TPlainIndex | TSearchIndex;
37
+ type TValidatorPurpose = 'insert' | 'update' | 'patch';
37
38
  declare class AsCollection<T extends TAtscriptAnnotatedTypeConstructor> {
38
39
  protected readonly asMongo: AsMongo;
39
40
  protected readonly _type: T;
40
41
  protected readonly logger: TGenericLogger;
41
42
  readonly name: string;
42
43
  readonly collection: Collection<InstanceType<T>>;
44
+ protected readonly validators: Map<TValidatorPurpose, Validator<T>>;
45
+ protected _indexes: Map<string, TIndex>;
46
+ protected _vectorFilters: Map<string, string>;
47
+ protected _flatMap?: Map<string, TAtscriptAnnotatedType>;
43
48
  constructor(asMongo: AsMongo, _type: T, logger?: TGenericLogger);
44
49
  exists(): Promise<boolean>;
45
50
  ensureExists(): Promise<void>;
51
+ /**
52
+ * Returns the a type definition of the "_id" prop.
53
+ */
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
+ */
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
+ */
72
+ getValidator(purpose: TValidatorPurpose): any;
46
73
  get type(): TAtscriptAnnotatedType<TAtscriptTypeObject>;
47
- protected _indexes: Map<string, TIndex>;
48
- protected _vectorFilters: Map<string, string>;
49
74
  get indexes(): Map<string, TIndex>;
50
75
  protected _addIndexField(type: TPlainIndex['type'], name: string, field: string, weight?: number): void;
51
76
  protected _setSearchIndex(type: TSearchIndex['type'], name: string | undefined, definition: TMongoSearchIndexDefinition): void;
52
77
  protected _addFieldToSearchIndex(type: TSearchIndex['type'], _name: string | undefined, fieldName: string, analyzer?: string): void;
53
- protected _flatMap?: Map<string, TAtscriptAnnotatedType>;
54
- protected _flattenType(type: TAtscriptAnnotatedType, prefix?: string): void;
78
+ protected _flattenType(type: TAtscriptAnnotatedType, prefix?: string, inComplexTypeOrArray?: boolean): void;
55
79
  protected _prepareIndexesForCollection(): void;
80
+ protected _uniqueProps: Set<string>;
81
+ get uniqueProps(): Set<string>;
56
82
  protected _finalizeIndexesForCollection(): void;
57
83
  protected _prepareIndexesForField(fieldName: string, metadata: TMetadataMap<AtscriptMetadata>): void;
58
84
  protected _flatten(): void;
59
- get flatMap(): Map<string, TAtscriptAnnotatedType> | undefined;
85
+ get flatMap(): Map<string, TAtscriptAnnotatedType>;
60
86
  syncIndexes(): Promise<void>;
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
+ };
61
119
  }
62
120
  type TVectorSimilarity = 'cosine' | 'euclidean' | 'dotProduct';
63
121
  type TMongoSearchIndexDefinition = {
@@ -81,5 +139,26 @@ type TMongoSearchIndexDefinition = {
81
139
  };
82
140
  };
83
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
+ };
84
163
 
85
164
  export { AsCollection, AsMongo, MongoPlugin };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { AnnotationSpec, isInterface, isPrimitive, isRef, isStructure } from "@atscript/core";
2
- import { isAnnotatedType } from "@atscript/typescript";
3
- import { MongoClient } from "mongodb";
1
+ import { AnnotationSpec, isArray, isInterface, isPrimitive, isRef, isStructure } from "@atscript/core";
2
+ import { defineAnnotatedType, isAnnotatedType, isAnnotatedTypeOfPrimitive } from "@atscript/typescript";
3
+ import { MongoClient, ObjectId } from "mongodb";
4
4
 
5
5
  //#region packages/mongo/src/plugin/primitives.ts
6
6
  const primitives = { mongo: { extensions: {
@@ -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",
@@ -226,7 +235,57 @@ const annotations = { mongo: {
226
235
  description: "The **name of the vector search index** this field should be used as a filter for."
227
236
  }]
228
237
  })
229
- }
238
+ },
239
+ patch: { strategy: new AnnotationSpec({
240
+ description: "Defines the **patching strategy** for updating MongoDB documents.\n\n- **\"replace\"** → The field or object will be **fully replaced**.\n- **\"merge\"** → The field or object will be **merged recursively** (applies only to objects, not arrays).\n\n**Example:**\n```atscript\n@mongo.patch.strategy \"merge\"\nsettings: {\n notifications: boolean\n preferences: {\n theme: string\n }\n}\n```\n",
241
+ nodeType: ["prop"],
242
+ multiple: false,
243
+ argument: {
244
+ name: "strategy",
245
+ type: "string",
246
+ description: "The **patch strategy** for this field: `\"replace\"` (default) or `\"merge\"`.",
247
+ values: ["replace", "merge"]
248
+ },
249
+ validate(token, args, doc) {
250
+ const field = token.parentNode;
251
+ const errors = [];
252
+ const definition = field.getDefinition();
253
+ if (!definition) return errors;
254
+ let wrongType = false;
255
+ if (isRef(definition)) {
256
+ const def = doc.unwindType(definition.id, definition.chain)?.def;
257
+ if (!isStructure(def) && !isInterface(def) && !isArray(def)) wrongType = true;
258
+ } else if (!isStructure(definition) && !isInterface(definition) && !isArray(definition)) wrongType = true;
259
+ if (wrongType) errors.push({
260
+ message: `[mongo] type of object or array expected when using @mongo.patch.strategy`,
261
+ severity: 1,
262
+ range: token.range
263
+ });
264
+ return errors;
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
+ }
288
+ }) }
230
289
  } };
231
290
 
232
291
  //#endregion
@@ -253,6 +312,304 @@ const NoopLogger = {
253
312
  debug: () => {}
254
313
  };
255
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
+
256
613
  //#endregion
257
614
  //#region packages/mongo/src/lib/as-collection.ts
258
615
  function _define_property$1(obj, key, value) {
@@ -265,9 +622,14 @@ function _define_property$1(obj, key, value) {
265
622
  else obj[key] = value;
266
623
  return obj;
267
624
  }
268
- const INDEX_PREFIX = "anscript__";
625
+ const INDEX_PREFIX = "atscript__";
269
626
  const DEFAULT_INDEX_NAME = "DEFAULT";
270
- function indexKey(type, name) {
627
+ /**
628
+ * Generates a key for mongo index
629
+ * @param type index type
630
+ * @param name index name
631
+ * @returns index key
632
+ */ function indexKey(type, name) {
271
633
  const cleanName = name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 127 - INDEX_PREFIX.length - type.length - 2);
272
634
  return `${INDEX_PREFIX}${type}__${cleanName}`;
273
635
  }
@@ -279,6 +641,64 @@ var AsCollection = class {
279
641
  const exists = await this.exists();
280
642
  if (!exists) await this.asMongo.db.createCollection(this.name, { comment: "Created by Atscript Mongo Collection" });
281
643
  }
644
+ /**
645
+ * Returns the a type definition of the "_id" prop.
646
+ */ get idType() {
647
+ const idProp = this.type.type.props.get("_id");
648
+ const idTags = idProp?.type.tags;
649
+ if (idTags?.has("objectId") && idTags?.has("mongo")) return "objectId";
650
+ if (idProp?.type.kind === "") return idProp.type.designType;
651
+ return "objectId";
652
+ }
653
+ /**
654
+ * Transforms an "_id" value to the expected type (`ObjectId`, `number`, or `string`).
655
+ * Assumes input has already been validated.
656
+ *
657
+ * @param {string | number | ObjectId} id - The validated ID.
658
+ * @returns {string | number | ObjectId} - The transformed ID.
659
+ * @throws {Error} If the `_id` type is unknown.
660
+ */ prepareId(id) {
661
+ switch (this.idType) {
662
+ case "objectId": return id instanceof ObjectId ? id : new ObjectId(id);
663
+ case "number": return Number(id);
664
+ case "string": return String(id);
665
+ default: throw new Error("Unknown \"_id\" type");
666
+ }
667
+ }
668
+ /**
669
+ * Retrieves a validator for a given purpose. If the validator is not already cached,
670
+ * it creates and stores a new one based on the purpose.
671
+ *
672
+ * @param {TValidatorPurpose} purpose - The validation purpose (`input`, `update`, `patch`).
673
+ * @returns {Validator} The corresponding validator instance.
674
+ * @throws {Error} If an unknown purpose is provided.
675
+ */ getValidator(purpose) {
676
+ if (!this.validators.has(purpose)) switch (purpose) {
677
+ case "insert": {
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
+ }));
688
+ break;
689
+ }
690
+ case "update": {
691
+ this.validators.set(purpose, this.type.validator({ plugins: [validateMongoIdPlugin] }));
692
+ break;
693
+ }
694
+ case "patch": {
695
+ this.validators.set(purpose, CollectionPatcher.prepareValidator(this));
696
+ break;
697
+ }
698
+ default: throw new Error(`Unknown validator purpose: ${purpose}`);
699
+ }
700
+ return this.validators.get(purpose);
701
+ }
282
702
  get type() {
283
703
  return this._type;
284
704
  }
@@ -323,18 +743,24 @@ else {
323
743
  if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
324
744
  }
325
745
  }
326
- _flattenType(type, prefix) {
746
+ _flattenType(type, prefix = "", inComplexTypeOrArray = false) {
327
747
  switch (type.type.kind) {
328
748
  case "object":
749
+ this._flatMap?.set(prefix || "", type);
329
750
  const items = Array.from(type.type.props.entries());
330
- 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);
331
752
  break;
332
753
  case "array":
333
- 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);
334
760
  break;
335
761
  case "intersection":
336
762
  case "tuple":
337
- 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);
338
764
  default:
339
765
  this._flatMap?.set(prefix || "", type);
340
766
  break;
@@ -355,6 +781,9 @@ else {
355
781
  text: { fuzzy: { maxEdits: textSearch.fuzzy || 0 } }
356
782
  });
357
783
  }
784
+ get uniqueProps() {
785
+ return this._uniqueProps;
786
+ }
358
787
  _finalizeIndexesForCollection() {
359
788
  for (const [key, value] of Array.from(this._vectorFilters.entries())) {
360
789
  const index = this._indexes.get(key);
@@ -363,6 +792,10 @@ else {
363
792
  path: value
364
793
  });
365
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
+ }
366
799
  }
367
800
  _prepareIndexesForField(fieldName, metadata) {
368
801
  for (const index of metadata.get("mongo.index.plain") || []) this._addIndexField("plain", index === true ? fieldName : index, fieldName);
@@ -478,20 +911,80 @@ else toUpdate.add(remote.name);
478
911
  default:
479
912
  }
480
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
+ }
932
+ prepareInsert(payload) {
933
+ const v = this.getValidator("insert");
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 };
938
+ if (data._id) data._id = this.prepareId(data._id);
939
+ else if (this.idType !== "objectId") throw new Error("Missing \"_id\" field");
940
+ prepared.push(data);
941
+ } else throw new Error("Invalid payload");
942
+ return prepared.length === 1 ? prepared[0] : prepared;
943
+ }
944
+ prepareReplace(payload) {
945
+ const v = this.getValidator("update");
946
+ if (v.validate(payload)) {
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
+ };
962
+ }
963
+ throw new Error("Invalid payload");
964
+ }
965
+ prepareUpdate(payload) {
966
+ const v = this.getValidator("patch");
967
+ if (v.validate(payload)) return new CollectionPatcher(this, payload).preparePatch();
968
+ throw new Error("Invalid payload");
969
+ }
481
970
  constructor(asMongo, _type, logger = NoopLogger) {
482
971
  _define_property$1(this, "asMongo", void 0);
483
972
  _define_property$1(this, "_type", void 0);
484
973
  _define_property$1(this, "logger", void 0);
485
974
  _define_property$1(this, "name", void 0);
486
975
  _define_property$1(this, "collection", void 0);
976
+ _define_property$1(this, "validators", void 0);
487
977
  _define_property$1(this, "_indexes", void 0);
488
978
  _define_property$1(this, "_vectorFilters", void 0);
489
979
  _define_property$1(this, "_flatMap", void 0);
980
+ _define_property$1(this, "_uniqueProps", void 0);
490
981
  this.asMongo = asMongo;
491
982
  this._type = _type;
492
983
  this.logger = logger;
984
+ this.validators = new Map();
493
985
  this._indexes = new Map();
494
986
  this._vectorFilters = new Map();
987
+ this._uniqueProps = new Set();
495
988
  if (!isAnnotatedType(_type)) throw new Error("Atscript Annotated Type expected");
496
989
  const name = _type.metadata.get("mongo.collection");
497
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.16",
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.16",
39
- "@atscript/typescript": "^0.0.16"
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",