@atscript/db-mongo 0.1.161 → 0.1.163

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_mongo_accumulator = require("./mongo-accumulator-BW-RNFg0.cjs");
3
+ const require_search_messages = require("./search-messages-BqLxxrb8.cjs");
3
4
  let _atscript_db = require("@atscript/db");
4
5
  let mongodb = require("mongodb");
5
6
  let _atscript_db_agg = require("@atscript/db/agg");
@@ -79,6 +80,19 @@ function containsAggregationExpr(v) {
79
80
  return false;
80
81
  }
81
82
  /**
83
+ * A field's current value in an aggregation pipeline update — a missing field
84
+ * counts as `0`, like the `$inc` / `$mul` update operators (the pipeline
85
+ * `$add` / `$multiply` would yield `null`) — so a document written before an
86
+ * `@db.onUpdate.inc` counter existed starts counting.
87
+ */
88
+ function fieldOpCurrent(field) {
89
+ return { $ifNull: [`$${field}`, 0] };
90
+ }
91
+ /** Aggregation expression adding `by` to `field` (an `$inc` in a pipeline update). */
92
+ function incExpr(field, by) {
93
+ return { $add: [fieldOpCurrent(field), by] };
94
+ }
95
+ /**
82
96
  * CollectionPatcher is a small helper that converts a *patch payload* produced
83
97
  * by Atscript into a shape that the official MongoDB driver understands – a
84
98
  * triple of `(filter, update, options)` to be fed to `collection.updateOne()`.
@@ -143,9 +157,9 @@ var CollectionPatcher = class {
143
157
  updateOptions: this.optionsObj
144
158
  };
145
159
  }
146
- /** Builds a MongoDB aggregation expression for an $inc or $mul field op. */
160
+ /** Aggregation expression of an `$inc` / `$mul` field op (see {@link fieldOpCurrent}). */
147
161
  _fieldOpExpr(key, op, value) {
148
- return op === "inc" ? { $add: [`$${key}`, value] } : { $multiply: [`$${key}`, value] };
162
+ return op === "inc" ? incExpr(key, value) : { $multiply: [fieldOpCurrent(key), value] };
149
163
  }
150
164
  /**
151
165
  * Helper – lazily create `$set` section and assign *key* → *value*.
@@ -1840,8 +1854,12 @@ const PK_INDEX = mongoIndexKey("unique", "__pk");
1840
1854
  * index of a skipped key rebuild, the indexes over a column whose type change
1841
1855
  * is pending (its present-only filter is typed).
1842
1856
  */
1843
- async function syncIndexesImpl(host, opts) {
1844
- await host.ensureCollectionExists();
1857
+ async function syncIndexesImpl(host, opts, dryRun = false) {
1858
+ /** Indexes dropped because their definition drifted — their re-creation is a rebuild. */
1859
+ const replaced = /* @__PURE__ */ new Set();
1860
+ if (dryRun) {
1861
+ if (!await host.db.listCollections({ name: host.collection.collectionName }, { nameOnly: true }).hasNext()) return [];
1862
+ } else await host.ensureCollectionExists();
1845
1863
  const allIndexes = /* @__PURE__ */ new Map();
1846
1864
  for (const [key, index] of host._table.indexes.entries()) {
1847
1865
  const fields = {};
@@ -1888,6 +1906,7 @@ async function syncIndexesImpl(host, opts) {
1888
1906
  for (const remote of existingIndexes) if (isKept(remote.name, remote.key, remote.weights)) kept.add(remote.name);
1889
1907
  for (const name of kept) indexesToCreate.delete(name);
1890
1908
  const { attempt, throwIfAny } = (0, _atscript_db.createFailureCollector)("index sync");
1909
+ const { changes, apply } = (0, _atscript_db.createChangeRecorder)(dryRun, attempt);
1891
1910
  let defaultCollation;
1892
1911
  const collectionDefaultCollation = async () => {
1893
1912
  if (defaultCollation === void 0) defaultCollation = (await host.db.listCollections({ name: host.collection.collectionName }).next())?.options?.collation ?? null;
@@ -1906,16 +1925,21 @@ async function syncIndexesImpl(host, opts) {
1906
1925
  if (fieldsMatch && weightsMatch && optionsMatch) indexesToCreate.delete(remote.name);
1907
1926
  else if (violation !== void 0) {
1908
1927
  indexesToCreate.delete(remote.name);
1928
+ if (dryRun) continue;
1909
1929
  await attempt(`replace unique index "${remote.name}"`, () => Promise.reject(/* @__PURE__ */ new Error(`existing documents violate the new index (duplicate key ${JSON.stringify(violation)}); the current index is kept`)));
1910
- } else {
1930
+ } else if (dryRun) replaced.add(remote.name);
1931
+ else {
1911
1932
  host._log("dropIndex", remote.name);
1912
- await attempt(`drop index "${remote.name}"`, () => host.collection.dropIndex(remote.name));
1933
+ if (await attempt(`drop index "${remote.name}"`, () => host.collection.dropIndex(remote.name))) replaced.add(remote.name);
1913
1934
  }
1914
1935
  }
1915
- } else {
1936
+ } else await apply({
1937
+ name: remote.name,
1938
+ action: "drop"
1939
+ }, `drop index "${remote.name}"`, () => {
1916
1940
  host._log("dropIndex", remote.name);
1917
- await attempt(`drop index "${remote.name}"`, () => host.collection.dropIndex(remote.name));
1918
- }
1941
+ return host.collection.dropIndex(remote.name);
1942
+ });
1919
1943
  }
1920
1944
  for (const [key, value] of allIndexes.entries()) {
1921
1945
  if (!isPlainIndex(value) || !indexesToCreate.has(key)) continue;
@@ -1955,8 +1979,12 @@ async function syncIndexesImpl(host, opts) {
1955
1979
  indexOptions = { name: key };
1956
1980
  break;
1957
1981
  }
1958
- await attempt(label, () => host.collection.createIndex(value.fields, indexOptions));
1982
+ await apply({
1983
+ name: key,
1984
+ action: replaced.has(key) ? "rebuild" : "add"
1985
+ }, label, () => host.collection.createIndex(value.fields, indexOptions));
1959
1986
  }
1987
+ const search = (0, _atscript_db.createChangeRecorder)(dryRun);
1960
1988
  try {
1961
1989
  const toUpdate = /* @__PURE__ */ new Set();
1962
1990
  const existingSearchIndexes = await host.collection.listSearchIndexes().toArray();
@@ -1979,31 +2007,41 @@ async function syncIndexesImpl(host, opts) {
1979
2007
  break;
1980
2008
  default:
1981
2009
  }
1982
- } else if (remote.status !== "DELETING") {
2010
+ } else if (remote.status !== "DELETING") await search.apply({
2011
+ name: remote.name,
2012
+ action: "drop"
2013
+ }, remote.name, () => {
1983
2014
  host._log("dropSearchIndex", remote.name);
1984
- await host.collection.dropSearchIndex(remote.name);
1985
- }
2015
+ return host.collection.dropSearchIndex(remote.name);
2016
+ });
1986
2017
  }
