@web-ts-toolkit/moo 0.43.0 → 0.48.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.
@@ -54,12 +54,24 @@ var normalizeAttributes = (attributes) => {
54
54
  return normalizedAttributes;
55
55
  };
56
56
  var getDocumentValue = (document, paths, field) => document.get(paths[field]);
57
+ var dependencyPathsFor = (options, field) => {
58
+ if (!options.syncFields[field]) return [];
59
+ if (field === "attributes") return options.attributePaths ?? [];
60
+ if (field === "roles") return options.rolePaths ?? [];
61
+ return options.passwordPaths ?? [];
62
+ };
63
+ var hasDependencyChanged = (options, field, isModified) => dependencyPathsFor(options, field).some(isModified);
64
+ var collectDependencyPaths = (options) => [
65
+ ...dependencyPathsFor(options, "attributes"),
66
+ ...dependencyPathsFor(options, "roles"),
67
+ ...dependencyPathsFor(options, "password")
68
+ ];
57
69
  var buildTrackedPaths = (options) => uniqueStrings([
58
70
  options.paths.providerId,
59
71
  ...Object.entries(options.syncFields).filter(([, enabled]) => enabled).flatMap(
60
72
  ([field]) => field === "enabled" ? [options.paths.enabled, options.paths.archived] : [options.paths[field]]
61
73
  ),
62
- ...options.syncFields.attributes && options.attributePaths ? options.attributePaths : []
74
+ ...collectDependencyPaths(options)
63
75
  ]);
