@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/README.md +122 -7
- package/index.d.mts +2 -2
- package/index.d.ts +2 -2
- package/index.js +386 -57
- package/index.mjs +386 -57
- package/is.d.mts +2 -2
- package/is.d.ts +2 -2
- package/is.js +8 -7
- package/is.mjs +8 -7
- package/llms.txt +33 -5
- package/package.json +3 -3
- package/plugins/cascade-delete.d.mts +151 -4
- package/plugins/cascade-delete.d.ts +151 -4
- package/plugins/cascade-delete.js +354 -33
- package/plugins/cascade-delete.mjs +354 -33
- package/plugins/index.d.mts +1 -1
- package/plugins/index.d.ts +1 -1
- package/plugins/index.js +354 -33
- package/plugins/index.mjs +354 -33
- package/plugins/keycloak-user-sync.d.mts +32 -4
- package/plugins/keycloak-user-sync.d.ts +32 -4
- package/plugins/keycloak-user-sync.js +208 -45
- package/plugins/keycloak-user-sync.mjs +208 -45
- package/plugins/model-function.d.mts +46 -0
- package/plugins/model-function.d.ts +46 -0
- package/plugins/new-document.d.mts +21 -2
- package/plugins/new-document.d.ts +21 -2
- package/schema.d.mts +34 -5
- package/schema.d.ts +34 -5
- package/schema.js +24 -17
- package/schema.mjs +24 -17
|
@@ -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
|
|
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
|
|
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}`, {
|
|
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
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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 (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
429
|
+
const user = await findByIdentity(identity, value);
|
|
380
430
|
if (user) return { user, duplicateEmailsAllowed };
|
|
381
431
|
}
|
|
382
432
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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,33 +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
|
-
|
|
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({
|
|
595
|
+
username,
|
|
450
596
|
...payload,
|
|
451
|
-
...
|
|
452
|
-
password:
|
|
597
|
+
...creationPassword && {
|
|
598
|
+
password: creationPassword,
|
|
453
599
|
passwordTemporary: options.passwordTemporary ?? false
|
|
454
600
|
}
|
|
455
601
|
}) ?? null;
|
|
456
602
|
created = true;
|
|
457
603
|
} catch (error) {
|
|
458
|
-
if (
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
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;
|
|
462
609
|
}
|
|
463
610
|
}
|
|
464
611
|
if (!user?.id) throw new Error("Keycloak did not return a user ID");
|
|
@@ -473,6 +620,8 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
473
620
|
currentEmail: readDocumentValue(document, "email"),
|
|
474
621
|
remoteEmail: user.email
|
|
475
622
|
});
|
|
623
|
+
const desiredPassword = !created && !passwordAlreadyApplied && (recoveredPartial || state.passwordChanged) ? readDesiredPassword() : cachedPassword ?? null;
|
|
624
|
+
const willResetPassword = Boolean(desiredPassword) && !created && !passwordAlreadyApplied && (recoveredPartial || state.passwordChanged);
|
|
476
625
|
if (!created) {
|
|
477
626
|
if (emailVerificationPlan.initialLinkSameEmail) delete payload.emailVerified;
|
|
478
627
|
if (emailVerificationPlan.changed) {
|
|
@@ -481,21 +630,35 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
481
630
|
payload.emailVerified = false;
|
|
482
631
|
}
|
|
483
632
|
if (shouldSyncAttributes) payload.attributes = mergeAttributes(user, document, options);
|
|
484
|
-
if (
|
|
485
|
-
|
|
486
|
-
|
|
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
|
+
}
|
|
487
649
|
}
|
|
488
650
|
}
|
|
489
|
-
if (!created && state.passwordChanged && desiredPassword) {
|
|
490
|
-
await realmHandle.userById(userId).resetPassword(desiredPassword, { temporary: options.passwordTemporary ?? false });
|
|
491
|
-
}
|
|
492
651
|
if (created || state.changedFields.has("roles")) await syncRoles(user, document);
|
|
493
652
|
if (emailVerificationPlan.changed && options.sendVerificationEmailOnChange !== false) {
|
|
494
653
|
await realmHandle.userById(userId).sendVerifyEmail();
|
|
495
654
|
}
|
|
496
655
|
};
|
|
497
656
|
const deleteDocument = async (document) => {
|
|
498
|
-
|
|
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);
|
|
499
662
|
if (user?.id) await realmHandle.userById(user.id).delete();
|
|
500
663
|
};
|
|
501
664
|
schema.pre("save", async function keycloakUserSyncPreSave() {
|
|
@@ -510,7 +673,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
510
673
|
const state = { ...changePlan, wasNew: this.isNew };
|
|
511
674
|
this.$locals[syncStateKey] = state;
|
|
512
675
|
if (!state.shouldSync || this.isNew) return;
|
|
513
|
-
state.previous = await
|
|
676
|
+
state.previous = await readPersistedSnapshot(this);
|
|
514
677
|
if (providerIdChanged) {
|
|
515
678
|
const previousProviderId = stringValue(getPreviousValue(state.previous, "providerId"));
|
|
516
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
|
|
9
|
+
type UniqueNullableBase = {
|
|
3
10
|
type: StringConstructor;
|
|
4
11
|
index: {
|
|
5
12
|
unique: boolean;
|
|
6
13
|
partialFilterExpression: {
|
|
7
|
-
[
|
|
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
|
-
|
|
22
|
+
type UniqueEmptiableBase = {
|
|
16
23
|
type: StringConstructor;
|
|
17
24
|
index: {
|
|
18
25
|
unique: boolean;
|
|
19
26
|
partialFilterExpression: {
|
|
20
|
-
[
|
|
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 };
|