@classytic/mongokit 3.32.0 → 3.33.0

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.
Files changed (33) hide show
  1. package/dist/{PaginationEngine-CSh-P8Nh.mjs → PaginationEngine-GdyX29tE.mjs} +13 -11
  2. package/dist/{Repository-B_1y9cCf.mjs → Repository-BArvU32x.mjs} +149 -36
  3. package/dist/{Repository-BAzymuOz.d.mts → Repository-BdSkQ8vc.d.mts} +27 -4
  4. package/dist/actions/index.d.mts +1 -1
  5. package/dist/actions/index.mjs +1 -1
  6. package/dist/ai/index.d.mts +1 -1
  7. package/dist/better-auth/index.d.mts +19 -0
  8. package/dist/better-auth/index.mjs +5 -3
  9. package/dist/{create-repository-mEdfH9sz.d.mts → create-repository-BU4x9Kb0.d.mts} +4 -4
  10. package/dist/{create-repository-a3DQ5tzt.mjs → create-repository-CPNbktFG.mjs} +2 -2
  11. package/dist/id-chunks-BmAwsqNk.mjs +67 -0
  12. package/dist/{index-Beyr1r4d.d.mts → index-DKEql4cL.d.mts} +1 -1
  13. package/dist/index.d.mts +15 -14
  14. package/dist/index.mjs +60 -54
  15. package/dist/{multi-tenant.plugin-2VtSSm1Y.d.mts → multi-tenant.plugin-BwSvTirB.d.mts} +3 -3
  16. package/dist/{operations-CuzX9m2Z.d.mts → operations-DZodvjC-.d.mts} +22 -3
  17. package/dist/outbox/index.d.mts +1 -1
  18. package/dist/outbox/index.mjs +1 -1
  19. package/dist/pagination/PaginationEngine.mjs +1 -1
  20. package/dist/{plugin-options-BsFH0sTi.d.mts → plugin-options-DXnlKM3B.d.mts} +31 -1
  21. package/dist/plugins/index.d.mts +3 -3
  22. package/dist/plugins/index.mjs +2 -2
  23. package/dist/{repository-Q16ErTu8.d.mts → repository-H_ffGgpE.d.mts} +8 -0
  24. package/dist/testkit/index.d.mts +2 -2
  25. package/dist/testkit/index.mjs +1 -1
  26. package/dist/{timestamp.plugin-CAXzHs9y.mjs → timestamp.plugin-BEc6TANm.mjs} +7 -5
  27. package/dist/{to-plain-BuX1RUZV.d.mts → to-plain-Dyz_qvuo.d.mts} +3 -3
  28. package/dist/{update-w5i-pa_c.mjs → update-jSXp3nu3.mjs} +48 -28
  29. package/dist/utils/index.d.mts +54 -2
  30. package/dist/utils/index.mjs +2 -1
  31. package/dist/{validation-chain.plugin-BihbMQRq.mjs → validation-chain.plugin-CyK1s9Ze.mjs} +219 -20
  32. package/dist/{validation-chain.plugin-BSshLJTA.d.mts → validation-chain.plugin-yQRob3Av.d.mts} +2 -2
  33. package/package.json +17 -17
@@ -35,18 +35,20 @@ function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId, cursorValue
35
35
  const primaryField = sortFields[0] || "_id";
36
36
  const direction = sort[primaryField];
37
37
  const operator = direction === 1 ? "$gt" : "$lt";