64
76
  var planChangedFields = (options, isNew, isModified, currentProviderId) => {
65
77
  const changedFields = /* @__PURE__ */ new Set();
@@ -69,7 +81,9 @@ var planChangedFields = (options, isNew, isModified, currentProviderId) => {
69
81
  const paths = field === "enabled" ? [options.paths.enabled, options.paths.archived] : [options.paths[field]];
70
82
  if (paths.some(isModified)) changedFields.add(field);
71
83
  }
72
- if (options.syncFields.attributes && options.attributePaths?.some(isModified)) changedFields.add("attributes");
84
+ if (hasDependencyChanged(options, "attributes", isModified)) changedFields.add("attributes");
85
+ if (hasDependencyChanged(options, "roles", isModified)) changedFields.add("roles");
86
+ if (hasDependencyChanged(options, "password", isModified)) changedFields.add("password");
73
87
  const shouldPersistMissingProviderId = stringValue(currentProviderId) === null;
74
88
  const shouldSync = isNew || shouldPersistMissingProviderId || changedFields.size > 0;
75
89
  return {
@@ -288,6 +302,8 @@ var normalizeOptions = (schema, options) => {
288
302
  paths,
289
303
  syncFields,
290
304
  attributePaths: normalizeOptionalStringList(options.attributePaths, "attributePaths"),
305
+ rolePaths: normalizeOptionalStringList(options.rolePaths, "rolePaths"),
306
+ passwordPaths: normalizeOptionalStringList(options.passwordPaths, "passwordPaths"),
291
307
  managedAttributes: normalizeOptionalStringList(options.managedAttributes, "managedAttributes"),
292
308
  managedRoles: normalizeOptionalStringList(options.managedRoles, "managedRoles"),
293
309
  maxRolesPerSync: normalizeMaxRolesPerSync(options.maxRolesPerSync)
@@ -311,6 +327,26 @@ var normalizeOptions = (schema, options) => {
311
327
  return normalized;
312
328
  };
313
329
  var getState = (document) => document.$locals[syncStateKey];
330
+ var loggerTokenPattern = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
331
+ var toLoggerToken = (value) => {
332
+ if (typeof value !== "string") return void 0;
333
+ if (value.length === 0 || value.length > 64) return void 0;
334
+ if (!loggerTokenPattern.test(value)) return void 0;
335
+ return value;
336
+ };
337
+ var toLoggedError = (error) => {
338
+ if (typeof error !== "object" || error === null) return { name: "UnknownError" };
339
+ try {
340
+ const candidate = error;
341
+ const name = toLoggerToken(candidate.name) ?? "Error";
342
+ const code = toLoggerToken(candidate.code);
343
+ const rawStatus = candidate.status ?? candidate.statusCode;
344
+ const status = typeof rawStatus === "number" && Number.isSafeInteger(rawStatus) && rawStatus >= 0 && rawStatus <= 999 ? rawStatus : void 0;
345
+ return { name, ...code === void 0 ? {} : { code }, ...status === void 0 ? {} : { status } };
346
+ } catch {
347
+ return { name: "Error" };
348
+ }
349
+ };
314
350
  var getDocumentSession = (document) => document.$session?.() ?? null;
315
351
  var assertNoDocumentSession = (document, operation) => {
316
352
  if (getDocumentSession(document)) {
@@ -331,6 +367,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
331
367
  const identities = options.identifyBy;
332
368
  const realmHandle = options.client.realm(options.realm);
333
369
  const trackedPaths = buildTrackedPaths(options);
370
+ const identitySnapshotPaths = uniqueStrings([...trackedPaths, paths.providerId, paths.username, paths.email]);
334
371
  const buildSafeErrorContext = (operation, document) => ({
335
372
  operation,
336
373
  localDocumentId: document._id === void 0 || document._id === null ? void 0 : String(document._id)
@@ -339,7 +376,10 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
339
376
  const safeContext = buildSafeErrorContext(operation, document);
340
377
  const logger = options.logger === false ? null : options.logger ?? console;
341
378
  try {
342
- await logger?.error(`Keycloak user sync failed during ${operation}`, { ...safeContext, error });
379
+ await logger?.error(`Keycloak user sync failed during ${operation}`, {
380
+ ...safeContext,
381
+ error: toLoggedError(error)
382
+ });
343
383
  } catch {
344
384
  }
345
385
  try {
@@ -352,7 +392,8 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
352
392
  const readDocumentValue = (document, field) => getDocumentValue(document, paths, field);
353
393
  const getPreviousValue = (previous, field) => getPathValue(previous, paths[field]);
354
394
  const getUsername = (document) => stringValue(readDocumentValue(document, "username")) ?? stringValue(readDocumentValue(document, "email"));
355
- const resolveUser = async (document, previous) => {
395
+ const readPersistedSnapshot = async (document) => document.constructor.findById(document._id).select(identitySnapshotPaths).session(getDocumentSession(document)).lean();
396
+ const resolveUser = async (document, snapshot, wasNew) => {
356
397
  const realm = await realmHandle.get();
357
398
  if (!realm) throw new Error(`Keycloak realm "${options.realm}" was not found`);
358
399
  const duplicateEmailsAllowed = realm.duplicateEmailsAllowed === true;
@@ -361,40 +402,133 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
361
402
  if (!user.id) return user;
362
403
  return await realmHandle.userById(user.id).get() ?? user;
363
404
  };
364
- for (const identity of orderedIdentities) {
365
- if (identity === "providerId") {
366
- for (const providerId of uniqueStrings([
367
- readDocumentValue(document, "providerId"),
368
- getPreviousValue(previous, "providerId")
369
- ])) {
370
- const user = await realmHandle.userById(providerId).get();
371
- if (user) return { user, duplicateEmailsAllowed };
372
- }
405
+ const findByProviderId = async (providerId) => realmHandle.userById(providerId).get();
406
+ const findByUsername = async (username) => realmHandle.user(username).get();
407
+ const findByEmail = async (email) => {
408
+ const matches = (await realmHandle.searchUsers(email, { attribute: "email", exact: true, first: 0, max: 2 })).filter((user) => normalizeEmail(user.email) === normalizeEmail(email));
409
+ if (matches.length > 1) {
410
+ throw new Error("Cannot identify a unique Keycloak user for the configured email identity");
373
411
  }
374
- if (identity === "username") {
375
- for (const username of uniqueStrings([
376
- readDocumentValue(document, "username"),
377
- getPreviousValue(previous, "username")
412
+ if (matches.length === 1) return getCompleteUserById(matches[0]);
413
+ return null;
414
+ };
415
+ const findByIdentity = async (identity, value) => {
416
+ if (identity === "providerId") return findByProviderId(value);
417
+ if (identity === "username") return findByUsername(value);
418
+ return findByEmail(value);
419
+ };
420
+ const identityConflict = () => new Error(
421
+ "keycloakUserSyncPlugin detected a Keycloak identity conflict; refusing to relink to a different remote user"
422
+ );
423
+ if (wasNew) {
424
+ for (const identity of orderedIdentities) {
425
+ for (const value of uniqueStrings([
426
+ readDocumentValue(document, identity),
427
+ getPreviousValue(snapshot, identity)
378
428
  ])) {
379
- const user = await realmHandle.user(username).get();
429
+ const user = await findByIdentity(identity, value);
380
430
  if (user) return { user, duplicateEmailsAllowed };
381
431
  }
382
432
  }
383
- if (identity === "email") {
384
- for (const email of uniqueStrings([
385
- readDocumentValue(document, "email"),
386
- getPreviousValue(previous, "email")
387
- ])) {
388
- const matches = (await realmHandle.searchUsers(email, { attribute: "email", exact: true, first: 0, max: 2 })).filter((user) => normalizeEmail(user.email) === normalizeEmail(email));
389
- if (matches.length > 1) {
390
- throw new Error("Cannot identify a unique Keycloak user for the configured email identity");
391
- }
392
- if (matches.length === 1) return { user: await getCompleteUserById(matches[0]), duplicateEmailsAllowed };
393
- }
433
+ return { user: null, duplicateEmailsAllowed };
434
+ }
435
+ if (!snapshot) {
436
+ throw new Error(
437
+ "keycloakUserSyncPlugin cannot verify the persisted Keycloak identity for this document; refusing to sync a possibly relinked account"
438
+ );
439
+ }
440
+ const persistedValue = (identity) => stringValue(getPreviousValue(snapshot, identity));
441
+ const currentValue = (identity) => stringValue(readDocumentValue(document, identity));
442
+ const persistedProviderId = persistedValue("providerId");
443
+ const currentProviderId = currentValue("providerId");
444
+ if (persistedProviderId && currentProviderId && persistedProviderId !== currentProviderId) {
445
+ throw new Error("keycloakUserSyncPlugin providerId is server-controlled and cannot be changed after persistence");
446
+ }
447
+ const isRelinkProtected = (identity) => identity === "username" || identity === "email" && !duplicateEmailsAllowed;
448
+ const checkCurrentDoesNotRelink = async (identity, boundId) => {
449
+ if (!identities.includes(identity) || !isRelinkProtected(identity)) return;
450
+ const current = currentValue(identity);
451
+ if (!current) return;
452
+ const persisted = persistedValue(identity);
453
+ const unchanged = identity === "email" ? persisted !== null && normalizeEmail(persisted) === normalizeEmail(current) : persisted === current;
454
+ if (persisted !== null && unchanged) return;
455
+ const user = await findByIdentity(identity, current);
456
+ if (user?.id && user.id !== boundId) throw identityConflict();
457
+ };
458
+ if (persistedProviderId) {
459
+ const bound2 = await findByProviderId(persistedProviderId);
460
+ if (!bound2?.id) {
461
+ throw new Error(
462
+ "keycloakUserSyncPlugin persisted Keycloak identity no longer exists; refusing to relink to a different remote user"
463
+ );
464
+ }
465
+ await checkCurrentDoesNotRelink("username", bound2.id);
466
+ await checkCurrentDoesNotRelink("email", bound2.id);
467
+ return { user: bound2, duplicateEmailsAllowed };
468
+ }
469
+ let bound = null;
470
+ for (const identity of orderedIdentities) {
471
+ if (identity === "providerId") continue;
472
+ const persisted = persistedValue(identity);
473
+ if (!persisted) continue;
474
+ const user = await findByIdentity(identity, persisted);
475
+ if (!user?.id) continue;
476
+ if (bound?.id && bound.id !== user.id) throw identityConflict();
477
+ bound ??= user;
478
+ }
479
+ if (bound?.id) {
480
+ await checkCurrentDoesNotRelink("username", bound.id);
481
+ await checkCurrentDoesNotRelink("email", bound.id);
482
+ if (currentProviderId) {
483
+ const owner = await findByProviderId(currentProviderId);
484
+ if (owner?.id && owner.id !== bound.id) throw identityConflict();
394
485
  }
486
+ return { user: bound, duplicateEmailsAllowed };
487
+ }
488
+ if (currentProviderId) {
489
+ const owner = await findByProviderId(currentProviderId);
490
+ if (owner?.id) throw identityConflict();
491
+ }
492
+ for (const identity of orderedIdentities) {
493
+ if (identity === "providerId") continue;
494
+ const current = currentValue(identity);
495
+ if (!current) continue;
496
+ const user = await findByIdentity(identity, current);
497
+ if (user?.id) throw identityConflict();
395
498
  }
396
499
  return { user: null, duplicateEmailsAllowed };
397
500
  };
501
+ const getProvisioningFlags = (error) => {
502
+ if (typeof error !== "object" || error === null) return null;
503
+ const candidate = error;
504
+ if (candidate.name !== "UserPasswordProvisioningError" && !("accountPersists" in candidate) && !("initialProvisioning" in candidate)) {
505
+ return null;
506
+ }
507
+ return candidate;
508
+ };
509
+ const isAlreadyExistsError = (error) => error instanceof Error && /already exists in realm/.test(error.message);
510
+ const readIntendedEnabled = (document) => {
511
+ if (!syncFields.enabled) return void 0;
512
+ const archived = readDocumentValue(document, "archived");
513
+ if (typeof archived === "boolean") return !archived;
514
+ const enabled = readDocumentValue(document, "enabled");
515
+ if (typeof enabled === "boolean") return enabled;
516
+ return void 0;
517
+ };
518
+ const recoverPartialCreation = async (document, username, creationError, desiredPassword) => {
519
+ if (identities.length === 0) throw creationError;
520
+ if (isAlreadyExistsError(creationError)) throw creationError;
521
+ const flags = getProvisioningFlags(creationError);
522
+ if (flags && flags.accountPersists === false) throw creationError;
523
+ if (desiredPassword && !(flags && flags.accountPersists === true)) throw creationError;
524
+ const recoveredUser = await resolveCreatedUser(document, username);
525
+ if (!recoveredUser?.id) throw creationError;
526
+ if (desiredPassword) {
527
+ const candidateUsername = stringValue(recoveredUser.username);
528
+ if (!candidateUsername || candidateUsername !== username) throw creationError;
529
+ }
530
+ return { user: recoveredUser, passwordAlreadyApplied: flags?.passwordApplied === true };
531
+ };
398
532
  const resolveCreatedUser = async (document, username) => {
399
533
  const userByUsername = await realmHandle.user(username).get();
400
534
  if (userByUsername) return userByUsername;
@@ -432,34 +566,46 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
432
566
  document.unmarkModified(paths.providerId);
433
567
  };
434
568
  const syncDocument = async (document, state) => {
435
- const resolved = await resolveUser(document, state.previous);
569
+ const resolved = await resolveUser(document, state.previous, state.wasNew);
436
570
  let user = resolved.user;
437
571
  let created = false;
572
+ let recoveredPartial = false;
573
+ let passwordAlreadyApplied = false;
438
574
  const shouldSyncProfile = state.wasNew || ["username", "email", "emailVerified", "firstName", "lastName", "enabled"].some(
439
575
  (field) => state.changedFields.has(field)
440
576
  );
441
577
  const shouldSyncAttributes = state.wasNew || state.changedFields.has("attributes");
442
578
  const payload = shouldSyncProfile ? buildProfilePayload(document, options, Boolean(resolved.user), state.wasNew ? void 0 : state.changedFields) : {};
443
- const desiredPassword = getDesiredPassword(document, options);
579
+ let cachedPassword;
580
+ let passwordEvaluated = false;
581
+ const readDesiredPassword = () => {
582
+ if (!passwordEvaluated) {
583
+ cachedPassword = getDesiredPassword(document, options);
584
+ passwordEvaluated = true;
585
+ }
586
+ return cachedPassword ?? null;
587
+ };
444
588
  if (!user) {
445
589
  const username = getUsername(document);
446
590
  if (!username) throw new Error("Cannot create a Keycloak user without a username or email");
447
591
  payload.attributes = mergeAttributes(null, document, options);
592
+ const creationPassword = readDesiredPassword();
448
593
  try {
449
594
  user = await realmHandle.user(username).create({
450
595
  username,
451
596
  ...payload,
452
- ...desiredPassword && {
453
- password: desiredPassword,
597
+ ...creationPassword && {
598
+ password: creationPassword,
454
599
  passwordTemporary: options.passwordTemporary ?? false
455
600
  }
456
601
  }) ?? null;
457
602
  created = true;
458
603
  } catch (error) {
459
- if (identities.length === 0) throw error;
460
- const recoveredUser = await resolveCreatedUser(document, username);
461
- if (!recoveredUser) throw error;
462
- user = recoveredUser;
604
+ if (!state.wasNew) throw error;
605
+ const recovered = await recoverPartialCreation(document, username, error, creationPassword);
606
+ user = recovered.user;
607
+ recoveredPartial = true;
608
+ passwordAlreadyApplied = recovered.passwordAlreadyApplied;
463
609
  }
464
610
  }
465
611
  if (!user?.id) throw new Error("Keycloak did not return a user ID");
@@ -474,6 +620,8 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
474
620
  currentEmail: readDocumentValue(document, "email"),
475
621
  remoteEmail: user.email
476
622
  });
623
+ const desiredPassword = !created && !passwordAlreadyApplied && (recoveredPartial || state.passwordChanged) ? readDesiredPassword() : cachedPassword ?? null;
624
+ const willResetPassword = Boolean(desiredPassword) && !created && !passwordAlreadyApplied && (recoveredPartial || state.passwordChanged);
477
625
  if (!created) {
478
626
  if (emailVerificationPlan.initialLinkSameEmail) delete payload.emailVerified;
479
627
  if (emailVerificationPlan.changed) {
@@ -482,21 +630,35 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
482
630
  payload.emailVerified = false;
483
631
  }
484
632
  if (shouldSyncAttributes) payload.attributes = mergeAttributes(user, document, options);
485
- if (Object.keys(payload).length > 0) {
486
- await realmHandle.userById(userId).update(payload);
487
- user = { ...user, ...payload };
633
+ if (willResetPassword) {
634
+ const { enabled: deferredEnabled, ...profileWithoutEnabled } = payload;
635
+ const intendedEnabled = typeof deferredEnabled === "boolean" ? deferredEnabled : readIntendedEnabled(document) ?? true;
636
+ if (Object.keys(profileWithoutEnabled).length > 0) {
637
+ await realmHandle.userById(userId).update(profileWithoutEnabled);
638
+ user = { ...user, ...profileWithoutEnabled };
639
+ }
640
+ await realmHandle.userById(userId).resetPassword(desiredPassword, { temporary: options.passwordTemporary ?? false });
641
+ user = { ...user };
642
+ await realmHandle.userById(userId).update({ enabled: intendedEnabled });
643
+ user = { ...user, enabled: intendedEnabled };
644
+ } else {
645
+ if (Object.keys(payload).length > 0) {
646
+ await realmHandle.userById(userId).update(payload);
647
+ user = { ...user, ...payload };
648
+ }
488
649
  }
489
650
  }
490
- if (!created && state.passwordChanged && desiredPassword) {
491
- await realmHandle.userById(userId).resetPassword(desiredPassword, { temporary: options.passwordTemporary ?? false });
492
- }
493
651
  if (created || state.changedFields.has("roles")) await syncRoles(user, document);
494
652
  if (emailVerificationPlan.changed && options.sendVerificationEmailOnChange !== false) {
495
653
  await realmHandle.userById(userId).sendVerifyEmail();
496
654
  }
497
655
  };
498
656
  const deleteDocument = async (document) => {
499
- const { user } = await resolveUser(document);
657
+ if (document.isNew) {
658
+ throw new Error("keycloakUserSyncPlugin cannot delete a Keycloak user for a document that was never persisted");
659
+ }
660
+ const snapshot = await readPersistedSnapshot(document);
661
+ const { user } = await resolveUser(document, snapshot, false);
500
662
  if (user?.id) await realmHandle.userById(user.id).delete();
501
663
  };
502
664
  schema.pre("save", async function keycloakUserSyncPreSave() {
@@ -511,7 +673,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
511
673
  const state = { ...changePlan, wasNew: this.isNew };
512
674
  this.$locals[syncStateKey] = state;
513
675
  if (!state.shouldSync || this.isNew) return;
514
- state.previous = await this.constructor.findById(this._id).select(trackedPaths).session(getDocumentSession(this)).lean();
676
+ state.previous = await readPersistedSnapshot(this);
515
677
  if (providerIdChanged) {
516
678
  const previousProviderId = stringValue(getPreviousValue(state.previous, "providerId"));
517
679
  const currentProviderId = stringValue(readDocumentValue(this, "providerId"));
@@ -1,9 +1,23 @@
1
1
  import { HydratedDocument, Types, Schema, Model } from 'mongoose';
2
2
 
3
+ /**
4
+ * Hydrated document for a raw doc type with the given instance methods.
5
+ */
3
6
  type ModelDocument<TRawDocType, TInstanceMethods = Record<string, never>> = HydratedDocument<TRawDocType, TInstanceMethods>;
7
+ /**
8
+ * Instance-method surface added by one `modelFunctionPlugin` registration.
9
+ * Keep method result types free of the enclosing `ModelDocument` alias (use
10
+ * `void` for in-place mutation or a plain value) to avoid circular
11
+ * `CartDocument`/`CartMethods` aliases, which fail with TS2456.
12
+ */
4
13
  type ModelFunctionInstanceMethods<TMethodName extends string, TArgs extends unknown[] = unknown[], TResult = unknown> = {
5
14
  [K in TMethodName]: (...args: TArgs) => TResult;
6
15
  };
16
+ /**
17
+ * Static-method surface added by one registration: `fnName(doc, ...args)`
18
+ * plus the `fnNameById(docId, ...args)` loader, which returns `null` when no
19
+ * document matches.
20
+ */
7
21
  type ModelFunctionStaticMethods<TMethodName extends string, TDocument, TArgs extends unknown[] = unknown[], TResult = unknown> = {
8
22
  [K in TMethodName]: (doc: TDocument, ...args: TArgs) => TResult;
9
23
  } & {
@@ -13,6 +27,38 @@ interface ModelFunctionPluginOptions<TMethodName extends string, TDocument, TArg
13
27
  fnName: TMethodName;
14
28
  fn(doc: TDocument, ...args: TArgs): TResult;
15
29
  }
30
+ /**
31
+ * Registers one sync or async helper as an instance method, a static method
32
+ * taking the document first, and a `ById` static that loads the document
33
+ * (returning `null` when missing) before invoking `fn`. The helper runs with
34
+ * the historical mongoose-bound callback context and receives the document
35
+ * as its first argument.
36
+ *
37
+ * Prefer inference over explicit generics: `schema.plugin(modelFunctionPlugin,
38
+ * { fnName, fn })` infers the method name, argument tuple, and result from
39
+ * the options. When explicit type arguments are needed, the second argument
40
+ * is the method *name* (`'applyDiscount'`), not the methods object. Keep
41
+ * `TResult` free of the enclosing document alias to avoid circular types.
42
+ *
43
+ * @example
44
+ * type Cart = { name: string; price: number };
45
+ * type CartMethods =
46
+ * ModelFunctionInstanceMethods<'applyDiscount', [suffix: string, priceChange: number], void> &
47
+ * ModelFunctionInstanceMethods<'applyDiscountAsync', [suffix: string, priceChange: number], Promise<void>>;
48
+ * type CartDocument = ModelDocument<Cart, CartMethods>;
49
+ * type CartModel = Model<Cart, Record<string, never>, CartMethods> &
50
+ * ModelFunctionStaticMethods<'applyDiscount', CartDocument, [suffix: string, priceChange: number], void> &
51
+ * ModelFunctionStaticMethods<'applyDiscountAsync', CartDocument, [suffix: string, priceChange: number], Promise<void>>;
52
+ *
53
+ * const cartSchema = new Schema<Cart, CartModel, CartMethods>({ name: String, price: Number });
54
+ * cartSchema.plugin(modelFunctionPlugin, {
55
+ * fnName: 'applyDiscount',
56
+ * fn: (cart: CartDocument, suffix: string, priceChange: number) => {
57
+ * cart.name = `${cart.name}-${suffix}`;
58
+ * cart.price += priceChange;
59
+ * },
60
+ * });
61
+ */
16
62
  declare function modelFunctionPlugin<TRawDocType, TMethodName extends string = string, TInstanceMethods = Record<string, never>, TDocument = ModelDocument<TRawDocType, TInstanceMethods>, TArgs extends unknown[] = unknown[], TResult = unknown>(schema: Schema<TRawDocType, Model<TRawDocType>, TInstanceMethods>, options: ModelFunctionPluginOptions<TMethodName, TDocument, TArgs, TResult>): void;
17
63
 
18
64
  export { type ModelDocument, type ModelFunctionInstanceMethods, type ModelFunctionPluginOptions, type ModelFunctionStaticMethods, modelFunctionPlugin };
@@ -1,9 +1,23 @@
1
1
  import { HydratedDocument, Types, Schema, Model } from 'mongoose';
2
2
 
3
+ /**
4
+ * Hydrated document for a raw doc type with the given instance methods.
5
+ */
3
6
  type ModelDocument<TRawDocType, TInstanceMethods = Record<string, never>> = HydratedDocument<TRawDocType, TInstanceMethods>;
7
+ /**
8
+ * Instance-method surface added by one `modelFunctionPlugin` registration.
9
+ * Keep method result types free of the enclosing `ModelDocument` alias (use
10
+ * `void` for in-place mutation or a plain value) to avoid circular
11
+ * `CartDocument`/`CartMethods` aliases, which fail with TS2456.
12
+ */
4
13
  type ModelFunctionInstanceMethods<TMethodName extends string, TArgs extends unknown[] = unknown[], TResult = unknown> = {
5
14
  [K in TMethodName]: (...args: TArgs) => TResult;
6
15
  };
16
+ /**
17
+ * Static-method surface added by one registration: `fnName(doc, ...args)`
18
+ * plus the `fnNameById(docId, ...args)` loader, which returns `null` when no
19
+ * document matches.
20
+ */
7
21
  type ModelFunctionStaticMethods<TMethodName extends string, TDocument, TArgs extends unknown[] = unknown[], TResult = unknown> = {
8
22
  [K in TMethodName]: (doc: TDocument, ...args: TArgs) => TResult;
9
23
  } & {
@@ -13,6 +27,38 @@ interface ModelFunctionPluginOptions<TMethodName extends string, TDocument, TArg
13
27
  fnName: TMethodName;
14
28
  fn(doc: TDocument, ...args: TArgs): TResult;
15
29
  }
30
+ /**
31
+ * Registers one sync or async helper as an instance method, a static method
32
+ * taking the document first, and a `ById` static that loads the document
33
+ * (returning `null` when missing) before invoking `fn`. The helper runs with
34
+ * the historical mongoose-bound callback context and receives the document
35
+ * as its first argument.
36
+ *
37
+ * Prefer inference over explicit generics: `schema.plugin(modelFunctionPlugin,
38
+ * { fnName, fn })` infers the method name, argument tuple, and result from
39
+ * the options. When explicit type arguments are needed, the second argument
40
+ * is the method *name* (`'applyDiscount'`), not the methods object. Keep
41
+ * `TResult` free of the enclosing document alias to avoid circular types.
42
+ *
43
+ * @example
44
+ * type Cart = { name: string; price: number };
45
+ * type CartMethods =
46
+ * ModelFunctionInstanceMethods<'applyDiscount', [suffix: string, priceChange: number], void> &
47
+ * ModelFunctionInstanceMethods<'applyDiscountAsync', [suffix: string, priceChange: number], Promise<void>>;
48
+ * type CartDocument = ModelDocument<Cart, CartMethods>;
49
+ * type CartModel = Model<Cart, Record<string, never>, CartMethods> &
50
+ * ModelFunctionStaticMethods<'applyDiscount', CartDocument, [suffix: string, priceChange: number], void> &
51
+ * ModelFunctionStaticMethods<'applyDiscountAsync', CartDocument, [suffix: string, priceChange: number], Promise<void>>;
52
+ *
53
+ * const cartSchema = new Schema<Cart, CartModel, CartMethods>({ name: String, price: Number });
54
+ * cartSchema.plugin(modelFunctionPlugin, {
55
+ * fnName: 'applyDiscount',
56
+ * fn: (cart: CartDocument, suffix: string, priceChange: number) => {
57
+ * cart.name = `${cart.name}-${suffix}`;
58
+ * cart.price += priceChange;
59
+ * },
60
+ * });
61
+ */
16
62
  declare function modelFunctionPlugin<TRawDocType, TMethodName extends string = string, TInstanceMethods = Record<string, never>, TDocument = ModelDocument<TRawDocType, TInstanceMethods>, TArgs extends unknown[] = unknown[], TResult = unknown>(schema: Schema<TRawDocType, Model<TRawDocType>, TInstanceMethods>, options: ModelFunctionPluginOptions<TMethodName, TDocument, TArgs, TResult>): void;
17
63
 
18
64
  export { type ModelDocument, type ModelFunctionInstanceMethods, type ModelFunctionPluginOptions, type ModelFunctionStaticMethods, modelFunctionPlugin };
@@ -1,12 +1,31 @@
1
1
  import { HydratedDocument, Schema } from 'mongoose';
2
2
 
3
3
  interface NewDocumentPluginOptions<TDocument = unknown, TResult = unknown> {
4
- /** Runs after the first successful save for a newly inserted document. */
4
+ /**
5
+ * Runs once, after the first successful `save()` of a newly inserted
6
+ * document. This is a post-save notification, not durable delivery: a
7
+ * throwing callback rejects the post-save hook but cannot roll back the
8
+ * committed write, and no outbox, retry, or exactly-once guarantee across
9
+ * transaction retries is provided. Applications needing transactional
10
+ * delivery should record their own outbox intent and process it after
11
+ * commit.
12
+ */
5
13
  fn(this: TDocument, document: TDocument): TResult | Promise<TResult>;
6
14
  }
7
15
  /**
8
16
  * Runs a callback once, after a newly inserted document is successfully saved.
9
- * Existing documents saved later do not trigger the callback.
17
+ *
18
+ * Supported operations: only document `save()` is observed, via a `pre('save')`
19
+ * snapshot of `isNew` plus a `post('save')` invocation. Query inserts,
20
+ * `insertMany()` fast paths that skip document middleware, and updates to
21
+ * existing documents never trigger the callback. Later saves of the same
22
+ * document do not re-trigger it.
23
+ *
24
+ * Delivery scope: post-save notification, not durable delivery. The callback
25
+ * runs after MongoDB persistence; its failure cannot roll back the write,
26
+ * and transaction retries/reentrant saves carry no exactly-once guarantee.
27
+ * Write an application-owned outbox record inside the transaction when
28
+ * exactly-once downstream delivery is required.
10
29
  */
11
30
  declare function newDocumentPlugin<TRawDocType, TDocument = HydratedDocument<TRawDocType>>(schema: Schema<TRawDocType>, options: NewDocumentPluginOptions<TDocument>): void;
12
31
 
@@ -1,12 +1,31 @@
1
1
  import { HydratedDocument, Schema } from 'mongoose';
2
2
 
3
3
  interface NewDocumentPluginOptions<TDocument = unknown, TResult = unknown> {
4
- /** Runs after the first successful save for a newly inserted document. */
4
+ /**
5
+ * Runs once, after the first successful `save()` of a newly inserted
6
+ * document. This is a post-save notification, not durable delivery: a
7
+ * throwing callback rejects the post-save hook but cannot roll back the
8
+ * committed write, and no outbox, retry, or exactly-once guarantee across
9
+ * transaction retries is provided. Applications needing transactional
10
+ * delivery should record their own outbox intent and process it after
11
+ * commit.
12
+ */
5
13
  fn(this: TDocument, document: TDocument): TResult | Promise<TResult>;
6
14
  }
7
15
  /**
8
16
  * Runs a callback once, after a newly inserted document is successfully saved.
9
- * Existing documents saved later do not trigger the callback.
17
+ *
18
+ * Supported operations: only document `save()` is observed, via a `pre('save')`
19
+ * snapshot of `isNew` plus a `post('save')` invocation. Query inserts,
20
+ * `insertMany()` fast paths that skip document middleware, and updates to
21
+ * existing documents never trigger the callback. Later saves of the same
22
+ * document do not re-trigger it.
23
+ *
24
+ * Delivery scope: post-save notification, not durable delivery. The callback
25
+ * runs after MongoDB persistence; its failure cannot roll back the write,
26
+ * and transaction retries/reentrant saves carry no exactly-once guarantee.
27
+ * Write an application-owned outbox record inside the transaction when
28
+ * exactly-once downstream delivery is required.
10
29
  */
11
30
  declare function newDocumentPlugin<TRawDocType, TDocument = HydratedDocument<TRawDocType>>(schema: Schema<TRawDocType>, options: NewDocumentPluginOptions<TDocument>): void;
12
31
 
package/schema.d.mts CHANGED
@@ -1,10 +1,17 @@
1
+ /**
2
+ * Arbitrary Mongoose schema-type options merged over the helper defaults.
3
+ * Provided keys win over the defaults at runtime via object spread; the
4
+ * helpers type this with `Omit<Base, keyof TOverrides> & TOverrides` so an
5
+ * override of `default`, `trim`, or `index` is reflected in the inferred
6
+ * return type instead of keeping the fixed default type.
7
+ */
1
8
  type SchemaOptionOverrides = Record<string, unknown>;
2
- declare const uniqueNullableString: (field: string, options?: SchemaOptionOverrides) => {
9
+ type UniqueNullableBase = {
3
10
  type: StringConstructor;
4
11
  index: {
5
12
  unique: boolean;
6
13
  partialFilterExpression: {
7
- [x: string]: {
14
+ [field: string]: {
8
15
  $type: string;
9
16
  };
10
17
  };
@@ -12,12 +19,12 @@ declare const uniqueNullableString: (field: string, options?: SchemaOptionOverri
12
19
  trim: boolean;
13
20
  default: null;
14
21
  };
15
- declare const uniqueEmptiableString: (field: string, options?: SchemaOptionOverrides) => {
22
+ type UniqueEmptiableBase = {
16
23
  type: StringConstructor;
17
24
  index: {
18
25
  unique: boolean;
19
26
  partialFilterExpression: {
20
- [x: string]: {
27
+ [field: string]: {
21
28
  $type: string;
22
29
  $gt: string;
23
30
  };
@@ -26,5 +33,27 @@ declare const uniqueEmptiableString: (field: string, options?: SchemaOptionOverr
26
33
  trim: boolean;
27
34
  default: null;
28
35
  };
36
+ /**
37
+ * Partial-unique string field that allows any number of `null` values while
38
+ * still rejecting duplicate non-null strings (including `''`). The partial
39
+ * index only covers string-typed values (`{ [field]: { $type: 'string' } }`),
40
+ * so `null`/missing entries never conflict. Extra `options` are spread over
41
+ * the defaults and reflected in the return type.
42
+ *
43
+ * @example
44
+ * const userSchema = new Schema({ email: uniqueNullableString('email') });
45
+ */
46
+ declare const uniqueNullableString: <TOverrides extends SchemaOptionOverrides = Record<string, never>>(field: string, options?: TOverrides) => Omit<UniqueNullableBase, keyof TOverrides> & TOverrides;
47
+ /**
48
+ * Partial-unique string field that additionally ignores empty strings:
49
+ * both `null` and `''` can repeat, while duplicate non-empty strings are
50
+ * rejected. The partial index only covers strings longer than `''`
51
+ * (`{ [field]: { $type: 'string', $gt: '' } }`). Extra `options` are spread
52
+ * over the defaults and reflected in the return type.
53
+ *
54
+ * @example
55
+ * const userSchema = new Schema({ username: uniqueEmptiableString('username') });
56
+ */
57
+ declare const uniqueEmptiableString: <TOverrides extends SchemaOptionOverrides = Record<string, never>>(field: string, options?: TOverrides) => Omit<UniqueEmptiableBase, keyof TOverrides> & TOverrides;
29
58
 
30
- export { uniqueEmptiableString, uniqueNullableString };
59
+ export { type SchemaOptionOverrides, uniqueEmptiableString, uniqueNullableString };