@atscript/db-mongo 0.1.154 → 0.1.156

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
@@ -162,11 +162,14 @@ var CollectionPatcher = class {
162
162
  this.currentSetStage = { $set: { [key]: val } };
163
163
  this.updatePipeline.push(this.currentSetStage);
164
164
  }
165
- /** Set a leaf, lifting an `$inc`/`$mul` field op into an aggregation expression if present. */
166
- _setLeaf(key, value) {
165
+ /**
166
+ * Set a leaf, lifting an `$inc`/`$mul` field op into an aggregation expression if present.
167
+ * `type`: the field's type, when known.
168
+ */
169
+ _setLeaf(key, value, type) {
167
170
  const fieldOp = (0, _atscript_db.getDbFieldOp)(value);
168
171
  if (fieldOp) this._set(key, this._fieldOpExpr(key, fieldOp.op, fieldOp.value));
169
- else if (containsAggregationExpr(value)) this._set(key, { $literal: value });
172
+ else if (containsAggregationExpr(value) || replacesWhole(value, type)) this._set(key, { $literal: value });
170
173
  else this._set(key, value);
171
174
  }
172
175
  /**
@@ -187,7 +190,7 @@ var CollectionPatcher = class {
187
190
  if (isObjectValue && topLevelArray && !flatType?.metadata?.has("db.json")) this.parseArrayPatch(key, value, flatType);
188
191
  else if (isObjectValue && flatType?.metadata?.get("db.patch.strategy") === "merge") this.flattenPayload(value, key);
189
192
  else if (isObjectValue && flatType?.type.kind === "object" && !flatType.metadata?.has("db.json")) this._flattenReplaceObject(value, key, flatType);
190
- else if (key !== "_id") this._setLeaf(key, value);
193
+ else if (key !== "_id") this._setLeaf(key, value, flatType);
191
194
  }
192
195
  return this.updatePipeline;
193
196
  }
@@ -213,7 +216,7 @@ var CollectionPatcher = class {
213
216
  else this._flattenReplaceObject(childValue, childKey, propType);
214
217
  continue;
215
218
  }
216
- this._setLeaf(childKey, childValue);
219
+ this._setLeaf(childKey, childValue, propType);
217
220
  }
218
221
  }
219
222
  /**
@@ -361,6 +364,16 @@ var CollectionPatcher = class {
361
364
  else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
362
365
  }
363
366
  };
367
+ /**
368
+ * Whether a plain-object patch value replaces the stored object instead of
369
+ * merging into it: a `@db.json` object, or a union of objects / `T | null`
370
+ * whose member may switch (since 0.1.155). A field the patcher cannot type
371
+ * (e.g. under a `@db.column` name) keeps the merge.
372
+ */
373
+ function replacesWhole(value, type) {
374
+ if (!type || !(0, _atscript_db.isPlainObject)(value)) return false;
375
+ return type.metadata?.has("db.json") || type.type.kind === "union";
376
+ }
364
377
  //#endregion
365
378
  //#region src/lib/mongo-types.ts
366
379
  const INDEX_PREFIX = "atscript__";
@@ -1624,6 +1637,30 @@ async function hasRowsImpl(host, tableName) {
1624
1637
  ...host._getSessionOpts()
1625
1638
  }) !== null;
1626
1639
  }
1640
+ /**
1641
+ * Documents `fields` cannot identify (see `BaseDbAdapter.countKeyViolations`):
1642
+ * those missing one of them or holding `null`, plus every document sharing
1643
+ * its values with another — one `$facet` aggregation.
1644
+ */
1645
+ async function countKeyViolationsImpl(host, fields, tableName) {
1646
+ const key = Object.fromEntries(fields.map((f, i) => [`k${i}`, `$${f}`]));
1647
+ const [result] = await host.db.collection(tableName ?? host.resolveTableName(false)).aggregate([{ $facet: {
1648
+ nulls: [{ $match: { $or: fields.map((f) => ({ [f]: null })) } }, { $count: "n" }],
1649
+ dups: [
1650
+ { $match: { $and: fields.map((f) => ({ [f]: { $ne: null } })) } },
1651
+ { $group: {
1652
+ _id: key,
1653
+ n: { $sum: 1 }
1654
+ } },
1655
+ { $match: { n: { $gt: 1 } } },
1656
+ { $group: {
1657
+ _id: null,
1658
+ n: { $sum: "$n" }
1659
+ } }
1660
+ ]
1661
+ } }], host._getSessionOpts()).toArray();
1662
+ return (result?.nulls[0]?.n ?? 0) + (result?.dups[0]?.n ?? 0);
1663
+ }
1627
1664
  /** Kind of the object stored under `name` (`listCollections` reports views as `"view"`). */