38
- if (cursorValue === null || cursorValue === void 0) if (direction === 1) return {
39
- ...baseFilters,
40
- $or: [{
38
+ if (cursorValue === null || cursorValue === void 0) {
39
+ if (direction === 1) return {
40
+ ...baseFilters,
41
+ $or: [{
42
+ [primaryField]: null,
43
+ _id: { $gt: cursorId }
44
+ }, { [primaryField]: { $ne: null } }]
45
+ };
46
+ else return {
47
+ ...baseFilters,
41
48
  [primaryField]: null,
42
- _id: { $gt: cursorId }
43
- }, { [primaryField]: { $ne: null } }]
44
- };
45
- else return {
46
- ...baseFilters,
47
- [primaryField]: null,
48
- _id: { $lt: cursorId }
49
- };
49
+ _id: { $lt: cursorId }
50
+ };
51
+ }
50
52
  return {
51
53
  ...baseFilters,
52
54
  $or: [{ [primaryField]: { [operator]: cursorValue } }, {
@@ -1,12 +1,14 @@
1
1
  import { i as isValidIdForType, t as getSchemaIdType } from "./id-resolution-BqDrk0S2.mjs";
2
- import { a as count, b as LookupBuilder, c as getByQuery, d as deleteById, f as deleteByQuery, h as createMany, l as getOrCreate, m as create, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, x as compileFilterToMongo, y as distinct } from "./update-w5i-pa_c.mjs";
2
+ import { a as narrowToIds, i as keysetFilter, o as selectKeysetChunk } from "./id-chunks-BmAwsqNk.mjs";
3
+ import { a as count, b as LookupBuilder, c as getByQuery, d as deleteById, f as deleteByQuery, h as createMany, l as getOrCreate, m as create, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, x as compileFilterToMongo, y as distinct } from "./update-jSXp3nu3.mjs";
3
4
  import { n as warn } from "./logger-K7dPmu2U.mjs";
4
5
  import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-BuByZwKU.mjs";
5
- import { r as calculateTotalPages, t as PaginationEngine } from "./PaginationEngine-CSh-P8Nh.mjs";
6
+ import { r as calculateTotalPages, t as PaginationEngine } from "./PaginationEngine-GdyX29tE.mjs";
6
7
  import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
7
8
  import { RepositoryBase, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry } from "@classytic/repo-core/repository";
8
9
  import mongoose from "mongoose";
9
10
  import { isFilter } from "@classytic/repo-core/filter";
11
+ import { VersionConflictError, conservativeMongoIsTransientConflict } from "@classytic/repo-core/errors";
10
12
  import { HOOK_PRIORITY } from "@classytic/repo-core/hooks";
11
13
  import { compileUpdateSpecToMongo, isUpdateSpec } from "@classytic/repo-core/update";
12
14
  import { decodeAggCursor, encodeAggCursor, isKeysetMode, normalizeGroupBy, validateMeasures } from "@classytic/repo-core/aggregate";
@@ -48,10 +50,7 @@ function createKeysetPurgePort(repo, baseFilter, session) {
48
50
  bypassTenant: true
49
51
  };
50
52
  let cursor = null;
51
- const selectFilter = () => cursor == null ? baseFilter : {
52
- ...baseFilter,
53
- _id: { $gt: cursor }
54
- };
53
+ const selectFilter = () => keysetFilter(baseFilter, cursor);
55
54
  return { async purgeChunk(strategy, limit) {
56
55
  if (strategy.type === "anonymize") {
57
56
  if (Object.values(strategy.fields).some((v) => typeof v === "function")) {
@@ -60,13 +59,13 @@ function createKeysetPurgePort(repo, baseFilter, session) {
60
59
  return processed;
61
60
  }
62
61
  }
63
- const idDocs = await repo.Model.find(selectFilter(), { _id: 1 }).sort({ _id: 1 }).limit(limit).session(session ?? null).lean().exec();
62
+ const idDocs = await selectKeysetChunk(repo.Model, selectFilter(), limit, {
63
+ session,
64
+ projection: { _id: 1 }
65
+ });
64
66
  if (idDocs.length === 0) return 0;
65
67
  const ids = idDocs.map((d) => d._id);
66
- const chunkFilter = {
67
- ...baseFilter,
68
- _id: { $in: ids }
69
- };
68
+ const chunkFilter = narrowToIds(baseFilter, ids);
70
69
  switch (strategy.type) {
71
70
  case "hard":
72
71
  await repo.deleteMany(chunkFilter, {
@@ -101,7 +100,7 @@ function createKeysetPurgePort(repo, baseFilter, session) {
101
100
  * is the same write), this matches the at-least-once contract.
102
101
  */
103
102
  async function purgeAnonymizeFunctional(repo, selectFilter, fields, limit, session) {
104
- const docs = await repo.Model.find(selectFilter).sort({ _id: 1 }).limit(limit).session(session ?? null).lean().exec();
103
+ const docs = await selectKeysetChunk(repo.Model, selectFilter, limit, { session });
105
104
  if (docs.length === 0) return {
106
105
  processed: 0,
107
106
  lastId: null
@@ -138,10 +137,11 @@ function transactionResolutionOf(caps) {
138
137
  /**
139
138
  * What MongoDB supports through mongokit's surface, DECLARED (not observed).
140
139
  *
141
- * Deployment-dependent flags (`transactions`, `nestedTransactions`,
142
- * `changeStreams` — all three need a replica set or mongos) are `true` here
143
- * because they describe the product, not the server you happen to be pointed
144
- * at. **Do not read this constant to decide whether a live connection can run
140
+ * Deployment-dependent flags (`transactions`, `changeStreams` — both need a
141
+ * replica set or mongos) are `true` here because they describe the product,
142
+ * not the server you happen to be pointed at. `nestedTransactions` is NOT one
143
+ * of them: it is `false` unconditionally, because the refusal comes from
144
+ * mongokit's own tx-bound proxy rather than from the deployment. **Do not read this constant to decide whether a live connection can run
145
145
  * a transaction** — that is {@link resolveMongoCapabilities}, which is what
146
146
  * `Repository#capabilities` returns. This one is for the cross-kit conformance
147
147
  * harness (which overrides environment-specific limits where it is
@@ -150,9 +150,11 @@ function transactionResolutionOf(caps) {
150
150
  const MONGOKIT_CAPABILITIES = Object.freeze({
151
151
  transactionsResolution: "declared",
152
152
  transactions: true,
153
- nestedTransactions: true,
153
+ nestedTransactions: false,
154
+ transactionRetry: "managed",
154
155
  upsert: true,
155
156
  duplicateKeyError: true,
157
+ optimisticConcurrency: true,
156
158
  distinct: true,
157
159
  aggregate: true,
158
160
  aggregateOps: Object.freeze({
@@ -251,7 +253,7 @@ function deriveCapabilities(support) {
251
253
  ...MONGOKIT_CAPABILITIES,
252
254
  transactionsResolution: support === "unknown" ? "unknown" : "observed",
253
255
  transactions: available,
254
- nestedTransactions: available,
256
+ nestedTransactions: false,
255
257
  changeStreams: available
256
258
  });
257
259
  }
@@ -830,7 +832,8 @@ function compile(filter) {
830
832
  } };
831
833
  case "regex": return { $regexMatch: {
832
834
  input: `$${filter.field}`,
833
- regex: filter.pattern
835
+ regex: filter.pattern,
836
+ ...filter.flags ? { options: filter.flags } : {}
834
837
  } };
835
838
  case "and": {
836
839
  if (filter.children.length === 0) return true;
@@ -1347,14 +1350,11 @@ function buildKeysetPredicate(sort, cursor) {
1347
1350
  function createMongoArchivePort(repo, filter, session) {
1348
1351
  return {
1349
1352
  async readChunk(limit) {
1350
- return await repo.Model.find(filter).sort({ _id: 1 }).limit(limit).session(session ?? null).lean().exec();
1353
+ return selectKeysetChunk(repo.Model, filter, limit, { session });
1351
1354
  },
1352
1355
  async deleteChunk(docs) {
1353
1356
  const ids = docs.map((doc) => doc._id);
1354
- return (await repo.deleteMany({
1355
- ...filter,
1356
- _id: { $in: ids }
1357
- }, {
1357
+ return (await repo.deleteMany(narrowToIds(filter, ids), {
1358
1358
  session,
1359
1359
  bypassTenant: true,
1360
1360
  mode: "hard"
@@ -2204,6 +2204,33 @@ function assertNoMixedPatchShape(opName, patch) {
2204
2204
  const flatKeys = patchKeys.filter((k) => !k.startsWith("$"));
2205
2205
  throw createError(400, `[${opName}] assertNoMixedPatchShape: patch mixes Mongo operators (${operatorKeys.join(", ")}) with raw field keys (${flatKeys.join(", ")}). Mongo would silently DROP the flat keys — that's a write-loss bug we refuse to forward. Either wrap the flat keys in $set explicitly, or remove the operator keys.`);
2206
2206
  }
2207
+ /**
2208
+ * Under `ifVersion`, the CAS owns the version field EXCLUSIVELY.
2209
+ *
2210
+ * The write merges `$inc: { [versionField]: 1 }` into the caller's payload, so
2211
+ * a payload that also touches the version field produces one of two silent
2212
+ * wrongs: Mongo rejects the whole update with a path-conflict error that names
2213
+ * an internal field the caller never wrote (`$set: { __v }`, `$unset`,
2214
+ * `$rename`), or the merge quietly overrides the caller's intent (`$inc: { __v: 5 }`
2215
+ * becomes `1`). Neither is a failure a caller can act on, so the payload is
2216
+ * refused up front and the reason is named.
2217
+ *
2218
+ * `$inc: { [versionField]: 1 }` is permitted: it is exactly what the CAS does
2219
+ * anyway, so a caller declaring it redundantly is stating the truth.
2220
+ */
2221
+ function assertCasOwnsVersionField(opName, patch, versionField) {
2222
+ for (const [op, value] of Object.entries(patch)) {
2223
+ if (!op.startsWith("$")) {
2224
+ if (op === versionField) throw createError(400, `[${opName}] payload sets '${versionField}' directly, but the optimistic-concurrency CAS owns that field — it increments it as part of the same write. Remove it; the expected value belongs in \`options.ifVersion\`, not the payload.`);
2225
+ continue;
2226
+ }
2227
+ if (typeof value !== "object" || value === null) continue;
2228
+ const fields = value;
2229
+ if (!(versionField in fields)) continue;
2230
+ if (op === "$inc" && fields[versionField] === 1) continue;
2231
+ throw createError(400, `[${opName}] payload's ${op} targets '${versionField}', but the optimistic-concurrency CAS owns that field — it increments it as part of the same write. ` + (op === "$inc" ? `Only \`$inc: { ${versionField}: 1 }\` is accepted (it matches what the CAS does); any other increment would be silently overridden. ` : `A conflicting ${op} would either fail the update with a Mongo path conflict or be silently overridden. `) + `Remove it; the expected version belongs in \`options.ifVersion\`.`);
2232
+ }
2233
+ }
2207
2234
  /** Mongo `operationType` → portable `ChangeEvent.operation`. */
2208
2235
  const CHANGE_OPERATION_MAP = {
2209
2236
  insert: "create",
@@ -2280,6 +2307,8 @@ var Repository = class extends RepositoryBase {
2280
2307
  model;
2281
2308
  _pagination;
2282
2309
  idField;
2310
+ /** Version field for `ifVersion` CAS writes. Default '__v'. */
2311
+ versionField;
2283
2312
  searchMode;
2284
2313
  searchFields;
2285
2314
  _hasTextIndex = null;
@@ -2307,6 +2336,7 @@ var Repository = class extends RepositoryBase {
2307
2336
  this.model = Model.modelName;
2308
2337
  this._pagination = new PaginationEngine(Model, paginationConfig);
2309
2338
  this.idField = options.idField ?? "_id";
2339
+ this.versionField = options.versionField ?? "__v";
2310
2340
  this.searchMode = options.searchMode ?? "text";
2311
2341
  this.searchFields = options.searchFields;
2312
2342
  this._wireStrictQueryStripDiagnostic(options.warnOnStrictQueryStrip === true);
@@ -3218,7 +3248,78 @@ var Repository = class extends RepositoryBase {
3218
3248
  }
3219
3249
  }
3220
3250
  let result;
3221
- if (effectiveIdField !== "_id") result = await this._withResilience(context, () => updateByQuery(this.Model, {
3251
+ if (options.ifVersion !== void 0) {
3252
+ /**
3253
+ * Optimistic CAS: the version predicate joins the match filter, so
3254
+ * the check and the write are ONE round-trip — no read-then-write
3255
+ * window. The `$inc` rides the same operation; a versioned write
3256
+ * that did not bump the version would let the NEXT writer pass the
3257
+ * same CAS and silently clobber this one.
3258
+ */
3259
+ const vf = this.versionField;
3260
+ const payload = context.data || data;
3261
+ assertNoMixedPatchShape("update(ifVersion)", payload);
3262
+ assertCasOwnsVersionField("update(ifVersion)", payload, vf);
3263
+ const casUpdate = Object.keys(payload).some((k) => k.startsWith("$")) ? {
3264
+ ...payload,
3265
+ $inc: {
3266
+ ...payload.$inc ?? {},
3267
+ [vf]: 1
3268
+ }
3269
+ } : {
3270
+ $set: payload,
3271
+ $inc: { [vf]: 1 }
3272
+ };
3273
+ /**
3274
+ * `throwOnNotFound` MUST NOT ride into the CAS write. `updateByQuery`
3275
+ * throws its own 404 on ANY filter miss when the flag is set — and a CAS
3276
+ * miss is usually not "not found", it is "found at another version". The
3277
+ * disambiguation below is the contract's authority on which of the two
3278
+ * happened; letting the inner 404 fire first made it unreachable, so a
3279
+ * caller passing `throwOnNotFound: true` got "Document not found" for a
3280
+ * document that exists — a false statement, and one that invites the
3281
+ * blind retry the CAS exists to prevent. The genuinely-gone case still
3282
+ * honours the flag via the shared `!result && wantsThrow` throw below.
3283
+ */
3284
+ result = await this._withResilience(context, () => updateByQuery(
3285
+ this.Model,
3286
+ /**
3287
+ * Injected scope FIRST, caller authority LAST. `context.query`
3288
+ * is hook-supplied (tenant scoping, soft-delete filters) and
3289
+ * must only ever NARROW the match — spread last it could
3290
+ * REPLACE `_id` (retargeting the write) or `__v` (defeating the
3291
+ * CAS that is the entire point of this branch).
3292
+ */
3293
+ {
3294
+ ...context.query || {},
3295
+ [effectiveIdField]: id,
3296
+ [vf]: options.ifVersion
3297
+ },
3298
+ casUpdate,
3299
+ {
3300
+ ...context,
3301
+ throwOnNotFound: false
3302
+ }
3303
+ ));
3304
+ if (!result) {
3305
+ /**
3306
+ * Disambiguate the miss — the contract's teeth. Filter-matched-
3307
+ * nothing is TWO different facts: record gone (stays null, the
3308
+ * MinimalRepo miss contract) or record moved past the expected
3309
+ * version (MUST throw — null would read as not-found and invite
3310
+ * a blind retry that overwrites the concurrent write).
3311
+ */
3312
+ const current = await this.Model.findOne({
3313
+ ...context.query || {},
3314
+ [effectiveIdField]: id
3315
+ }).session(context.session ?? null).lean().exec();
3316
+ if (current) throw new VersionConflictError({
3317
+ expectedVersion: options.ifVersion,
3318
+ ...typeof current[vf] === "number" ? { actualVersion: current[vf] } : {},
3319
+ id: String(id)
3320
+ });
3321
+ }
3322
+ } else if (effectiveIdField !== "_id") result = await this._withResilience(context, () => updateByQuery(this.Model, {
3222
3323
  [effectiveIdField]: id,
3223
3324
  ...context.query || {}
3224
3325
  }, context.data || data, context));
@@ -3471,14 +3572,15 @@ var Repository = class extends RepositoryBase {
3471
3572
  } };
3472
3573
  } else {
3473
3574
  const callerSet = patch.$set ?? {};
3474
- if (isStateNoop) if (Object.keys(callerSet).length === 0) {
3475
- const { $set, ...rest } = patch;
3476
- update = rest;
3575
+ if (isStateNoop) {
3576
+ if (Object.keys(callerSet).length === 0) {
3577
+ const { $set, ...rest } = patch;
3578
+ update = rest;
3579
+ } else update = {
3580
+ ...patch,
3581
+ $set: { ...callerSet }
3582
+ };
3477
3583
  } else update = {
3478
- ...patch,
3479
- $set: { ...callerSet }
3480
- };
3481
- else update = {
3482
3584
  ...patch,
3483
3585
  $set: {
3484
3586
  ...callerSet,
@@ -4310,9 +4412,9 @@ var Repository = class extends RepositoryBase {
4310
4412
  const appendLookupStages$2 = (pipeline) => appendLookupStages(pipeline, lookups, projection, isKeyset && sort ? this._parseSort(sort) : void 0);
4311
4413
  if (isKeyset && sort) {
4312
4414
  const parsedSort = this._parseSort(sort);
4313
- const { validateKeysetSort } = await import("./PaginationEngine-CSh-P8Nh.mjs").then((n) => n.n);
4314
- const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-CSh-P8Nh.mjs").then((n) => n.i);
4315
- const { getPrimaryField } = await import("./PaginationEngine-CSh-P8Nh.mjs").then((n) => n.n);
4415
+ const { validateKeysetSort } = await import("./PaginationEngine-GdyX29tE.mjs").then((n) => n.n);
4416
+ const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-GdyX29tE.mjs").then((n) => n.i);
4417
+ const { getPrimaryField } = await import("./PaginationEngine-GdyX29tE.mjs").then((n) => n.n);
4316
4418
  const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
4317
4419
  const cursorVersion = this._pagination.config.cursorVersion ?? 1;
4318
4420
  const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
@@ -4511,7 +4613,7 @@ var Repository = class extends RepositoryBase {
4511
4613
  */
4512
4614
  async withTransaction(callback, options = {}) {
4513
4615
  return withTransaction(this.Model.db, async (session) => {
4514
- return callback(createTxBoundRepo(this, session));
4616
+ return callback(createTxBoundRepo(this, session), { session });
4515
4617
  }, options);
4516
4618
  }
4517
4619
  /**
@@ -4684,6 +4786,16 @@ var Repository = class extends RepositoryBase {
4684
4786
  return isDuplicateKeyError(err);
4685
4787
  }
4686
4788
  /**
4789
+ * Classify a transactional-write failure as a TRANSIENT concurrency
4790
+ * conflict — one MongoDB designed callers to recover from by re-running
4791
+ * the transaction (`TransientTransactionError` label / WriteConflict 112).
4792
+ * Consumed by repo-core's `retryingTransaction`; conservative on purpose —
4793
+ * never classifies from message text.
4794
+ */
4795
+ isTransientConflictError(err) {
4796
+ return conservativeMongoIsTransientConflict(err);
4797
+ }
4798
+ /**
4687
4799
  * Handle errors with proper HTTP status codes.
4688
4800
  *
4689
4801
  * **Transactional retry signal preservation.** MongoDB driver errors
@@ -4703,6 +4815,7 @@ var Repository = class extends RepositoryBase {
4703
4815
  const labels = error.errorLabels;
4704
4816
  const hasLabel = error.hasErrorLabel;
4705
4817
  if (Array.isArray(labels) && (labels.includes("TransientTransactionError") || labels.includes("UnknownTransactionCommitResult")) || typeof hasLabel === "function" && (hasLabel.call(error, "TransientTransactionError") || hasLabel.call(error, "UnknownTransactionCommitResult"))) return error;
4818
+ if (error instanceof mongoose.mongo.MongoOperationTimeoutError) return error;
4706
4819
  if (error instanceof mongoose.Error.ValidationError) {
4707
4820
  const messages = Object.values(error.errors).map((err) => err.message);
4708
4821
  return createError(400, `Validation Error: ${messages.join(", ")}`);
@@ -1,13 +1,14 @@
1
- import { _ as WithTransactionOptions, a as DeleteManyResult, f as OperationOptions, g as UpdateOptions, h as UpdateManyResult, i as CreateOptions, l as LookupPopulateOptions, m as SessionOptions, o as DeleteResult, p as ReadOptions, r as CacheableOptions, s as FindOneAndUpdateOptions, t as AggregateOptions, u as LookupPopulateResult, v as LookupBuilder, y as LookupOptions } from "./operations-CuzX9m2Z.mjs";
1
+ import { _ as UpdateOptions, a as CreateOptions, b as LookupOptions, c as FindOneAndUpdateOptions, d as LookupPopulateResult, g as UpdateManyResult, h as SessionOptions, m as ReadOptions, o as DeleteManyResult, p as OperationOptions, r as CacheableOptions, s as DeleteResult, t as AggregateOptions, u as LookupPopulateOptions, v as WithTransactionOptions, y as LookupBuilder } from "./operations-DZodvjC-.mjs";
2
2
  import { a as PopulateSpec, i as ObjectId, l as SortSpec$1, o as ReadPreferenceType, s as SelectSpec } from "./core-62oo3N1C.mjs";
3
3
  import { c as PaginationConfig, r as CollationOptions, t as AggregatePaginationOptions } from "./pagination-jSUdrACK.mjs";
4
- import { _ as RepositoryOptions, d as PluginType, f as PrioritizedHook, o as Middleware, p as RepositoryContext } from "./repository-Q16ErTu8.mjs";
4
+ import { _ as RepositoryOptions, d as PluginType, f as PrioritizedHook, o as Middleware, p as RepositoryContext } from "./repository-H_ffGgpE.mjs";
5
5
  import { t as PaginationEngine } from "./PaginationEngine-CBS1XGzp.mjs";
6
6
  import { AggPaginationRequest, AggRequest, AggResult, ArchiveOptions, ArchiveResult, ArchiveSink, ChangeEvent, FilterInput, KeysetAggPaginationResult, QueryOptions, RepoCapabilities, RepositoryBase, TenantPurgeOptions, TenantPurgeResult, TenantPurgeStrategy, WatchOptions } from "@classytic/repo-core/repository";
7
7
  import { ClientSession, Expression, Model, PipelineStage, PopulateOptions } from "mongoose";
8
8
  import { Filter } from "@classytic/repo-core/filter";
9
- import { UpdateInput } from "@classytic/repo-core/update";
9
+ import { RepositoryCacheHandle } from "@classytic/repo-core/cache";
10
10
  import { HttpError } from "@classytic/repo-core/errors";
11
+ import { UpdateInput } from "@classytic/repo-core/update";
11
12
  import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, OffsetPaginationResultCore } from "@classytic/repo-core/pagination";
12
13
  //#region src/query/AggregationBuilder.d.ts
13
14
  /** Options for $vectorSearch stage (Atlas only) */
@@ -520,6 +521,16 @@ interface TransitionMachine {
520
521
  assertTransition(entityId: string, from: string, to: string): void;
521
522
  }
522
523
  declare class Repository<TDoc = unknown> extends RepositoryBase {
524
+ /**
525
+ * Cache handle ATTACHED BY the unified cache plugin at wiring time —
526
+ * `undefined` when no cache is configured. Declared (optional) so
527
+ * consumers and this class's own methods read `this.cache` typed instead
528
+ * of probing through `as unknown as { cache?: … }` — mongokit itself
529
+ * carried that erasure in `invalidateAggregateCache`, and every host that
530
+ * wanted the handle copied it. The plugin's `Object.assign` keeps working
531
+ * unchanged; nothing here constructs the handle.
532
+ */
533
+ cache?: RepositoryCacheHandle;
523
534
  /**
524
535
  * Runtime capability descriptor required by `StandardRepo<TDoc>`
525
536
  * (repo-core 0.6.0). Hosts feature-detect once at boot instead of
@@ -556,6 +567,8 @@ declare class Repository<TDoc = unknown> extends RepositoryBase {
556
567
  readonly model: string;
557
568
  readonly _pagination: PaginationEngine<TDoc>;
558
569
  readonly idField: string;
570
+ /** Version field for `ifVersion` CAS writes. Default '__v'. */
571
+ readonly versionField: string;
559
572
  readonly searchMode: 'text' | 'regex' | 'auto';
560
573
  readonly searchFields: string[] | undefined;
561
574
  [key: string]: unknown;
@@ -1747,7 +1760,9 @@ declare class Repository<TDoc = unknown> extends RepositoryBase {
1747
1760
  * });
1748
1761
  * ```
1749
1762
  */
1750
- withTransaction<T>(callback: (txRepo: this) => Promise<T>, options?: WithTransactionOptions): Promise<T>;
1763
+ withTransaction<T>(callback: (txRepo: this, uow?: {
1764
+ session?: ClientSession;
1765
+ }) => Promise<T>, options?: WithTransactionOptions): Promise<T>;
1751
1766
  /**
1752
1767
  * Execute custom query with event emission
1753
1768
  */
@@ -1827,6 +1842,14 @@ declare class Repository<TDoc = unknown> extends RepositoryBase {
1827
1842
  * }
1828
1843
  */
1829
1844
  isDuplicateKeyError(err: unknown): boolean;
1845
+ /**
1846
+ * Classify a transactional-write failure as a TRANSIENT concurrency
1847
+ * conflict — one MongoDB designed callers to recover from by re-running
1848
+ * the transaction (`TransientTransactionError` label / WriteConflict 112).
1849
+ * Consumed by repo-core's `retryingTransaction`; conservative on purpose —
1850
+ * never classifies from message text.
1851
+ */
1852
+ isTransientConflictError(err: unknown): boolean;
1830
1853
  /**
1831
1854
  * Handle errors with proper HTTP status codes.
1832
1855
  *
@@ -1,2 +1,2 @@
1
- import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-Beyr1r4d.mjs";
1
+ import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-DKEql4cL.mjs";
2
2
  export { aggregate_d_exports as aggregate, create_d_exports as create, delete_d_exports as deleteActions, read_d_exports as read, update_d_exports as update };
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "../rolldown-runtime-D7D4PA-g.mjs";
2
- import { g as create_exports, i as update_exports, p as delete_exports, u as read_exports, v as aggregate_exports } from "../update-w5i-pa_c.mjs";
2
+ import { g as create_exports, i as update_exports, p as delete_exports, u as read_exports, v as aggregate_exports } from "../update-jSXp3nu3.mjs";
3
3
  //#region src/actions/index.ts
4
4
  var actions_exports = /* @__PURE__ */ __exportAll({
5
5
  aggregate: () => aggregate_exports,
@@ -1,4 +1,4 @@
1
- import { l as Plugin } from "../repository-Q16ErTu8.mjs";
1
+ import { l as Plugin } from "../repository-H_ffGgpE.mjs";
2
2
  import { PipelineStage } from "mongoose";
3
3
  //#region src/ai/types.d.ts
4
4
  /** Supported similarity metrics for vector search */
@@ -156,6 +156,25 @@ interface BetterAuthOverlayOptions<TDoc = Record<string, unknown>> {
156
156
  * `@classytic/mongokit` to wire OpenAPI auto-gen.
157
157
  */
158
158
  schemaGenerator?: any;
159
+ /**
160
+ * Return a WRITABLE repository. Default `false` — the overlay is a
161
+ * read-side projection, and its repository is sealed
162
+ * (`asReadOnlyRepo`): writes throw, and `capabilities.readOnly` lets a
163
+ * host refuse write ROUTES at boot.
164
+ *
165
+ * Better Auth owns writes to these collections and enforces invariants
166
+ * the documents cannot: password hashing, session revocation, org
167
+ * membership cascades, and every plugin hook (passkey, SSO, ...). A
168
+ * generic `POST /users` through this repository bypasses all of it and
169
+ * writes a row Better Auth never saw. That is one route-config line away
170
+ * whenever the repository is writable, so the seal is the default rather
171
+ * than a docstring.
172
+ *
173
+ * Set `true` ONLY for administrative repair paths where you have
174
+ * accepted that responsibility. Normal identity mutations go through
175
+ * `auth.api`.
176
+ */
177
+ unsafeWritable?: boolean;
159
178
  }
160
179
  /**
161
180
  * Create a `DataAdapter<TDoc>` over a Better Auth collection.
@@ -1,5 +1,6 @@
1
- import { n as Repository } from "../Repository-B_1y9cCf.mjs";
1
+ import { n as Repository } from "../Repository-BArvU32x.mjs";
2
2
  import { n as createMongooseAdapter } from "../adapter-BitHg-CN.mjs";
3
+ import { asReadOnlyRepo } from "@classytic/repo-core/repository";
3
4
  import { pluralizeBetterAuthCollection, resolveBetterAuthCollections } from "@classytic/repo-core/better-auth";
4
5
  //#region src/better-auth/index.ts
5
6
  /**
@@ -95,7 +96,7 @@ function registerBetterAuthStubs(mongoose, options = {}) {
95
96
  * would silently drop this call's `additionalFields` / `indexes`).
96
97
  */
97
98
  async function createBetterAuthOverlay(options) {
98
- const { auth, mongoose, collection, additionalFields = {}, indexes = [], usePlural = false, RepositoryClass, schemaGenerator } = options;
99
+ const { auth, mongoose, collection, additionalFields = {}, indexes = [], usePlural = false, RepositoryClass, schemaGenerator, unsafeWritable = false } = options;
99
100
  const ctx = await auth.$context;
100
101
  const tableConfig = ctx.tables[collection];
101
102
  if (!tableConfig) throw new Error(`[mongokit:better-auth] Better Auth has no table named '${collection}'. Available: ${Object.keys(ctx.tables).join(", ")}. Did you enable the right plugin in your betterAuth() config?`);
@@ -142,7 +143,8 @@ async function createBetterAuthOverlay(options) {
142
143
  for (const idx of indexes) schema.index(idx.fields, idx.options);
143
144
  Model = mongoose.model(finalName, schema);
144
145
  }
145
- const repository = new (RepositoryClass ?? Repository)(Model);
146
+ const built = new (RepositoryClass ?? Repository)(Model);
147
+ const repository = unsafeWritable ? built : asReadOnlyRepo(built, { reason: `Better Auth owns writes to '${finalName}' (hashing, session revocation, org cascades, plugin hooks) — mutate via auth.api, not generic CRUD. Pass \`unsafeWritable: true\` to this overlay if raw administrative writes are required` });
146
148
  return createMongooseAdapter({
147
149
  model: Model,
148
150
  repository,
@@ -1,8 +1,8 @@
1
1
  import { c as PaginationConfig } from "./pagination-jSUdrACK.mjs";
2
- import { _ as RepositoryOptions, d as PluginType } from "./repository-Q16ErTu8.mjs";
3
- import { T as AuditTrailOptions, _ as RepositoryCachePluginOptions, i as CustomIdOptions, t as MultiTenantOptions } from "./multi-tenant.plugin-2VtSSm1Y.mjs";
4
- import { n as Repository } from "./Repository-BAzymuOz.mjs";
5
- import { i as Logger, o as SoftDeleteOptions } from "./plugin-options-BsFH0sTi.mjs";
2
+ import { _ as RepositoryOptions, d as PluginType } from "./repository-H_ffGgpE.mjs";
3
+ import { T as AuditTrailOptions, _ as RepositoryCachePluginOptions, i as CustomIdOptions, t as MultiTenantOptions } from "./multi-tenant.plugin-BwSvTirB.mjs";
4
+ import { n as Repository } from "./Repository-BdSkQ8vc.mjs";
5
+ import { i as Logger, o as SoftDeleteOptions } from "./plugin-options-DXnlKM3B.mjs";
6
6
  import { Model } from "mongoose";
7
7
  //#region src/create-repository.d.ts
8
8
  /**
@@ -1,5 +1,5 @@
1
- import { n as Repository } from "./Repository-B_1y9cCf.mjs";
2
- import { a as customIdPlugin, f as auditTrailPlugin, i as multiTenantPlugin, m as auditLogPlugin, n as softDeletePlugin, t as timestampPlugin, u as cachePlugin } from "./timestamp.plugin-CAXzHs9y.mjs";
1
+ import { n as Repository } from "./Repository-BArvU32x.mjs";
2
+ import { a as customIdPlugin, f as auditTrailPlugin, i as multiTenantPlugin, m as auditLogPlugin, n as softDeletePlugin, t as timestampPlugin, u as cachePlugin } from "./timestamp.plugin-BEc6TANm.mjs";
3
3
  import { n as batchOperationsPlugin, t as methodRegistryPlugin } from "./method-registry.plugin-DblM7V6j.mjs";
4
4
  //#region src/create-repository.ts
5
5
  function isLoggerAudit(value) {
@@ -0,0 +1,67 @@
1
+ //#region src/utils/id-chunks.ts
2
+ /**
3
+ * Default slice for `$in` fan-out. Larger than `runChunkedPurge`'s 1000 on purpose:
4
+ * a purge chunk bounds the WRITE LOCK SCOPE of each progressive strategy step, while a
5
+ * snapshot slice bounds only the query-document size and the per-call working set —
6
+ * 10k ObjectIds is ~120 KB of `$in`, far under every limit, at 10× fewer round trips.
7
+ */
8
+ const DEFAULT_ID_CHUNK = 1e4;
9
+ /**
10
+ * Collect the bare id values matching `filter`, streaming via a driver cursor so the
11
+ * only O(n) allocation is the returned array itself (a `find().lean()` materializes an
12
+ * intermediate array of `{_id}` documents first — roughly double the peak for large n).
13
+ */
14
+ async function collectIds(model, filter, options = {}) {
15
+ const idField = options.idField ?? "_id";
16
+ const ids = [];
17
+ const cursor = model.find(filter, { [idField]: 1 }).session(options.session ?? null).lean().cursor();
18
+ for await (const doc of cursor) ids.push(doc[idField]);
19
+ return ids;
20
+ }
21
+ /**
22
+ * Yield `ids` in consecutive slices of at most `size`. Yields nothing for an empty
23
+ * array. `size` must be a positive integer — sliced `$in` correctness depends on it,
24
+ * so a nonsense size throws instead of silently degrading to one giant slice.
25
+ */
26
+ function* idChunks(ids, size) {
27
+ if (!Number.isInteger(size) || size < 1) throw new Error(`idChunks: size must be a positive integer, got ${String(size)}`);
28
+ for (let i = 0; i < ids.length; i += size) yield ids.slice(i, i + size);
29
+ }
30
+ /**
31
+ * Compose the keyset-progressed select filter: the base predicate, narrowed to rows past
32
+ * the last committed `_id`. `cursor == null` means "from the start". Selection paired
33
+ * with this must sort `_id`-ascending ({@link selectKeysetChunk} does), so `$gt: cursor`
34
+ * never revisits a processed row and never skips an unprocessed one. Never mutates
35
+ * `baseFilter`.
36
+ */
37
+ function keysetFilter(baseFilter, cursor) {
38
+ return cursor == null ? baseFilter : {
39
+ ...baseFilter,
40
+ _id: { $gt: cursor }
41
+ };
42
+ }
43
+ /**
44
+ * The `_id`-ascending bounded chunk select both action ports share:
45
+ * `find(filter).sort({_id: 1}).limit(limit).lean()`. Lean on purpose — chunk consumers
46
+ * want plain objects, not hydrated documents. Pass `projection: {_id: 1}` when only ids
47
+ * are needed (the purge port's common path); omit it for full docs (archive's readChunk,
48
+ * anonymize-functional).
49
+ */
50
+ async function selectKeysetChunk(model, filter, limit, options = {}) {
51
+ return await model.find(filter, options.projection).sort({ _id: 1 }).limit(limit).session(options.session ?? null).lean().exec();
52
+ }
53
+ /**
54
+ * Re-assert the base predicate on an id-narrowed WRITE: `{...base, _id: {$in: ids}}`.
55
+ * The `$in` alone would be enough to address the chunk, but carrying the base predicate
56
+ * defends against a row whose scope fields changed between the chunk select and the
57
+ * write — such a row is silently skipped instead of written outside its cohort. Both
58
+ * action ports use this shape; use it for any select-then-write over a chunk.
59
+ */
60
+ function narrowToIds(baseFilter, ids) {
61
+ return {
62
+ ...baseFilter,
63
+ _id: { $in: ids }
64
+ };
65
+ }
66
+ //#endregion
67
+ export { narrowToIds as a, keysetFilter as i, collectIds as n, selectKeysetChunk as o, idChunks as r, DEFAULT_ID_CHUNK as t };
@@ -1,4 +1,4 @@
1
- import { c as GroupResult, d as MinMaxResult, f as OperationOptions, g as UpdateOptions, h as UpdateManyResult, i as CreateOptions, o as DeleteResult, s as FindOneAndUpdateOptions, y as LookupOptions } from "./operations-CuzX9m2Z.mjs";
1
+ import { _ as UpdateOptions, a as CreateOptions, b as LookupOptions, c as FindOneAndUpdateOptions, f as MinMaxResult, g as UpdateManyResult, l as GroupResult, p as OperationOptions, s as DeleteResult } from "./operations-DZodvjC-.mjs";
2
2
  import { a as PopulateSpec, i as ObjectId, l as SortSpec, o as ReadPreferenceType, s as SelectSpec, t as AnyDocument } from "./core-62oo3N1C.mjs";
3
3
  import { p as UpdateWithValidationResult } from "./type-utils-CLzRFtIA.mjs";
4
4
  import { Model, PipelineStage } from "mongoose";