@atscript/mongo 0.1.31 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,301 +1,11 @@
1
- import { AnnotationSpec, isArray, isInterface, isPrimitive, isRef, isStructure } from "@atscript/core";
2
- import { defineAnnotatedType, flattenAnnotatedType, isAnnotatedType, isAnnotatedTypeOfPrimitive } from "@atscript/typescript/utils";
1
+ import { AtscriptDbTable, BaseDbAdapter, walkFilter } from "@atscript/utils-db";
3
2
  import { MongoClient, ObjectId } from "mongodb";
3
+ import { defineAnnotatedType, isAnnotatedTypeOfPrimitive } from "@atscript/typescript/utils";
4
4
 
5
- //#region packages/mongo/src/plugin/annotations.ts
6
- const analyzers = [
7
- "lucene.standard",
8
- "lucene.simple",
9
- "lucene.whitespace",
10
- "lucene.english",
11
- "lucene.french",
12
- "lucene.german",
13
- "lucene.italian",
14
- "lucene.portuguese",
15
- "lucene.spanish",
16
- "lucene.chinese",
17
- "lucene.hindi",
18
- "lucene.bengali",
19
- "lucene.russian",
20
- "lucene.arabic"
21
- ];
22
- const annotations = {
23
- collection: new AnnotationSpec({
24
- description: "Marks an interface as a **MongoDB collection**.\n\n- Use together with `@db.table \"name\"` which provides the collection name.\n- Automatically injects a **non-optional** `_id` field if not explicitly defined.\n- `_id` must be of type **`string`**, **`number`**, or **`mongo.objectId`**.\n\n**Example:**\n```atscript\n@db.table \"users\"\n@db.mongo.collection\nexport interface User {\n _id: mongo.objectId\n email: string.email\n}\n```\n",
25
- nodeType: ["interface"],
26
- validate(token, args, doc) {
27
- const parent = token.parentNode;
28
- const struc = parent?.getDefinition();
29
- const errors = [];
30
- if (isInterface(parent) && parent.props.has("_id") && isStructure(struc)) {
31
- const _id = parent.props.get("_id");
32
- const isOptional = !!_id.token("optional");
33
- if (isOptional) errors.push({
34
- message: `[db.mongo] _id can't be optional in Mongo Collection`,
35
- severity: 1,
36
- range: _id.token("identifier").range
37
- });
38
- const definition = _id.getDefinition();
39
- if (!definition) return errors;
40
- let wrongType = false;
41
- if (isRef(definition)) {
42
- const def = doc.unwindType(definition.id, definition.chain)?.def;
43
- if (isPrimitive(def) && !["string", "number"].includes(def.config.type)) wrongType = true;
44
- } else wrongType = true;
45
- if (wrongType) errors.push({
46
- message: `[db.mongo] _id must be of type string, number or mongo.objectId`,
47
- severity: 1,
48
- range: _id.token("identifier").range
49
- });
50
- }
51
- return errors;
52
- },
53
- modify(token, args, doc) {
54
- const parent = token.parentNode;
55
- const struc = parent?.getDefinition();
56
- if (isInterface(parent) && !parent.props.has("_id") && isStructure(struc)) struc.addVirtualProp({
57
- name: "_id",
58
- type: "mongo.objectId",
59
- documentation: "Mongodb Primary Key ObjectId"
60
- });
61
- }
62
- }),
63
- autoIndexes: new AnnotationSpec({
64
- description: "Switch on/off the automatic index creation. Works with as-mongo moost controller.\n\nDefault: true",
65
- nodeType: ["interface"],
66
- argument: {
67
- name: "type",
68
- type: "boolean",
69
- description: "On/Off the automatic index creation"
70
- }
71
- }),
72
- index: { text: new AnnotationSpec({
73
- description: "Creates a **legacy MongoDB text index** for full-text search with optional **weight** specification.\n\nUse this when you need per-field weight control. For simple full-text indexing without weights, use the generic `@db.index.fulltext` instead.\n\n**Example:**\n```atscript\n@db.mongo.index.text 5\nbio: string\n```\n",
74
- nodeType: ["prop"],
75
- argument: {
76
- optional: true,
77
- name: "weight",
78
- type: "number",
79
- description: "Field importance in search results (higher = more relevant). Defaults to `1`."
80
- }
81
- }) },
82
- search: {
83
- dynamic: new AnnotationSpec({
84
- description: "Creates a **dynamic MongoDB Search Index** that applies to the entire collection.\n\n- **Indexes all text fields automatically** (no need to specify fields).\n- Supports **language analyzers** for text tokenization.\n- Enables **fuzzy search** (typo tolerance) if needed.\n\n**Example:**\n```atscript\n@db.mongo.search.dynamic \"lucene.english\", 1\nexport interface MongoCollection {}\n```\n",
85
- nodeType: ["interface"],
86
- multiple: false,
87
- argument: [{
88
- optional: true,
89
- name: "analyzer",
90
- type: "string",
91
- description: "The **text analyzer** for tokenization. Defaults to `\"lucene.standard\"`.\n\n**Available options:** `\"lucene.standard\"`, `\"lucene.english\"`, `\"lucene.spanish\"`, etc.",
92
- values: analyzers
93
- }, {
94
- optional: true,
95
- name: "fuzzy",
96
- type: "number",
97
- description: "Maximum typo tolerance (`0-2`). Defaults to `0` (no fuzzy search).\n\n- `0` → Exact match required.\n- `1` → Allows small typos (e.g., `\"mongo\"` ≈ `\"mango\"`).\n- `2` → More typo tolerance (e.g., `\"mongodb\"` ≈ `\"mangodb\"`)."
98
- }]
99
- }),
100
- static: new AnnotationSpec({
101
- description: "Defines a **MongoDB Atlas Search Index** for the collection. The props can refer to this index using `@db.mongo.search.text` annotation.\n\n- **Creates a named search index** for full-text search.\n- **Specify analyzers and fuzzy search** behavior at the index level.\n- **Fields must explicitly use `@db.mongo.search.text`** to be included in this search index.\n\n**Example:**\n```atscript\n@db.mongo.search.static \"lucene.english\", 1, \"mySearchIndex\"\nexport interface MongoCollection {}\n```\n",
102
- nodeType: ["interface"],
103
- multiple: true,
104
- argument: [
105
- {
106
- optional: true,
107
- name: "analyzer",
108
- type: "string",
109
- description: "The text analyzer for tokenization. Defaults to `\"lucene.standard\"`.\n\n**Available options:** `\"lucene.standard\"`, `\"lucene.english\"`, `\"lucene.spanish\"`, `\"lucene.german\"`, etc.",
110
- values: analyzers
111
- },
112
- {
113
- optional: true,
114
- name: "fuzzy",
115
- type: "number",
116
- description: "Maximum typo tolerance (`0-2`). **Defaults to `0` (no fuzzy matching).**\n\n- `0` → No typos allowed (exact match required).\n- `1` → Allows small typos (e.g., \"mongo\" ≈ \"mango\").\n- `2` → More typo tolerance (e.g., \"mongodb\" ≈ \"mangodb\")."
117
- },
118
- {
119
- optional: true,
120
- name: "indexName",
121
- type: "string",
122
- description: "The name of the search index. Fields must reference this name using `@db.mongo.search.text`. If not set, defaults to `\"DEFAULT\"`."
123
- }
124
- ]
125
- }),
126
- text: new AnnotationSpec({
127
- description: "Marks a field to be **included in a MongoDB Atlas Search Index** defined by `@db.mongo.search.static`.\n\n- **The field has to reference an existing search index name**.\n- If index name is not defined, a new search index with default attributes will be created.\n\n**Example:**\n```atscript\n@db.mongo.search.text \"lucene.english\", \"mySearchIndex\"\nfirstName: string\n```\n",
128
- nodeType: ["prop"],
129
- multiple: true,
130
- argument: [{
131
- optional: true,
132
- name: "analyzer",
133
- type: "string",
134
- description: "The text analyzer for tokenization. Defaults to `\"lucene.standard\"`.\n\n**Available options:** `\"lucene.standard\"`, `\"lucene.english\"`, `\"lucene.spanish\"`, `\"lucene.german\"`, etc.",
135
- values: analyzers
136
- }, {
137
- optional: true,
138
- name: "indexName",
139
- type: "string",
140
- description: "The **name of the search index** defined in `@db.mongo.search.static`. This links the field to the correct index. If not set, defaults to `\"DEFAULT\"`."
141
- }]
142
- }),
143
- vector: new AnnotationSpec({
144
- description: "Creates a **MongoDB Vector Search Index** for **semantic search, embeddings, and AI-powered search**.\n\n- Each field that stores vector embeddings **must define its own vector index**.\n- Supports **cosine similarity, Euclidean distance, and dot product similarity**.\n- Vector fields must be an **array of numbers**.\n\n**Example:**\n```atscript\n@db.mongo.search.vector 512, \"cosine\"\nembedding: mongo.vector\n```\n",
145
- nodeType: ["prop"],
146
- multiple: false,
147
- argument: [
148
- {
149
- optional: false,
150
- name: "dimensions",
151
- type: "number",
152
- description: "The **number of dimensions in the vector** (e.g., 512 for OpenAI embeddings).",
153
- values: [
154
- "512",
155
- "768",
156
- "1024",
157
- "1536",
158
- "3072",
159
- "4096"
160
- ]
161
- },
162
- {
163
- optional: true,
164
- name: "similarity",
165
- type: "string",
166
- description: "The **similarity metric** used for vector search. Defaults to `\"cosine\"`.\n\n**Available options:** `\"cosine\"`, `\"euclidean\"`, `\"dotProduct\"`.",
167
- values: [
168
- "cosine",
169
- "euclidean",
170
- "dotProduct"
171
- ]
172
- },
173
- {
174
- optional: true,
175
- name: "indexName",
176
- type: "string",
177
- description: "The **name of the vector search index** (optional, defaults to property name)."
178
- }
179
- ]
180
- }),
181
- filter: new AnnotationSpec({
182
- description: "Assigns a field as a **filter field** for a **MongoDB Vector Search Index**.\n\n- The assigned field **must be indexed** for efficient filtering.\n- Filters allow vector search queries to return results **only within a specific category, user group, or tag**.\n- The vector index must be defined using `@db.mongo.search.vector`.\n\n**Example:**\n```atscript\n@db.mongo.search.vector 512, \"cosine\"\nembedding: number[]\n\n@db.mongo.search.filter \"embedding\"\ncategory: string\n```\n",
183
- nodeType: ["prop"],
184
- multiple: true,
185
- argument: [{
186
- optional: false,
187
- name: "indexName",
188
- type: "string",
189
- description: "The **name of the vector search index** this field should be used as a filter for."
190
- }]
191
- })
192
- },
193
- patch: { strategy: new AnnotationSpec({
194
- 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@db.mongo.patch.strategy \"merge\"\nsettings: {\n notifications: boolean\n preferences: {\n theme: string\n }\n}\n```\n",
195
- nodeType: ["prop"],
196
- multiple: false,
197
- argument: {
198
- name: "strategy",
199
- type: "string",
200
- description: "The **patch strategy** for this field: `\"replace\"` (default) or `\"merge\"`.",
201
- values: ["replace", "merge"]
202
- },
203
- validate(token, args, doc) {
204
- const field = token.parentNode;
205
- const errors = [];
206
- const definition = field.getDefinition();
207
- if (!definition) return errors;
208
- let wrongType = false;
209
- if (isRef(definition)) {
210
- const def = doc.unwindType(definition.id, definition.chain)?.def;
211
- if (!isStructure(def) && !isInterface(def) && !isArray(def)) wrongType = true;
212
- } else if (!isStructure(definition) && !isInterface(definition) && !isArray(definition)) wrongType = true;
213
- if (wrongType) errors.push({
214
- message: `[db.mongo] type of object or array expected when using @db.mongo.patch.strategy`,
215
- severity: 1,
216
- range: token.range
217
- });
218
- return errors;
219
- }
220
- }) },
221
- array: { uniqueItems: new AnnotationSpec({
222
- 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 `@expect.array.key` properties, *uniqueness is implied* and this annotation is unnecessary (but harmless).\n\n**Example:**\n```atscript\n@db.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",
223
- nodeType: ["prop"],
224
- multiple: false,
225
- validate(token, args, doc) {
226
- const field = token.parentNode;
227
- const errors = [];
228
- const definition = field.getDefinition();
229
- if (!definition) return errors;
230
- let wrongType = false;
231
- if (isRef(definition)) {
232
- const def = doc.unwindType(definition.id, definition.chain)?.def;
233
- if (!isArray(def)) wrongType = true;
234
- } else if (!isArray(definition)) wrongType = true;
235
- if (wrongType) errors.push({
236
- message: `[db.mongo] type of array expected when using @db.mongo.array.uniqueItems`,
237
- severity: 1,
238
- range: token.range
239
- });
240
- return errors;
241
- }
242
- }) }
243
- };
244
-
245
- //#endregion
246
- //#region packages/mongo/src/plugin/primitives.ts
247
- const primitives = { mongo: { extensions: {
248
- objectId: {
249
- type: "string",
250
- documentation: "Represents a **MongoDB ObjectId**.\n\n- Stored as a **string** but can be converted to an ObjectId at runtime.\n- Useful for handling `_id` fields and queries that require ObjectId conversion.\n- Automatically converts string `_id` values into **MongoDB ObjectId** when needed.\n\n**Example:**\n```atscript\nuserId: mongo.objectId\n```\n",
251
- annotations: { "expect.pattern": { pattern: "^[a-fA-F0-9]{24}$" } }
252
- },
253
- vector: {
254
- type: {
255
- kind: "array",
256
- of: "number"
257
- },
258
- documentation: "Represents a **MongoDB Vector (Array of Numbers)** for **Vector Search**.\n\n- Equivalent to `number[]` but explicitly used for **vector embeddings**.\n\n**Example:**\n```atscript\nembedding: mongo.vector\n```\n"
259
- }
260
- } } };
261
-
262
- //#endregion
263
- //#region packages/mongo/src/plugin/index.ts
264
- const MongoPlugin = () => ({
265
- name: "mongo",
266
- config() {
267
- return {
268
- primitives,
269
- annotations: { db: { mongo: annotations } }
270
- };
271
- }
272
- });
273
-
274
- //#endregion
275
5
  //#region packages/mongo/src/lib/validate-plugins.ts
276
6
  const validateMongoIdPlugin = (ctx, def, value) => {
277
7
  if (ctx.path === "_id" && def.type.tags.has("objectId")) return ctx.validateAnnotatedType(def, value instanceof ObjectId ? value.toString() : value);
278
8
  };
279
- const validateMongoUniqueArrayItemsPlugin = (ctx, def, value) => {
280
- if (def.metadata.has("db.mongo.array.uniqueItems") && def.type.kind === "array") {
281
- if (Array.isArray(value)) {
282
- const separator = "▼↩";
283
- const seen = new Set();
284
- const keyProps = CollectionPatcher.getKeyProps(def);
285
- for (const item of value) {
286
- let key = "";
287
- if (keyProps.size > 0) for (const prop of keyProps) key += JSON.stringify(item[prop]) + separator;
288
- else key = JSON.stringify(item);
289
- if (seen.has(key)) {
290
- ctx.error(`Duplicate items are not allowed`);
291
- return false;
292
- }
293
- seen.add(key);
294
- }
295
- }
296
- }
297
- return undefined;
298
- };
299
9
 
300
10
  //#endregion
301
11
  //#region packages/mongo/src/lib/collection-patcher.ts
@@ -330,25 +40,25 @@ var CollectionPatcher = class CollectionPatcher {
330
40
  * Build a runtime *Validator* that understands the extended patch payload.
331
41
  *
332
42
  * * Adds per‑array *patch* wrappers (the `$replace`, `$insert`, … fields).
333
- * * Honors `db.mongo.patch.strategy === "merge"` metadata.
43
+ * * Honors `db.patch.strategy === "merge"` metadata.
334
44
  *
335
45
  * @param collection Target collection wrapper
336
46
  * @returns Atscript Validator
337
- */ static prepareValidator(collection) {
338
- return collection.createValidator({
339
- plugins: [validateMongoIdPlugin, validateMongoUniqueArrayItemsPlugin],
47
+ */ static prepareValidator(context) {
48
+ return context.createValidator({
49
+ plugins: [validateMongoIdPlugin],
340
50
  replace: (def, path) => {
341
51
  if (path === "" && def.type.kind === "object") {
342
52
  const obj = defineAnnotatedType("object").copyMetadata(def.metadata);
343
53
  for (const [prop, type] of def.type.props.entries()) obj.prop(prop, defineAnnotatedType().refTo(type).copyMetadata(type.metadata).optional(prop !== "_id").$type);
344
54
  return obj.$type;
345
55
  }
346
- if (def.type.kind === "array" && collection.flatMap.get(path)?.metadata.get("db.mongo.__topLevelArray") && !def.metadata.has("db.mongo.__patchArrayValue")) {
56
+ if (def.type.kind === "array" && context.flatMap.get(path)?.metadata.get("db.mongo.__topLevelArray") && !def.metadata.has("db.mongo.__patchArrayValue")) {
347
57
  const defArray = def;
348
- const mergeStrategy = defArray.metadata.get("db.mongo.patch.strategy") === "merge";
58
+ const mergeStrategy = defArray.metadata.get("db.patch.strategy") === "merge";
349
59
  function getPatchType() {
350
- const isPrimitive$1 = isAnnotatedTypeOfPrimitive(defArray.type.of);
351
- if (isPrimitive$1) return defineAnnotatedType().refTo(def).copyMetadata(def.metadata).annotate("db.mongo.__patchArrayValue").optional().$type;
60
+ const isPrimitive = isAnnotatedTypeOfPrimitive(defArray.type.of);
61
+ if (isPrimitive) return defineAnnotatedType().refTo(def).copyMetadata(def.metadata).annotate("db.mongo.__patchArrayValue").optional().$type;
352
62
  if (defArray.type.of.type.kind === "object") {
353
63
  const objType = defArray.type.of.type;
354
64
  const t = defineAnnotatedType("object").copyMetadata(defArray.type.of.metadata);
@@ -366,7 +76,7 @@ else t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).opt
366
76
  }
367
77
  return def;
368
78
  },
369
- partial: (def, path) => path !== "" && def.metadata.get("db.mongo.patch.strategy") === "merge"
79
+ partial: (def, path) => path !== "" && def.metadata.get("db.patch.strategy") === "merge"
370
80
  });
371
81
  }
372
82
  /**
@@ -419,7 +129,7 @@ else t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).opt
419
129
  const flatType = this.collection.flatMap.get(key);
420
130
  const topLevelArray = flatType?.metadata?.get("db.mongo.__topLevelArray");
421
131
  if (typeof value === "object" && topLevelArray) this.parseArrayPatch(key, value);
422
- else if (typeof value === "object" && this.collection.flatMap.get(key)?.metadata?.get("db.mongo.patch.strategy") === "merge") this.flattenPayload(value, key);
132
+ else if (typeof value === "object" && this.collection.flatMap.get(key)?.metadata?.get("db.patch.strategy") === "merge") this.flattenPayload(value, key);
423
133
  else if (key !== "_id") this._set(key, value);
424
134
  }
425
135
  return this.updatePipeline;
@@ -473,7 +183,7 @@ else if (key !== "_id") this._set(key, value);
473
183
  * - unique / keyed → delegate to _upsert (insert-or-update)
474
184
  */ _insert(key, input, keyProps) {
475
185
  if (!input?.length) return;
476
- const uniqueItems = this.collection.flatMap.get(key)?.metadata?.has("db.mongo.array.uniqueItems");
186
+ const uniqueItems = this.collection.flatMap.get(key)?.metadata?.has("expect.array.uniqueItems");
477
187
  if (uniqueItems || keyProps.size > 0) this._upsert(key, input, keyProps);
478
188
  else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
479
189
  }
@@ -511,7 +221,7 @@ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
511
221
  */ _update(key, input, keyProps) {
512
222
  if (!input?.length) return;
513
223
  if (keyProps.size > 0) {
514
- const mergeStrategy = this.collection.flatMap.get(key)?.metadata?.get("db.mongo.patch.strategy") === "merge";
224
+ const mergeStrategy = this.collection.flatMap.get(key)?.metadata?.get("db.patch.strategy") === "merge";
515
225
  const keys = [...keyProps];
516
226
  this._set(key, { $reduce: {
517
227
  input,
@@ -568,17 +278,34 @@ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
568
278
  };
569
279
 
570
280
  //#endregion
571
- //#region packages/mongo/src/lib/logger.ts
572
- const NoopLogger = {
573
- error: () => {},
574
- warn: () => {},
575
- log: () => {},
576
- info: () => {},
577
- debug: () => {}
281
+ //#region packages/mongo/src/lib/mongo-filter.ts
282
+ const EMPTY = {};
283
+ const mongoVisitor = {
284
+ comparison(field, op, value) {
285
+ if (op === "$eq") return { [field]: value };
286
+ return { [field]: { [op]: value } };
287
+ },
288
+ and(children) {
289
+ if (children.length === 0) return EMPTY;
290
+ if (children.length === 1) return children[0];
291
+ return { $and: children };
292
+ },
293
+ or(children) {
294
+ if (children.length === 0) return { _impossible: true };
295
+ if (children.length === 1) return children[0];
296
+ return { $or: children };
297
+ },
298
+ not(child) {
299
+ return { $nor: [child] };
300
+ }
578
301
  };
302
+ function buildMongoFilter(filter) {
303
+ if (!filter || Object.keys(filter).length === 0) return EMPTY;
304
+ return walkFilter(filter, mongoVisitor);
305
+ }
579
306
 
580
307
  //#endregion
581
- //#region packages/mongo/src/lib/as-collection.ts
308
+ //#region packages/mongo/src/lib/mongo-adapter.ts
582
309
  function _define_property$1(obj, key, value) {
583
310
  if (key in obj) Object.defineProperty(obj, key, {
584
311
  value,
@@ -591,43 +318,38 @@ else obj[key] = value;
591
318
  }
592
319
  const INDEX_PREFIX = "atscript__";
593
320
  const DEFAULT_INDEX_NAME = "DEFAULT";
594
- /**
595
- * Generates a key for mongo index
596
- * @param type index type
597
- * @param name index name
598
- * @returns index key
599
- */ function indexKey(type, name) {
321
+ function mongoIndexKey(type, name) {
600
322
  const cleanName = name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 127 - INDEX_PREFIX.length - type.length - 2);
601
323
  return `${INDEX_PREFIX}${type}__${cleanName}`;
602
324
  }
603
- var AsCollection = class {
604
- createValidator(opts) {
605
- return this._type.validator(opts);
325
+ var MongoAdapter = class extends BaseDbAdapter {
326
+ get collection() {
327
+ if (!this._collection) this._collection = this.db.collection(this.resolveTableName(false));
328
+ return this._collection;
606
329
  }
607
- async exists() {
608
- return this.asMongo.collectionExists(this.name);
330
+ aggregate(pipeline) {
331
+ return this.collection.aggregate(pipeline);
609
332
  }
610
- async ensureExists() {
611
- const exists = await this.exists();
612
- if (!exists) await this.asMongo.db.createCollection(this.name, { comment: "Created by Atscript Mongo Collection" });
613
- }
614
- /**
615
- * Returns the a type definition of the "_id" prop.
616
- */ get idType() {
617
- const idProp = this.type.type.props.get("_id");
333
+ get idType() {
334
+ const idProp = this._table.type.type.props.get("_id");
618
335
  const idTags = idProp?.type.tags;
619
336
  if (idTags?.has("objectId") && idTags?.has("mongo")) return "objectId";
620
337
  if (idProp?.type.kind === "") return idProp.type.designType;
621
338
  return "objectId";
622
339
  }
340
+ prepareId(id, fieldType) {
341
+ const tags = fieldType.type.tags;
342
+ if (tags?.has("objectId") && tags?.has("mongo")) return id instanceof ObjectId ? id : new ObjectId(id);
343
+ if (fieldType.type.kind === "") {
344
+ const dt = fieldType.type.designType;
345
+ if (dt === "number") return Number(id);
346
+ }
347
+ return String(id);
348
+ }
623
349
  /**
624
- * Transforms an "_id" value to the expected type (`ObjectId`, `number`, or `string`).
625
- * Assumes input has already been validated.
626
- *
627
- * @param {string | number | ObjectId} id - The validated ID.
628
- * @returns {string | number | ObjectId} - The transformed ID.
629
- * @throws {Error} If the `_id` type is unknown.
630
- */ prepareId(id) {
350
+ * Convenience method that uses `idType` to transform an ID value.
351
+ * For use in controllers that don't have access to the field type.
352
+ */ prepareIdFromIdType(id) {
631
353
  switch (this.idType) {
632
354
  case "objectId": return id instanceof ObjectId ? id : new ObjectId(id);
633
355
  case "number": return Number(id);
@@ -635,91 +357,59 @@ var AsCollection = class {
635
357
  default: throw new Error("Unknown \"_id\" type");
636
358
  }
637
359
  }
638
- /**
639
- * Retrieves a validator for a given purpose. If the validator is not already cached,
640
- * it creates and stores a new one based on the purpose.
641
- *
642
- * @param {TValidatorPurpose} purpose - The validation purpose (`input`, `update`, `patch`).
643
- * @returns {Validator} The corresponding validator instance.
644
- * @throws {Error} If an unknown purpose is provided.
645
- */ getValidator(purpose) {
646
- if (!this.validators.has(purpose)) switch (purpose) {
647
- case "insert": {
648
- this.validators.set(purpose, this.createValidator({
649
- plugins: [validateMongoIdPlugin, validateMongoUniqueArrayItemsPlugin],
650
- replace(type, path) {
651
- if (path === "_id" && type.type.tags.has("objectId")) return {
652
- ...type,
653
- optional: true
654
- };
655
- return type;
656
- }
657
- }));
658
- break;
659
- }
660
- case "update": {
661
- this.validators.set(purpose, this.createValidator({ plugins: [validateMongoIdPlugin] }));
662
- break;
360
+ supportsNestedObjects() {
361
+ return true;
362
+ }
363
+ supportsNativePatch() {
364
+ return true;
365
+ }
366
+ getValidatorPlugins() {
367
+ return [validateMongoIdPlugin];
368
+ }
369
+ getTopLevelArrayTag() {
370
+ return "db.mongo.__topLevelArray";
371
+ }
372
+ getAdapterTableName(type) {
373
+ return undefined;
374
+ }
375
+ buildInsertValidator(table) {
376
+ return table.createValidator({
377
+ plugins: this.getValidatorPlugins(),
378
+ replace: (type, path) => {
379
+ if (path === "_id" && type.type.tags?.has("objectId")) return {
380
+ ...type,
381
+ optional: true
382
+ };
383
+ if (table.defaults.has(path)) return {
384
+ ...type,
385
+ optional: true
386
+ };
387
+ return type;
663
388
  }
664
- case "patch": {
665
- this.validators.set(purpose, CollectionPatcher.prepareValidator(this));
666
- break;
667
- }
668
- default: throw new Error(`Unknown validator purpose: ${purpose}`);
669
- }
670
- return this.validators.get(purpose);
671
- }
672
- get type() {
673
- return this._type;
674
- }
675
- get indexes() {
676
- this._flatten();
677
- return this._indexes;
389
+ });
678
390
  }
679
- _addIndexField(type, name, field, weight) {
680
- const key = indexKey(type, name);
681
- let index = this._indexes.get(key);
682
- const value = type === "text" ? "text" : 1;
683
- if (index) index.fields[field] = value;
684
- else {
685
- const weights = {};
686
- index = {
687
- key,
688
- name,
689
- type,
690
- fields: { [field]: value },
691
- weights
692
- };
693
- this._indexes.set(key, index);
694
- }
695
- if (weight) index.weights[field] = weight;
391
+ buildPatchValidator(table) {
392
+ return CollectionPatcher.prepareValidator(this.getPatcherContext());
696
393
  }
697
- _setSearchIndex(type, name, definition) {
698
- const key = indexKey(type, name || DEFAULT_INDEX_NAME);
699
- this._indexes.set(key, {
700
- key,
701
- name: name || DEFAULT_INDEX_NAME,
702
- type,
703
- definition
704
- });
394
+ /** Returns the context object used by CollectionPatcher. */ getPatcherContext() {
395
+ return {
396
+ flatMap: this._table.flatMap,
397
+ prepareId: (id) => this.prepareIdFromIdType(id),
398
+ createValidator: (opts) => this._table.createValidator(opts)
399
+ };
705
400
  }
706
- _addFieldToSearchIndex(type, _name, fieldName, analyzer) {
707
- const name = _name || DEFAULT_INDEX_NAME;
708
- let index = this._indexes.get(indexKey(type, name));
709
- if (!index && type === "search_text") {
710
- this._setSearchIndex(type, name, {
711
- mappings: { fields: {} },
712
- text: { fuzzy: { maxEdits: 0 } }
713
- });
714
- index = this._indexes.get(indexKey(type, name));
715
- }
716
- if (index) {
717
- index.definition.mappings.fields[fieldName] = { type: "string" };
718
- if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
719
- }
401
+ async nativePatch(filter, patch) {
402
+ const mongoFilter = buildMongoFilter(filter);
403
+ const patcher = new CollectionPatcher(this.getPatcherContext(), patch);
404
+ const { updateFilter, updateOptions } = patcher.preparePatch();
405
+ const result = await this.collection.updateOne(mongoFilter, updateFilter, updateOptions);
406
+ return {
407
+ matchedCount: result.matchedCount,
408
+ modifiedCount: result.modifiedCount
409
+ };
720
410
  }
721
- _prepareIndexesForCollection() {
722
- const typeMeta = this.type.metadata;
411
+ onBeforeFlatten(type) {
412
+ const typeMeta = type.metadata;
723
413
  const dynamicText = typeMeta.get("db.mongo.search.dynamic");
724
414
  if (dynamicText) this._setSearchIndex("dynamic_text", "_", {
725
415
  mappings: { dynamic: true },
@@ -732,73 +422,65 @@ else {
732
422
  text: { fuzzy: { maxEdits: textSearch.fuzzy || 0 } }
733
423
  });
734
424
  }
735
- get uniqueProps() {
736
- return this._uniqueProps;
737
- }
738
- _finalizeIndexesForCollection() {
739
- for (const [key, value] of Array.from(this._vectorFilters.entries())) {
740
- const index = this._indexes.get(key);
741
- if (index && index.type === "vector") index.definition.fields?.push({
742
- type: "filter",
743
- path: value
744
- });
745
- }
746
- for (const [, value] of Array.from(this._indexes.entries())) if (value.type === "unique") {
747
- const keys = Object.keys(value.fields);
748
- if (keys.length === 1) this._uniqueProps.add(keys[0]);
749
- }
750
- }
751
- _prepareIndexesForField(fieldName, metadata) {
752
- for (const index of metadata.get("db.index.plain") || []) {
753
- const name = index === true ? fieldName : index.name || fieldName;
754
- this._addIndexField("plain", name, fieldName);
425
+ onFieldScanned(field, type, metadata) {
426
+ if (field !== "_id" && metadata.has("meta.id")) {
427
+ this._table.removePrimaryKey(field);
428
+ this._addMongoIndexField("unique", "__pk", field);
429
+ this._table.addUniqueField(field);
755
430
  }
756
- for (const index of metadata.get("db.index.unique") || []) {
757
- const name = index === true ? fieldName : index.name || fieldName;
758
- this._addIndexField("unique", name, fieldName);
431
+ const defaultFn = metadata.get("db.default.fn");
432
+ if (defaultFn === "increment") {
433
+ const physicalName = metadata.get("db.column") ?? field;
434
+ this._incrementFields.add(physicalName);
759
435
  }
760
436
  for (const index of metadata.get("db.index.fulltext") || []) {
761
437
  const name = index === true ? "" : index.name || "";
762
- this._addIndexField("text", name, fieldName, 1);
438
+ const weight = index !== true && typeof index === "object" ? index.weight || 1 : 1;
439
+ this._addMongoIndexField("text", name, field, weight);
763
440
  }
764
- const textWeight = metadata.get("db.mongo.index.text");
765
- if (textWeight) this._addIndexField("text", "", fieldName, textWeight === true ? 1 : textWeight);
766
- for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, fieldName, index.analyzer);
441
+ for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, index.analyzer);
767
442
  const vectorIndex = metadata.get("db.mongo.search.vector");
768
- if (vectorIndex) this._setSearchIndex("vector", vectorIndex.indexName || fieldName, { fields: [{
443
+ if (vectorIndex) this._setSearchIndex("vector", vectorIndex.indexName || field, { fields: [{
769
444
  type: "vector",
770
- path: fieldName,
445
+ path: field,
771
446
  similarity: vectorIndex.similarity || "dotProduct",
772
447
  numDimensions: vectorIndex.dimensions
773
448
  }] });
774
- for (const index of metadata.get("db.mongo.search.filter") || []) this._vectorFilters.set(indexKey("vector", index.indexName), fieldName);
775
- }
776
- _flatten() {
777
- if (!this._flatMap) {
778
- this._prepareIndexesForCollection();
779
- this._flatMap = flattenAnnotatedType(this.type, {
780
- topLevelArrayTag: "db.mongo.__topLevelArray",
781
- excludePhantomTypes: true,
782
- onField: (path, _type, metadata) => this._prepareIndexesForField(path, metadata)
449
+ for (const index of metadata.get("db.mongo.search.filter") || []) this._vectorFilters.set(mongoIndexKey("vector", index.indexName), field);
450
+ }
451
+ onAfterFlatten() {
452
+ this._table.addPrimaryKey("_id");
453
+ for (const [key, value] of this._vectorFilters.entries()) {
454
+ const index = this._mongoIndexes.get(key);
455
+ if (index && index.type === "vector") index.definition.fields?.push({
456
+ type: "filter",
457
+ path: value
783
458
  });
784
- this._finalizeIndexesForCollection();
785
459
  }
786
460
  }
787
- getSearchIndexes() {
461
+ /** Returns MongoDB-specific search index map (internal). */ getMongoSearchIndexes() {
788
462
  if (!this._searchIndexesMap) {
463
+ this._table.flatMap;
789
464
  this._searchIndexesMap = new Map();
790
- let deafultIndex;
791
- for (const index of this.indexes.values()) switch (index.type) {
465
+ let defaultIndex;
466
+ for (const index of this._table.indexes.values()) if (index.type === "fulltext" && !defaultIndex) defaultIndex = {
467
+ key: index.key,
468
+ name: index.name,
469
+ type: "text",
470
+ fields: Object.fromEntries(index.fields.map((f) => [f.name, "text"])),
471
+ weights: Object.fromEntries(index.fields.filter((f) => f.weight).map((f) => [f.name, f.weight]))
472
+ };
473
+ for (const index of this._mongoIndexes.values()) switch (index.type) {
792
474
  case "text": {
793
- if (!deafultIndex) deafultIndex = index;
475
+ if (!defaultIndex) defaultIndex = index;
794
476
  break;
795
477
  }
796
478
  case "dynamic_text": {
797
- deafultIndex = index;
479
+ defaultIndex = index;
798
480
  break;
799
481
  }
800
482
  case "search_text": {
801
- if (!deafultIndex || deafultIndex?.type === "text") deafultIndex = index;
483
+ if (!defaultIndex || defaultIndex.type === "text") defaultIndex = index;
802
484
  this._searchIndexesMap.set(index.name, index);
803
485
  break;
804
486
  }
@@ -808,21 +490,231 @@ else {
808
490
  }
809
491
  default:
810
492
  }
811
- if (deafultIndex && !this._searchIndexesMap.has(DEFAULT_INDEX_NAME)) this._searchIndexesMap.set(DEFAULT_INDEX_NAME, deafultIndex);
493
+ if (defaultIndex && !this._searchIndexesMap.has(DEFAULT_INDEX_NAME)) this._searchIndexesMap.set(DEFAULT_INDEX_NAME, defaultIndex);
812
494
  }
813
495
  return this._searchIndexesMap;
814
496
  }
815
- getSearchIndex(name = DEFAULT_INDEX_NAME) {
816
- return this.getSearchIndexes().get(name);
497
+ /** Returns a specific MongoDB search index by name. */ getMongoSearchIndex(name = DEFAULT_INDEX_NAME) {
498
+ return this.getMongoSearchIndexes().get(name);
817
499
  }
818
- get flatMap() {
819
- this._flatten();
820
- return this._flatMap;
500
+ /** Returns available search indexes as generic metadata for UI. */ getSearchIndexes() {
501
+ const mongoIndexes = this.getMongoSearchIndexes();
502
+ return [...mongoIndexes.entries()].map(([name, index]) => ({
503
+ name,
504
+ description: `${index.type} index`
505
+ }));
506
+ }
507
+ /**
508
+ * Builds a MongoDB `$search` pipeline stage.
509
+ * Override `buildVectorSearchStage` in subclasses to provide embeddings.
510
+ */ buildSearchStage(text, indexName) {
511
+ const index = this.getMongoSearchIndex(indexName);
512
+ if (!index) return undefined;
513
+ if (index.type === "vector") return this.buildVectorSearchStage(text, index);
514
+ return { $search: {
515
+ index: index.key,
516
+ text: {
517
+ query: text,
518
+ path: { wildcard: "*" }
519
+ }
520
+ } };
521
+ }
522
+ /**
523
+ * Builds a vector search stage. Override in subclasses to generate embeddings.
524
+ * Returns `undefined` by default (vector search requires custom implementation).
525
+ */ buildVectorSearchStage(text, index) {
526
+ return undefined;
527
+ }
528
+ async search(text, query, indexName) {
529
+ const searchStage = this.buildSearchStage(text, indexName);
530
+ if (!searchStage) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
531
+ const filter = buildMongoFilter(query.filter);
532
+ const controls = query.controls || {};
533
+ const pipeline = [searchStage, { $match: filter }];
534
+ if (controls.$sort) pipeline.push({ $sort: controls.$sort });
535
+ if (controls.$skip) pipeline.push({ $skip: controls.$skip });
536
+ if (controls.$limit) pipeline.push({ $limit: controls.$limit });
537
+ else pipeline.push({ $limit: 1e3 });
538
+ if (controls.$select) pipeline.push({ $project: controls.$select.asProjection });
539
+ return this.collection.aggregate(pipeline).toArray();
540
+ }
541
+ async searchWithCount(text, query, indexName) {
542
+ const searchStage = this.buildSearchStage(text, indexName);
543
+ if (!searchStage) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
544
+ const filter = buildMongoFilter(query.filter);
545
+ const controls = query.controls || {};
546
+ const pipeline = [
547
+ searchStage,
548
+ { $match: filter },
549
+ { $facet: {
550
+ data: [
551
+ controls.$sort ? { $sort: controls.$sort } : undefined,
552
+ controls.$skip ? { $skip: controls.$skip } : undefined,
553
+ controls.$limit ? { $limit: controls.$limit } : undefined,
554
+ controls.$select ? { $project: controls.$select.asProjection } : undefined
555
+ ].filter(Boolean),
556
+ meta: [{ $count: "count" }]
557
+ } }
558
+ ];
559
+ const result = await this.collection.aggregate(pipeline).toArray();
560
+ return {
561
+ data: result[0]?.data || [],
562
+ count: result[0]?.meta[0]?.count || 0
563
+ };
564
+ }
565
+ async findManyWithCount(query) {
566
+ const filter = buildMongoFilter(query.filter);
567
+ const controls = query.controls || {};
568
+ const pipeline = [{ $match: filter }, { $facet: {
569
+ data: [
570
+ controls.$sort ? { $sort: controls.$sort } : undefined,
571
+ controls.$skip ? { $skip: controls.$skip } : undefined,
572
+ controls.$limit ? { $limit: controls.$limit } : undefined,
573
+ controls.$select ? { $project: controls.$select.asProjection } : undefined
574
+ ].filter(Boolean),
575
+ meta: [{ $count: "count" }]
576
+ } }];
577
+ const result = await this.collection.aggregate(pipeline).toArray();
578
+ return {
579
+ data: result[0]?.data || [],
580
+ count: result[0]?.meta[0]?.count || 0
581
+ };
582
+ }
583
+ async collectionExists() {
584
+ if (this.asMongo) return this.asMongo.collectionExists(this._table.tableName);
585
+ const cols = await this.db.listCollections({ name: this._table.tableName }).toArray();
586
+ return cols.length > 0;
587
+ }
588
+ async ensureCollectionExists() {
589
+ const exists = await this.collectionExists();
590
+ if (!exists) await this.db.createCollection(this._table.tableName, { comment: "Created by Atscript Mongo Adapter" });
591
+ }
592
+ async insertOne(data) {
593
+ if (this._incrementFields.size > 0) {
594
+ const fields = this._fieldsNeedingIncrement(data);
595
+ if (fields.length > 0) {
596
+ const maxValues = await this._getMaxValues(fields);
597
+ for (const physical of fields) data[physical] = (maxValues.get(physical) ?? 0) + 1;
598
+ }
599
+ }
600
+ const result = await this.collection.insertOne(data);
601
+ return { insertedId: result.insertedId };
602
+ }
603
+ async insertMany(data) {
604
+ if (this._incrementFields.size > 0) {
605
+ const allFields = new Set();
606
+ for (const item of data) for (const f of this._fieldsNeedingIncrement(item)) allFields.add(f);
607
+ if (allFields.size > 0) {
608
+ const maxValues = await this._getMaxValues([...allFields]);
609
+ for (const item of data) for (const physical of allFields) if (item[physical] === undefined || item[physical] === null) {
610
+ const next = (maxValues.get(physical) ?? 0) + 1;
611
+ item[physical] = next;
612
+ maxValues.set(physical, next);
613
+ } else if (typeof item[physical] === "number") {
614
+ const current = maxValues.get(physical) ?? 0;
615
+ if (item[physical] > current) maxValues.set(physical, item[physical]);
616
+ }
617
+ }
618
+ }
619
+ const result = await this.collection.insertMany(data);
620
+ return {
621
+ insertedCount: result.insertedCount,
622
+ insertedIds: Object.values(result.insertedIds)
623
+ };
624
+ }
625
+ async findOne(query) {
626
+ const filter = buildMongoFilter(query.filter);
627
+ const opts = this._buildFindOptions(query.controls);
628
+ return this.collection.findOne(filter, opts);
629
+ }
630
+ async findMany(query) {
631
+ const filter = buildMongoFilter(query.filter);
632
+ const opts = this._buildFindOptions(query.controls);
633
+ return this.collection.find(filter, opts).toArray();
634
+ }
635
+ async count(query) {
636
+ const filter = buildMongoFilter(query.filter);
637
+ return this.collection.countDocuments(filter);
638
+ }
639
+ async updateOne(filter, data) {
640
+ const mongoFilter = buildMongoFilter(filter);
641
+ const result = await this.collection.updateOne(mongoFilter, { $set: data });
642
+ return {
643
+ matchedCount: result.matchedCount,
644
+ modifiedCount: result.modifiedCount
645
+ };
646
+ }
647
+ async replaceOne(filter, data) {
648
+ const mongoFilter = buildMongoFilter(filter);
649
+ const result = await this.collection.replaceOne(mongoFilter, data);
650
+ return {
651
+ matchedCount: result.matchedCount,
652
+ modifiedCount: result.modifiedCount
653
+ };
654
+ }
655
+ async deleteOne(filter) {
656
+ const mongoFilter = buildMongoFilter(filter);
657
+ const result = await this.collection.deleteOne(mongoFilter);
658
+ return { deletedCount: result.deletedCount };
659
+ }
660
+ async updateMany(filter, data) {
661
+ const mongoFilter = buildMongoFilter(filter);
662
+ const result = await this.collection.updateMany(mongoFilter, { $set: data });
663
+ return {
664
+ matchedCount: result.matchedCount,
665
+ modifiedCount: result.modifiedCount
666
+ };
667
+ }
668
+ async replaceMany(filter, data) {
669
+ const mongoFilter = buildMongoFilter(filter);
670
+ const result = await this.collection.updateMany(mongoFilter, { $set: data });
671
+ return {
672
+ matchedCount: result.matchedCount,
673
+ modifiedCount: result.modifiedCount
674
+ };
675
+ }
676
+ async deleteMany(filter) {
677
+ const mongoFilter = buildMongoFilter(filter);
678
+ const result = await this.collection.deleteMany(mongoFilter);
679
+ return { deletedCount: result.deletedCount };
680
+ }
681
+ async ensureTable() {
682
+ return this.ensureCollectionExists();
821
683
  }
822
684
  async syncIndexes() {
823
- await this.ensureExists();
685
+ await this.ensureCollectionExists();
686
+ const allIndexes = new Map();
687
+ for (const [key, index] of this._table.indexes.entries()) {
688
+ const fields = {};
689
+ const weights = {};
690
+ let mongoType;
691
+ if (index.type === "fulltext") {
692
+ mongoType = "text";
693
+ for (const f of index.fields) {
694
+ fields[f.name] = "text";
695
+ if (f.weight) weights[f.name] = f.weight;
696
+ }
697
+ } else {
698
+ mongoType = index.type;
699
+ for (const f of index.fields) fields[f.name] = 1;
700
+ }
701
+ allIndexes.set(key, {
702
+ key,
703
+ name: index.name,
704
+ type: mongoType,
705
+ fields,
706
+ weights
707
+ });
708
+ }
709
+ for (const [key, index] of this._mongoIndexes.entries()) if (index.type === "text") {
710
+ const existing = allIndexes.get(key);
711
+ if (existing && existing.type === "text") {
712
+ Object.assign(existing.fields, index.fields);
713
+ Object.assign(existing.weights, index.weights);
714
+ } else allIndexes.set(key, index);
715
+ } else allIndexes.set(key, index);
824
716
  const existingIndexes = await this.collection.listIndexes().toArray();
825
- const indexesToCreate = new Map(this.indexes);
717
+ const indexesToCreate = new Map(allIndexes);
826
718
  for (const remote of existingIndexes) {
827
719
  if (!remote.name.startsWith(INDEX_PREFIX)) continue;
828
720
  if (indexesToCreate.has(remote.name)) {
@@ -832,54 +724,21 @@ else {
832
724
  case "unique":
833
725
  case "text": {
834
726
  if ((local.type === "text" || objMatch(local.fields, remote.key)) && objMatch(local.weights || {}, remote.weights || {})) indexesToCreate.delete(remote.name);
835
- else {
836
- this.logger.debug(`dropping index "${remote.name}"`);
837
- await this.collection.dropIndex(remote.name);
838
- }
839
- break;
840
- }
841
- default:
842
- }
843
- } else {
844
- this.logger.debug(`dropping index "${remote.name}"`);
845
- await this.collection.dropIndex(remote.name);
846
- }
847
- }
848
- const toUpdate = new Set();
849
- const existingSearchIndexes = await this.collection.listSearchIndexes().toArray();
850
- for (const remote of existingSearchIndexes) {
851
- if (!remote.name.startsWith(INDEX_PREFIX)) continue;
852
- if (indexesToCreate.has(remote.name)) {
853
- const local = indexesToCreate.get(remote.name);
854
- const right = remote.latestDefinition;
855
- switch (local.type) {
856
- case "dynamic_text":
857
- case "search_text": {
858
- const left = local.definition;
859
- if (left.analyzer === right.analyzer && fieldsMatch(left.mappings.fields || {}, right.mappings.fields || {})) indexesToCreate.delete(remote.name);
860
- else toUpdate.add(remote.name);
861
- break;
862
- }
863
- case "vector": {
864
- if (vectorFieldsMatch(local.definition.fields || [], right.fields || [])) indexesToCreate.delete(remote.name);
865
- else toUpdate.add(remote.name);
727
+ else await this.collection.dropIndex(remote.name);
866
728
  break;
867
729
  }
868
730
  default:
869
731
  }
870
- } else if (remote.status !== "DELETING") {
871
- this.logger.debug(`dropping search index "${remote.name}"`);
872
- await this.collection.dropSearchIndex(remote.name);
873
- } else this.logger.debug(`search index "${remote.name}" is in deleting status`);
732
+ } else await this.collection.dropIndex(remote.name);
874
733
  }
875
- for (const [key, value] of Array.from(indexesToCreate.entries())) switch (value.type) {
734
+ for (const [key, value] of allIndexes.entries()) switch (value.type) {
876
735
  case "plain": {
877
- this.logger.debug(`creating index "${key}"`);
736
+ if (!indexesToCreate.has(key)) continue;
878
737
  await this.collection.createIndex(value.fields, { name: key });
879
738
  break;
880
739
  }
881
740
  case "unique": {
882
- this.logger.debug(`creating index "${key}"`);
741
+ if (!indexesToCreate.has(key)) continue;
883
742
  await this.collection.createIndex(value.fields, {
884
743
  name: key,
885
744
  unique: true
@@ -887,160 +746,168 @@ else toUpdate.add(remote.name);
887
746
  break;
888
747
  }
889
748
  case "text": {
890
- this.logger.debug(`creating index "${key}"`);
749
+ if (!indexesToCreate.has(key)) continue;
891
750
  await this.collection.createIndex(value.fields, {
892
751
  weights: value.weights,
893
752
  name: key
894
753
  });
895
754
  break;
896
755
  }
897
- case "dynamic_text":
898
- case "search_text":
899
- case "vector": {
900
- if (toUpdate.has(key)) {
901
- this.logger.debug(`updating search index "${key}"`);
902
- await this.collection.updateSearchIndex(key, value.definition);
903
- } else {
904
- this.logger.debug(`creating search index "${key}"`);
905
- await this.collection.createSearchIndex({
756
+ default:
757
+ }
758
+ try {
759
+ const toUpdate = new Set();
760
+ const existingSearchIndexes = await this.collection.listSearchIndexes().toArray();
761
+ for (const remote of existingSearchIndexes) {
762
+ if (!remote.name.startsWith(INDEX_PREFIX)) continue;
763
+ if (indexesToCreate.has(remote.name)) {
764
+ const local = indexesToCreate.get(remote.name);
765
+ const right = remote.latestDefinition;
766
+ switch (local.type) {
767
+ case "dynamic_text":
768
+ case "search_text": {
769
+ const left = local.definition;
770
+ if (left.analyzer === right.analyzer && fieldsMatch(left.mappings.fields || {}, right.mappings.fields || {})) indexesToCreate.delete(remote.name);
771
+ else toUpdate.add(remote.name);
772
+ break;
773
+ }
774
+ case "vector": {
775
+ if (vectorFieldsMatch(local.definition.fields || [], right.fields || [])) indexesToCreate.delete(remote.name);
776
+ else toUpdate.add(remote.name);
777
+ break;
778
+ }
779
+ default:
780
+ }
781
+ } else if (remote.status !== "DELETING") await this.collection.dropSearchIndex(remote.name);
782
+ }
783
+ for (const [key, value] of indexesToCreate.entries()) switch (value.type) {
784
+ case "dynamic_text":
785
+ case "search_text":
786
+ case "vector": {
787
+ if (toUpdate.has(key)) await this.collection.updateSearchIndex(key, value.definition);
788
+ else await this.collection.createSearchIndex({
906
789
  name: key,
907
790
  type: value.type === "vector" ? "vectorSearch" : "search",
908
791
  definition: value.definition
909
792
  });
793
+ break;
910
794
  }
911
- break;
795
+ default:
796
+ }
797
+ } catch {}
798
+ }
799
+ /** Returns physical field names of increment fields that are undefined in the data. */ _fieldsNeedingIncrement(data) {
800
+ const result = [];
801
+ for (const physical of this._incrementFields) if (data[physical] === undefined || data[physical] === null) result.push(physical);
802
+ return result;
803
+ }
804
+ /** Reads current max value for each field via $group aggregation. */ async _getMaxValues(physicalFields) {
805
+ const aliases = physicalFields.map((f) => [`max__${f.replace(/\./g, "__")}`, f]);
806
+ const group = { _id: null };
807
+ for (const [alias, field] of aliases) group[alias] = { $max: `$${field}` };
808
+ const result = await this.collection.aggregate([{ $group: group }]).toArray();
809
+ const maxMap = new Map();
810
+ if (result.length > 0) {
811
+ const row = result[0];
812
+ for (const [alias, field] of aliases) {
813
+ const val = row[alias];
814
+ maxMap.set(field, typeof val === "number" ? val : 0);
912
815
  }
913
- default:
914
816
  }
817
+ return maxMap;
818
+ }
819
+ _buildFindOptions(controls) {
820
+ const opts = {};
821
+ if (!controls) return opts;
822
+ if (controls.$sort) opts.sort = controls.$sort;
823
+ if (controls.$limit) opts.limit = controls.$limit;
824
+ if (controls.$skip) opts.skip = controls.$skip;
825
+ if (controls.$select) opts.projection = controls.$select.asProjection;
826
+ return opts;
827
+ }
828
+ _addMongoIndexField(type, name, field, weight) {
829
+ const key = mongoIndexKey(type, name);
830
+ let index = this._mongoIndexes.get(key);
831
+ const value = type === "text" ? "text" : 1;
832
+ if (index) index.fields[field] = value;
833
+ else {
834
+ index = {
835
+ key,
836
+ name,
837
+ type,
838
+ fields: { [field]: value },
839
+ weights: {}
840
+ };
841
+ this._mongoIndexes.set(key, index);
842
+ }
843
+ if (weight) index.weights[field] = weight;
915
844
  }
916
- insert(payload, options) {
917
- const toInsert = this.prepareInsert(payload);
918
- return Array.isArray(toInsert) ? this.collection.insertMany(toInsert, options) : this.collection.insertOne(toInsert, options);
919
- }
920
- replace(payload, options) {
921
- const [filter, replace, opts] = this.prepareReplace(payload).toArgs();
922
- return this.collection.replaceOne(filter, replace, {
923
- ...opts,
924
- ...options
925
- });
926
- }
927
- update(payload, options) {
928
- const [filter, update, opts] = this.prepareUpdate(payload).toArgs();
929
- return this.collection.updateOne(filter, update, {
930
- ...opts,
931
- ...options
845
+ _setSearchIndex(type, name, definition) {
846
+ const key = mongoIndexKey(type, name || DEFAULT_INDEX_NAME);
847
+ this._mongoIndexes.set(key, {
848
+ key,
849
+ name: name || DEFAULT_INDEX_NAME,
850
+ type,
851
+ definition
932
852
  });
933
853
  }
934
- prepareInsert(payload) {
935
- const v = this.getValidator("insert");
936
- const arr = Array.isArray(payload) ? payload : [payload];
937
- const prepared = [];
938
- for (const item of arr) if (v.validate(item)) {
939
- const data = { ...item };
940
- if (data._id) data._id = this.prepareId(data._id);
941
- else if (this.idType !== "objectId") throw new Error("Missing \"_id\" field");
942
- prepared.push(data);
943
- } else throw new Error("Invalid payload");
944
- return prepared.length === 1 ? prepared[0] : prepared;
945
- }
946
- prepareReplace(payload) {
947
- const v = this.getValidator("update");
948
- if (v.validate(payload)) {
949
- const _id = this.prepareId(payload._id);
950
- const data = {
951
- ...payload,
952
- _id
953
- };
954
- return {
955
- toArgs: () => [
956
- { _id },
957
- data,
958
- {}
959
- ],
960
- filter: { _id },
961
- updateFilter: data,
962
- updateOptions: {}
963
- };
854
+ _addFieldToSearchIndex(type, _name, fieldName, analyzer) {
855
+ const name = _name || DEFAULT_INDEX_NAME;
856
+ let index = this._mongoIndexes.get(mongoIndexKey(type, name));
857
+ if (!index && type === "search_text") {
858
+ this._setSearchIndex(type, name, {
859
+ mappings: { fields: {} },
860
+ text: { fuzzy: { maxEdits: 0 } }
861
+ });
862
+ index = this._mongoIndexes.get(mongoIndexKey(type, name));
964
863
  }
965
- throw new Error("Invalid payload");
966
- }
967
- prepareUpdate(payload) {
968
- const v = this.getValidator("patch");
969
- if (v.validate(payload)) return new CollectionPatcher(this, payload).preparePatch();
970
- throw new Error("Invalid payload");
971
- }
972
- constructor(asMongo, _type, logger = NoopLogger) {
973
- _define_property$1(this, "asMongo", void 0);
974
- _define_property$1(this, "_type", void 0);
975
- _define_property$1(this, "logger", void 0);
976
- _define_property$1(this, "name", void 0);
977
- _define_property$1(this, "collection", void 0);
978
- _define_property$1(this, "validators", void 0);
979
- _define_property$1(this, "_indexes", void 0);
980
- _define_property$1(this, "_vectorFilters", void 0);
981
- _define_property$1(this, "_flatMap", void 0);
982
- _define_property$1(this, "_uniqueProps", void 0);
983
- _define_property$1(this, "_searchIndexesMap", void 0);
984
- this.asMongo = asMongo;
985
- this._type = _type;
986
- this.logger = logger;
987
- this.validators = new Map();
988
- this._indexes = new Map();
989
- this._vectorFilters = new Map();
990
- this._uniqueProps = new Set();
991
- if (!isAnnotatedType(_type)) throw new Error("Atscript Annotated Type expected");
992
- const name = _type.metadata.get("db.table");
993
- if (!name) throw new Error("@db.table annotation expected with collection name");
994
- if (_type.type.kind !== "object") throw new Error("Mongo collection must be an object type");
995
- this.name = name;
996
- this.collection = asMongo.db.collection(name);
864
+ if (index) {
865
+ index.definition.mappings.fields[fieldName] = { type: "string" };
866
+ if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
867
+ }
868
+ }
869
+ constructor(db, asMongo) {
870
+ super(), _define_property$1(this, "db", void 0), _define_property$1(this, "asMongo", void 0), _define_property$1(this, "_collection", void 0), _define_property$1(this, "_mongoIndexes", void 0), _define_property$1(this, "_vectorFilters", void 0), _define_property$1(this, "_searchIndexesMap", void 0), _define_property$1(this, "_incrementFields", void 0), this.db = db, this.asMongo = asMongo, this._mongoIndexes = new Map(), this._vectorFilters = new Map(), this._incrementFields = new Set();
997
871
  }
998
872
  };
999
- /**
1000
- * Vector Index fields matching
1001
- */ function vectorFieldsMatch(left, right) {
1002
- const leftMap = new Map();
1003
- left.forEach((f) => leftMap.set(f.path, f));
1004
- const rightMap = new Map();
1005
- (right || []).forEach((f) => rightMap.set(f.path, f));
1006
- if (leftMap.size === rightMap.size) {
1007
- let match = true;
1008
- for (const [key, left$1] of leftMap.entries()) {
1009
- const right$1 = rightMap.get(key);
1010
- if (!right$1) {
1011
- match = false;
1012
- break;
1013
- }
1014
- if (left$1.type === right$1.type && left$1.path === right$1.path && left$1.similarity === right$1.similarity && left$1.numDimensions === right$1.numDimensions) continue;
1015
- match = false;
1016
- break;
1017
- }
1018
- return match;
1019
- } else return false;
873
+ function objMatch(o1, o2) {
874
+ const keys1 = Object.keys(o1);
875
+ const keys2 = Object.keys(o2);
876
+ if (keys1.length !== keys2.length) return false;
877
+ return keys1.every((key) => o1[key] === o2[key]);
1020
878
  }
1021
- /**
1022
- * Search Index fields matching
1023
- */ function fieldsMatch(left, right) {
879
+ function fieldsMatch(left, right) {
1024
880
  if (!left || !right) return left === right;
1025
881
  const leftKeys = Object.keys(left);
1026
882
  const rightKeys = Object.keys(right);
1027
883
  if (leftKeys.length !== rightKeys.length) return false;
1028
884
  return leftKeys.every((key) => {
1029
885
  if (!(key in right)) return false;
1030
- const leftField = left[key];
1031
- const rightField = right[key];
1032
- return leftField.type === rightField.type && leftField.analyzer === rightField.analyzer;
886
+ return left[key].type === right[key].type && left[key].analyzer === right[key].analyzer;
1033
887
  });
1034
888
  }
1035
- /**
1036
- * Shallow object matching
1037
- */ function objMatch(o1, o2) {
1038
- const keys1 = Object.keys(o1);
1039
- const keys2 = Object.keys(o2);
1040
- if (keys1.length !== keys2.length) return false;
1041
- return keys1.every((key) => o1[key] === o2[key]);
889
+ function vectorFieldsMatch(left, right) {
890
+ const leftMap = new Map(left.map((f) => [f.path, f]));
891
+ const rightMap = new Map((right || []).map((f) => [f.path, f]));
892
+ if (leftMap.size !== rightMap.size) return false;
893
+ for (const [key, l] of leftMap.entries()) {
894
+ const r = rightMap.get(key);
895
+ if (!r) return false;
896
+ if (l.type !== r.type || l.path !== r.path || l.similarity !== r.similarity || l.numDimensions !== r.numDimensions) return false;
897
+ }
898
+ return true;
1042
899
  }
1043
900
 
901
+ //#endregion
902
+ //#region packages/mongo/src/lib/logger.ts
903
+ const NoopLogger = {
904
+ error: () => {},
905
+ warn: () => {},
906
+ log: () => {},
907
+ info: () => {},
908
+ debug: () => {}
909
+ };
910
+
1044
911
  //#endregion
1045
912
  //#region packages/mongo/src/lib/as-mongo.ts
1046
913
  function _define_property(obj, key, value) {
@@ -1065,25 +932,35 @@ var AsMongo = class {
1065
932
  const list = await this.getCollectionsList();
1066
933
  return list.has(name);
1067
934
  }
1068
- getCollection(type, logger) {
1069
- let collection = this._collections.get(type);
1070
- if (!collection) {
1071
- collection = new AsCollection(this, type, logger || this.logger);
1072
- this._collections.set(type, collection);
935
+ getAdapter(type) {
936
+ this._ensureCreated(type);
937
+ return this._adapters.get(type);
938
+ }
939
+ getTable(type, logger) {
940
+ this._ensureCreated(type, logger);
941
+ return this._tables.get(type);
942
+ }
943
+ _ensureCreated(type, logger) {
944
+ if (!this._adapters.has(type)) {
945
+ const adapter = new MongoAdapter(this.db, this);
946
+ const table = new AtscriptDbTable(type, adapter, logger || this.logger);
947
+ this._adapters.set(type, adapter);
948
+ this._tables.set(type, table);
1073
949
  }
1074
- return collection;
1075
950
  }
1076
951
  constructor(client, logger = NoopLogger) {
1077
952
  _define_property(this, "logger", void 0);
1078
953
  _define_property(this, "client", void 0);
1079
954
  _define_property(this, "collectionsList", void 0);
1080
- _define_property(this, "_collections", void 0);
955
+ _define_property(this, "_adapters", void 0);
956
+ _define_property(this, "_tables", void 0);
1081
957
  this.logger = logger;
1082
- this._collections = new WeakMap();
958
+ this._adapters = new WeakMap();
959
+ this._tables = new WeakMap();
1083
960
  if (typeof client === "string") this.client = new MongoClient(client);
1084
961
  else this.client = client;
1085
962
  }
1086
963
  };
1087
964
 
1088
965
  //#endregion
1089
- export { AsCollection, AsMongo, MongoPlugin };
966
+ export { AsMongo, CollectionPatcher, MongoAdapter, buildMongoFilter, validateMongoIdPlugin };