@web-ts-toolkit/moo 0.43.0 → 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 +207 -45
- package/plugins/keycloak-user-sync.mjs +207 -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,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
|
-
|
|
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
|
-
...
|
|
453
|
-
password:
|
|
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 (
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
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 (
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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 };
|