@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.
package/README.md CHANGED
@@ -10,8 +10,8 @@ pnpm add mongoose @web-ts-toolkit/moo
10
10
 
11
11
  Peer dependencies:
12
12
 
13
- - `mongoose >= 8`
14
- - `@egose/keycloak-fluent ^0.7` only when using `@web-ts-toolkit/moo/plugins/keycloak-user-sync`
13
+ - `mongoose >= 8` (tested with Mongoose 8.24.x and 9.x)
14
+ - `@egose/keycloak-fluent >=0.12.1 <0.15.0` only when using `@web-ts-toolkit/moo/plugins/keycloak-user-sync` (tested floor `0.12.1`, current `0.14.x`)
15
15
 
16
16
  ## Highlights
17
17
 
@@ -46,7 +46,7 @@ Subpath entrypoints:
46
46
 
47
47
  - `@web-ts-toolkit/moo/schema` — schema field helpers
48
48
  - `@web-ts-toolkit/moo/is` — type guards such as `isObjectId(...)`
49
- - `@web-ts-toolkit/moo/utils` — mongoose utilities
49
+ - `@web-ts-toolkit/moo/utils` — `isSchema`, `isObjectIdType`, `isReference`, and the `ReferenceShape` type
50
50
  - `@web-ts-toolkit/moo/plugins` — plugin entrypoint
51
51
  - `@web-ts-toolkit/moo/plugins/cascade-delete` — cascade-delete plugin
52
52
  - `@web-ts-toolkit/moo/plugins/model-function` — model-function plugin
@@ -73,6 +73,79 @@ userSchema.plugin(cascadeDeletePlugin, {
73
73
  });
74
74
  ```
75
75
 
76
+ ## Model Function Plugin
77
+
78
+ ```ts
79
+ import mongoose, { type Model } from 'mongoose';
80
+ import {
81
+ type ModelDocument,
82
+ type ModelFunctionInstanceMethods,
83
+ type ModelFunctionStaticMethods,
84
+ modelFunctionPlugin,
85
+ } from '@web-ts-toolkit/moo';
86
+
87
+ type Cart = {
88
+ name: string;
89
+ price: number;
90
+ };
91
+
92
+ // Keep result types free of the CartDocument alias (plain values, not the
93
+ // document) so the aliases never circularly reference each other.
94
+ type CartMethods = ModelFunctionInstanceMethods<'applyDiscount', [suffix: string, priceChange: number], number> &
95
+ ModelFunctionInstanceMethods<'applyDiscountAsync', [suffix: string, priceChange: number], Promise<number>>;
96
+
97
+ type CartDocument = ModelDocument<Cart, CartMethods>;
98
+
99
+ type CartModel = Model<Cart, Record<string, never>, CartMethods> &
100
+ ModelFunctionStaticMethods<'applyDiscount', CartDocument, [suffix: string, priceChange: number], number> &
101
+ ModelFunctionStaticMethods<
102
+ 'applyDiscountAsync',
103
+ CartDocument,
104
+ [suffix: string, priceChange: number],
105
+ Promise<number>
106
+ >;
107
+
108
+ const cartSchema = new mongoose.Schema<Cart, CartModel, CartMethods>({
109
+ name: { type: String, required: true },
110
+ price: { type: Number, required: true },
111
+ });
112
+
113
+ // No explicit plugin generics: the method name, argument tuple, and result
114
+ // are inferred from the options. The second type argument is the method
115
+ // *name* (for example `'applyDiscount'`), never the methods object.
116
+ cartSchema.plugin(modelFunctionPlugin, {
117
+ fnName: 'applyDiscount',
118
+ fn: (cart: CartDocument, suffix: string, priceChange: number) => {
119
+ cart.price += priceChange;
120
+ return cart.price;
121
+ },
122
+ });
123
+
124
+ cartSchema.plugin(modelFunctionPlugin, {
125
+ fnName: 'applyDiscountAsync',
126
+ fn: async (cart: CartDocument, suffix: string, priceChange: number) => {
127
+ cart.price += priceChange;
128
+ return cart.price;
129
+ },
130
+ });
131
+
132
+ const Cart = mongoose.model<Cart, CartModel>('Cart', cartSchema);
133
+
134
+ const cart = await Cart.create({ name: 'laptop', price: 2000 });
135
+ const syncPrice: number = Cart.applyDiscount(cart, 'premium', 100);
136
+ const instancePrice: number = cart.applyDiscount('premium', 100);
137
+ const byId: number | null = await Cart.applyDiscountById(cart._id.toString(), 'premium', 100);
138
+ const asyncById: number | null = await Cart.applyDiscountAsyncById(cart._id, 'premium', 100);
139
+ ```
140
+
141
+ Each registration adds three typed surfaces: an instance method, a static taking the document first, and a `ById` static that loads the document and returns `null` when it is missing. Wrong argument types fail compilation.
142
+
143
+ ## Schema Helpers
144
+
145
+ `uniqueNullableString(field, overrides?)` returns a partial-unique field definition that allows any number of `null`/missing values while rejecting duplicate strings (including `''`): the partial index only covers string-typed values (`{ [field]: { $type: 'string' } }`). `uniqueEmptiableString(field, overrides?)` additionally ignores empty strings (both `null` and `''` repeat; only duplicate non-empty strings conflict) via `{ [field]: { $type: 'string', $gt: '' } }`. Extra `overrides` are spread over the defaults at runtime and reflected in the inferred return type, so overriding `default`, `trim`, or `index` replaces the fixed default type instead of keeping it.
146
+
147
+ `isObjectId(value)` is a strict canonical guard: only 24-character lowercase hex strings and `mongoose.Types.ObjectId` instances from this package's `mongoose` copy pass. Uppercase hex, 12-byte strings, structural impostors with matching `toString()`, foreign BSON copies, buffers, and throwing accessors are rejected without invoking arbitrary coercion.
148
+
76
149
  ## New Document Plugin
77
150
 
78
151
  ```ts
