@atscript/mongo 0.1.31 → 0.1.33

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, getKeyProps, 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
@@ -310,45 +20,29 @@ else obj[key] = value;
310
20
  return obj;
311
21
  }
312
22
  var CollectionPatcher = class CollectionPatcher {
313
- /**
314
- * Extract a set of *key properties* (annotated with `@expect.array.key`) from an
315
- * array‐of‐objects type definition. These keys uniquely identify an element
316
- * inside the array and are later used for `$update`, `$remove` and `$upsert`.
317
- *
318
- * @param def Atscript array type
319
- * @returns Set of property names marked as keys; empty set if none
320
- */ static getKeyProps(def) {
321
- if (def.type.of.type.kind === "object") {
322
- const objType = def.type.of.type;
323
- const keyProps = new Set();
324
- for (const [key, val] of objType.props.entries()) if (val.metadata.get("expect.array.key")) keyProps.add(key);
325
- return keyProps;
326
- }
327
- return new Set();
328
- }
329
23
  /**
330
24
  * Build a runtime *Validator* that understands the extended patch payload.
331
25
  *
332
26
  * * Adds per‑array *patch* wrappers (the `$replace`, `$insert`, … fields).
333
- * * Honors `db.mongo.patch.strategy === "merge"` metadata.
27
+ * * Honors `db.patch.strategy === "merge"` metadata.
334
28
  *
335
29
  * @param collection Target collection wrapper
336
30
  * @returns Atscript Validator
337
- */ static prepareValidator(collection) {
338
- return collection.createValidator({
339
- plugins: [validateMongoIdPlugin, validateMongoUniqueArrayItemsPlugin],
31
+ */ static prepareValidator(context) {
32
+ return context.createValidator({
33
+ plugins: [validateMongoIdPlugin],
340
34
  replace: (def, path) => {
341
35
  if (path === "" && def.type.kind === "object") {
342
36
  const obj = defineAnnotatedType("object").copyMetadata(def.metadata);
343
37
  for (const [prop, type] of def.type.props.entries()) obj.prop(prop, defineAnnotatedType().refTo(type).copyMetadata(type.metadata).optional(prop !== "_id").$type);
344
38
  return obj.$type;
345
39
  }
346
- if (def.type.kind === "array" && collection.flatMap.get(path)?.metadata.get("db.mongo.__topLevelArray") && !def.metadata.has("db.mongo.__patchArrayValue")) {
40
+ if (def.type.kind === "array" && context.flatMap.get(path)?.metadata.get("db.mongo.__topLevelArray") && !def.metadata.has("db.mongo.__patchArrayValue")) {
347
41
  const defArray = def;
348
- const mergeStrategy = defArray.metadata.get("db.mongo.patch.strategy") === "merge";
42
+ const mergeStrategy = defArray.metadata.get("db.patch.strategy") === "merge";
349
43
  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;
44
+ const isPrimitive = isAnnotatedTypeOfPrimitive(defArray.type.of);
45
+ if (isPrimitive) return defineAnnotatedType().refTo(def).copyMetadata(def.metadata).annotate("db.mongo.__patchArrayValue").optional().$type;
352
46
  if (defArray.type.of.type.kind === "object") {
353
47
  const objType = defArray.type.of.type;
354
48
  const t = defineAnnotatedType("object").copyMetadata(defArray.type.of.metadata);
@@ -366,7 +60,7 @@ else t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).opt
366
60
  }
367
61
  return def;
368
62
  },
369
- partial: (def, path) => path !== "" && def.metadata.get("db.mongo.patch.strategy") === "merge"
63
+ partial: (def, path) => path !== "" && def.metadata.get("db.patch.strategy") === "merge"
370
64
  });
371
65
  }
372
66
  /**
@@ -396,14 +90,12 @@ else t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).opt
396
90
  * @param val Value to be written
397
91
  * @private
398
92
  */ _set(key, val) {
399
- for (const pipe of this.updatePipeline) {
400
- if (!pipe.$set) pipe.$set = {};
401
- if (!pipe.$set[key]) {
402
- pipe.$set[key] = val;
403
- return;
404
- }
93
+ if (this.currentSetStage && !(key in this.currentSetStage.$set)) {
94
+ this.currentSetStage.$set[key] = val;
95
+ return;
405
96
  }
406
- this.updatePipeline.push({ $set: { [key]: val } });
97
+ this.currentSetStage = { $set: { [key]: val } };
98
+ this.updatePipeline.push(this.currentSetStage);
407
99
  }
408
100
  /**
409
101
  * Recursively walk through the patch *payload* and convert it into `$set`/…
@@ -418,8 +110,8 @@ else t.prop(key, defineAnnotatedType().refTo(val).copyMetadata(def.metadata).opt
418
110
  const key = evalKey(_key);
419
111
  const flatType = this.collection.flatMap.get(key);
420
112
  const topLevelArray = flatType?.metadata?.get("db.mongo.__topLevelArray");
421
- 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);
113
+ if (typeof value === "object" && topLevelArray) this.parseArrayPatch(key, value, flatType);
114
+ else if (typeof value === "object" && flatType?.metadata?.get("db.patch.strategy") === "merge") this.flattenPayload(value, key);
423
115
  else if (key !== "_id") this._set(key, value);
424
116
  }
425
117
  return this.updatePipeline;
@@ -431,19 +123,19 @@ else if (key !== "_id") this._set(key, value);
431
123
  * @param key Dotted path to the array field
432
124
  * @param value Payload slice for that field
433
125
  * @private
434
- */ parseArrayPatch(key, value) {
435
- const flatType = this.collection.flatMap.get(key);
126
+ */ parseArrayPatch(key, value, flatType) {
436
127
  const toRemove = value.$remove;
437
128
  const toReplace = value.$replace;
438
129
  const toInsert = value.$insert;
439
130
  const toUpsert = value.$upsert;
440
131
  const toUpdate = value.$update;
441
- const keyProps = flatType?.type.kind === "array" ? CollectionPatcher.getKeyProps(flatType) : new Set();
442
- this._remove(key, toRemove, keyProps);
132
+ const keyProps = flatType.type.kind === "array" ? getKeyProps(flatType) : new Set();
133
+ const keys = keyProps.size > 0 ? [...keyProps] : [];
134
+ this._remove(key, toRemove, keys, flatType);
443
135
  this._replace(key, toReplace);
444
- this._insert(key, toInsert, keyProps);
445
- this._upsert(key, toUpsert, keyProps);
446
- this._update(key, toUpdate, keyProps);
136
+ this._insert(key, toInsert, keys, flatType);
137
+ this._upsert(key, toUpsert, keys, flatType);
138
+ this._update(key, toUpdate, keys, flatType);
447
139
  }
448
140
  /**
449
141
  * Build an *aggregation‐expression* that checks equality by **all** keys in
@@ -456,7 +148,8 @@ else if (key !== "_id") this._set(key, value);
456
148
  * @param left Base token for *left* expression (e.g. `"$$el"`)
457
149
  * @param right Base token for *right* expression (e.g. `"$$this"`)
458
150
  */ _keysEqual(keys, left, right) {
459
- return keys.map((k) => ({ $eq: [`${left}.${k}`, `${right}.${k}`] })).reduce((acc, cur) => acc ? { $and: [acc, cur] } : cur);
151
+ const eqs = keys.map((k) => ({ $eq: [`${left}.${k}`, `${right}.${k}`] }));
152
+ return eqs.length === 1 ? eqs[0] : { $and: eqs };
460
153
  }
461
154
  /**
462
155
  * `$replace` – overwrite the entire array with `input`.
@@ -471,20 +164,19 @@ else if (key !== "_id") this._set(key, value);
471
164
  * `$insert`
472
165
  * - plain append → $concatArrays
473
166
  * - unique / keyed → delegate to _upsert (insert-or-update)
474
- */ _insert(key, input, keyProps) {
167
+ */ _insert(key, input, keys, flatType) {
475
168
  if (!input?.length) return;
476
- const uniqueItems = this.collection.flatMap.get(key)?.metadata?.has("db.mongo.array.uniqueItems");
477
- if (uniqueItems || keyProps.size > 0) this._upsert(key, input, keyProps);
169
+ const uniqueItems = flatType.metadata?.has("expect.array.uniqueItems");
170
+ if (uniqueItems || keys.length > 0) this._upsert(key, input, keys, flatType);
478
171
  else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
479
172
  }
480
173
  /**
481
174
  * `$upsert`
482
175
  * - keyed → remove existing matching by key(s) then append candidate
483
176
  * - unique → $setUnion (deep equality)
484
- */ _upsert(key, input, keyProps) {
177
+ */ _upsert(key, input, keys, _flatType) {
485
178
  if (!input?.length) return;
486
- if (keyProps.size > 0) {
487
- const keys = [...keyProps];
179
+ if (keys.length > 0) {
488
180
  this._set(key, { $reduce: {
489
181
  input,
490
182
  initialValue: { $ifNull: [`$${key}`, []] },
@@ -508,11 +200,10 @@ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
508
200
  * `$update`
509
201
  * - keyed → map array and merge / replace matching element(s)
510
202
  * - non-keyed → behave like `$addToSet` (insert only when not present)
511
- */ _update(key, input, keyProps) {
203
+ */ _update(key, input, keys, flatType) {
512
204
  if (!input?.length) return;
513
- if (keyProps.size > 0) {
514
- const mergeStrategy = this.collection.flatMap.get(key)?.metadata?.get("db.mongo.patch.strategy") === "merge";
515
- const keys = [...keyProps];
205
+ if (keys.length > 0) {
206
+ const mergeStrategy = flatType.metadata?.get("db.patch.strategy") === "merge";
516
207
  this._set(key, { $reduce: {
517
208
  input,
518
209
  initialValue: { $ifNull: [`$${key}`, []] },
@@ -532,23 +223,21 @@ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
532
223
  * `$remove`
533
224
  * - keyed → filter out any element whose key set matches a payload item
534
225
  * - non-keyed → deep equality remove (`$setDifference`)
535
- */ _remove(key, input, keyProps) {
226
+ */ _remove(key, input, keys, _flatType) {
536
227
  if (!input?.length) return;
537
- if (keyProps.size > 0) {
538
- const keys = [...keyProps];
539
- this._set(key, { $let: {
540
- vars: { rem: input },
541
- in: { $filter: {
542
- input: { $ifNull: [`$${key}`, []] },
543
- as: "el",
544
- cond: { $not: { $anyElementTrue: { $map: {
545
- input: "$$rem",
546
- as: "r",
547
- in: this._keysEqual(keys, "$$el", "$$r")
548
- } } } }
549
- } }
550
- } });
551
- } else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
228
+ if (keys.length > 0) this._set(key, { $let: {
229
+ vars: { rem: input },
230
+ in: { $filter: {
231
+ input: { $ifNull: [`$${key}`, []] },
232
+ as: "el",
233
+ cond: { $not: { $anyElementTrue: { $map: {
234
+ input: "$$rem",
235
+ as: "r",
236
+ in: this._keysEqual(keys, "$$el", "$$r")
237
+ } } } }
238
+ } }
239
+ } });
240
+ else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
552
241
  }
553
242
  constructor(collection, payload) {
554
243
  _define_property$2(this, "collection", void 0);
@@ -558,27 +247,47 @@ else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
558
247
  * Filled only with the `_id` field right now.
559
248
  */ _define_property$2(this, "filterObj", void 0);
560
249
  /** MongoDB *update* document being built. */ _define_property$2(this, "updatePipeline", void 0);
250
+ /** Current `$set` stage being populated. */ _define_property$2(this, "currentSetStage", void 0);
561
251
  /** Additional *options* (mainly `arrayFilters`). */ _define_property$2(this, "optionsObj", void 0);
562
252
  this.collection = collection;
563
253
  this.payload = payload;
564
254
  this.filterObj = {};
565
255
  this.updatePipeline = [];
256
+ this.currentSetStage = null;
566
257
  this.optionsObj = {};
567
258
  }
568
259
  };
260
+ _define_property$2(CollectionPatcher, "getKeyProps", getKeyProps);
569
261
 
570
262
  //#endregion
571
- //#region packages/mongo/src/lib/logger.ts
572
- const NoopLogger = {
573
- error: () => {},
574
- warn: () => {},
575
- log: () => {},
576
- info: () => {},
577
- debug: () => {}
263
+ //#region packages/mongo/src/lib/mongo-filter.ts
264
+ const EMPTY = {};
265
+ const mongoVisitor = {
266
+ comparison(field, op, value) {
267
+ if (op === "$eq") return { [field]: value };
268
+ return { [field]: { [op]: value } };
269
+ },
270
+ and(children) {
271
+ if (children.length === 0) return EMPTY;
272
+ if (children.length === 1) return children[0];
273
+ return { $and: children };
274
+ },
275
+ or(children) {
276
+ if (children.length === 0) return { _impossible: true };
277
+ if (children.length === 1) return children[0];
278
+ return { $or: children };
279
+ },
280
+ not(child) {
281
+ return { $nor: [child] };
282
+ }
578
283
  };
284
+ function buildMongoFilter(filter) {
285
+ if (!filter || Object.keys(filter).length === 0) return EMPTY;
286
+ return walkFilter(filter, mongoVisitor);
287
+ }
579
288
 
580
289
  //#endregion
581
- //#region packages/mongo/src/lib/as-collection.ts
290
+ //#region packages/mongo/src/lib/mongo-adapter.ts
582
291
  function _define_property$1(obj, key, value) {
583
292
  if (key in obj) Object.defineProperty(obj, key, {
584
293
  value,
@@ -591,43 +300,38 @@ else obj[key] = value;
591
300
  }
592
301
  const INDEX_PREFIX = "atscript__";
593
302
  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) {
303
+ function mongoIndexKey(type, name) {
600
304
  const cleanName = name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 127 - INDEX_PREFIX.length - type.length - 2);
601
305
  return `${INDEX_PREFIX}${type}__${cleanName}`;
602
306
  }
603
- var AsCollection = class {
604
- createValidator(opts) {
605
- return this._type.validator(opts);
307
+ var MongoAdapter = class extends BaseDbAdapter {
308
+ get collection() {
309
+ if (!this._collection) this._collection = this.db.collection(this.resolveTableName(false));
310
+ return this._collection;
606
311
  }
607
- async exists() {
608
- return this.asMongo.collectionExists(this.name);
312
+ aggregate(pipeline) {
313
+ return this.collection.aggregate(pipeline);
609
314
  }
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");
315
+ get idType() {
316
+ const idProp = this._table.type.type.props.get("_id");
618
317
  const idTags = idProp?.type.tags;
619
318
  if (idTags?.has("objectId") && idTags?.has("mongo")) return "objectId";
620
319
  if (idProp?.type.kind === "") return idProp.type.designType;
621
320
  return "objectId";
622
321
  }
322
+ prepareId(id, fieldType) {
323
+ const tags = fieldType.type.tags;
324
+ if (tags?.has("objectId") && tags?.has("mongo")) return id instanceof ObjectId ? id : new ObjectId(id);
325
+ if (fieldType.type.kind === "") {
326
+ const dt = fieldType.type.designType;
327
+ if (dt === "number") return Number(id);
328
+ }
329
+ return String(id);
330
+ }
623
331
  /**
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) {
332
+ * Convenience method that uses `idType` to transform an ID value.
333
+ * For use in controllers that don't have access to the field type.
334
+ */ prepareIdFromIdType(id) {
631
335
  switch (this.idType) {
632
336
  case "objectId": return id instanceof ObjectId ? id : new ObjectId(id);
633
337
  case "number": return Number(id);
@@ -635,91 +339,59 @@ var AsCollection = class {
635
339
  default: throw new Error("Unknown \"_id\" type");
636
340
  }
637
341
  }
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;
342
+ supportsNestedObjects() {
343
+ return true;
344
+ }
345
+ supportsNativePatch() {
346
+ return true;
347
+ }
348
+ getValidatorPlugins() {
349
+ return [validateMongoIdPlugin];
350
+ }
351
+ getTopLevelArrayTag() {
352
+ return "db.mongo.__topLevelArray";
353
+ }
354
+ getAdapterTableName(type) {
355
+ return undefined;
356
+ }
357
+ buildInsertValidator(table) {
358
+ return table.createValidator({
359
+ plugins: this.getValidatorPlugins(),
360
+ replace: (type, path) => {
361
+ if (path === "_id" && type.type.tags?.has("objectId")) return {
362
+ ...type,
363
+ optional: true
364
+ };
365
+ if (table.defaults.has(path)) return {
366
+ ...type,
367
+ optional: true
368
+ };
369
+ return type;
663
370
  }
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;
371
+ });
674
372
  }
675
- get indexes() {
676
- this._flatten();
677
- return this._indexes;
373
+ buildPatchValidator(table) {
374
+ return CollectionPatcher.prepareValidator(this.getPatcherContext());
678
375
  }
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;
696
- }
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
- });
376
+ /** Returns the context object used by CollectionPatcher. */ getPatcherContext() {
377
+ return {
378
+ flatMap: this._table.flatMap,
379
+ prepareId: (id) => this.prepareIdFromIdType(id),
380
+ createValidator: (opts) => this._table.createValidator(opts)
381
+ };
705
382
  }
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
- }
383
+ async nativePatch(filter, patch) {
384
+ const mongoFilter = buildMongoFilter(filter);
385
+ const patcher = new CollectionPatcher(this.getPatcherContext(), patch);
386
+ const { updateFilter, updateOptions } = patcher.preparePatch();
387
+ const result = await this.collection.updateOne(mongoFilter, updateFilter, updateOptions);
388
+ return {
389
+ matchedCount: result.matchedCount,
390
+ modifiedCount: result.modifiedCount
391
+ };
720
392
  }
721
- _prepareIndexesForCollection() {
722
- const typeMeta = this.type.metadata;
393
+ onBeforeFlatten(type) {
394
+ const typeMeta = type.metadata;
723
395
  const dynamicText = typeMeta.get("db.mongo.search.dynamic");
724
396
  if (dynamicText) this._setSearchIndex("dynamic_text", "_", {
725
397
  mappings: { dynamic: true },
@@ -732,73 +404,65 @@ else {
732
404
  text: { fuzzy: { maxEdits: textSearch.fuzzy || 0 } }
733
405
  });
734
406
  }
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
- });
407
+ onFieldScanned(field, type, metadata) {
408
+ if (field !== "_id" && metadata.has("meta.id")) {
409
+ this._table.removePrimaryKey(field);
410
+ this._addMongoIndexField("unique", "__pk", field);
411
+ this._table.addUniqueField(field);
745
412
  }
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);
755
- }
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);
413
+ const defaultFn = metadata.get("db.default.fn");
414
+ if (defaultFn === "increment") {
415
+ const physicalName = metadata.get("db.column") ?? field;
416
+ this._incrementFields.add(physicalName);
759
417
  }
760
418
  for (const index of metadata.get("db.index.fulltext") || []) {
761
419
  const name = index === true ? "" : index.name || "";
762
- this._addIndexField("text", name, fieldName, 1);
420
+ const weight = index !== true && typeof index === "object" ? index.weight || 1 : 1;
421
+ this._addMongoIndexField("text", name, field, weight);
763
422
  }
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);
423
+ for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, index.analyzer);
767
424
  const vectorIndex = metadata.get("db.mongo.search.vector");
768
- if (vectorIndex) this._setSearchIndex("vector", vectorIndex.indexName || fieldName, { fields: [{
425
+ if (vectorIndex) this._setSearchIndex("vector", vectorIndex.indexName || field, { fields: [{
769
426
  type: "vector",
770
- path: fieldName,
427
+ path: field,
771
428
  similarity: vectorIndex.similarity || "dotProduct",
772
429
  numDimensions: vectorIndex.dimensions
773
430
  }] });
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)
431
+ for (const index of metadata.get("db.mongo.search.filter") || []) this._vectorFilters.set(mongoIndexKey("vector", index.indexName), field);
432
+ }
433
+ onAfterFlatten() {
434
+ this._table.addPrimaryKey("_id");
435
+ for (const [key, value] of this._vectorFilters.entries()) {
436
+ const index = this._mongoIndexes.get(key);
437
+ if (index && index.type === "vector") index.definition.fields?.push({
438
+ type: "filter",
439
+ path: value
783
440
  });
784
- this._finalizeIndexesForCollection();
785
441
  }
786
442
  }
787
- getSearchIndexes() {
443
+ /** Returns MongoDB-specific search index map (internal). */ getMongoSearchIndexes() {
788
444
  if (!this._searchIndexesMap) {
445
+ this._table.flatMap;
789
446
  this._searchIndexesMap = new Map();
790
- let deafultIndex;
791
- for (const index of this.indexes.values()) switch (index.type) {
447
+ let defaultIndex;
448
+ for (const index of this._table.indexes.values()) if (index.type === "fulltext" && !defaultIndex) defaultIndex = {
449
+ key: index.key,
450
+ name: index.name,
451
+ type: "text",
452
+ fields: Object.fromEntries(index.fields.map((f) => [f.name, "text"])),
453
+ weights: Object.fromEntries(index.fields.filter((f) => f.weight).map((f) => [f.name, f.weight]))
454
+ };
455
+ for (const index of this._mongoIndexes.values()) switch (index.type) {
792
456
  case "text": {
793
- if (!deafultIndex) deafultIndex = index;
457
+ if (!defaultIndex) defaultIndex = index;
794
458
  break;
795
459
  }
796
460
  case "dynamic_text": {
797
- deafultIndex = index;
461
+ defaultIndex = index;
798
462
  break;
799
463
  }
800
464
  case "search_text": {
801
- if (!deafultIndex || deafultIndex?.type === "text") deafultIndex = index;
465
+ if (!defaultIndex || defaultIndex.type === "text") defaultIndex = index;
802
466
  this._searchIndexesMap.set(index.name, index);
803
467
  break;
804
468
  }
@@ -808,21 +472,231 @@ else {
808
472
  }
809
473
  default:
810
474
  }
811
- if (deafultIndex && !this._searchIndexesMap.has(DEFAULT_INDEX_NAME)) this._searchIndexesMap.set(DEFAULT_INDEX_NAME, deafultIndex);
475
+ if (defaultIndex && !this._searchIndexesMap.has(DEFAULT_INDEX_NAME)) this._searchIndexesMap.set(DEFAULT_INDEX_NAME, defaultIndex);
812
476
  }
813
477
  return this._searchIndexesMap;
814
478
  }
815
- getSearchIndex(name = DEFAULT_INDEX_NAME) {
816
- return this.getSearchIndexes().get(name);
479
+ /** Returns a specific MongoDB search index by name. */ getMongoSearchIndex(name = DEFAULT_INDEX_NAME) {
480
+ return this.getMongoSearchIndexes().get(name);
481
+ }
482
+ /** Returns available search indexes as generic metadata for UI. */ getSearchIndexes() {
483
+ const mongoIndexes = this.getMongoSearchIndexes();
484
+ return [...mongoIndexes.entries()].map(([name, index]) => ({
485
+ name,
486
+ description: `${index.type} index`
487
+ }));
488
+ }
489
+ /**
490
+ * Builds a MongoDB `$search` pipeline stage.
491
+ * Override `buildVectorSearchStage` in subclasses to provide embeddings.
492
+ */ buildSearchStage(text, indexName) {
493
+ const index = this.getMongoSearchIndex(indexName);
494
+ if (!index) return undefined;
495
+ if (index.type === "vector") return this.buildVectorSearchStage(text, index);
496
+ return { $search: {
497
+ index: index.key,
498
+ text: {
499
+ query: text,
500
+ path: { wildcard: "*" }
501
+ }
502
+ } };
503
+ }
504
+ /**
505
+ * Builds a vector search stage. Override in subclasses to generate embeddings.
506
+ * Returns `undefined` by default (vector search requires custom implementation).
507
+ */ buildVectorSearchStage(text, index) {
508
+ return undefined;
509
+ }
510
+ async search(text, query, indexName) {
511
+ const searchStage = this.buildSearchStage(text, indexName);
512
+ if (!searchStage) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
513
+ const filter = buildMongoFilter(query.filter);
514
+ const controls = query.controls || {};
515
+ const pipeline = [searchStage, { $match: filter }];
516
+ if (controls.$sort) pipeline.push({ $sort: controls.$sort });
517
+ if (controls.$skip) pipeline.push({ $skip: controls.$skip });
518
+ if (controls.$limit) pipeline.push({ $limit: controls.$limit });
519
+ else pipeline.push({ $limit: 1e3 });
520
+ if (controls.$select) pipeline.push({ $project: controls.$select.asProjection });
521
+ return this.collection.aggregate(pipeline).toArray();
522
+ }
523
+ async searchWithCount(text, query, indexName) {
524
+ const searchStage = this.buildSearchStage(text, indexName);
525
+ if (!searchStage) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
526
+ const filter = buildMongoFilter(query.filter);
527
+ const controls = query.controls || {};
528
+ const pipeline = [
529
+ searchStage,
530
+ { $match: filter },
531
+ { $facet: {
532
+ data: [
533
+ controls.$sort ? { $sort: controls.$sort } : undefined,
534
+ controls.$skip ? { $skip: controls.$skip } : undefined,
535
+ controls.$limit ? { $limit: controls.$limit } : undefined,
536
+ controls.$select ? { $project: controls.$select.asProjection } : undefined
537
+ ].filter(Boolean),
538
+ meta: [{ $count: "count" }]
539
+ } }
540
+ ];
541
+ const result = await this.collection.aggregate(pipeline).toArray();
542
+ return {
543
+ data: result[0]?.data || [],
544
+ count: result[0]?.meta[0]?.count || 0
545
+ };
546
+ }
547
+ async findManyWithCount(query) {
548
+ const filter = buildMongoFilter(query.filter);
549
+ const controls = query.controls || {};
550
+ const pipeline = [{ $match: filter }, { $facet: {
551
+ data: [
552
+ controls.$sort ? { $sort: controls.$sort } : undefined,
553
+ controls.$skip ? { $skip: controls.$skip } : undefined,
554
+ controls.$limit ? { $limit: controls.$limit } : undefined,
555
+ controls.$select ? { $project: controls.$select.asProjection } : undefined
556
+ ].filter(Boolean),
557
+ meta: [{ $count: "count" }]
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 collectionExists() {
566
+ if (this.asMongo) return this.asMongo.collectionExists(this._table.tableName);
567
+ const cols = await this.db.listCollections({ name: this._table.tableName }).toArray();
568
+ return cols.length > 0;
569
+ }
570
+ async ensureCollectionExists() {
571
+ const exists = await this.collectionExists();
572
+ if (!exists) await this.db.createCollection(this._table.tableName, { comment: "Created by Atscript Mongo Adapter" });
573
+ }
574
+ async insertOne(data) {
575
+ if (this._incrementFields.size > 0) {
576
+ const fields = this._fieldsNeedingIncrement(data);
577
+ if (fields.length > 0) {
578
+ const maxValues = await this._getMaxValues(fields);
579
+ for (const physical of fields) data[physical] = (maxValues.get(physical) ?? 0) + 1;
580
+ }
581
+ }
582
+ const result = await this.collection.insertOne(data);
583
+ return { insertedId: result.insertedId };
584
+ }
585
+ async insertMany(data) {
586
+ if (this._incrementFields.size > 0) {
587
+ const allFields = new Set();
588
+ for (const item of data) for (const f of this._fieldsNeedingIncrement(item)) allFields.add(f);
589
+ if (allFields.size > 0) {
590
+ const maxValues = await this._getMaxValues([...allFields]);
591
+ for (const item of data) for (const physical of allFields) if (item[physical] === undefined || item[physical] === null) {
592
+ const next = (maxValues.get(physical) ?? 0) + 1;
593
+ item[physical] = next;
594
+ maxValues.set(physical, next);
595
+ } else if (typeof item[physical] === "number") {
596
+ const current = maxValues.get(physical) ?? 0;
597
+ if (item[physical] > current) maxValues.set(physical, item[physical]);
598
+ }
599
+ }
600
+ }
601
+ const result = await this.collection.insertMany(data);
602
+ return {
603
+ insertedCount: result.insertedCount,
604
+ insertedIds: Object.values(result.insertedIds)
605
+ };
606
+ }
607
+ async findOne(query) {
608
+ const filter = buildMongoFilter(query.filter);
609
+ const opts = this._buildFindOptions(query.controls);
610
+ return this.collection.findOne(filter, opts);
817
611
  }
818
- get flatMap() {
819
- this._flatten();
820
- return this._flatMap;
612
+ async findMany(query) {
613
+ const filter = buildMongoFilter(query.filter);
614
+ const opts = this._buildFindOptions(query.controls);
615
+ return this.collection.find(filter, opts).toArray();
616
+ }
617
+ async count(query) {
618
+ const filter = buildMongoFilter(query.filter);
619
+ return this.collection.countDocuments(filter);
620
+ }
621
+ async updateOne(filter, data) {
622
+ const mongoFilter = buildMongoFilter(filter);
623
+ const result = await this.collection.updateOne(mongoFilter, { $set: data });
624
+ return {
625
+ matchedCount: result.matchedCount,
626
+ modifiedCount: result.modifiedCount
627
+ };
628
+ }
629
+ async replaceOne(filter, data) {
630
+ const mongoFilter = buildMongoFilter(filter);
631
+ const result = await this.collection.replaceOne(mongoFilter, data);
632
+ return {
633
+ matchedCount: result.matchedCount,
634
+ modifiedCount: result.modifiedCount
635
+ };
636
+ }
637
+ async deleteOne(filter) {
638
+ const mongoFilter = buildMongoFilter(filter);
639
+ const result = await this.collection.deleteOne(mongoFilter);
640
+ return { deletedCount: result.deletedCount };
641
+ }
642
+ async updateMany(filter, data) {
643
+ const mongoFilter = buildMongoFilter(filter);
644
+ const result = await this.collection.updateMany(mongoFilter, { $set: data });
645
+ return {
646
+ matchedCount: result.matchedCount,
647
+ modifiedCount: result.modifiedCount
648
+ };
649
+ }
650
+ async replaceMany(filter, data) {
651
+ const mongoFilter = buildMongoFilter(filter);
652
+ const result = await this.collection.updateMany(mongoFilter, { $set: data });
653
+ return {
654
+ matchedCount: result.matchedCount,
655
+ modifiedCount: result.modifiedCount
656
+ };
657
+ }
658
+ async deleteMany(filter) {
659
+ const mongoFilter = buildMongoFilter(filter);
660
+ const result = await this.collection.deleteMany(mongoFilter);
661
+ return { deletedCount: result.deletedCount };
662
+ }
663
+ async ensureTable() {
664
+ return this.ensureCollectionExists();
821
665
  }
822
666
  async syncIndexes() {
823
- await this.ensureExists();
667
+ await this.ensureCollectionExists();
668
+ const allIndexes = new Map();
669
+ for (const [key, index] of this._table.indexes.entries()) {
670
+ const fields = {};
671
+ const weights = {};
672
+ let mongoType;
673
+ if (index.type === "fulltext") {
674
+ mongoType = "text";
675
+ for (const f of index.fields) {
676
+ fields[f.name] = "text";
677
+ if (f.weight) weights[f.name] = f.weight;
678
+ }
679
+ } else {
680
+ mongoType = index.type;
681
+ for (const f of index.fields) fields[f.name] = 1;
682
+ }
683
+ allIndexes.set(key, {
684
+ key,
685
+ name: index.name,
686
+ type: mongoType,
687
+ fields,
688
+ weights
689
+ });
690
+ }
691
+ for (const [key, index] of this._mongoIndexes.entries()) if (index.type === "text") {
692
+ const existing = allIndexes.get(key);
693
+ if (existing && existing.type === "text") {
694
+ Object.assign(existing.fields, index.fields);
695
+ Object.assign(existing.weights, index.weights);
696
+ } else allIndexes.set(key, index);
697
+ } else allIndexes.set(key, index);
824
698
  const existingIndexes = await this.collection.listIndexes().toArray();
825
- const indexesToCreate = new Map(this.indexes);
699
+ const indexesToCreate = new Map(allIndexes);
826
700
  for (const remote of existingIndexes) {
827
701
  if (!remote.name.startsWith(INDEX_PREFIX)) continue;
828
702
  if (indexesToCreate.has(remote.name)) {
@@ -832,54 +706,21 @@ else {
832
706
  case "unique":
833
707
  case "text": {
834
708
  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
- }
709
+ else await this.collection.dropIndex(remote.name);
839
710
  break;
840
711
  }
841
712
  default:
842
713
  }
843
- } else {
844
- this.logger.debug(`dropping index "${remote.name}"`);
845
- await this.collection.dropIndex(remote.name);
846
- }
714
+ } else await this.collection.dropIndex(remote.name);
847
715
  }
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);
866
- break;
867
- }
868
- default:
869
- }
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`);
874
- }
875
- for (const [key, value] of Array.from(indexesToCreate.entries())) switch (value.type) {
716
+ for (const [key, value] of allIndexes.entries()) switch (value.type) {
876
717
  case "plain": {
877
- this.logger.debug(`creating index "${key}"`);
718
+ if (!indexesToCreate.has(key)) continue;
878
719
  await this.collection.createIndex(value.fields, { name: key });
879
720
  break;
880
721
  }
881
722
  case "unique": {
882
- this.logger.debug(`creating index "${key}"`);
723
+ if (!indexesToCreate.has(key)) continue;
883
724
  await this.collection.createIndex(value.fields, {
884
725
  name: key,
885
726
  unique: true
@@ -887,160 +728,168 @@ else toUpdate.add(remote.name);
887
728
  break;
888
729
  }
889
730
  case "text": {
890
- this.logger.debug(`creating index "${key}"`);
731
+ if (!indexesToCreate.has(key)) continue;
891
732
  await this.collection.createIndex(value.fields, {
892
733
  weights: value.weights,
893
734
  name: key
894
735
  });
895
736
  break;
896
737
  }
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({
738
+ default:
739
+ }
740
+ try {
741
+ const toUpdate = new Set();
742
+ const existingSearchIndexes = await this.collection.listSearchIndexes().toArray();
743
+ for (const remote of existingSearchIndexes) {
744
+ if (!remote.name.startsWith(INDEX_PREFIX)) continue;
745
+ if (indexesToCreate.has(remote.name)) {
746
+ const local = indexesToCreate.get(remote.name);
747
+ const right = remote.latestDefinition;
748
+ switch (local.type) {
749
+ case "dynamic_text":
750
+ case "search_text": {
751
+ const left = local.definition;
752
+ if (left.analyzer === right.analyzer && fieldsMatch(left.mappings.fields || {}, right.mappings.fields || {})) indexesToCreate.delete(remote.name);
753
+ else toUpdate.add(remote.name);
754
+ break;
755
+ }
756
+ case "vector": {
757
+ if (vectorFieldsMatch(local.definition.fields || [], right.fields || [])) indexesToCreate.delete(remote.name);
758
+ else toUpdate.add(remote.name);
759
+ break;
760
+ }
761
+ default:
762
+ }
763
+ } else if (remote.status !== "DELETING") await this.collection.dropSearchIndex(remote.name);
764
+ }
765
+ for (const [key, value] of indexesToCreate.entries()) switch (value.type) {
766
+ case "dynamic_text":
767
+ case "search_text":
768
+ case "vector": {
769
+ if (toUpdate.has(key)) await this.collection.updateSearchIndex(key, value.definition);
770
+ else await this.collection.createSearchIndex({
906
771
  name: key,
907
772
  type: value.type === "vector" ? "vectorSearch" : "search",
908
773
  definition: value.definition
909
774
  });
775
+ break;
910
776
  }
911
- break;
777
+ default:
778
+ }
779
+ } catch {}
780
+ }
781
+ /** Returns physical field names of increment fields that are undefined in the data. */ _fieldsNeedingIncrement(data) {
782
+ const result = [];
783
+ for (const physical of this._incrementFields) if (data[physical] === undefined || data[physical] === null) result.push(physical);
784
+ return result;
785
+ }
786
+ /** Reads current max value for each field via $group aggregation. */ async _getMaxValues(physicalFields) {
787
+ const aliases = physicalFields.map((f) => [`max__${f.replace(/\./g, "__")}`, f]);
788
+ const group = { _id: null };
789
+ for (const [alias, field] of aliases) group[alias] = { $max: `$${field}` };
790
+ const result = await this.collection.aggregate([{ $group: group }]).toArray();
791
+ const maxMap = new Map();
792
+ if (result.length > 0) {
793
+ const row = result[0];
794
+ for (const [alias, field] of aliases) {
795
+ const val = row[alias];
796
+ maxMap.set(field, typeof val === "number" ? val : 0);
912
797
  }
913
- default:
914
798
  }
799
+ return maxMap;
800
+ }
801
+ _buildFindOptions(controls) {
802
+ const opts = {};
803
+ if (!controls) return opts;
804
+ if (controls.$sort) opts.sort = controls.$sort;
805
+ if (controls.$limit) opts.limit = controls.$limit;
806
+ if (controls.$skip) opts.skip = controls.$skip;
807
+ if (controls.$select) opts.projection = controls.$select.asProjection;
808
+ return opts;
809
+ }
810
+ _addMongoIndexField(type, name, field, weight) {
811
+ const key = mongoIndexKey(type, name);
812
+ let index = this._mongoIndexes.get(key);
813
+ const value = type === "text" ? "text" : 1;
814
+ if (index) index.fields[field] = value;
815
+ else {
816
+ index = {
817
+ key,
818
+ name,
819
+ type,
820
+ fields: { [field]: value },
821
+ weights: {}
822
+ };
823
+ this._mongoIndexes.set(key, index);
824
+ }
825
+ if (weight) index.weights[field] = weight;
915
826
  }
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
827
+ _setSearchIndex(type, name, definition) {
828
+ const key = mongoIndexKey(type, name || DEFAULT_INDEX_NAME);
829
+ this._mongoIndexes.set(key, {
830
+ key,
831
+ name: name || DEFAULT_INDEX_NAME,
832
+ type,
833
+ definition
932
834
  });
933
835
  }
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
- };
836
+ _addFieldToSearchIndex(type, _name, fieldName, analyzer) {
837
+ const name = _name || DEFAULT_INDEX_NAME;
838
+ let index = this._mongoIndexes.get(mongoIndexKey(type, name));
839
+ if (!index && type === "search_text") {
840
+ this._setSearchIndex(type, name, {
841
+ mappings: { fields: {} },
842
+ text: { fuzzy: { maxEdits: 0 } }
843
+ });
844
+ index = this._mongoIndexes.get(mongoIndexKey(type, name));
964
845
  }
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);
846
+ if (index) {
847
+ index.definition.mappings.fields[fieldName] = { type: "string" };
848
+ if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
849
+ }
850
+ }
851
+ constructor(db, asMongo) {
852
+ super(), _define_property$1(this, "db", void 0), _define_property$1(this, "asMongo", void 0), _define_property$1(this, "_collection", void 0), _define_property$1(this, "_mongoIndexes", void 0), _define_property$1(this, "_vectorFilters", void 0), _define_property$1(this, "_searchIndexesMap", void 0), _define_property$1(this, "_incrementFields", void 0), this.db = db, this.asMongo = asMongo, this._mongoIndexes = new Map(), this._vectorFilters = new Map(), this._incrementFields = new Set();
997
853
  }
998
854
  };
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;
855
+ function objMatch(o1, o2) {
856
+ const keys1 = Object.keys(o1);
857
+ const keys2 = Object.keys(o2);
858
+ if (keys1.length !== keys2.length) return false;
859
+ return keys1.every((key) => o1[key] === o2[key]);
1020
860
  }
1021
- /**
1022
- * Search Index fields matching
1023
- */ function fieldsMatch(left, right) {
861
+ function fieldsMatch(left, right) {
1024
862
  if (!left || !right) return left === right;
1025
863
  const leftKeys = Object.keys(left);
1026
864
  const rightKeys = Object.keys(right);
1027
865
  if (leftKeys.length !== rightKeys.length) return false;
1028
866
  return leftKeys.every((key) => {
1029
867
  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;
868
+ return left[key].type === right[key].type && left[key].analyzer === right[key].analyzer;
1033
869
  });
1034
870
  }
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]);
871
+ function vectorFieldsMatch(left, right) {
872
+ const leftMap = new Map(left.map((f) => [f.path, f]));
873
+ const rightMap = new Map((right || []).map((f) => [f.path, f]));
874
+ if (leftMap.size !== rightMap.size) return false;
875
+ for (const [key, l] of leftMap.entries()) {
876
+ const r = rightMap.get(key);
877
+ if (!r) return false;
878
+ if (l.type !== r.type || l.path !== r.path || l.similarity !== r.similarity || l.numDimensions !== r.numDimensions) return false;
879
+ }
880
+ return true;
1042
881
  }
1043
882
 
883
+ //#endregion
884
+ //#region packages/mongo/src/lib/logger.ts
885
+ const NoopLogger = {
886
+ error: () => {},
887
+ warn: () => {},
888
+ log: () => {},
889
+ info: () => {},
890
+ debug: () => {}
891
+ };
892
+
1044
893
  //#endregion
1045
894
  //#region packages/mongo/src/lib/as-mongo.ts
1046
895
  function _define_property(obj, key, value) {
@@ -1065,25 +914,35 @@ var AsMongo = class {
1065
914
  const list = await this.getCollectionsList();
1066
915
  return list.has(name);
1067
916
  }
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);
917
+ getAdapter(type) {
918
+ this._ensureCreated(type);
919
+ return this._adapters.get(type);
920
+ }
921
+ getTable(type, logger) {
922
+ this._ensureCreated(type, logger);
923
+ return this._tables.get(type);
924
+ }
925
+ _ensureCreated(type, logger) {
926
+ if (!this._adapters.has(type)) {
927
+ const adapter = new MongoAdapter(this.db, this);
928
+ const table = new AtscriptDbTable(type, adapter, logger || this.logger);
929
+ this._adapters.set(type, adapter);
930
+ this._tables.set(type, table);
1073
931
  }
1074
- return collection;
1075
932
  }
1076
933
  constructor(client, logger = NoopLogger) {
1077
934
  _define_property(this, "logger", void 0);
1078
935
  _define_property(this, "client", void 0);
1079
936
  _define_property(this, "collectionsList", void 0);
1080
- _define_property(this, "_collections", void 0);
937
+ _define_property(this, "_adapters", void 0);
938
+ _define_property(this, "_tables", void 0);
1081
939
  this.logger = logger;
1082
- this._collections = new WeakMap();
940
+ this._adapters = new WeakMap();
941
+ this._tables = new WeakMap();
1083
942
  if (typeof client === "string") this.client = new MongoClient(client);
1084
943
  else this.client = client;
1085
944
  }
1086
945
  };
1087
946
 
1088
947
  //#endregion
1089
- export { AsCollection, AsMongo, MongoPlugin };
948
+ export { AsMongo, CollectionPatcher, MongoAdapter, buildMongoFilter, validateMongoIdPlugin };