1987
2018
  for (const [key, value] of indexesToCreate.entries()) switch (value.type) {
1988
2019
  case "dynamic_text":
1989
2020
  case "search_text":
1990
- case "vector":
1991
- if (toUpdate.has(key)) {
1992
- host._log("updateSearchIndex", key, value.definition);
1993
- await host.collection.updateSearchIndex(key, value.definition);
1994
- } else {
2021
+ case "vector": {
2022
+ const update = toUpdate.has(key);
2023
+ await search.apply({
2024
+ name: key,
2025
+ action: update ? "rebuild" : "add"
2026
+ }, key, () => {
2027
+ if (update) {
2028
+ host._log("updateSearchIndex", key, value.definition);
2029
+ return host.collection.updateSearchIndex(key, value.definition);
2030
+ }
1995
2031
  host._log("createSearchIndex", key, value.type);
1996
- await host.collection.createSearchIndex({
2032
+ return host.collection.createSearchIndex({
1997
2033
  name: key,
1998
2034
  type: value.type === "vector" ? "vectorSearch" : "search",
1999
2035
  definition: value.definition
2000
2036
  });
2001
- }
2037
+ });
2002
2038
  break;
2039
+ }
2003
2040
  default:
2004
2041
  }
2005
2042
  } catch {}
2006
2043
  throwIfAny();
2044
+ return [...changes, ...search.changes];
2007
2045
  }
2008
2046
  /**
2009
2047
  * The collation a plain / unique index is built with (since 0.1.151): the
@@ -2273,22 +2311,33 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2273
2311
  * Uses MongoDB's Convenient Transaction API (`session.withTransaction()`).
2274
2312
  * This handles txnNumber management and automatic retry for
2275
2313
  * `TransientTransactionError` / `UnknownTransactionCommitResult`.
2314
+ *
2315
+ * `opts.isolation` (since 0.1.162): a MongoDB transaction reads one
2316
+ * snapshot, which satisfies `'read committed'` and `'repeatable read'`;
2317
+ * `'serializable'` throws `ISOLATION_NOT_SUPPORTED` (snapshot isolation
2318
+ * allows write skew), and so does any level on a topology without
2319
+ * transactions (standalone), where `fn` would run without one.
2276
2320
  */
2277
- async withTransaction(fn) {
2321
+ async withTransaction(fn, opts) {
2322
+ const isolation = this._checkIsolation(opts);
2278
2323
  if (this._getTransactionState()) return fn();
2279
- if (this._txDisabled || !this._client) return fn();
2324
+ const passThrough = () => {
2325
+ if (isolation) throw this._isolationNotSupported(`Isolation '${isolation}' needs a MongoDB transaction — this topology (standalone) has none`);
2326
+ return fn();
2327
+ };
2328
+ if (this._txDisabled || !this._client) return passThrough();
2280
2329
  try {
2281
2330
  const topology = this._client.topology;
2282
2331
  if (topology) {
2283
2332
  const type = (topology.description ?? topology.s?.description)?.type;
2284
2333
  if (type === "Single" || type === "Unknown") {
2285
2334
  this._txDisabled = true;
2286
- return fn();
2335
+ return passThrough();
2287
2336
  }
2288
2337
  }
2289
2338
  } catch {
2290
2339
  this._txDisabled = true;
2291
- return fn();
2340
+ return passThrough();
2292
2341
  }
2293
2342
  const session = this._client.startSession();
2294
2343
  try {
@@ -2303,6 +2352,17 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2303
2352
  } catch {}
2304
2353
  }
2305
2354
  }
2355
+ static _ISOLATION_LEVELS = new Set(["read committed", "repeatable read"]);
2356
+ /**
2357
+ * `'read committed'` and `'repeatable read'` — a transaction's snapshot
2358
+ * read satisfies both. No `'serializable'` (snapshot isolation allows
2359
+ * write skew). Locking reads are not supported (`supportsReadLocks()` is
2360
+ * false → `LOCK_NOT_SUPPORTED`): use `$cas` / `withOptimisticRetry`.
2361
+ * @since 0.1.162
2362
+ */
2363
+ isolationLevels() {
2364
+ return MongoAdapter._ISOLATION_LEVELS;
2365
+ }
2306
2366
  static _noSession = Object.freeze({});
2307
2367
  /** Returns `{ session }` opts if inside a transaction, empty object otherwise. */
2308
2368
  _getSessionOpts() {
@@ -2670,6 +2730,8 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2670
2730
  for (const field of this._pendingUniqueFields) this._addMongoIndexField("unique", "__pk", meta.documentPath(field));
2671
2731
  for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
2672
2732
  this._pendingSearchFields = [];
2733
+ const [encrypted] = meta.encryptedFields;
2734
+ if (encrypted !== void 0 && [...this._mongoIndexes.values()].some((i) => i.type === "dynamic_text")) throw new Error(require_search_messages.dynamicSearchEncryptedMessage(encrypted));
2673
2735
  const textBearing = [...this._table.indexes.values()].filter((i) => i.type === "fulltext" && i.fields.some((f) => !f.integer));
2674
2736
  if (textBearing.length > 1) throw new Error(`MongoDB allows one text index per collection: "${this._table.tableName}" declares ${textBearing.length} @db.index.fulltext names with text fields (${textBearing.map((i) => `"${i.name}"`).join(", ")}). Merge them into one name; integer-only fulltext indexes may be declared separately.`);
2675
2737
  for (const fulltext of this._table.indexes.values()) {
@@ -3150,12 +3212,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
3150
3212
  modifiedCount: 0
3151
3213
  };
3152
3214
  const versionColumn = this._table.versionColumnPhysical;
3215
+ const counters = this._table.onUpdateIncPhysical;
3153
3216
  this._log("replaceOne", mongoFilter, data);
3154
- if (versionColumn !== void 0) {
3155
- const pipeline = [{ $replaceWith: {
3156
- ...data,
3157
- [versionColumn]: { $add: [`$${versionColumn}`, 1] }
3158
- } }];
3217
+ if (versionColumn !== void 0 || counters.length > 0) {
3218
+ const replacement = {};
3219
+ for (const [key, value] of Object.entries(data)) replacement[key] = { $literal: value };
3220
+ for (const col of counters) replacement[col] = incExpr(col, 1);
3221
+ if (versionColumn !== void 0) replacement[versionColumn] = { $add: [`$${versionColumn}`, 1] };
3222
+ const pipeline = [{ $replaceWith: replacement }];
3159
3223
  return this._wrapUpdate(() => this.collection.updateOne(mongoFilter, pipeline, this._getSessionOpts()));
3160
3224
  }
3161
3225
  return this._wrapUpdate(() => this.collection.replaceOne(mongoFilter, data, this._getSessionOpts()));
@@ -3174,7 +3238,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
3174
3238
  });
3175
3239
  }