@@ -92,6 +165,20 @@ userSchema.plugin(newDocumentPlugin, {
92
165
 
93
166
  The plugin runs `fn` after the first successful `save()` of a new document. Later saves of the same document do not run it.
94
167
 
168
+ Supported operations: only document `save()` is observed (a `pre('save')` snapshot of `isNew` plus a `post('save')` invocation). Query inserts, `insertMany()` fast paths that skip document middleware, and updates to existing documents never trigger the callback.
169
+
170
+ Delivery scope: post-save notification, not durable delivery. The callback runs after MongoDB persistence; a throwing callback rejects the post-save hook but cannot roll back the committed write, and there is no outbox, retry, or exactly-once guarantee across transaction retries or reentrant saves. Applications that need transactional delivery should record their own outbox intent inside the MongoDB transaction and process it after commit.
171
+
172
+ ## Cascade Delete Plugin
173
+
174
+ Relationship mode (`localField` + `foreignField`) is fail-closed: missing/`null`/empty-string local keys and empty reference arrays resolve to zero dependents and delete nothing, so unrelated records with a missing/`null` foreign key survive. A local field omitted by a projection is distinguished from an intentionally empty relation — when the path is provably deselected the lookup throws instead of silently reporting zero dependents. Supplemental `extraForeignFilter` constraints are composed conjunctively (`{ $and: [relationship, extra] }`), so an extra filter on the relationship field (including `$or`/`$and` payloads) can only narrow, never replace or widen, the deletion set. Explicit full-filter mode (`foreignFilter`) is a separate contract: the resolved filter is used as-is and any `extraForeignFilter` is ignored; resolvers returning `null`/`undefined`/non-objects resolve to zero dependents. Empty full filters (`{}`) are rejected by default at registration and at runtime because they would match the whole dependent collection; there is currently no broad-delete opt-in. Invalid `model`/field combinations and non-object static filters throw at `schema.plugin(...)` time.
175
+
176
+ Deletion always removes fully hydrated dependent documents through their own document `deleteOne()` (never bulk writes), so nested cascades and custom document hooks observe required fields across multi-level custom-`localField` chains. Internal deletion traversal pages dependent `_id`s (`batchSize`, default `100`) and bounds in-flight deletes (`maxConcurrency`, default `8`; forced to `1` inside an active transaction), instead of materializing and deleting the whole set at once. Public `findDependents()` still returns an array. A dependent-hook failure rejects the parent `deleteOne()` after the parent is already removed (fail-fast, remaining batches skipped; abort the transaction to restore everything when in one); already-deleted rows are skipped. Repeated references delete once per level; diamonds/cycles terminate with at-least-once hook delivery.
177
+
178
+ Supported operations and timing: only document `deleteOne()` is intercepted (`pre`/`post('deleteOne', { document: true, query: false })`). Query deletes (`deleteMany()`, `findOneAndDelete()`, query `deleteOne()`) and query updates bypass the cascade. The `pre('deleteOne')` hook validates the execution context before the parent is removed (cross-client sessions and transactions on standalone topologies reject there); the `post('deleteOne')` hook runs after the parent is already removed, so a later dependent failure rejects the parent promise without restoring the parent outside a transaction. Dependent models resolve through the owning model's connection and the effective session (explicit `deleteOne({ session })` option, else the document-bound `$session()`) flows through dependent reads and deletes including nested cascades. `maxConcurrency` and `batchSize` must be integers `>= 1` when provided and are validated at `schema.plugin(...)` time.
179
+
180
+ Typed filters accept MongoDB operators per field (`{ price: { $gt: 5 } }`, `{ price: { $in: [...] } }`, plus `$and`/`$or` payloads through the index signature) and are forwarded to Mongoose unchanged. The no-argument `findDependents()`/`findOrphans()` overloads return `Partial` maps: entries whose lookup is unsupported or resolved to nullish are omitted rather than reported as empty arrays. `findOrphans()` support: scalar `_id` relations and `[{ type: ObjectId, ref }]` arrays are supported; non-`_id` local keys with a hardcoded `distinct('_id')`, dotted foreign paths, `{ type: [ObjectId], ref }` syntax, dynamic `refPath`, and `foreignFilter`-only relationships are unsupported and resolve to `null` (omitted from the map) rather than silently succeeding. Null/missing foreign rows are reported as orphans; per-page `$not/$in` batching is not a correct collection-scale anti-join, so a server-side `$lookup` strategy is deferred to a follow-up.
181
+
95
182
  ## Keycloak User Sync
96
183
 
97
184
  Install the optional Keycloak peer before using this entrypoint:
@@ -120,9 +207,26 @@ const userSchema = new Schema({
120
207
  lastName: String,
121
208
  archived: Boolean,
122
209
  roles: [String],
123
- pendingPassword: String,
210
+ tier: String,
211
+ tenantId: String,
212
+ subscription: { plan: String },
213
+ // No stored pendingPassword path: the pending credential below is a
214
+ // virtual and is never persisted to MongoDB.
124
215
  });
125
216
 
217
+ // Genuinely non-persisted pending-password input. Virtuals are not stored in
218
+ // MongoDB; the value lives only in memory until the Keycloak reset-password
219
+ // call. Never add a persisted `pendingPassword` schema path holding
220
+ // plaintext, and clear the virtual after sync.
221
+ userSchema
222
+ .virtual('pendingPassword') // pragma: allowlist secret
223
+ .get(function (this: { $locals: Record<string, unknown> }) {
224
+ return this.$locals.pendingPassword as string | undefined;
225
+ })
226
+ .set(function (this: { $locals: Record<string, unknown> }, value: string | undefined) {
227
+ this.$locals.pendingPassword = value;
228
+ });
229
+
126
230
  userSchema.plugin(keycloakUserSyncPlugin, {
127
231
  client: keycloak,
128
232
  realm: 'application',
@@ -144,7 +248,14 @@ userSchema.plugin(keycloakUserSyncPlugin, {
144
248
  mapPassword(document) {
145
249
  return document.get('pendingPassword') as string | undefined;
146
250
  },
251
+ // Declared mapper dependencies: the mapped schema paths above must exist.
252
+ // passwordPaths covers the non-persisted virtual backing input.
147
253
  attributePaths: ['tenantId', 'subscription.plan'],
254
+ rolePaths: ['tier'],
255
+ passwordPaths: ['pendingPassword'],
256
+ mapRoles(_roles, document) {
257
+ return document.get('tier') === 'pro' ? ['editor'] : [];
258
+ },
148
259
  mapAttributes(document) {
149
260
  return {
150
261
  tenantId: document.get('tenantId'),
@@ -170,10 +281,12 @@ The plugin:
170
281
  - can update the Keycloak password when `syncFields.password` is explicitly enabled
171
282
  - reads `archived` as the inverse of Keycloak `enabled`, falling back to an `enabled` field
172
283
 
173
- Configuration is validated when `schema.plugin(...)` runs. `realm`, configured paths, managed role/attribute names, and attribute trigger paths are trimmed and must be non-empty. `identifyBy` accepts one identity or a non-empty ordered list containing only `providerId`, `username`, and `email`. Built-in synced field paths must exist in the Mongoose schema; mapper-driven `attributePaths` can name dynamic fields that the mapper reads. Register the plugin only once per schema.
284
+ Configuration is validated when `schema.plugin(...)` runs. `realm`, configured paths, managed role/attribute names, and attribute/role/password trigger paths are trimmed and must be non-empty. `identifyBy` accepts one identity or a non-empty ordered list containing only `providerId`, `username`, and `email`. Built-in synced field paths must exist in the Mongoose schema; mapper-driven `attributePaths`/`rolePaths`/`passwordPaths` name the additional Mongoose paths the mapper reads (as in the example above, where `tenantId`, `subscription.plan`, `tier`, and the `pendingPassword` virtual are all declared). Register the plugin only once per schema.
174
285
 
175
286
  The plugin snapshots its options at registration, so later mutation of the caller's options object does not change sync behavior. `providerId` is server-controlled after persistence: applications may set it while creating a new document to link an existing Keycloak user, but changing it later is rejected before any remote call so ordinary document updates cannot redirect synchronization to another Keycloak account.
176
287
 
288
+ Saves and deletes share one persisted-identity boundary. New local documents perform explicitly authorized initial linking through their current identity values. Existing documents resolve only through their persisted snapshot: a stored `providerId` is authoritative, and a changed username or email that resolves to a different remote user fails closed instead of relinking — the conflicting update, credential reset, role change, or delete is never sent to that other account. A stale persisted `providerId`, a missing persisted local row, or a delete of a never-persisted document is likewise rejected before any destructive remote call. These identity rejections apply independently of `throwOnError`: with `throwOnError: false` a conflicting save still succeeds locally while leaving the other account untouched, and a conflicting delete always blocks local deletion so the verified binding remains retryable. Migration implication: after adopting this contract, rows whose stored `providerId` no longer exists remotely fail closed on sync; clear or re-link the stored binding explicitly rather than changing a profile field and expecting a silent relink.
289
+
177
290
  When the realm allows duplicate emails, the plugin always prioritizes configured `providerId` and `username` identities before email. An email lookup proceeds only when it has exactly one match; multiple matches are reported as an error, and no user is changed or deleted.
178
291
 
179
292
  Use `paths` to map different Mongoose path names. Use `syncFields` to disable profile fields, role syncing, or attribute syncing. Use `mapRoles` to translate application roles. Role sync is additive-only by default: desired local roles are assigned, but unrelated existing Keycloak realm roles are preserved. Set `managedRoles` to the exact role names this plugin owns; only those roles may be removed when omitted from an explicit local roles array. An absent or non-array roles value is treated as no role-sync intent. An empty array intentionally removes currently assigned managed roles while preserving unmanaged roles. `ensureRoles` defaults to `true`, so desired missing roles are created before assignment; set it to `false` if typos or insufficient administrative privileges should fail instead of creating roles. `maxRolesPerSync` defaults to `100` and rejects larger desired role arrays before role lookup or mapping requests, bounding caller-controlled remote work.
@@ -184,9 +297,11 @@ For existing Keycloak users, owned string profile fields (`username`, `email`, `
184
297
 
185
298
  Attributes are normalized to Keycloak string arrays. Use `mapAttributes` for dynamic key/value mappings and `attributePaths` to list the Mongoose fields that should trigger an attribute resync. Existing unmanaged Keycloak attributes are preserved, including when a user is resolved by email. Set `managedAttributes` to the keys this plugin owns; managed keys are replaced by the mapper result and removed when omitted, returned as `null`/`undefined`, returned as an empty array, or mapped to unsupported object values. Attribute keys named `__proto__`, `prototype`, or `constructor` are rejected.
186
299
 
187
- Password sync is disabled by default to avoid sending stored password hashes to Keycloak. Enable it only when the mapped value is the plaintext password intended for Keycloak, such as a short-lived `pendingPassword` field. Passwords are never included in user-create or profile-update payloads; both newly created users and existing users are updated through Keycloak's reset-password endpoint with the configured `passwordTemporary` value. The plugin stores a newly resolved Keycloak user ID before optional password, role, and verification-email work so a later retry can converge on the same remote user instead of creating a duplicate. The application owns the lifecycle of plaintext password fields: keep them short-lived and prevent them from being persisted, logged, traced, or sent to error reporters beyond the intended Keycloak operation.
300
+ Mapper dependencies are explicit: `attributePaths`, `rolePaths`, and `passwordPaths` declare the extra Mongoose paths that trigger the `attributes`/`roles`/`password` operations when `mapAttributes`/`mapRoles`/`mapPassword` read beyond their configured paths (as with the `tier` and virtual `pendingPassword` inputs above). Dependencies are honored only while the corresponding `syncFields` entry is enabled; disabled fields stay fully inert. Dependency arrays are validated and snapshotted at registration. The password mapper is evaluated lazily — only for creation (new remote user, exactly once, with the value reused by the same-save recovery attempt) or when a declared password change indicates actual password-sync intent — so unrelated updates never invoke it and never reset credentials; likewise `mapRoles` runs only inside role sync for creations or `roles` changes.
301
+
302
+ Password sync is disabled by default to avoid sending stored password hashes to Keycloak. Enable it only when the mapped value is a short-lived, non-persisted plaintext input such as the `pendingPassword` virtual above — never an ordinary stored plaintext schema path. Passwords are never included in user-create or profile-update payloads; both newly created users and existing users are updated through Keycloak's reset-password endpoint with the configured `passwordTemporary` value. The plugin stores a newly resolved Keycloak user ID before optional password, role, and verification-email work so a later retry can converge on the same remote user instead of creating a duplicate. A partially provisioned account stays disabled until its required credentials succeed: profile updates never carry `enabled`, and enablement follows the credential reset. The application owns the lifecycle of plaintext password inputs: keep them short-lived and non-persisted, and prevent them from being logged, traced, or sent to error reporters beyond the intended Keycloak operation.
188
303
 
189
- Errors are logged to `console.error` and rethrown by default. The default logger context is redacted to safe metadata such as `operation` and `localDocumentId`; it never includes the Mongoose document, profile payload, email address, or password. `onError` receives the same safe context by default. Set `includeDocumentInErrorContext: true` only for private handlers that are allowed to receive the full Mongoose document, which can contain plaintext passwords, email addresses, attributes, and arbitrary application fields. Logger and `onError` failures do not replace the original Keycloak sync error; with `throwOnError: false`, those observer failures are best-effort and are swallowed.
304
+ Errors are logged to `console.error` and rethrown by default. The logger boundary receives only an allowlisted summary — `{ operation, localDocumentId, error: { name, code?, status? } }` with a validated error name and optional machine codes — and never the message, stack, cause, request/response data, or arbitrary error properties, so transport secrets cannot flow into logs. `onError` still receives the original error for private processing with the same safe context by default. Set `includeDocumentInErrorContext: true` only for private handlers that are allowed to receive the full Mongoose document, which can contain plaintext passwords, email addresses, attributes, and arbitrary application fields. Logger and `onError` failures do not replace the original Keycloak sync error; with `throwOnError: false`, those observer failures are best-effort and are swallowed.
190
305
 
191
306
  Only document `save()` and document `deleteOne()` are intercepted. Query operations such as `updateOne()`, `findOneAndUpdate()`, and query `deleteOne()` bypass the plugin. The selected delivery contract is direct, non-atomic hooks: Keycloak work is not part of the MongoDB commit, and the plugin rejects documents saved or deleted with a Mongoose session or transaction. Applications that need transactional delivery should write their own outbox intent in the MongoDB transaction and process it after commit.
192
307
 
package/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { ModelDocument, ModelFunctionInstanceMethods, ModelFunctionPluginOptions, ModelFunctionStaticMethods, modelFunctionPlugin } from './plugins/model-function.mjs';
2
- export { CascadeDeleteDependencyMap, CascadeDeleteDocumentMethods, CascadeDeleteModelStatics, CascadeDeletePluginOptions, FilterResolver, QueryFilter, cascadeDeletePlugin } from './plugins/cascade-delete.mjs';
2
+ export { CascadeDeleteDependencyMap, CascadeDeleteDocumentMethods, CascadeDeleteModelStatics, CascadeDeletePluginOptions, FilterResolver, QueryFilter, QueryOperators, cascadeDeletePlugin } from './plugins/cascade-delete.mjs';
3
3
  export { NewDocumentPluginOptions, newDocumentPlugin } from './plugins/new-document.mjs';
4
- export { uniqueEmptiableString, uniqueNullableString } from './schema.mjs';
4
+ export { SchemaOptionOverrides, uniqueEmptiableString, uniqueNullableString } from './schema.mjs';
5
5
  export { isObjectId } from './is.mjs';
6
6
  import 'mongoose';
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { ModelDocument, ModelFunctionInstanceMethods, ModelFunctionPluginOptions, ModelFunctionStaticMethods, modelFunctionPlugin } from './plugins/model-function.js';
2
- export { CascadeDeleteDependencyMap, CascadeDeleteDocumentMethods, CascadeDeleteModelStatics, CascadeDeletePluginOptions, FilterResolver, QueryFilter, cascadeDeletePlugin } from './plugins/cascade-delete.js';
2
+ export { CascadeDeleteDependencyMap, CascadeDeleteDocumentMethods, CascadeDeleteModelStatics, CascadeDeletePluginOptions, FilterResolver, QueryFilter, QueryOperators, cascadeDeletePlugin } from './plugins/cascade-delete.js';
3
3
  export { NewDocumentPluginOptions, newDocumentPlugin } from './plugins/new-document.js';
4
- export { uniqueEmptiableString, uniqueNullableString } from './schema.js';
4
+ export { SchemaOptionOverrides, uniqueEmptiableString, uniqueNullableString } from './schema.js';
5
5
  export { isObjectId } from './is.js';
6
6
  import 'mongoose';