1628
1665
  async function getObjectKindImpl(host, name) {
1629
1666
  const type = (await host.db.listCollections({ name }, { nameOnly: true }).toArray())[0]?.type;
@@ -1733,6 +1770,27 @@ async function dropColumnsImpl(host, columns) {
1733
1770
  await host.collection.updateMany({}, { $unset: unsetSpec }, host._getSessionOpts());
1734
1771
  }
1735
1772
  /**
1773
+ * Drops the managed indexes over any of `columns` (or a path under one).
1774
+ * Schema sync calls it before {@link dropColumnsImpl}: a unique index on a
1775
+ * required field indexes a `$unset` document as `null`, so the second one
1776
+ * collides (E11000) — e.g. the old key field's `__pk` index when a `@meta.id`
1777
+ * move removes that field. `syncIndexesImpl` then builds what the model wants.
1778
+ */
1779
+ async function dropIndexesForColumnsImpl(host, columns) {
1780
+ const dropped = new Set(columns);
1781
+ let existing;
1782
+ try {
1783
+ existing = await host.collection.listIndexes().toArray();
1784
+ } catch (error) {
1785
+ if (error.code === 26) return;
1786
+ throw error;
1787
+ }
1788
+ for (const remote of existing) if (remote.name.startsWith("atscript__") && coversAny(remote.key, remote.weights, dropped)) {
1789
+ host._log("dropIndex", remote.name);
1790
+ await host.collection.dropIndex(remote.name);
1791
+ }
1792
+ }
1793
+ /**
1736
1794
  * The segments of a field's physical document path with `$[]` after every
1737
1795
  * segment whose logical prefix is an array in the flatMap — Mongo's
1738
1796
  * all-positional operator walks every element. `physicalPath` defaults to
@@ -1766,7 +1824,23 @@ function resolveSyncDefault(field) {
1766
1824
  if (!field.defaultValue) return;
1767
1825
  if (field.defaultValue.kind === "value") return field.designType === "string" ? field.defaultValue.value : JSON.parse(field.defaultValue.value);
1768
1826
  }
1769
- async function syncIndexesImpl(host) {
1827
+ /**
1828
+ * Whether an index's fields include one of `paths` or a path under one —
1829
+ * a text index lists its fields in `weights` (its key is `_fts` / `_ftsx`).
1830
+ */
1831
+ function coversAny(key, weights, paths) {
1832
+ return [...Object.keys(key), ...Object.keys(weights ?? {})].some((f) => paths.has(f) || hasAncestorIn(f, paths));
1833
+ }
1834
+ /** The `__pk` unique index of non-`_id` `@meta.id` fields. */
1835
+ const PK_INDEX = mongoIndexKey("unique", "__pk");
1836
+ /**
1837
+ * Reconciles the managed indexes with the model. `opts` (schema sync, safe
1838
+ * mode) names the indexes that stay as they are — neither dropped, replaced
1839
+ * nor created — because the change they depend on was skipped: the `__pk`
1840
+ * index of a skipped key rebuild, the indexes over a column whose type change
1841
+ * is pending (its present-only filter is typed).
1842
+ */
1843
+ async function syncIndexesImpl(host, opts) {
1770
1844
  await host.ensureCollectionExists();
1771
1845
  const allIndexes = /* @__PURE__ */ new Map();
1772
1846
  for (const [key, index] of host._table.indexes.entries()) {
@@ -1807,6 +1881,12 @@ async function syncIndexesImpl(host) {
1807
1881
  } else allIndexes.set(key, index);
1808
1882
  const existingIndexes = await host.collection.listIndexes().toArray();
1809
1883
  const indexesToCreate = new Map(allIndexes);
1884
+ const keepColumns = new Set(opts?.keepColumns ?? []);
1885
+ const isKept = (name, key, weights) => opts?.keepPrimaryKey === true && name === PK_INDEX || coversAny(key, weights, keepColumns);
1886
+ const kept = /* @__PURE__ */ new Set();
1887
+ for (const index of allIndexes.values()) if (isPlainIndex(index) && isKept(index.key, index.fields, index.weights)) kept.add(index.key);
1888
+ for (const remote of existingIndexes) if (isKept(remote.name, remote.key, remote.weights)) kept.add(remote.name);
1889
+ for (const name of kept) indexesToCreate.delete(name);
1810
1890
  const { attempt, throwIfAny } = (0, _atscript_db.createFailureCollector)("index sync");
1811
1891
  let defaultCollation;
1812
1892
  const collectionDefaultCollation = async () => {
@@ -1814,7 +1894,7 @@ async function syncIndexesImpl(host) {
1814
1894
  return defaultCollation ?? void 0;
1815
1895
  };
1816
1896
  for (const remote of existingIndexes) {
1817
- if (!remote.name.startsWith("atscript__")) continue;
1897
+ if (!remote.name.startsWith("atscript__") || kept.has(remote.name)) continue;
1818
1898
  if (indexesToCreate.has(remote.name)) {
1819
1899
  const local = indexesToCreate.get(remote.name);
1820
1900
  if (isPlainIndex(local)) {
@@ -2154,6 +2234,8 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2154
2234
  _hasExplicitId = false;
2155
2235
  /** Unique fields accumulated during onFieldScanned, returned via getMetadataOverrides. */
2156
2236
  _pendingUniqueFields = [];
2237
+ /** {@link _patcherFlatMap}'s memo. */
2238
+ _patcherFlatMapMemo;
2157
2239
  constructor(db, client, options = {}) {
2158
2240
  super();
2159
2241
  this.db = db;
@@ -2439,11 +2521,32 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2439
2521
  /** Returns the context object used by CollectionPatcher. */
2440
2522
  getPatcherContext() {
2441
2523
  return {
2442
- flatMap: this._table.flatMap,
2524
+ flatMap: this._patcherFlatMap(),
2443
2525
  prepareId: (id) => this.prepareIdFromIdType(id),
2444
2526
  createValidator: (opts) => this._table.createValidator(opts)
2445
2527
  };
2446
2528
  }
2529
+ /**
2530
+ * The table's flat map keyed by document path: a patch reaches the patcher
2531
+ * with `@db.column` names already applied (`translatePatchKeys`), so a
2532
+ * renamed union / `@db.json` field is found — and replaced, not merged —
2533
+ * under its stored name. Derived fields are left out (they map to their
2534
+ * source's path). Built once per flat map.
2535
+ */
2536
+ _patcherFlatMap() {
2537
+ const src = this._table.flatMap;
2538
+ let memo = this._patcherFlatMapMemo;
2539
+ if (memo?.src !== src) {
2540
+ const meta = this._table.getMetadata();
2541
+ const map = /* @__PURE__ */ new Map();
2542
+ for (const [path, type] of src) if (!meta.derivedFields.has(path)) map.set(meta.documentPath(path), type);
2543
+ memo = this._patcherFlatMapMemo = {
2544
+ src,
2545
+ map
2546
+ };
2547
+ }
2548
+ return memo.map;
2549
+ }
2447
2550
  async nativePatch(filter, patch, ops, expectedVersion, opts) {
2448
2551
  const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "nativePatch");
2449
2552
  if (!mongoFilter) return {
@@ -2487,7 +2590,7 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2487
2590
  }
2488
2591
  onFieldScanned(field, _type, metadata) {
2489
2592
  if (field === "_id") this._hasExplicitId = true;
2490
- if (field !== "_id" && metadata.has("meta.id")) this._pendingUniqueFields.push(field);
2593
+ if (field !== "_id" && !field.includes(".") && metadata.has("meta.id")) this._pendingUniqueFields.push(field);
2491
2594
  for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
2492
2595
  indexName: index.indexName,
2493
2596
  field,
@@ -3101,14 +3204,19 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
3101
3204
  async getObjectKind(name) {
3102
3205
  return getObjectKindImpl(this, name);
3103
3206
  }
3207
+ async countKeyViolations(fields, tableName) {
3208
+ return countKeyViolationsImpl(this, fields, tableName);
3209
+ }
3104
3210
  /**
3105
3211
  * No physical primary key on MongoDB (`_id` is fixed): a `@meta.id` move is
3106
- * an index change that `syncIndexes` reconciles. Schema sync refuses the
3107
- * change on a populated collection for cross-adapter consistency.
3212
+ * an index change that `syncIndexes` reconciles. Like on the other adapters,
3213
+ * schema sync refuses it on a populated collection whose documents do not
3214
+ * satisfy the new key. An old key field removed in the same sync loses its
3215
+ * `__pk` index first (`dropIndexesForColumns`).
3108
3216
  */
3109
3217
  async rebuildPrimaryKey(_change) {}
3110
- async syncIndexes() {
3111
- return syncIndexesImpl(this);
3218
+ async syncIndexes(opts) {
3219
+ return syncIndexesImpl(this, opts);
3112
3220
  }
3113
3221
  async syncColumns(diff) {
3114
3222
  return syncColumnsImpl(this, diff);
@@ -3116,6 +3224,9 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
3116
3224
  async dropColumns(columns) {
3117
3225
  return dropColumnsImpl(this, columns);
3118
3226
  }
3227
+ async dropIndexesForColumns(columns) {
3228
+ return dropIndexesForColumnsImpl(this, columns);
3229
+ }
3119
3230
  async renameTable(oldName) {
3120
3231
  return renameTableImpl(this, oldName);
3121
3232
  }
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, TTableResolver, TViewCapability, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
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";
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";
@@ -74,7 +74,10 @@ declare class CollectionPatcher {
74
74
  * @private
75
75
  */
76
76
  private _set;
77
- /** Set a leaf, lifting an `$inc`/`$mul` field op into an aggregation expression if present. */
77
+ /**
78
+ * Set a leaf, lifting an `$inc`/`$mul` field op into an aggregation expression if present.
79
+ * `type`: the field's type, when known.
80
+ */
78
81
  private _setLeaf;
79
82
  /**
80
83
  * Recursively walk through the patch *payload* and convert it into `$set`/…
@@ -301,6 +304,8 @@ declare class MongoAdapter extends BaseDbAdapter {
301
304
  protected _hasExplicitId: boolean;
302
305
  /** Unique fields accumulated during onFieldScanned, returned via getMetadataOverrides. */
303
306
  private _pendingUniqueFields;
307
+ /** {@link _patcherFlatMap}'s memo. */
308
+ private _patcherFlatMapMemo?;
304
309
  constructor(db: Db, client?: MongoClient | undefined, options?: TMongoAdapterOptions);
305
310
  private get _client();
306
311
  /** Every adapter over this client (or database handle) shares one session (since 0.1.128). */
@@ -435,6 +440,14 @@ declare class MongoAdapter extends BaseDbAdapter {
435
440
  loadRelations(rows: Array<Record<string, unknown>>, withRelations: WithRelation[], relations: ReadonlyMap<string, TDbRelation>, foreignKeys: ReadonlyMap<string, TDbForeignKey>, tableResolver?: TTableResolver): Promise<void>;
436
441
  /** Returns the context object used by CollectionPatcher. */
437
442
  getPatcherContext(): TCollectionPatcherContext;
443
+ /**
444
+ * The table's flat map keyed by document path: a patch reaches the patcher
445
+ * with `@db.column` names already applied (`translatePatchKeys`), so a
446
+ * renamed union / `@db.json` field is found — and replaced, not merged —
447
+ * under its stored name. Derived fields are left out (they map to their
448
+ * source's path). Built once per flat map.
449
+ */
450
+ private _patcherFlatMap;
438
451
  nativePatch(filter: FilterExpr, patch: unknown, ops?: TFieldOps, expectedVersion?: number, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
439
452
  onBeforeFlatten(_type: unknown): void;
440
453
  onFieldScanned(field: string, _type: unknown, metadata: TMetadataMap<AtscriptMetadata>): void;
@@ -574,15 +587,19 @@ declare class MongoAdapter extends BaseDbAdapter {
574
587
  ensureTable(): Promise<void>;
575
588
  hasRows(tableName?: string): Promise<boolean>;
576
589
  getObjectKind(name: string): Promise<TDbObjectKind | undefined>;
590
+ countKeyViolations(fields: readonly string[], tableName?: string): Promise<number>;
577
591
  /**
578
592
  * No physical primary key on MongoDB (`_id` is fixed): a `@meta.id` move is
579
- * an index change that `syncIndexes` reconciles. Schema sync refuses the
580
- * change on a populated collection for cross-adapter consistency.
593
+ * an index change that `syncIndexes` reconciles. Like on the other adapters,
594
+ * schema sync refuses it on a populated collection whose documents do not
595
+ * satisfy the new key. An old key field removed in the same sync loses its
596
+ * `__pk` index first (`dropIndexesForColumns`).
581
597
  */
582
598
  rebuildPrimaryKey(_change: TPrimaryKeyChange): Promise<void>;
583
- syncIndexes(): Promise<void>;
599
+ syncIndexes(opts?: TSyncIndexesOptions): Promise<void>;
584
600
  syncColumns(diff: TColumnDiff): Promise<TSyncColumnResult>;
585
601
  dropColumns(columns: string[]): Promise<void>;
602
+ dropIndexesForColumns(columns: string[]): Promise<void>;
586
603
  renameTable(oldName: string): Promise<void>;
587
604
  recreateTable(): Promise<void>;
588
605
  dropTable(): Promise<void>;
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, TTableResolver, TViewCapability, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
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";
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";
@@ -74,7 +74,10 @@ declare class CollectionPatcher {
74
74
  * @private
75
75
  */
76
76
  private _set;
77
- /** Set a leaf, lifting an `$inc`/`$mul` field op into an aggregation expression if present. */
77
+ /**
78
+ * Set a leaf, lifting an `$inc`/`$mul` field op into an aggregation expression if present.
79
+ * `type`: the field's type, when known.
80
+ */
78
81
  private _setLeaf;
79
82
  /**
80
83
  * Recursively walk through the patch *payload* and convert it into `$set`/…
@@ -301,6 +304,8 @@ declare class MongoAdapter extends BaseDbAdapter {
301
304
  protected _hasExplicitId: boolean;
302
305
  /** Unique fields accumulated during onFieldScanned, returned via getMetadataOverrides. */
303
306
  private _pendingUniqueFields;
307
+ /** {@link _patcherFlatMap}'s memo. */
308
+ private _patcherFlatMapMemo?;
304
309
  constructor(db: Db, client?: MongoClient | undefined, options?: TMongoAdapterOptions);
305
310
  private get _client();
306
311
  /** Every adapter over this client (or database handle) shares one session (since 0.1.128). */
@@ -435,6 +440,14 @@ declare class MongoAdapter extends BaseDbAdapter {
435
440
  loadRelations(rows: Array<Record<string, unknown>>, withRelations: WithRelation[], relations: ReadonlyMap<string, TDbRelation>, foreignKeys: ReadonlyMap<string, TDbForeignKey>, tableResolver?: TTableResolver): Promise<void>;
436
441
  /** Returns the context object used by CollectionPatcher. */
437
442
  getPatcherContext(): TCollectionPatcherContext;
443
+ /**
444
+ * The table's flat map keyed by document path: a patch reaches the patcher
445
+ * with `@db.column` names already applied (`translatePatchKeys`), so a
446
+ * renamed union / `@db.json` field is found — and replaced, not merged —
447
+ * under its stored name. Derived fields are left out (they map to their
448
+ * source's path). Built once per flat map.
449
+ */
450
+ private _patcherFlatMap;
438
451
  nativePatch(filter: FilterExpr, patch: unknown, ops?: TFieldOps, expectedVersion?: number, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
439
452
  onBeforeFlatten(_type: unknown): void;
440
453
  onFieldScanned(field: string, _type: unknown, metadata: TMetadataMap<AtscriptMetadata>): void;
@@ -574,15 +587,19 @@ declare class MongoAdapter extends BaseDbAdapter {
574
587
  ensureTable(): Promise<void>;
575
588
  hasRows(tableName?: string): Promise<boolean>;
576
589
  getObjectKind(name: string): Promise<TDbObjectKind | undefined>;
590
+ countKeyViolations(fields: readonly string[], tableName?: string): Promise<number>;
577
591
  /**
578
592
  * No physical primary key on MongoDB (`_id` is fixed): a `@meta.id` move is
579
- * an index change that `syncIndexes` reconciles. Schema sync refuses the
580
- * change on a populated collection for cross-adapter consistency.
593
+ * an index change that `syncIndexes` reconciles. Like on the other adapters,
594
+ * schema sync refuses it on a populated collection whose documents do not
595
+ * satisfy the new key. An old key field removed in the same sync loses its
596
+ * `__pk` index first (`dropIndexesForColumns`).
581
597
  */
582
598
  rebuildPrimaryKey(_change: TPrimaryKeyChange): Promise<void>;
583
- syncIndexes(): Promise<void>;
599
+ syncIndexes(opts?: TSyncIndexesOptions): Promise<void>;
584
600
  syncColumns(diff: TColumnDiff): Promise<TSyncColumnResult>;
585
601
  dropColumns(columns: string[]): Promise<void>;
602
+ dropIndexesForColumns(columns: string[]): Promise<void>;
586
603
  renameTable(oldName: string): Promise<void>;
587
604
  recreateTable(): Promise<void>;
588
605
  dropTable(): Promise<void>;
package/dist/index.mjs CHANGED
@@ -161,11 +161,14 @@ var CollectionPatcher = class {
161
161
  this.currentSetStage = { $set: { [key]: val } };
162
162
  this.updatePipeline.push(this.currentSetStage);
163
163
  }
164
- /** Set a leaf, lifting an `$inc`/`$mul` field op into an aggregation expression if present. */
165
- _setLeaf(key, value) {
164
+ /**
165
+ * Set a leaf, lifting an `$inc`/`$mul` field op into an aggregation expression if present.
166
+ * `type`: the field's type, when known.
167
+ */
168
+ _setLeaf(key, value, type) {
166
169
  const fieldOp = getDbFieldOp(value);
167
170
  if (fieldOp) this._set(key, this._fieldOpExpr(key, fieldOp.op, fieldOp.value));
168
- else if (containsAggregationExpr(value)) this._set(key, { $literal: value });
171
+ else if (containsAggregationExpr(value) || replacesWhole(value, type)) this._set(key, { $literal: value });
169
172
  else this._set(key, value);
170
173
  }
171
174
  /**
@@ -186,7 +189,7 @@ var CollectionPatcher = class {
186
189
  if (isObjectValue && topLevelArray && !flatType?.metadata?.has("db.json")) this.parseArrayPatch(key, value, flatType);
187
190
  else if (isObjectValue && flatType?.metadata?.get("db.patch.strategy") === "merge") this.flattenPayload(value, key);
188
191
  else if (isObjectValue && flatType?.type.kind === "object" && !flatType.metadata?.has("db.json")) this._flattenReplaceObject(value, key, flatType);
189
- else if (key !== "_id") this._setLeaf(key, value);
192
+ else if (key !== "_id") this._setLeaf(key, value, flatType);
190
193
  }
191
194
  return this.updatePipeline;
192
195
  }
@@ -212,7 +215,7 @@ var CollectionPatcher = class {
212
215
  else this._flattenReplaceObject(childValue, childKey, propType);
213
216
  continue;
214
217
  }
215
- this._setLeaf(childKey, childValue);
218
+ this._setLeaf(childKey, childValue, propType);
216
219
  }
217
220
  }
218
221
  /**
@@ -360,6 +363,16 @@ var CollectionPatcher = class {
360
363
  else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
361
364
  }
362
365
  };
366
+ /**
367
+ * Whether a plain-object patch value replaces the stored object instead of
368
+ * merging into it: a `@db.json` object, or a union of objects / `T | null`
369
+ * whose member may switch (since 0.1.155). A field the patcher cannot type
370
+ * (e.g. under a `@db.column` name) keeps the merge.
371
+ */
372
+ function replacesWhole(value, type) {
373
+ if (!type || !isPlainObject(value)) return false;
374
+ return type.metadata?.has("db.json") || type.type.kind === "union";
375
+ }
363
376
  //#endregion
364
377
  //#region src/lib/mongo-types.ts
365
378
  const INDEX_PREFIX = "atscript__";
@@ -1623,6 +1636,30 @@ async function hasRowsImpl(host, tableName) {
1623
1636
  ...host._getSessionOpts()
1624
1637
  }) !== null;
1625
1638
  }
1639
+ /**
1640
+ * Documents `fields` cannot identify (see `BaseDbAdapter.countKeyViolations`):
1641
+ * those missing one of them or holding `null`, plus every document sharing
1642
+ * its values with another — one `$facet` aggregation.
1643
+ */
1644
+ async function countKeyViolationsImpl(host, fields, tableName) {
1645
+ const key = Object.fromEntries(fields.map((f, i) => [`k${i}`, `$${f}`]));
1646
+ const [result] = await host.db.collection(tableName ?? host.resolveTableName(false)).aggregate([{ $facet: {
1647
+ nulls: [{ $match: { $or: fields.map((f) => ({ [f]: null })) } }, { $count: "n" }],
1648
+ dups: [
1649
+ { $match: { $and: fields.map((f) => ({ [f]: { $ne: null } })) } },
1650
+ { $group: {
1651
+ _id: key,
1652
+ n: { $sum: 1 }
1653
+ } },
1654
+ { $match: { n: { $gt: 1 } } },
1655
+ { $group: {
1656
+ _id: null,
1657
+ n: { $sum: "$n" }
1658
+ } }
1659
+ ]
1660
+ } }], host._getSessionOpts()).toArray();
1661
+ return (result?.nulls[0]?.n ?? 0) + (result?.dups[0]?.n ?? 0);
1662
+ }
1626
1663
  /** Kind of the object stored under `name` (`listCollections` reports views as `"view"`). */
1627
1664
  async function getObjectKindImpl(host, name) {
1628
1665
  const type = (await host.db.listCollections({ name }, { nameOnly: true }).toArray())[0]?.type;
@@ -1732,6 +1769,27 @@ async function dropColumnsImpl(host, columns) {
1732
1769
  await host.collection.updateMany({}, { $unset: unsetSpec }, host._getSessionOpts());
1733
1770
  }
1734
1771
  /**
1772
+ * Drops the managed indexes over any of `columns` (or a path under one).
1773
+ * Schema sync calls it before {@link dropColumnsImpl}: a unique index on a
1774
+ * required field indexes a `$unset` document as `null`, so the second one
1775
+ * collides (E11000) — e.g. the old key field's `__pk` index when a `@meta.id`
1776
+ * move removes that field. `syncIndexesImpl` then builds what the model wants.
1777
+ */
1778
+ async function dropIndexesForColumnsImpl(host, columns) {
1779
+ const dropped = new Set(columns);
1780
+ let existing;
1781
+ try {
1782
+ existing = await host.collection.listIndexes().toArray();
1783
+ } catch (error) {
1784
+ if (error.code === 26) return;
1785
+ throw error;
1786
+ }
1787
+ for (const remote of existing) if (remote.name.startsWith("atscript__") && coversAny(remote.key, remote.weights, dropped)) {
1788
+ host._log("dropIndex", remote.name);
1789
+ await host.collection.dropIndex(remote.name);
1790
+ }
1791
+ }
1792
+ /**
1735
1793
  * The segments of a field's physical document path with `$[]` after every
1736
1794
  * segment whose logical prefix is an array in the flatMap — Mongo's
1737
1795
  * all-positional operator walks every element. `physicalPath` defaults to
@@ -1765,7 +1823,23 @@ function resolveSyncDefault(field) {
1765
1823
  if (!field.defaultValue) return;
1766
1824
  if (field.defaultValue.kind === "value") return field.designType === "string" ? field.defaultValue.value : JSON.parse(field.defaultValue.value);
1767
1825
  }
1768
- async function syncIndexesImpl(host) {
1826
+ /**
1827
+ * Whether an index's fields include one of `paths` or a path under one —
1828
+ * a text index lists its fields in `weights` (its key is `_fts` / `_ftsx`).
1829
+ */
1830
+ function coversAny(key, weights, paths) {
1831
+ return [...Object.keys(key), ...Object.keys(weights ?? {})].some((f) => paths.has(f) || hasAncestorIn(f, paths));
1832
+ }
1833
+ /** The `__pk` unique index of non-`_id` `@meta.id` fields. */
1834
+ const PK_INDEX = mongoIndexKey("unique", "__pk");
1835
+ /**
1836
+ * Reconciles the managed indexes with the model. `opts` (schema sync, safe
1837
+ * mode) names the indexes that stay as they are — neither dropped, replaced
1838
+ * nor created — because the change they depend on was skipped: the `__pk`
1839
+ * index of a skipped key rebuild, the indexes over a column whose type change
1840
+ * is pending (its present-only filter is typed).
1841
+ */
1842
+ async function syncIndexesImpl(host, opts) {
1769
1843
  await host.ensureCollectionExists();
1770
1844
  const allIndexes = /* @__PURE__ */ new Map();
1771
1845
  for (const [key, index] of host._table.indexes.entries()) {
@@ -1806,6 +1880,12 @@ async function syncIndexesImpl(host) {
1806
1880
  } else allIndexes.set(key, index);
1807
1881
  const existingIndexes = await host.collection.listIndexes().toArray();
1808
1882
  const indexesToCreate = new Map(allIndexes);
1883
+ const keepColumns = new Set(opts?.keepColumns ?? []);
1884
+ const isKept = (name, key, weights) => opts?.keepPrimaryKey === true && name === PK_INDEX || coversAny(key, weights, keepColumns);
1885
+ const kept = /* @__PURE__ */ new Set();
1886
+ for (const index of allIndexes.values()) if (isPlainIndex(index) && isKept(index.key, index.fields, index.weights)) kept.add(index.key);
1887
+ for (const remote of existingIndexes) if (isKept(remote.name, remote.key, remote.weights)) kept.add(remote.name);
1888
+ for (const name of kept) indexesToCreate.delete(name);
1809
1889
  const { attempt, throwIfAny } = createFailureCollector("index sync");
1810
1890
  let defaultCollation;
1811
1891
  const collectionDefaultCollation = async () => {
@@ -1813,7 +1893,7 @@ async function syncIndexesImpl(host) {
1813
1893
  return defaultCollation ?? void 0;
1814
1894
  };
1815
1895
  for (const remote of existingIndexes) {
1816
- if (!remote.name.startsWith("atscript__")) continue;
1896
+ if (!remote.name.startsWith("atscript__") || kept.has(remote.name)) continue;
1817
1897
  if (indexesToCreate.has(remote.name)) {
1818
1898
  const local = indexesToCreate.get(remote.name);
1819
1899
  if (isPlainIndex(local)) {
@@ -2153,6 +2233,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2153
2233
  _hasExplicitId = false;
2154
2234
  /** Unique fields accumulated during onFieldScanned, returned via getMetadataOverrides. */
2155
2235
  _pendingUniqueFields = [];
2236
+ /** {@link _patcherFlatMap}'s memo. */
2237
+ _patcherFlatMapMemo;
2156
2238
  constructor(db, client, options = {}) {
2157
2239
  super();
2158
2240
  this.db = db;
@@ -2438,11 +2520,32 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2438
2520
  /** Returns the context object used by CollectionPatcher. */
2439
2521
  getPatcherContext() {
2440
2522
  return {
2441
- flatMap: this._table.flatMap,
2523
+ flatMap: this._patcherFlatMap(),
2442
2524
  prepareId: (id) => this.prepareIdFromIdType(id),
2443
2525
  createValidator: (opts) => this._table.createValidator(opts)
2444
2526
  };
2445
2527
  }
2528
+ /**
2529
+ * The table's flat map keyed by document path: a patch reaches the patcher
2530
+ * with `@db.column` names already applied (`translatePatchKeys`), so a
2531
+ * renamed union / `@db.json` field is found — and replaced, not merged —
2532
+ * under its stored name. Derived fields are left out (they map to their
2533
+ * source's path). Built once per flat map.
2534
+ */
2535
+ _patcherFlatMap() {
2536
+ const src = this._table.flatMap;
2537
+ let memo = this._patcherFlatMapMemo;
2538
+ if (memo?.src !== src) {
2539
+ const meta = this._table.getMetadata();
2540
+ const map = /* @__PURE__ */ new Map();
2541
+ for (const [path, type] of src) if (!meta.derivedFields.has(path)) map.set(meta.documentPath(path), type);
2542
+ memo = this._patcherFlatMapMemo = {
2543
+ src,
2544
+ map
2545
+ };
2546
+ }
2547
+ return memo.map;
2548
+ }
2446
2549
  async nativePatch(filter, patch, ops, expectedVersion, opts) {
2447
2550
  const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "nativePatch");
2448
2551
  if (!mongoFilter) return {
@@ -2486,7 +2589,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2486
2589
  }
2487
2590
  onFieldScanned(field, _type, metadata) {
2488
2591
  if (field === "_id") this._hasExplicitId = true;
2489
- if (field !== "_id" && metadata.has("meta.id")) this._pendingUniqueFields.push(field);
2592
+ if (field !== "_id" && !field.includes(".") && metadata.has("meta.id")) this._pendingUniqueFields.push(field);
2490
2593
  for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
2491
2594
  indexName: index.indexName,
2492
2595
  field,
@@ -3100,14 +3203,19 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
3100
3203
  async getObjectKind(name) {
3101
3204
  return getObjectKindImpl(this, name);
3102
3205
  }
3206
+ async countKeyViolations(fields, tableName) {
3207
+ return countKeyViolationsImpl(this, fields, tableName);
3208
+ }
3103
3209
  /**
3104
3210
  * No physical primary key on MongoDB (`_id` is fixed): a `@meta.id` move is
3105
- * an index change that `syncIndexes` reconciles. Schema sync refuses the
3106
- * change on a populated collection for cross-adapter consistency.
3211
+ * an index change that `syncIndexes` reconciles. Like on the other adapters,
3212
+ * schema sync refuses it on a populated collection whose documents do not
3213
+ * satisfy the new key. An old key field removed in the same sync loses its
3214
+ * `__pk` index first (`dropIndexesForColumns`).
3107
3215
  */
3108
3216
  async rebuildPrimaryKey(_change) {}
3109
- async syncIndexes() {
3110
- return syncIndexesImpl(this);
3217
+ async syncIndexes(opts) {
3218
+ return syncIndexesImpl(this, opts);
3111
3219
  }
3112
3220
  async syncColumns(diff) {
3113
3221
  return syncColumnsImpl(this, diff);
@@ -3115,6 +3223,9 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
3115
3223
  async dropColumns(columns) {
3116
3224
  return dropColumnsImpl(this, columns);
3117
3225
  }
3226
+ async dropIndexesForColumns(columns) {
3227
+ return dropIndexesForColumnsImpl(this, columns);
3228
+ }
3118
3229
  async renameTable(oldName) {
3119
3230
  return renameTableImpl(this, oldName);
3120
3231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mongo",
3
- "version": "0.1.154",
3
+ "version": "0.1.156",
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.103",
50
- "@atscript/typescript": "^0.1.103",
49
+ "@atscript/core": "^0.1.106",
50
+ "@atscript/typescript": "^0.1.106",
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.103"
54
+ "unplugin-atscript": "^0.1.106"
55
55
  },
56
56
  "peerDependencies": {
57
- "@atscript/core": "^0.1.103",
58
- "@atscript/typescript": "^0.1.103",
57
+ "@atscript/core": "^0.1.106",
58
+ "@atscript/typescript": "^0.1.106",
59
59
  "@uniqu/core": "^0.1.15",
60
60
  "mongodb": "^6.17.0",
61
- "@atscript/db": "^0.1.154"
61
+ "@atscript/db": "^0.1.156"
62
62
  },
63
63
  "scripts": {
64
64
  "postinstall": "asc -f dts",