@web-ts-toolkit/moo 0.42.2 → 0.47.1

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/index.mjs CHANGED
@@ -51,6 +51,121 @@ var resolveFilter = (value, document) => {
51
51
  }
52
52
  return result;
53
53
  };
54
+ var isNonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
55
+ var isEmptyFilterObject = (value) => Object.keys(value).length === 0;
56
+ var EMPTY_FULL_FILTER_MESSAGE = "cascadeDeletePlugin rejects empty foreignFilter by default because {} would match every document in the dependent collection; provide a non-empty filter object. Broad deletes are not implicitly enabled.";
57
+ var DEFAULT_MAX_CONCURRENCY = 8;
58
+ var DEFAULT_BATCH_SIZE = 100;
59
+ var assertPositiveIntOption = (value, name, modelName) => {
60
+ if (value === void 0) return;
61
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
62
+ throw new Error(
63
+ `cascadeDeletePlugin for model "${modelName}" requires \`${name}\` to be an integer >= 1 when provided`
64
+ );
65
+ }
66
+ };
67
+ var assertPluginOptions = (options) => {
68
+ if (!isPlainObject2(options)) {
69
+ throw new Error("cascadeDeletePlugin requires options with a non-empty `model` name");
70
+ }
71
+ const { model, localField, foreignField, foreignFilter, extraForeignFilter } = options;
72
+ if (!isNonEmptyString(model)) {
73
+ throw new Error("cascadeDeletePlugin requires a non-empty `model` name");
74
+ }
75
+ if (localField !== void 0 && !isNonEmptyString(localField)) {
76
+ throw new Error("cascadeDeletePlugin requires `localField` to be a non-empty string when provided");
77
+ }
78
+ assertPositiveIntOption(options.maxConcurrency, "maxConcurrency", model);
79
+ assertPositiveIntOption(options.batchSize, "batchSize", model);
80
+ if (foreignField !== void 0 && !isNonEmptyString(foreignField)) {
81
+ throw new Error("cascadeDeletePlugin requires `foreignField` to be a non-empty string when provided");
82
+ }
83
+ if (foreignFilter !== void 0) {
84
+ if (!isFunction2(foreignFilter) && !isPlainObject2(foreignFilter)) {
85
+ throw new Error(
86
+ `cascadeDeletePlugin invalid \`foreignFilter\` for model "${model}": expected a plain filter object or a resolver function`
87
+ );
88
+ }
89
+ if (isPlainObject2(foreignFilter) && isEmptyFilterObject(foreignFilter)) {
90
+ throw new Error(`cascadeDeletePlugin ${EMPTY_FULL_FILTER_MESSAGE}`);
91
+ }
92
+ } else if (!isNonEmptyString(localField) || !isNonEmptyString(foreignField)) {
93
+ throw new Error(
94
+ `cascadeDeletePlugin for model "${model}" requires either \`foreignFilter\` or both \`localField\` and \`foreignField\``
95
+ );
96
+ }
97
+ if (extraForeignFilter !== void 0) {
98
+ if (!isFunction2(extraForeignFilter) && !isPlainObject2(extraForeignFilter)) {
99
+ throw new Error(
100
+ `cascadeDeletePlugin invalid \`extraForeignFilter\` for model "${model}": expected a plain filter object or a resolver function`
101
+ );
102
+ }
103
+ }
104
+ };
105
+ var assertFullFilterNotEmpty = (filter, modelName) => {
106
+ if (isEmptyFilterObject(filter)) {
107
+ throw new Error(`cascadeDeletePlugin for model "${modelName}" ${EMPTY_FULL_FILTER_MESSAGE}`);
108
+ }
109
+ };
110
+ var isMissingScalarKey = (value) => {
111
+ if (value === void 0 || value === null) return true;
112
+ if (typeof value === "string" && value.trim().length === 0) return true;
113
+ return false;
114
+ };
115
+ var unwrapKeyValue = (value) => {
116
+ if (isPlainObject2(value)) {
117
+ const candidate = value._id;
118
+ if (candidate !== void 0 && candidate !== null) return candidate;
119
+ }
120
+ if (isObject(value)) {
121
+ try {
122
+ const candidate = value._id;
123
+ if (candidate !== void 0 && candidate !== null) return candidate;
124
+ } catch {
125
+ }
126
+ }
127
+ return value;
128
+ };
129
+ var normalizeKeyList = (localValue) => {
130
+ if (Array.isArray(localValue)) {
131
+ const keys = [];
132
+ for (const entry of localValue) {
133
+ const unwrapped2 = unwrapKeyValue(entry);
134
+ if (!isMissingScalarKey(unwrapped2)) keys.push(unwrapped2);
135
+ }
136
+ return keys.length > 0 ? keys : null;
137
+ }
138
+ const unwrapped = unwrapKeyValue(localValue);
139
+ if (isMissingScalarKey(unwrapped)) return null;
140
+ return [unwrapped];
141
+ };
142
+ var isPathDeselected = (owner, path) => {
143
+ try {
144
+ const candidate = owner;
145
+ if (typeof candidate.$isSelected === "function") {
146
+ const result = candidate.$isSelected(path);
147
+ if (typeof result === "boolean") return result === false;
148
+ }
149
+ if (typeof candidate.isSelected === "function") {
150
+ const result = candidate.isSelected(path);
151
+ if (typeof result === "boolean") return result === false;
152
+ }
153
+ } catch {
154
+ }
155
+ return false;
156
+ };
157
+ var buildRelationshipPredicate = (foreignField, localValue) => {
158
+ const keys = normalizeKeyList(localValue);
159
+ if (!keys) return null;
160
+ if (Array.isArray(localValue)) {
161
+ return { [foreignField]: { $in: keys } };
162
+ }
163
+ return { [foreignField]: keys[0] };
164
+ };
165
+ var combineWithRelationship = (relationship, extra) => {
166
+ if (!extra || isEmptyFilterObject(extra)) return relationship;
167
+ return { $and: [relationship, extra] };
168
+ };
54
169
  var applyQueryOptions = (query, options) => {
55
170
  let builder = query;
56
171
  if (options.select) {
@@ -65,8 +180,81 @@ var applyQueryOptions = (query, options) => {
65
180
  if (options.lean) {
66
181
  builder = builder.lean();
67
182
  }
183
+ if (options.session) {
184
+ builder = builder.session(options.session);
185
+ }
68
186
  return builder;
69
187
  };
188
+ var getOwningConnection = (owner) => {
189
+ const candidate = owner?.collection?.conn ?? owner?.constructor?.collection?.conn ?? null;
190
+ if (candidate && typeof candidate.model === "function") {
191
+ return candidate;
192
+ }
193
+ return mongoose2;
194
+ };
195
+ var getBoundSession = (owner) => {
196
+ try {
197
+ const session = owner?.$session?.() ?? null;
198
+ return session ?? null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ };
203
+ var resolveExecutionContext = (owner, targetModelName, explicitSession) => {
204
+ const session = explicitSession ?? getBoundSession(owner) ?? null;
205
+ const connection = getOwningConnection(owner);
206
+ const Target = connection.model(targetModelName);
207
+ return { connection, Target, session };
208
+ };
209
+ var isInTransaction = (session) => {
210
+ try {
211
+ return typeof session?.inTransaction === "function" ? session.inTransaction() : false;
212
+ } catch {
213
+ return false;
214
+ }
215
+ };
216
+ var getSessionClient = (session) => {
217
+ if (!session) return null;
218
+ return session.client ?? null;
219
+ };
220
+ var getConnectionClient = (connection) => {
221
+ try {
222
+ if (connection === mongoose2) {
223
+ return mongoose2.connection.getClient?.() ?? null;
224
+ }
225
+ return connection.getClient?.() ?? null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ };
230
+ var getTopologyType = (client) => {
231
+ try {
232
+ const topology = client?.topology;
233
+ const type = topology?.description?.type;
234
+ return typeof type === "string" ? type : null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ };
239
+ var assertTransactionSupported = (context) => {
240
+ const { connection, session } = context;
241
+ if (!session) return;
242
+ const sessionClient = getSessionClient(session);
243
+ const connectionClient = getConnectionClient(connection);
244
+ if (sessionClient && connectionClient && sessionClient !== connectionClient) {
245
+ throw new Error(
246
+ "cascadeDeletePlugin does not support sessions bound to a different connection/client; pass a session from the owning model connection"
247
+ );
248
+ }
249
+ if (isInTransaction(session)) {
250
+ const topologyType = getTopologyType(sessionClient);
251
+ if (topologyType === "Single") {
252
+ throw new Error(
253
+ "cascadeDeletePlugin does not support transactions on a standalone MongoDB deployment; use a replica set or sharded cluster, or delete without a transaction"
254
+ );
255
+ }
256
+ }
257
+ };
70
258
  var supportsDeleteOne = (value) => isObject(value) && typeof value.deleteOne === "function";
71
259
  var supportsRemove = (value) => isObject(value) && typeof value.remove === "function";
72
260
  var mergeResults = (previousResults, modelName, currentResults) => {
@@ -79,56 +267,189 @@ var mergeResults = (previousResults, modelName, currentResults) => {
79
267
  [modelName]: currentResults
80
268
  };
81
269
  };
270
+ var resolveDependentFilter = (owner, config) => {
271
+ const { model, localField, foreignField, foreignFilter, extraForeignFilter } = config;
272
+ const document = owner.toObject({ virtuals: false });
273
+ if (foreignFilter !== void 0) {
274
+ const resolved = resolveFilter(foreignFilter, document);
275
+ if (resolved === null) return null;
276
+ assertFullFilterNotEmpty(resolved, model);
277
+ return resolved;
278
+ }
279
+ if (localField && foreignField) {
280
+ const localValue = owner.get(localField);
281
+ if (normalizeKeyList(localValue) === null) {
282
+ if (isPathDeselected(owner, localField)) {
283
+ throw new Error(
284
+ `cascadeDeletePlugin for model "${model}" cannot resolve dependents: localField "${localField}" was not selected on the document; load the document with that path projected instead of assuming an empty relation`
285
+ );
286
+ }
287
+ return null;
288
+ }
289
+ const relationship = buildRelationshipPredicate(foreignField, localValue);
290
+ if (!relationship) return null;
291
+ const extraFilter = resolveFilter(extraForeignFilter, document);
292
+ return combineWithRelationship(relationship, extraFilter);
293
+ }
294
+ return null;
295
+ };
296
+ var resolveTraversalLimits = (options) => ({
297
+ maxConcurrency: typeof options.maxConcurrency === "number" ? options.maxConcurrency : DEFAULT_MAX_CONCURRENCY,
298
+ batchSize: typeof options.batchSize === "number" ? options.batchSize : DEFAULT_BATCH_SIZE
299
+ });
300
+ var extractIdList = (rows) => rows.map((row) => {
301
+ if (isPlainObject2(row)) return row._id;
302
+ if (isObject(row)) {
303
+ try {
304
+ const candidate = row._id;
305
+ if (candidate !== void 0) return candidate;
306
+ } catch {
307
+ }
308
+ }
309
+ return row;
310
+ });
311
+ var fetchDependentIdPage = async (Target, filter, session, lastId, batchSize) => {
312
+ const paged = lastId === void 0 ? filter : { $and: [filter, { _id: { $gt: lastId } }] };
313
+ let query = Target.find(paged).select("_id").sort({ _id: 1 }).limit(batchSize).lean();
314
+ if (session) query = query.session(session);
315
+ const rows = await query;
316
+ return extractIdList(Array.isArray(rows) ? rows : []);
317
+ };
318
+ var loadHydratedDependent = async (Target, id, session) => {
319
+ const query = Target.findById(id);
320
+ const result = session ? await query.session(session) : await query;
321
+ return result ?? null;
322
+ };
323
+ var runWithConcurrencyLimit = async (items, limit, task) => {
324
+ if (items.length === 0) return;
325
+ const workerCount = Math.max(1, Math.min(limit, items.length));
326
+ let next = 0;
327
+ let failed = false;
328
+ let firstError = null;
329
+ const workers = Array.from({ length: workerCount }, () => {
330
+ const run = async () => {
331
+ while (true) {
332
+ if (failed) return;
333
+ const index = next;
334
+ next += 1;
335
+ if (index >= items.length) return;
336
+ try {
337
+ await task(items[index]);
338
+ } catch (error) {
339
+ if (!failed) {
340
+ failed = true;
341
+ firstError = error;
342
+ }
343
+ throw error;
344
+ }
345
+ }
346
+ };
347
+ return run();
348
+ });
349
+ try {
350
+ await Promise.all(workers);
351
+ } catch {
352
+ throw firstError;
353
+ }
354
+ };
82
355
  function cascadeDeletePlugin(schema, options) {
356
+ assertPluginOptions(options);
83
357
  const { model, localField, foreignField, foreignFilter, extraForeignFilter } = options ?? {};
358
+ const filterConfig = {
359
+ model,
360
+ localField,
361
+ foreignField,
362
+ foreignFilter,
363
+ extraForeignFilter
364
+ };
365
+ const traversalLimits = resolveTraversalLimits(options ?? {});
84
366
  const findDependents = async function(queryOptions) {
85
- const Target = mongoose2.model(model);
86
- let query = null;
87
- const document = this.toObject({ virtuals: false });
88
- if (foreignFilter) {
89
- query = resolveFilter(foreignFilter, document);
90
- } else if (localField && foreignField) {
91
- const localValue = this.get(localField);
92
- const extraFilter = resolveFilter(extraForeignFilter, document) ?? {};
93
- query = {
94
- [foreignField]: Array.isArray(localValue) ? { $in: localValue } : localValue,
95
- ...extraFilter
96
- };
97
- }
367
+ const context = resolveExecutionContext(this, model, queryOptions?.session ?? null);
368
+ const { Target, session } = context;
369
+ const query = resolveDependentFilter(this, filterConfig);
98
370
  if (!query || !isPlainObject2(query)) {
99
371
  return [];
100
372
  }
101
- return await applyQueryOptions(Target.find(query), queryOptions);
373
+ const mergedOptions = session ? { ...queryOptions, session } : queryOptions;
374
+ return await applyQueryOptions(Target.find(query), mergedOptions);
102
375
  };
103
376
  const findOrphans = async function(queryOptions) {
104
377
  if (!localField || !foreignField) return null;
105
- const Target = mongoose2.model(model);
378
+ const context = resolveExecutionContext(this, model, queryOptions?.session ?? null);
379
+ const { Target, session } = context;
106
380
  const schemaValue = Target.schema.obj[foreignField];
107
381
  if (!schemaValue) return null;
108
382
  const isMyRef = isReference(schemaValue, this.modelName);
109
383
  if (!isMyRef) return null;
110
- const ids = await this.distinct("_id");
111
- const extraFilter = resolveFilter(extraForeignFilter) ?? {};
112
- const query = {
113
- [foreignField]: { $not: { $in: ids } },
114
- ...extraFilter
384
+ const ids = session != null ? await this.distinct("_id", {}, { session }) : await this.distinct("_id");
385
+ const extraFilter = resolveFilter(extraForeignFilter);
386
+ const relationship = {
387
+ [foreignField]: { $not: { $in: ids } }
115
388
  };
116
- return await applyQueryOptions(Target.find(query), queryOptions);
389
+ const query = combineWithRelationship(
390
+ relationship,
391
+ extraFilter
392
+ );
393
+ const mergedOptions = session ? { ...queryOptions, session } : queryOptions;
394
+ return await applyQueryOptions(Target.find(query), mergedOptions);
117
395
  };
118
- const deleteDependents = async function() {
119
- const documents = await findDependents.call(this, { select: "_id" });
120
- await Promise.all(
121
- documents.map((document) => {
122
- if (supportsDeleteOne(document)) {
123
- return document.deleteOne();
124
- }
125
- if (supportsRemove(document)) {
126
- return document.remove();
396
+ const deleteDependents = async function(operationOptions) {
397
+ const context = resolveExecutionContext(this, model, operationOptions?.session ?? null);
398
+ assertTransactionSupported(context);
399
+ const { Target, session } = context;
400
+ const filter = resolveDependentFilter(this, filterConfig);
401
+ if (!filter || !isPlainObject2(filter)) {
402
+ return;
403
+ }
404
+ const deleteSingle = async (document) => {
405
+ if (session != null && typeof document.$session === "function") {
406
+ try {
407
+ await document.$session?.(session);
408
+ } catch {
127
409
  }
128
- return Promise.resolve();
129
- })
130
- );
410
+ }
411
+ if (supportsDeleteOne(document)) {
412
+ return session != null ? document.deleteOne({ session }) : document.deleteOne();
413
+ }
414
+ if (supportsRemove(document)) {
415
+ return session != null ? document.remove({ session }) : document.remove();
416
+ }
417
+ return Promise.resolve();
418
+ };
419
+ const deleteById = async (id) => {
420
+ const hydrated = await loadHydratedDependent(Target, id, session);
421
+ if (!hydrated) return;
422
+ await deleteSingle(hydrated);
423
+ };
424
+ const effectiveConcurrency = session != null && isInTransaction(session) ? 1 : traversalLimits.maxConcurrency;
425
+ const seen = /* @__PURE__ */ new Set();
426
+ let lastId;
427
+ let firstPage = true;
428
+ for (; ; ) {
429
+ const ids = await fetchDependentIdPage(
430
+ Target,
431
+ filter,
432
+ session,
433
+ firstPage ? void 0 : lastId,
434
+ traversalLimits.batchSize
435
+ );
436
+ firstPage = false;
437
+ if (ids.length === 0) return;
438
+ const fresh = ids.filter((id) => {
439
+ const key = typeof id === "object" && id !== null ? String(id) : `${typeof id}:${String(id)}`;
440
+ if (seen.has(key)) return false;
441
+ seen.add(key);
442
+ return true;
443
+ });
444
+ await runWithConcurrencyLimit(fresh, effectiveConcurrency, deleteById);
445
+ lastId = ids[ids.length - 1];
446
+ if (ids.length < traversalLimits.batchSize) return;
447
+ }
131
448
  };
449
+ schema.pre("deleteOne", { document: true, query: false }, async function preDeleteOne(...args) {
450
+ const explicitSession = args[1]?.session ?? args[0]?.session ?? null;
451
+ assertTransactionSupported(resolveExecutionContext(this, model, explicitSession));
452
+ });
132
453
  schema.post("deleteOne", { document: true, query: false }, async function postDeleteOne() {
133
454
  await deleteDependents.call(this);
134
455
  });
@@ -173,36 +494,44 @@ function newDocumentPlugin(schema, options) {
173
494
  }
174
495
 
175
496
  // src/schema.ts
176
- var uniqueNullableString = (field, options = {}) => ({
177
- type: String,
178
- index: { unique: true, partialFilterExpression: { [field]: { $type: "string" } } },
179
- trim: true,
180
- default: null,
181
- ...options
182
- });
183
- var uniqueEmptiableString = (field, options = {}) => ({
184
- type: String,
185
- index: {
186
- unique: true,
187
- partialFilterExpression: { [field]: { $type: "string", $gt: "" } }
188
- },
189
- trim: true,
190
- default: null,
191
- ...options
192
- });
497
+ var uniqueNullableString = (field, options) => (
498
+ // The runtime spread applies overrides over the defaults; the cast expresses
499
+ // that override-wins merge because TypeScript cannot narrow a generic spread.
500
+ {
501
+ type: String,
502
+ index: { unique: true, partialFilterExpression: { [field]: { $type: "string" } } },
503
+ trim: true,
504
+ default: null,
505
+ ...options
506
+ }
507
+ );
508
+ var uniqueEmptiableString = (field, options) => (
509
+ // Same override-wins spread contract as uniqueNullableString above.
510
+ {
511
+ type: String,
512
+ index: {
513
+ unique: true,
514
+ partialFilterExpression: { [field]: { $type: "string", $gt: "" } }
515
+ },
516
+ trim: true,
517
+ default: null,
518
+ ...options
519
+ }
520
+ );
193
521
 
194
522
  // src/is.ts
195
523
  import { Types } from "mongoose";
196
524
  var { ObjectId } = Types;
525
+ var CANONICAL_OBJECT_ID_STRING = /^[0-9a-f]{24}$/;
197
526
  function isObjectId(value) {
198
- if (!ObjectId.isValid(value)) {
199
- return false;
200
- }
201
527
  try {
202
- const asString = String(value);
203
- const asObjectId = new ObjectId(asString);
204
- const asStringifiedObjectId = asObjectId.toString();
205
- return asString === asStringifiedObjectId;
528
+ if (typeof value === "string") {
529
+ return CANONICAL_OBJECT_ID_STRING.test(value);
530
+ }
531
+ if (value instanceof ObjectId) {
532
+ return true;
533
+ }
534
+ return false;
206
535
  } catch {
207
536
  return false;
208
537
  }
package/is.d.mts CHANGED
@@ -2,8 +2,8 @@ import { Types } from 'mongoose';
2
2
 
3
3
  declare const ObjectId: typeof Types.ObjectId;
4
4
  /**
5
- * Type guard that returns `true` when `value` is a valid MongoDB ObjectId
6
- * string or ObjectId instance.
5
+ * Type guard that returns `true` when `value` is a canonical MongoDB
6
+ * ObjectId string or ObjectId instance.
7
7
  *
8
8
  * @example
9
9
  * if (isObjectId(req.params.id)) { doSomething(); }
package/is.d.ts CHANGED
@@ -2,8 +2,8 @@ import { Types } from 'mongoose';
2
2
 
3
3
  declare const ObjectId: typeof Types.ObjectId;
4
4
  /**
5
- * Type guard that returns `true` when `value` is a valid MongoDB ObjectId
6
- * string or ObjectId instance.
5
+ * Type guard that returns `true` when `value` is a canonical MongoDB
6
+ * ObjectId string or ObjectId instance.
7
7
  *
8
8
  * @example
9
9
  * if (isObjectId(req.params.id)) { doSomething(); }
package/is.js CHANGED
@@ -25,15 +25,16 @@ __export(is_exports, {
25
25
  module.exports = __toCommonJS(is_exports);
26
26
  var import_mongoose = require("mongoose");
27
27
  var { ObjectId } = import_mongoose.Types;
28
+ var CANONICAL_OBJECT_ID_STRING = /^[0-9a-f]{24}$/;
28
29
  function isObjectId(value) {
29
- if (!ObjectId.isValid(value)) {
30
- return false;
31
- }
32
30
  try {
33
- const asString = String(value);
34
- const asObjectId = new ObjectId(asString);
35
- const asStringifiedObjectId = asObjectId.toString();
36
- return asString === asStringifiedObjectId;
31
+ if (typeof value === "string") {
32
+ return CANONICAL_OBJECT_ID_STRING.test(value);
33
+ }
34
+ if (value instanceof ObjectId) {
35
+ return true;
36
+ }
37
+ return false;
37
38
  } catch {
38
39
  return false;
39
40
  }
package/is.mjs CHANGED
@@ -1,15 +1,16 @@
1
1
  // src/is.ts
2
2
  import { Types } from "mongoose";
3
3
  var { ObjectId } = Types;
4
+ var CANONICAL_OBJECT_ID_STRING = /^[0-9a-f]{24}$/;
4
5
  function isObjectId(value) {
5
- if (!ObjectId.isValid(value)) {
6
- return false;
7
- }
8
6
  try {
9
- const asString = String(value);
10
- const asObjectId = new ObjectId(asString);
11
- const asStringifiedObjectId = asObjectId.toString();
12
- return asString === asStringifiedObjectId;
7
+ if (typeof value === "string") {
8
+ return CANONICAL_OBJECT_ID_STRING.test(value);
9
+ }
10
+ if (value instanceof ObjectId) {
11
+ return true;
12
+ }
13
+ return false;
13
14
  } catch {
14
15
  return false;
15
16
  }
package/llms.txt CHANGED
@@ -75,6 +75,26 @@ const keycloak = createManagedKeycloakClient({
75
75
  clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
76
76
  });
77
77
 
78
+ // Mapped attribute paths must exist in the schema. pendingPassword is a
79
+ // virtual below and is never persisted to MongoDB.
80
+ const userSchema = new Schema({
81
+ providerId: String,
82
+ username: String,
83
+ email: String,
84
+ roles: [String],
85
+ tier: String,
86
+ tenantId: String,
87
+ subscription: { plan: String },
88
+ });
89
+
90
+ userSchema.virtual('pendingPassword') // pragma: allowlist secret
91
+ .get(function (this: { $locals: Record<string, unknown> }) {
92
+ return this.$locals.pendingPassword as string | undefined;
93
+ })
94
+ .set(function (this: { $locals: Record<string, unknown> }, value: string | undefined) {
95
+ this.$locals.pendingPassword = value;
96
+ });
97
+
78
98
  userSchema.plugin(keycloakUserSyncPlugin, {
79
99
  client: keycloak,
80
100
  realm: 'application',
@@ -86,6 +106,9 @@ userSchema.plugin(keycloakUserSyncPlugin, {
86
106
  passwordTemporary: true,
87
107
  mapPassword: (doc) => doc.get('pendingPassword') as string | undefined,
88
108
  attributePaths: ['tenantId', 'subscription.plan'],
109
+ rolePaths: ['tier'],
110
+ passwordPaths: ['pendingPassword'],
111
+ mapRoles: (_roles, doc) => (doc.get('tier') === 'pro' ? ['editor'] : []),
89
112
  mapAttributes: (doc) => ({
90
113
  tenantId: doc.get('tenantId'),
91
114
  plan: doc.get('subscription.plan'),
@@ -96,8 +119,8 @@ userSchema.plugin(keycloakUserSyncPlugin, {
96
119
 
97
120
  ## Gotchas
98
121
 
99
- - peer dependency: `mongoose >= 8`
100
- - optional peer dependency: `@egose/keycloak-fluent ^0.7` only for `@web-ts-toolkit/moo/plugins/keycloak-user-sync`
122
+ - peer dependency: `mongoose >= 8` (tested with Mongoose 8.24.x and 9.x)
123
+ - optional peer dependency: `@egose/keycloak-fluent >=0.12.1 <0.15.0` only for `@web-ts-toolkit/moo/plugins/keycloak-user-sync` (tested floor `0.12.1`, current `0.14.x`)
101
124
  - root entrypoint exports schema helpers, `isObjectId`, and non-Keycloak plugins; dedicated plugin subpaths are also available
102
125
  - Keycloak user sync is not re-exported from root or `@web-ts-toolkit/moo/plugins`; import it from the direct subpath
103
126
  - `createManagedKeycloakClient` lazily uses `client_credentials`, renews on demand without timers, and deduplicates concurrent authentication; construct a client directly for custom grants
@@ -110,15 +133,20 @@ userSchema.plugin(keycloakUserSyncPlugin, {
110
133
  - Keycloak attributes sync from the `attributes` path or `mapAttributes`; values become string arrays
111
134
  - use `managedAttributes` for keys the plugin may remove; unmanaged remote attributes are preserved
112
135
  - use `attributePaths` when `mapAttributes` reads dynamic paths outside the default `attributes` path
113
- - password sync is opt-in with `syncFields.password: true`; map a plaintext pending password, not a stored hash
114
- - password updates use Keycloak reset-password; `passwordTemporary` defaults to false
136
+ - password sync is opt-in with `syncFields.password: true`; map a non-persisted pending-password virtual, not a stored hash or stored plaintext path
137
+ - `mapPassword` runs lazily (creation or declared password intent only); declare extra inputs with `passwordPaths`, role inputs with `rolePaths`
138
+ - Keycloak loggers receive only `{ operation, localDocumentId, error: { name, code?, status? } }`; `onError` keeps the original error
139
+ - cascade delete intercepts only document `deleteOne()`; `post` runs after the parent is removed (abort the transaction to restore); `maxConcurrency` default 8, `batchSize` default 100
140
+ - typed filters accept per-field operators (`{ price: { $gt: 5 } }`); no-argument `findDependents()`/`findOrphans()` return `Partial` maps (unsupported entries omitted)
141
+ - schema-helper `overrides` win over defaults in both runtime and types
142
+ - new-document plugin runs its callback only after the first successful `save()` of a newly inserted document
115
143
 
116
144
  ## Subpaths
117
145
 
118
146
  - `@web-ts-toolkit/moo/is` — `isObjectId`
119
147
  - `@web-ts-toolkit/moo/schema` — `uniqueNullableString`, `uniqueEmptiableString`
120
148
  - `@web-ts-toolkit/moo/utils` — `isSchema`, `isObjectIdType`, `isReference`
121
- - `@web-ts-toolkit/moo/plugins` — re-exports all plugins
149
+ - `@web-ts-toolkit/moo/plugins` — non-Keycloak plugins (`modelFunctionPlugin`, `cascadeDeletePlugin`, `newDocumentPlugin`)
122
150
  - `@web-ts-toolkit/moo/plugins/cascade-delete` — `cascadeDeletePlugin`
123
151
  - `@web-ts-toolkit/moo/plugins/model-function` — `modelFunctionPlugin`
124
152
  - `@web-ts-toolkit/moo/plugins/new-document` — `newDocumentPlugin`
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/moo",
3
3
  "description": "Mongoose helpers for schema fields, ObjectId checks, and document plugins",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/moo",
5
- "version": "0.42.2",
5
+ "version": "0.47.1",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "mongoose",
@@ -74,7 +74,7 @@
74
74
  "node": ">=22"
75
75
  },
76
76
  "peerDependencies": {
77
- "@egose/keycloak-fluent": "^0.12.1",
77
+ "@egose/keycloak-fluent": ">=0.12.1 <0.15.0",
78
78
  "mongoose": ">=8.0.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
@@ -83,7 +83,7 @@
83
83
  }
84
84
  },
85
85
  "dependencies": {
86
- "@web-ts-toolkit/utils": "0.42.2"
86
+ "@web-ts-toolkit/utils": "0.47.1"
87
87
  },
88
88
  "files": [
89
89
  "**/*",