3176
3240
  async replaceMany(filter, data) {
3177
- const updateDoc = buildMongoUpdateDoc(data, void 0, this._table.versionColumnPhysical);
3241
+ const updateDoc = buildMongoUpdateDoc(data, this._onUpdateIncOps(), this._table.versionColumnPhysical);
3178
3242
  return this._updateMatching(filter, (mongoFilter) => {
3179
3243
  this._log("replaceMany", mongoFilter, updateDoc);
3180
3244
  return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
@@ -3215,8 +3279,8 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
3215
3279
  * `__pk` index first (`dropIndexesForColumns`).
3216
3280
  */
3217
3281
  async rebuildPrimaryKey(_change) {}
3218
- async syncIndexes(opts) {
3219
- return syncIndexesImpl(this, opts);
3282
+ async _syncIndexes(dryRun, opts) {
3283
+ return syncIndexesImpl(this, opts, dryRun);
3220
3284
  }
3221
3285
  async syncColumns(diff) {
3222
3286
  return syncColumnsImpl(this, diff);
@@ -3605,6 +3669,7 @@ exports.buildMongoFilter = require_mongo_accumulator.buildMongoFilter;
3605
3669
  exports.buildMongoQuery = require_mongo_accumulator.buildMongoQuery;
3606
3670
  exports.collationOfAdapter = require_mongo_accumulator.collationOfAdapter;
3607
3671
  exports.createAdapter = createAdapter;
3672
+ exports.incExpr = incExpr;
3608
3673
  exports.mongoFilterStages = require_mongo_accumulator.mongoFilterStages;
3609
3674
  exports.mongoIndexKey = mongoIndexKey;
3610
3675
  exports.planStages = require_mongo_accumulator.planStages;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as buildMongoFilter, c as mongoFilterStages, i as TMongoFilterPlan, l as planStages, n as TMongoFieldCollation, o as buildMongoQuery, r as TMongoFilterOptions, s as collationOfAdapter, t as REL_FILTER_TEMP_PREFIX } from "./mongo-filter-CGd9ryOD.cjs";
2
- import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbCollation, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbRelation, TDbUpdateOptions, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TSyncIndexesOptions, TTableResolver, TViewCapability, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
2
+ import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbCollation, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbIsolationLevel, TDbObjectKind, TDbRelation, TDbUpdateOptions, TDbUpdateResult, TExistingTableOption, TFieldOps, TIndexChange, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TSyncIndexesOptions, TTableResolver, TTransactionOptions, TViewCapability, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
3
3
  import { AggregateOptions, AggregationCursor, ClientSession, Collection, Db, Document, Filter, MongoClient, ObjectId, UpdateFilter, UpdateOptions } from "mongodb";
4
4
  import { TAtscriptAnnotatedType, TMetadataMap, TValidatorOptions, TValidatorPlugin, Validator } from "@atscript/typescript/utils";
5
5
  import { AggregateFn, BucketUnit } from "@uniqu/core";
@@ -14,6 +14,8 @@ interface TCollectionPatcherContext {
14
14
  prepareId(id: any): any;
15
15
  createValidator(opts?: Partial<TValidatorOptions>): Validator<any>;
16
16
  }
17
+ /** Aggregation expression adding `by` to `field` (an `$inc` in a pipeline update). */
18
+ declare function incExpr(field: string, by: number): Document;
17
19
  /**
18
20
  * CollectionPatcher is a small helper that converts a *patch payload* produced
19
21
  * by Atscript into a shape that the official MongoDB driver understands – a
@@ -64,7 +66,7 @@ declare class CollectionPatcher {
64
66
  updateFilter: Document[];
65
67
  updateOptions: UpdateOptions;
66
68
  };
67
- /** Builds a MongoDB aggregation expression for an $inc or $mul field op. */
69
+ /** Aggregation expression of an `$inc` / `$mul` field op (see {@link fieldOpCurrent}). */
68
70
  private _fieldOpExpr;
69
71
  /**
70
72
  * Helper – lazily create `$set` section and assign *key* → *value*.
@@ -327,8 +329,23 @@ declare class MongoAdapter extends BaseDbAdapter {
327
329
  * Uses MongoDB's Convenient Transaction API (`session.withTransaction()`).
328
330
  * This handles txnNumber management and automatic retry for
329
331
  * `TransientTransactionError` / `UnknownTransactionCommitResult`.
330
- */
331
- withTransaction<T>(fn: () => Promise<T>): Promise<T>;
332
+ *
333
+ * `opts.isolation` (since 0.1.162): a MongoDB transaction reads one
334
+ * snapshot, which satisfies `'read committed'` and `'repeatable read'`;
335
+ * `'serializable'` throws `ISOLATION_NOT_SUPPORTED` (snapshot isolation
336
+ * allows write skew), and so does any level on a topology without
337
+ * transactions (standalone), where `fn` would run without one.
338
+ */
339
+ withTransaction<T>(fn: () => Promise<T>, opts?: TTransactionOptions): Promise<T>;
340
+ private static readonly _ISOLATION_LEVELS;
341
+ /**
342
+ * `'read committed'` and `'repeatable read'` — a transaction's snapshot
343
+ * read satisfies both. No `'serializable'` (snapshot isolation allows
344
+ * write skew). Locking reads are not supported (`supportsReadLocks()` is
345
+ * false → `LOCK_NOT_SUPPORTED`): use `$cas` / `withOptimisticRetry`.
346
+ * @since 0.1.162
347
+ */
348
+ isolationLevels(): ReadonlySet<TDbIsolationLevel>;
332
349
  private static readonly _noSession;
333
350
  /** Returns `{ session }` opts if inside a transaction, empty object otherwise. */
334
351
  protected _getSessionOpts(): {
@@ -596,7 +613,7 @@ declare class MongoAdapter extends BaseDbAdapter {
596
613
  * `__pk` index first (`dropIndexesForColumns`).
597
614
  */
598
615
  rebuildPrimaryKey(_change: TPrimaryKeyChange): Promise<void>;
599
- syncIndexes(opts?: TSyncIndexesOptions): Promise<void>;
616
+ protected _syncIndexes(dryRun: boolean, opts?: TSyncIndexesOptions): Promise<TIndexChange[]>;
600
617
  syncColumns(diff: TColumnDiff): Promise<TSyncColumnResult>;
601
618
  dropColumns(columns: string[]): Promise<void>;
602
619
  dropIndexesForColumns(columns: string[]): Promise<void>;
@@ -697,4 +714,4 @@ declare const validateMongoIdPlugin: TValidatorPlugin;
697
714
  /** A {@link DbSpace} over the database of `connection`; `options` go to every {@link MongoAdapter}. */
698
715
  declare function createAdapter(connection: string, options?: TMongoAdapterOptions): DbSpace;
699
716
  //#endregion
700
- export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, REL_FILTER_TEMP_PREFIX, TCollectionPatcherContext, TMongoAdapterOptions, TMongoFieldCollation, TMongoFilterOptions, TMongoFilterPlan, type TMongoIndex, type TMongoSearchIndexDefinition, type TPlainIndex, type TSearchIndex, buildMongoFilter, buildMongoQuery, collationOfAdapter, createAdapter, mongoFilterStages, mongoIndexKey, planStages, validateMongoIdPlugin };
717
+ export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, REL_FILTER_TEMP_PREFIX, TCollectionPatcherContext, TMongoAdapterOptions, TMongoFieldCollation, TMongoFilterOptions, TMongoFilterPlan, type TMongoIndex, type TMongoSearchIndexDefinition, type TPlainIndex, type TSearchIndex, buildMongoFilter, buildMongoQuery, collationOfAdapter, createAdapter, incExpr, mongoFilterStages, mongoIndexKey, planStages, validateMongoIdPlugin };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as buildMongoFilter, c as mongoFilterStages, i as TMongoFilterPlan, l as planStages, n as TMongoFieldCollation, o as buildMongoQuery, r as TMongoFilterOptions, s as collationOfAdapter, t as REL_FILTER_TEMP_PREFIX } from "./mongo-filter-CGd9ryOD.mjs";
2
- import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbCollation, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbRelation, TDbUpdateOptions, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TSyncIndexesOptions, TTableResolver, TViewCapability, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
2
+ import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbCollation, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbIsolationLevel, TDbObjectKind, TDbRelation, TDbUpdateOptions, TDbUpdateResult, TExistingTableOption, TFieldOps, TIndexChange, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TSyncIndexesOptions, TTableResolver, TTransactionOptions, TViewCapability, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
3
3
  import { AggregateOptions, AggregationCursor, ClientSession, Collection, Db, Document, Filter, MongoClient, ObjectId, UpdateFilter, UpdateOptions } from "mongodb";
4
4
  import { AggregateFn, BucketUnit } from "@uniqu/core";
5
5
  import { TAtscriptAnnotatedType, TMetadataMap, TValidatorOptions, TValidatorPlugin, Validator } from "@atscript/typescript/utils";
@@ -14,6 +14,8 @@ interface TCollectionPatcherContext {
14
14
  prepareId(id: any): any;
15
15
  createValidator(opts?: Partial<TValidatorOptions>): Validator<any>;
16
16
  }
17
+ /** Aggregation expression adding `by` to `field` (an `$inc` in a pipeline update). */
18
+ declare function incExpr(field: string, by: number): Document;
17
19
  /**
18
20
  * CollectionPatcher is a small helper that converts a *patch payload* produced
19
21
  * by Atscript into a shape that the official MongoDB driver understands – a
@@ -64,7 +66,7 @@ declare class CollectionPatcher {
64
66
  updateFilter: Document[];
65
67
  updateOptions: UpdateOptions;
66
68
  };
67
- /** Builds a MongoDB aggregation expression for an $inc or $mul field op. */
69
+ /** Aggregation expression of an `$inc` / `$mul` field op (see {@link fieldOpCurrent}). */
68
70
  private _fieldOpExpr;
69
71
  /**
70
72
  * Helper – lazily create `$set` section and assign *key* → *value*.
@@ -327,8 +329,23 @@ declare class MongoAdapter extends BaseDbAdapter {
327
329
  * Uses MongoDB's Convenient Transaction API (`session.withTransaction()`).
328
330
  * This handles txnNumber management and automatic retry for
329
331
  * `TransientTransactionError` / `UnknownTransactionCommitResult`.
330
- */
331
- withTransaction<T>(fn: () => Promise<T>): Promise<T>;
332
+ *
333
+ * `opts.isolation` (since 0.1.162): a MongoDB transaction reads one
334
+ * snapshot, which satisfies `'read committed'` and `'repeatable read'`;
335
+ * `'serializable'` throws `ISOLATION_NOT_SUPPORTED` (snapshot isolation
336
+ * allows write skew), and so does any level on a topology without
337
+ * transactions (standalone), where `fn` would run without one.
338
+ */
339
+ withTransaction<T>(fn: () => Promise<T>, opts?: TTransactionOptions): Promise<T>;
340
+ private static readonly _ISOLATION_LEVELS;
341
+ /**
342
+ * `'read committed'` and `'repeatable read'` — a transaction's snapshot
343
+ * read satisfies both. No `'serializable'` (snapshot isolation allows
344
+ * write skew). Locking reads are not supported (`supportsReadLocks()` is
345
+ * false → `LOCK_NOT_SUPPORTED`): use `$cas` / `withOptimisticRetry`.
346
+ * @since 0.1.162
347
+ */
348
+ isolationLevels(): ReadonlySet<TDbIsolationLevel>;
332
349
  private static readonly _noSession;
333
350
  /** Returns `{ session }` opts if inside a transaction, empty object otherwise. */
334
351
  protected _getSessionOpts(): {
@@ -596,7 +613,7 @@ declare class MongoAdapter extends BaseDbAdapter {
596
613
  * `__pk` index first (`dropIndexesForColumns`).
597
614
  */
598
615
  rebuildPrimaryKey(_change: TPrimaryKeyChange): Promise<void>;
599
- syncIndexes(opts?: TSyncIndexesOptions): Promise<void>;
616
+ protected _syncIndexes(dryRun: boolean, opts?: TSyncIndexesOptions): Promise<TIndexChange[]>;
600
617
  syncColumns(diff: TColumnDiff): Promise<TSyncColumnResult>;
601
618
  dropColumns(columns: string[]): Promise<void>;
602
619
  dropIndexesForColumns(columns: string[]): Promise<void>;
@@ -697,4 +714,4 @@ declare const validateMongoIdPlugin: TValidatorPlugin;
697
714
  /** A {@link DbSpace} over the database of `connection`; `options` go to every {@link MongoAdapter}. */
698
715
  declare function createAdapter(connection: string, options?: TMongoAdapterOptions): DbSpace;
699
716
  //#endregion
700
- export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, REL_FILTER_TEMP_PREFIX, TCollectionPatcherContext, TMongoAdapterOptions, TMongoFieldCollation, TMongoFilterOptions, TMongoFilterPlan, type TMongoIndex, type TMongoSearchIndexDefinition, type TPlainIndex, type TSearchIndex, buildMongoFilter, buildMongoQuery, collationOfAdapter, createAdapter, mongoFilterStages, mongoIndexKey, planStages, validateMongoIdPlugin };
717
+ export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, REL_FILTER_TEMP_PREFIX, TCollectionPatcherContext, TMongoAdapterOptions, TMongoFieldCollation, TMongoFilterOptions, TMongoFilterPlan, type TMongoIndex, type TMongoSearchIndexDefinition, type TPlainIndex, type TSearchIndex, buildMongoFilter, buildMongoQuery, collationOfAdapter, createAdapter, incExpr, mongoFilterStages, mongoIndexKey, planStages, validateMongoIdPlugin };
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { a as buildMongoQuery, c as planStages, f as sortStages, g as queryNodeToExpr, h as orNull, i as buildMongoFilter, l as correlate, n as distinctCountExpr, o as collationOfAdapter, p as exprToMongo, r as REL_FILTER_TEMP_PREFIX, s as mongoFilterStages, t as buildAccumulator, u as hasNullsPlacement } from "./mongo-accumulator-DMPFOscc.mjs";
2
- import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, andFilters, bucketTimeZoneUnavailable, computeInsights, containsRelationFilter, createFailureCollector, defaultFulltextIndex, geoIndexNotFoundMessage, getDbFieldOp, getKeyProps, getPath, isAtscriptDbView, isFieldRef, isPlainObject, isViewType, queryReadColumns, relationStaticFilter, resolveDesignType, searchIndexNotFoundMessage, searchTermInteger, tableNameOf, translateQueryTree, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
2
+ import { t as dynamicSearchEncryptedMessage } from "./search-messages-mQSpJ4GD.mjs";
3
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, andFilters, bucketTimeZoneUnavailable, computeInsights, containsRelationFilter, createChangeRecorder, createFailureCollector, defaultFulltextIndex, geoIndexNotFoundMessage, getDbFieldOp, getKeyProps, getPath, isAtscriptDbView, isFieldRef, isPlainObject, isViewType, queryReadColumns, relationStaticFilter, resolveDesignType, searchIndexNotFoundMessage, searchTermInteger, tableNameOf, translateQueryTree, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
3
4
  import { MongoBulkWriteError, MongoClient, MongoServerError, ObjectId } from "mongodb";
4
5
  import { resolveAggregateSearch } from "@atscript/db/agg";
5
6
  //#region src/lib/path-utils.ts
@@ -78,6 +79,19 @@ function containsAggregationExpr(v) {
78
79
  return false;
79
80
  }
80
81
  /**
82
+ * A field's current value in an aggregation pipeline update — a missing field
83
+ * counts as `0`, like the `$inc` / `$mul` update operators (the pipeline
84
+ * `$add` / `$multiply` would yield `null`) — so a document written before an
85
+ * `@db.onUpdate.inc` counter existed starts counting.
86
+ */
87
+ function fieldOpCurrent(field) {
88
+ return { $ifNull: [`$${field}`, 0] };
89
+ }
90
+ /** Aggregation expression adding `by` to `field` (an `$inc` in a pipeline update). */
91
+ function incExpr(field, by) {
92
+ return { $add: [fieldOpCurrent(field), by] };
93
+ }
94
+ /**
81
95
  * CollectionPatcher is a small helper that converts a *patch payload* produced
82
96
  * by Atscript into a shape that the official MongoDB driver understands – a
83
97
  * triple of `(filter, update, options)` to be fed to `collection.updateOne()`.
@@ -142,9 +156,9 @@ var CollectionPatcher = class {
142
156
  updateOptions: this.optionsObj
143
157
  };
144
158
  }
145
- /** Builds a MongoDB aggregation expression for an $inc or $mul field op. */
159
+ /** Aggregation expression of an `$inc` / `$mul` field op (see {@link fieldOpCurrent}). */
146
160
  _fieldOpExpr(key, op, value) {
147
- return op === "inc" ? { $add: [`$${key}`, value] } : { $multiply: [`$${key}`, value] };
161
+ return op === "inc" ? incExpr(key, value) : { $multiply: [fieldOpCurrent(key), value] };
148
162
  }
149
163
  /**
150
164
  * Helper – lazily create `$set` section and assign *key* → *value*.
@@ -1839,8 +1853,12 @@ const PK_INDEX = mongoIndexKey("unique", "__pk");
1839
1853
  * index of a skipped key rebuild, the indexes over a column whose type change
1840
1854
  * is pending (its present-only filter is typed).
1841
1855
  */
1842
- async function syncIndexesImpl(host, opts) {
1843
- await host.ensureCollectionExists();
1856
+ async function syncIndexesImpl(host, opts, dryRun = false) {
1857
+ /** Indexes dropped because their definition drifted — their re-creation is a rebuild. */
1858
+ const replaced = /* @__PURE__ */ new Set();
1859
+ if (dryRun) {
1860
+ if (!await host.db.listCollections({ name: host.collection.collectionName }, { nameOnly: true }).hasNext()) return [];
1861
+ } else await host.ensureCollectionExists();
1844
1862
  const allIndexes = /* @__PURE__ */ new Map();
1845
1863
  for (const [key, index] of host._table.indexes.entries()) {
1846
1864
  const fields = {};
@@ -1887,6 +1905,7 @@ async function syncIndexesImpl(host, opts) {
1887
1905
  for (const remote of existingIndexes) if (isKept(remote.name, remote.key, remote.weights)) kept.add(remote.name);
1888
1906
  for (const name of kept) indexesToCreate.delete(name);
1889
1907
  const { attempt, throwIfAny } = createFailureCollector("index sync");
1908
+ const { changes, apply } = createChangeRecorder(dryRun, attempt);
1890
1909
  let defaultCollation;
1891
1910
  const collectionDefaultCollation = async () => {
1892
1911
  if (defaultCollation === void 0) defaultCollation = (await host.db.listCollections({ name: host.collection.collectionName }).next())?.options?.collation ?? null;
@@ -1905,16 +1924,21 @@ async function syncIndexesImpl(host, opts) {
1905
1924
  if (fieldsMatch && weightsMatch && optionsMatch) indexesToCreate.delete(remote.name);
1906
1925
  else if (violation !== void 0) {
1907
1926
  indexesToCreate.delete(remote.name);
1927
+ if (dryRun) continue;
1908
1928
  await attempt(`replace unique index "${remote.name}"`, () => Promise.reject(/* @__PURE__ */ new Error(`existing documents violate the new index (duplicate key ${JSON.stringify(violation)}); the current index is kept`)));
1909
- } else {
1929
+ } else if (dryRun) replaced.add(remote.name);
1930
+ else {
1910
1931
  host._log("dropIndex", remote.name);
1911
- await attempt(`drop index "${remote.name}"`, () => host.collection.dropIndex(remote.name));
1932
+ if (await attempt(`drop index "${remote.name}"`, () => host.collection.dropIndex(remote.name))) replaced.add(remote.name);
1912
1933
  }
1913
1934
  }
1914
- } else {
1935
+ } else await apply({
1936
+ name: remote.name,
1937
+ action: "drop"
1938
+ }, `drop index "${remote.name}"`, () => {
1915
1939
  host._log("dropIndex", remote.name);
1916
- await attempt(`drop index "${remote.name}"`, () => host.collection.dropIndex(remote.name));
1917
- }
1940
+ return host.collection.dropIndex(remote.name);
1941
+ });
1918
1942
  }
1919
1943
  for (const [key, value] of allIndexes.entries()) {
1920
1944
  if (!isPlainIndex(value) || !indexesToCreate.has(key)) continue;
@@ -1954,8 +1978,12 @@ async function syncIndexesImpl(host, opts) {
1954
1978
  indexOptions = { name: key };
1955
1979
  break;
1956
1980
  }
1957
- await attempt(label, () => host.collection.createIndex(value.fields, indexOptions));
1981
+ await apply({
1982
+ name: key,
1983
+ action: replaced.has(key) ? "rebuild" : "add"
1984
+ }, label, () => host.collection.createIndex(value.fields, indexOptions));
1958
1985
  }
1986
+ const search = createChangeRecorder(dryRun);
1959
1987
  try {
1960
1988
  const toUpdate = /* @__PURE__ */ new Set();
1961
1989
  const existingSearchIndexes = await host.collection.listSearchIndexes().toArray();
@@ -1978,31 +2006,41 @@ async function syncIndexesImpl(host, opts) {
1978
2006
  break;
1979
2007
  default:
1980
2008
  }
1981
- } else if (remote.status !== "DELETING") {
2009
+ } else if (remote.status !== "DELETING") await search.apply({
2010
+ name: remote.name,
2011
+ action: "drop"
2012
+ }, remote.name, () => {
1982
2013
  host._log("dropSearchIndex", remote.name);
1983
- await host.collection.dropSearchIndex(remote.name);
1984
- }
2014
+ return host.collection.dropSearchIndex(remote.name);
2015
+ });
1985
2016
  }
1986
2017
  for (const [key, value] of indexesToCreate.entries()) switch (value.type) {
1987
2018
  case "dynamic_text":
1988
2019
  case "search_text":
1989
- case "vector":
1990
- if (toUpdate.has(key)) {
1991
- host._log("updateSearchIndex", key, value.definition);
1992
- await host.collection.updateSearchIndex(key, value.definition);
1993
- } else {
2020
+ case "vector": {
2021
+ const update = toUpdate.has(key);
2022
+ await search.apply({
2023
+ name: key,
2024
+ action: update ? "rebuild" : "add"
2025
+ }, key, () => {
2026
+ if (update) {
2027
+ host._log("updateSearchIndex", key, value.definition);
2028
+ return host.collection.updateSearchIndex(key, value.definition);
2029
+ }
1994
2030
  host._log("createSearchIndex", key, value.type);
1995
- await host.collection.createSearchIndex({
2031
+ return host.collection.createSearchIndex({
1996
2032
  name: key,
1997
2033
  type: value.type === "vector" ? "vectorSearch" : "search",
1998
2034
  definition: value.definition
1999
2035
  });
2000
- }
2036
+ });
2001
2037
  break;
2038
+ }
2002
2039
  default:
2003
2040
  }
2004
2041
  } catch {}
2005
2042
  throwIfAny();
2043
+ return [...changes, ...search.changes];
2006
2044
  }
2007
2045
  /**
2008
2046
  * The collation a plain / unique index is built with (since 0.1.151): the
@@ -2272,22 +2310,33 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2272
2310
  * Uses MongoDB's Convenient Transaction API (`session.withTransaction()`).
2273
2311
  * This handles txnNumber management and automatic retry for
2274
2312
  * `TransientTransactionError` / `UnknownTransactionCommitResult`.
2313
+ *
2314
+ * `opts.isolation` (since 0.1.162): a MongoDB transaction reads one
2315
+ * snapshot, which satisfies `'read committed'` and `'repeatable read'`;
2316
+ * `'serializable'` throws `ISOLATION_NOT_SUPPORTED` (snapshot isolation
2317
+ * allows write skew), and so does any level on a topology without
2318
+ * transactions (standalone), where `fn` would run without one.
2275
2319
  */
2276
- async withTransaction(fn) {
2320
+ async withTransaction(fn, opts) {
2321
+ const isolation = this._checkIsolation(opts);
2277
2322
  if (this._getTransactionState()) return fn();
2278
- if (this._txDisabled || !this._client) return fn();
2323
+ const passThrough = () => {
2324
+ if (isolation) throw this._isolationNotSupported(`Isolation '${isolation}' needs a MongoDB transaction — this topology (standalone) has none`);
2325
+ return fn();
2326
+ };
2327
+ if (this._txDisabled || !this._client) return passThrough();
2279
2328
  try {
2280
2329
  const topology = this._client.topology;
2281
2330
  if (topology) {
2282
2331
  const type = (topology.description ?? topology.s?.description)?.type;
2283
2332
  if (type === "Single" || type === "Unknown") {
2284
2333
  this._txDisabled = true;
2285
- return fn();
2334
+ return passThrough();
2286
2335
  }
2287
2336
  }
2288
2337
  } catch {
2289
2338
  this._txDisabled = true;
2290
- return fn();
2339
+ return passThrough();
2291
2340
  }
2292
2341
  const session = this._client.startSession();
2293
2342
  try {
@@ -2302,6 +2351,17 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2302
2351
  } catch {}
2303
2352
  }
2304
2353
  }
2354
+ static _ISOLATION_LEVELS = new Set(["read committed", "repeatable read"]);
2355
+ /**
2356
+ * `'read committed'` and `'repeatable read'` — a transaction's snapshot
2357
+ * read satisfies both. No `'serializable'` (snapshot isolation allows
2358
+ * write skew). Locking reads are not supported (`supportsReadLocks()` is
2359
+ * false → `LOCK_NOT_SUPPORTED`): use `$cas` / `withOptimisticRetry`.
2360
+ * @since 0.1.162
2361
+ */
2362
+ isolationLevels() {
2363
+ return MongoAdapter._ISOLATION_LEVELS;
2364
+ }
2305
2365
  static _noSession = Object.freeze({});
2306
2366
  /** Returns `{ session }` opts if inside a transaction, empty object otherwise. */
2307
2367
  _getSessionOpts() {
@@ -2669,6 +2729,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2669
2729
  for (const field of this._pendingUniqueFields) this._addMongoIndexField("unique", "__pk", meta.documentPath(field));
2670
2730
  for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
2671
2731
  this._pendingSearchFields = [];
2732
+ const [encrypted] = meta.encryptedFields;
2733
+ if (encrypted !== void 0 && [...this._mongoIndexes.values()].some((i) => i.type === "dynamic_text")) throw new Error(dynamicSearchEncryptedMessage(encrypted));
2672
2734
  const textBearing = [...this._table.indexes.values()].filter((i) => i.type === "fulltext" && i.fields.some((f) => !f.integer));
2673
2735
  if (textBearing.length > 1) throw new Error(`MongoDB allows one text index per collection: "${this._table.tableName}" declares ${textBearing.length} @db.index.fulltext names with text fields (${textBearing.map((i) => `"${i.name}"`).join(", ")}). Merge them into one name; integer-only fulltext indexes may be declared separately.`);
2674
2736
  for (const fulltext of this._table.indexes.values()) {
@@ -3149,12 +3211,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
3149
3211
  modifiedCount: 0
3150
3212
  };
3151
3213
  const versionColumn = this._table.versionColumnPhysical;
3214
+ const counters = this._table.onUpdateIncPhysical;
3152
3215
  this._log("replaceOne", mongoFilter, data);
3153
- if (versionColumn !== void 0) {
3154
- const pipeline = [{ $replaceWith: {
3155
- ...data,
3156
- [versionColumn]: { $add: [`$${versionColumn}`, 1] }
3157
- } }];
3216
+ if (versionColumn !== void 0 || counters.length > 0) {
3217
+ const replacement = {};
3218
+ for (const [key, value] of Object.entries(data)) replacement[key] = { $literal: value };
3219
+ for (const col of counters) replacement[col] = incExpr(col, 1);
3220
+ if (versionColumn !== void 0) replacement[versionColumn] = { $add: [`$${versionColumn}`, 1] };
3221
+ const pipeline = [{ $replaceWith: replacement }];
3158
3222
  return this._wrapUpdate(() => this.collection.updateOne(mongoFilter, pipeline, this._getSessionOpts()));
3159
3223
  }
3160
3224
  return this._wrapUpdate(() => this.collection.replaceOne(mongoFilter, data, this._getSessionOpts()));
@@ -3173,7 +3237,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
3173
3237
  });
3174
3238
  }
3175
3239
  async replaceMany(filter, data) {
3176
- const updateDoc = buildMongoUpdateDoc(data, void 0, this._table.versionColumnPhysical);
3240
+ const updateDoc = buildMongoUpdateDoc(data, this._onUpdateIncOps(), this._table.versionColumnPhysical);
3177
3241
  return this._updateMatching(filter, (mongoFilter) => {
3178
3242
  this._log("replaceMany", mongoFilter, updateDoc);
3179
3243
  return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
@@ -3214,8 +3278,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
3214
3278
  * `__pk` index first (`dropIndexesForColumns`).
3215
3279
  */
3216
3280
  async rebuildPrimaryKey(_change) {}
3217
- async syncIndexes(opts) {
3218
- return syncIndexesImpl(this, opts);
3281
+ async _syncIndexes(dryRun, opts) {
3282
+ return syncIndexesImpl(this, opts, dryRun);
3219
3283
  }
3220
3284
  async syncColumns(diff) {
3221
3285
  return syncColumnsImpl(this, diff);
@@ -3596,4 +3660,4 @@ function createAdapter(connection, options) {
3596
3660
  return new DbSpace(() => new MongoAdapter(db, client, options), { onClose: () => client.close() });
3597
3661
  }
3598
3662
  //#endregion
3599
- export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, REL_FILTER_TEMP_PREFIX, buildMongoFilter, buildMongoQuery, collationOfAdapter, createAdapter, mongoFilterStages, mongoIndexKey, planStages, validateMongoIdPlugin };
3663
+ export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, REL_FILTER_TEMP_PREFIX, buildMongoFilter, buildMongoQuery, collationOfAdapter, createAdapter, incExpr, mongoFilterStages, mongoIndexKey, planStages, validateMongoIdPlugin };
package/dist/plugin.cjs CHANGED
@@ -2,7 +2,9 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
+ const require_search_messages = require("./search-messages-BqLxxrb8.cjs");
5
6
  let _atscript_core = require("@atscript/core");
7
+ let _atscript_db_shared = require("@atscript/db/shared");
6
8
  //#region src/plugin/annotations.ts
7
9
  const analyzers = [
8
10
  "lucene.standard",
@@ -50,7 +52,7 @@ const fuzzyDescription = "Maximum typo tolerance, applied **at query time** to t
50
52
  */
51
53
  const annotations = {
52
54
  collection: new _atscript_core.AnnotationSpec({
53
- 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",
55
+ description: (0, _atscript_core.annotationExample)("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`**.", "@db.table \"users\"", "@db.mongo.collection", "export interface User {", " _id: mongo.objectId", " email: string.email", "}"),
54
56
  nodeType: ["interface"],
55
57
  passedWhenReferred: false,
56
58
  validate(token, args, doc) {
@@ -90,7 +92,7 @@ const annotations = {
90
92
  }
91
93
  }),
92
94
  capped: new _atscript_core.AnnotationSpec({
93
- description: "Creates a **capped collection** with a fixed maximum size.\n\n- Capped collections have fixed size and maintain insertion order.\n- Ideal for logs, event streams, and cache-like data.\n- Changing the cap size requires dropping and recreating the collection — use `@db.sync.method \"drop\"` to allow this.\n\n**Example:**\n```atscript\n@db.table \"logs\"\n@db.mongo.collection\n@db.mongo.capped 10485760, 10000\n@db.sync.method \"drop\"\nexport interface LogEntry {\n message: string\n timestamp: number\n}\n```\n",
95
+ description: (0, _atscript_core.annotationExample)("Creates a **capped collection** with a fixed maximum size.\n\n- Capped collections have fixed size and maintain insertion order.\n- Ideal for logs, event streams, and cache-like data.\n- Changing the cap size requires dropping and recreating the collection — use `@db.sync.method \"drop\"` to allow this.", "@db.table \"logs\"", "@db.mongo.collection", "@db.mongo.capped 10485760, 10000", "@db.sync.method \"drop\"", "export interface LogEntry {", " message: string", " timestamp: number", "}"),
94
96
  nodeType: ["interface"],
95
97
  passedWhenReferred: false,
96
98
  multiple: false,
@@ -108,10 +110,19 @@ const annotations = {
108
110
  }),
109
111
  search: {
110
112
  dynamic: new _atscript_core.AnnotationSpec({
111
- 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",
113
+ description: (0, _atscript_core.annotationExample)("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.", "@db.mongo.search.dynamic \"lucene.english\", 1", "export interface MongoCollection {}"),
112
114
  nodeType: ["interface"],
113
115
  passedWhenReferred: false,
114
116
  multiple: false,
117
+ validate(token, _args, doc) {
118
+ const encrypted = (0, _atscript_db_shared.findStoredField)(token.parentNode, doc, (prop, path) => prop.countAnnotations("db.encrypted") > 0 ? path : void 0);
119
+ if (!encrypted) return [];
120
+ return [{
121
+ message: require_search_messages.dynamicSearchEncryptedMessage(encrypted),
122
+ severity: 1,
123
+ range: token.range
124
+ }];
125
+ },
115
126
  argument: [{
116
127
  optional: true,
117
128
  name: "analyzer",
@@ -126,7 +137,7 @@ const annotations = {
126
137
  }]
127
138
  }),
128
139
  static: new _atscript_core.AnnotationSpec({
129
- 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",
140
+ description: (0, _atscript_core.annotationExample)("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.", "@db.mongo.search.static \"lucene.english\", 1, \"mySearchIndex\"", "export interface MongoCollection {}"),
130
141
  nodeType: ["interface"],
131
142
  passedWhenReferred: false,
132
143
  multiple: true,
@@ -160,8 +171,9 @@ const annotations = {
160
171
  ]
161
172
  }),
162
173
  text: new _atscript_core.AnnotationSpec({
163
- 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",
174
+ description: (0, _atscript_core.annotationExample)("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.", "@db.mongo.search.text \"lucene.english\", \"mySearchIndex\"", "firstName: string"),
164
175
  nodeType: ["prop"],
176
+ validate: (0, _atscript_db_shared.refuseCiphertext)("@db.mongo.search.text"),
165
177
  passedWhenReferred: false,
166
178
  multiple: true,
167
179
  argument: [{
@@ -178,8 +190,9 @@ const annotations = {
178
190
  }]
179
191
  }),
180
192
  autocomplete: new _atscript_core.AnnotationSpec({
181
- description: "Marks a field for **prefix / typeahead (as-you-type)** matching in a MongoDB Atlas Search Index.\n\n- Indexes the field as the Atlas **`autocomplete`** type, **and** double-maps it as `string` so exact-word hits still rank.\n- Lets `search()` match partial words: with the default `edgeGram` tokenization, `\"art\"` matches `\"Artem\"` **as you type** (no whole word required).\n- Use `nGram` tokenization for true mid-word (infix/substring) matching at higher index cost.\n- Like `@db.mongo.search.text`, the field joins the index named by `indexName` (or the default index).\n\n**Example:**\n```atscript\n@db.mongo.search.autocomplete \"users\"\nusername: string\n```\n",
193
+ description: (0, _atscript_core.annotationExample)("Marks a field for **prefix / typeahead (as-you-type)** matching in a MongoDB Atlas Search Index.\n\n- Indexes the field as the Atlas **`autocomplete`** type, **and** double-maps it as `string` so exact-word hits still rank.\n- Lets `search()` match partial words: with the default `edgeGram` tokenization, `\"art\"` matches `\"Artem\"` **as you type** (no whole word required).\n- Use `nGram` tokenization for true mid-word (infix/substring) matching at higher index cost.\n- Like `@db.mongo.search.text`, the field joins the index named by `indexName` (or the default index).", "@db.mongo.search.autocomplete \"users\"", "username: string"),
182
194
  nodeType: ["prop"],
195
+ validate: (0, _atscript_db_shared.refuseCiphertext)("@db.mongo.search.autocomplete"),
183
196
  passedWhenReferred: false,
184
197
  multiple: true,
185
198
  argument: [
@@ -229,7 +242,7 @@ const annotations = {
229
242
  //#region src/plugin/primitives.ts
230
243
  const primitives = { mongo: { extensions: { objectId: {
231
244
  type: "string",
232
- 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",
245
+ documentation: (0, _atscript_core.annotationExample)("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.", "userId: mongo.objectId"),
233
246
  annotations: { "expect.pattern": { pattern: "^[a-fA-F0-9]{24}$" } }
234
247
  } } } };
235
248
  //#endregion
package/dist/plugin.mjs CHANGED
@@ -1,4 +1,6 @@
1
- import { AnnotationSpec, isInterface, isPrimitive, isRef, isStructure } from "@atscript/core";
1
+ import { t as dynamicSearchEncryptedMessage } from "./search-messages-mQSpJ4GD.mjs";
2
+ import { AnnotationSpec, annotationExample, isInterface, isPrimitive, isRef, isStructure } from "@atscript/core";
3
+ import { findStoredField, refuseCiphertext } from "@atscript/db/shared";
2
4
  //#region src/plugin/annotations.ts
3
5
  const analyzers = [
4
6
  "lucene.standard",
@@ -46,7 +48,7 @@ const fuzzyDescription = "Maximum typo tolerance, applied **at query time** to t
46
48
  */
47
49
  const annotations = {
48
50
  collection: new AnnotationSpec({
49
- 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",
51
+ description: annotationExample("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`**.", "@db.table \"users\"", "@db.mongo.collection", "export interface User {", " _id: mongo.objectId", " email: string.email", "}"),
50
52
  nodeType: ["interface"],
51
53
  passedWhenReferred: false,
52
54
  validate(token, args, doc) {
@@ -86,7 +88,7 @@ const annotations = {
86
88
  }
87
89
  }),
88
90
  capped: new AnnotationSpec({
89
- description: "Creates a **capped collection** with a fixed maximum size.\n\n- Capped collections have fixed size and maintain insertion order.\n- Ideal for logs, event streams, and cache-like data.\n- Changing the cap size requires dropping and recreating the collection — use `@db.sync.method \"drop\"` to allow this.\n\n**Example:**\n```atscript\n@db.table \"logs\"\n@db.mongo.collection\n@db.mongo.capped 10485760, 10000\n@db.sync.method \"drop\"\nexport interface LogEntry {\n message: string\n timestamp: number\n}\n```\n",
91
+ description: annotationExample("Creates a **capped collection** with a fixed maximum size.\n\n- Capped collections have fixed size and maintain insertion order.\n- Ideal for logs, event streams, and cache-like data.\n- Changing the cap size requires dropping and recreating the collection — use `@db.sync.method \"drop\"` to allow this.", "@db.table \"logs\"", "@db.mongo.collection", "@db.mongo.capped 10485760, 10000", "@db.sync.method \"drop\"", "export interface LogEntry {", " message: string", " timestamp: number", "}"),
90
92
  nodeType: ["interface"],
91
93
  passedWhenReferred: false,
92
94
  multiple: false,
@@ -104,10 +106,19 @@ const annotations = {
104
106
  }),
105
107
  search: {
106
108
  dynamic: new AnnotationSpec({
107
- 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",
109
+ description: annotationExample("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.", "@db.mongo.search.dynamic \"lucene.english\", 1", "export interface MongoCollection {}"),
108
110
  nodeType: ["interface"],
109
111
  passedWhenReferred: false,
110
112
  multiple: false,
113
+ validate(token, _args, doc) {
114
+ const encrypted = findStoredField(token.parentNode, doc, (prop, path) => prop.countAnnotations("db.encrypted") > 0 ? path : void 0);
115
+ if (!encrypted) return [];
116
+ return [{
117
+ message: dynamicSearchEncryptedMessage(encrypted),
118
+ severity: 1,
119
+ range: token.range
120
+ }];
121
+ },
111
122
  argument: [{
112
123
  optional: true,
113
124
  name: "analyzer",
@@ -122,7 +133,7 @@ const annotations = {
122
133
  }]
123
134
  }),
124
135
  static: new AnnotationSpec({
125
- 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",
136
+ description: annotationExample("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.", "@db.mongo.search.static \"lucene.english\", 1, \"mySearchIndex\"", "export interface MongoCollection {}"),
126
137
  nodeType: ["interface"],
127
138
  passedWhenReferred: false,
128
139
  multiple: true,
@@ -156,8 +167,9 @@ const annotations = {
156
167
  ]
157
168
  }),
158
169
  text: new AnnotationSpec({
159
- 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",
170
+ description: annotationExample("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.", "@db.mongo.search.text \"lucene.english\", \"mySearchIndex\"", "firstName: string"),
160
171
  nodeType: ["prop"],
172
+ validate: refuseCiphertext("@db.mongo.search.text"),
161
173
  passedWhenReferred: false,
162
174
  multiple: true,
163
175
  argument: [{
@@ -174,8 +186,9 @@ const annotations = {
174
186
  }]
175
187
  }),
176
188
  autocomplete: new AnnotationSpec({
177
- description: "Marks a field for **prefix / typeahead (as-you-type)** matching in a MongoDB Atlas Search Index.\n\n- Indexes the field as the Atlas **`autocomplete`** type, **and** double-maps it as `string` so exact-word hits still rank.\n- Lets `search()` match partial words: with the default `edgeGram` tokenization, `\"art\"` matches `\"Artem\"` **as you type** (no whole word required).\n- Use `nGram` tokenization for true mid-word (infix/substring) matching at higher index cost.\n- Like `@db.mongo.search.text`, the field joins the index named by `indexName` (or the default index).\n\n**Example:**\n```atscript\n@db.mongo.search.autocomplete \"users\"\nusername: string\n```\n",
189
+ description: annotationExample("Marks a field for **prefix / typeahead (as-you-type)** matching in a MongoDB Atlas Search Index.\n\n- Indexes the field as the Atlas **`autocomplete`** type, **and** double-maps it as `string` so exact-word hits still rank.\n- Lets `search()` match partial words: with the default `edgeGram` tokenization, `\"art\"` matches `\"Artem\"` **as you type** (no whole word required).\n- Use `nGram` tokenization for true mid-word (infix/substring) matching at higher index cost.\n- Like `@db.mongo.search.text`, the field joins the index named by `indexName` (or the default index).", "@db.mongo.search.autocomplete \"users\"", "username: string"),
178
190
  nodeType: ["prop"],
191
+ validate: refuseCiphertext("@db.mongo.search.autocomplete"),
179
192
  passedWhenReferred: false,
180
193
  multiple: true,
181
194
  argument: [
@@ -225,7 +238,7 @@ const annotations = {
225
238
  //#region src/plugin/primitives.ts
226
239
  const primitives = { mongo: { extensions: { objectId: {
227
240
  type: "string",
228
- 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",
241
+ documentation: annotationExample("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.", "userId: mongo.objectId"),
229
242
  annotations: { "expect.pattern": { pattern: "^[a-fA-F0-9]{24}$" } }
230
243
  } } } };
231
244
  //#endregion
@@ -0,0 +1,16 @@
1
+ //#region src/lib/search-messages.ts
2
+ /**
3
+ * The refusal of `@db.mongo.search.dynamic` on a collection storing an
4
+ * `@db.encrypted` field (at `path`) — shared by the compile-time check and
5
+ * its runtime mirror.
6
+ */
7
+ function dynamicSearchEncryptedMessage(path) {
8
+ return `@db.mongo.search.dynamic indexes every field, including the @db.encrypted field "${path}" — Atlas Search over ciphertext is impossible; use @db.mongo.search.static with @db.mongo.search.text fields`;
9
+ }
10
+ //#endregion
11
+ Object.defineProperty(exports, "dynamicSearchEncryptedMessage", {
12
+ enumerable: true,
13
+ get: function() {
14
+ return dynamicSearchEncryptedMessage;
15
+ }
16
+ });
@@ -0,0 +1,11 @@
1
+ //#region src/lib/search-messages.ts
2
+ /**
3
+ * The refusal of `@db.mongo.search.dynamic` on a collection storing an
4
+ * `@db.encrypted` field (at `path`) — shared by the compile-time check and
5
+ * its runtime mirror.
6
+ */
7
+ function dynamicSearchEncryptedMessage(path) {
8
+ return `@db.mongo.search.dynamic indexes every field, including the @db.encrypted field "${path}" — Atlas Search over ciphertext is impossible; use @db.mongo.search.static with @db.mongo.search.text fields`;
9
+ }
10
+ //#endregion
11
+ export { dynamicSearchEncryptedMessage as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mongo",
3
- "version": "0.1.161",
3
+ "version": "0.1.163",
4
4
  "description": "Mongodb plugin for atscript.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -46,19 +46,19 @@
46
46
  "access": "public"
47
47
  },
48
48
  "devDependencies": {
49
- "@atscript/core": "^0.1.108",
50
- "@atscript/typescript": "^0.1.108",
49
+ "@atscript/core": "^0.1.109",
50
+ "@atscript/typescript": "^0.1.109",
51
51
  "@uniqu/core": "^0.1.15",
52
52
  "mongodb": "^6.17.0",
53
53
  "mongodb-memory-server-core": "^10.0.0",
54
- "unplugin-atscript": "^0.1.108"
54
+ "unplugin-atscript": "^0.1.109"
55
55
  },
56
56
  "peerDependencies": {
57
- "@atscript/core": "^0.1.108",
58
- "@atscript/typescript": "^0.1.108",
57
+ "@atscript/core": "^0.1.109",
58
+ "@atscript/typescript": "^0.1.109",
59
59
  "@uniqu/core": "^0.1.15",
60
60
  "mongodb": "^6.17.0",
61
- "@atscript/db": "^0.1.161"
61
+ "@atscript/db": "^0.1.163"
62
62
  },
63
63
  "scripts": {
64
64
  "build": "vp pack",