@web-ts-toolkit/moo 0.55.0 → 0.56.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/index.mjs CHANGED
@@ -480,6 +480,341 @@ function cascadeDeletePlugin(schema, options) {
480
480
  });
481
481
  }
482
482
 
483
+ // src/plugins/derived-fields/definitions.ts
484
+ function defineDerivedField(definition) {
485
+ return definition;
486
+ }
487
+ var DerivedFieldsDependencyError = class extends Error {
488
+ constructor(issues) {
489
+ const snapshot = Object.freeze(
490
+ issues.map(
491
+ ({ targetPath, missingPaths }) => Object.freeze({ targetPath, missingPaths: Object.freeze([...missingPaths]) })
492
+ )
493
+ );
494
+ const details = snapshot.map(
495
+ ({ targetPath, missingPaths }) => `${JSON.stringify(targetPath)} requires ${missingPaths.map((path) => JSON.stringify(path)).join(", ")}`
496
+ ).join("; ");
497
+ super(`derivedFieldsPlugin missing dependencies: ${details}`);
498
+ /** Stable discriminator, including across separately bundled entrypoints. */
499
+ this.code = "MOO_DERIVED_FIELDS_MISSING_DEPENDENCIES";
500
+ this.name = "DerivedFieldsDependencyError";
501
+ this.issues = snapshot;
502
+ }
503
+ };
504
+
505
+ // src/plugins/derived-fields/lifecycle.ts
506
+ import { isDeepStrictEqual } from "util";
507
+ import { Document } from "mongoose";
508
+
509
+ // src/plugins/derived-fields/planner.ts
510
+ var hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
511
+ var isFullySelected = (document, path) => {
512
+ const projection = document.$__?.selected;
513
+ if (projection) {
514
+ if (hasOwn(projection, path) && projection[path] !== 1 && projection[path] !== true) return false;
515
+ const keys = Object.keys(projection);
516
+ if (keys.some((key) => key.startsWith(`${path}.`))) return false;
517
+ if (path !== "_id" && keys.length === 1 && projection._id === false) return true;
518
+ }
519
+ return document.isDirectSelected(path);
520
+ };
521
+ function planDerivedFields(registration, document, options = {}) {
522
+ const replacedPaths = registration.inputPaths.filter(
523
+ (path) => options.knownReplacedPaths?.has(path) || document.isDirectModified(path) && !document.$__?.primitiveAtomics?.[path]
524
+ );
525
+ const replacements = new Set(replacedPaths);
526
+ const availablePaths = registration.inputPaths.filter(
527
+ (path) => document.isNew || replacements.has(path) || isFullySelected(document, path)
528
+ );
529
+ const available = new Set(availablePaths);
530
+ const fields = registration.fields.filter(
531
+ (field) => document.isNew || field.mode === "always" || document.isDirectModified(field.targetPath) || options.pendingWritePaths?.has(field.targetPath) || field.dependsOn.some((path) => document.isModified(path) || options.pendingWritePaths?.has(path))
532
+ );
533
+ const issues = [];
534
+ for (const field of fields) {
535
+ const dependencies = field.mode === "always" ? registration.inputPaths : field.dependsOn;
536
+ const missingPaths = dependencies.filter((path) => !available.has(path));
537
+ if (missingPaths.length) issues.push({ targetPath: field.targetPath, missingPaths });
538
+ }
539
+ if (issues.length) throw new DerivedFieldsDependencyError(issues);
540
+ return Object.freeze({
541
+ fields: Object.freeze(fields),
542
+ availablePaths: Object.freeze(availablePaths),
543
+ replacedPaths: Object.freeze(replacedPaths)
544
+ });
545
+ }
546
+
547
+ // src/plugins/derived-fields/registration.ts
548
+ var supportedDerivedFieldTypes = Object.freeze([
549
+ "String",
550
+ "Number",
551
+ "Boolean",
552
+ "Date",
553
+ "ObjectId",
554
+ "Decimal128",
555
+ "BigInt"
556
+ ]);
557
+ var registrationKey = /* @__PURE__ */ Symbol.for("@web-ts-toolkit/moo/derived-fields/registration");
558
+ var hasOwn2 = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
559
+ var isRootPath = (path) => path.length > 0 && !/[.\s$\0]/.test(path);
560
+ var isSupportedPath = (path, type) => isRootPath(path) && supportedDerivedFieldTypes.includes(type.instance);
561
+ var configurationError = (targetPath, reason) => {
562
+ throw new TypeError(`derivedFieldsPlugin target ${JSON.stringify(targetPath)}: ${reason}`);
563
+ };
564
+ var parseDefinition = (targetPath, value) => {
565
+ if (typeof value === "function") {
566
+ return Object.freeze({
567
+ targetPath,
568
+ mode: "always",
569
+ dependsOn: Object.freeze([]),
570
+ get: value
571
+ });
572
+ }
573
+ if (value === null || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
574
+ return configurationError(targetPath, "derived must be a getter function or a plain { dependsOn, get } descriptor");
575
+ }
576
+ if (Reflect.ownKeys(value).some((key) => key !== "dependsOn" && key !== "get") || !hasOwn2(value, "dependsOn") || !hasOwn2(value, "get")) {
577
+ return configurationError(targetPath, "derived descriptors require exactly dependsOn and get");
578
+ }
579
+ const descriptor = value;
580
+ if (!Array.isArray(descriptor.dependsOn) || typeof descriptor.get !== "function") {
581
+ return configurationError(targetPath, "dependsOn must be an array of root path names and get must be a function");
582
+ }
583
+ const dependencies = [];
584
+ for (const dependency of descriptor.dependsOn) {
585
+ if (typeof dependency !== "string" || !isRootPath(dependency)) {
586
+ return configurationError(
587
+ targetPath,
588
+ "dependsOn must contain only non-empty root path names (no dots, whitespace, or $)"
589
+ );
590
+ }
591
+ if (dependencies.includes(dependency)) {
592
+ return configurationError(targetPath, `duplicate dependency path ${JSON.stringify(dependency)}`);
593
+ }
594
+ dependencies.push(dependency);
595
+ }
596
+ return Object.freeze({
597
+ targetPath,
598
+ mode: "dependencies",
599
+ dependsOn: Object.freeze(dependencies),
600
+ get: descriptor.get
601
+ });
602
+ };
603
+ function registerDerivedFields(schema) {
604
+ const markedSchema = schema;
605
+ if (hasOwn2(markedSchema, registrationKey)) {
606
+ throw new TypeError("derivedFieldsPlugin is already registered on this schema");
607
+ }
608
+ const fields = [];
609
+ const versionKey = schema.options.versionKey;
610
+ const visitSchema = (current, prefix, ancestors) => {
611
+ current.eachPath((path, type) => {
612
+ const fullPath = `${prefix}${path}`;
613
+ if (!hasOwn2(type.options, "derived")) return;
614
+ if (prefix || !isSupportedPath(path, type)) {
615
+ configurationError(
616
+ fullPath,
617
+ `only root scalar persisted paths are supported (${supportedDerivedFieldTypes.join(", ")})`
618
+ );
619
+ }
620
+ if (path === versionKey) {
621
+ configurationError(
622
+ fullPath,
623
+ `Mongoose-managed version key ${JSON.stringify(versionKey)} cannot be a derived target`
624
+ );
625
+ }
626
+ if (path === "_id" || (type.$immutable ?? type.options.immutable)) {
627
+ configurationError(fullPath, "immutable targets are not supported");
628
+ }
629
+ fields.push(parseDefinition(fullPath, type.options.derived));
630
+ });
631
+ const virtuals = current.virtuals;
632
+ for (const [path, virtual] of Object.entries(virtuals)) {
633
+ if (hasOwn2(virtual.options, "derived")) {
634
+ configurationError(`${prefix}${path}`, "derived targets must be persisted schema paths, not virtuals");
635
+ }
636
+ }
637
+ if (ancestors.has(current)) return;
638
+ const nextAncestors = /* @__PURE__ */ new Set([...ancestors, current]);
639
+ const visitEmbedded = (type, path) => {
640
+ if (type.schema) {
641
+ visitSchema(type.schema, `${path}.`, nextAncestors);
642
+ return;
643
+ }
644
+ for (const member of type.schemaTypes ?? []) {
645
+ if (hasOwn2(member.options, "derived")) {
646
+ configurationError(path, "derived targets inside union containers are not supported");
647
+ }
648
+ visitEmbedded(member, path);
649
+ }
650
+ const embedded = type.getEmbeddedSchemaType?.() ?? type.caster ?? type.$embeddedSchemaType ?? type.$__schemaType;
651
+ if (!embedded || embedded === type) return;
652
+ const embeddedPath = `${path}.${type.instance === "Map" ? "$*" : "$"}`;
653
+ if (hasOwn2(embedded.options, "derived")) {
654
+ configurationError(embeddedPath, "derived targets inside containers are not supported");
655
+ }
656
+ visitEmbedded(embedded, embeddedPath);
657
+ };
658
+ current.eachPath((path, type) => visitEmbedded(type, `${prefix}${path}`));
659
+ };
660
+ visitSchema(schema, "", /* @__PURE__ */ new Set());
661
+ const targets = new Set(fields.map((field) => field.targetPath));
662
+ for (const field of fields) {
663
+ for (const dependency of field.dependsOn) {
664
+ if (dependency === versionKey) {
665
+ configurationError(
666
+ field.targetPath,
667
+ `Mongoose-managed version key ${JSON.stringify(versionKey)} cannot be a dependency`
668
+ );
669
+ }
670
+ if (targets.has(dependency)) {
671
+ configurationError(
672
+ field.targetPath,
673
+ `dependency path ${JSON.stringify(dependency)} is itself a derived target`
674
+ );
675
+ }
676
+ const type = schema.path(dependency);
677
+ if (!type) {
678
+ configurationError(field.targetPath, `unknown or non-persisted dependency path ${JSON.stringify(dependency)}`);
679
+ }
680
+ if (!isSupportedPath(dependency, type)) {
681
+ configurationError(
682
+ field.targetPath,
683
+ `dependency path ${JSON.stringify(dependency)} must be a supported root scalar persisted path`
684
+ );
685
+ }
686
+ }
687
+ }
688
+ const inputPaths = [];
689
+ schema.eachPath((path, type) => {
690
+ if (path !== versionKey && !targets.has(path) && isSupportedPath(path, type)) inputPaths.push(path);
691
+ });
692
+ const registration = Object.freeze({ fields: Object.freeze(fields), inputPaths: Object.freeze(inputPaths) });
693
+ Object.defineProperty(markedSchema, registrationKey, { value: registration });
694
+ return registration;
695
+ }
696
+
697
+ // src/plugins/derived-fields/snapshot.ts
698
+ import { Types } from "mongoose";
699
+ function cloneDerivedScalar(value) {
700
+ if (value instanceof Date) return new Date(value.getTime());
701
+ if (value !== null && typeof value === "object") {
702
+ const bson = value;
703
+ if (bson._bsontype === "ObjectId") return new Types.ObjectId(Buffer.from(bson.id));
704
+ if (bson._bsontype === "Decimal128") return new Types.Decimal128(Buffer.from(bson.bytes));
705
+ }
706
+ return value;
707
+ }
708
+ function readDerivedScalar(document, path) {
709
+ const id = document.populated(path);
710
+ return id === void 0 ? document.get(path, void 0, { getters: false }) : id;
711
+ }
712
+ function snapshotDerivedInputs(document, paths) {
713
+ return copyDerivedSnapshot(Object.fromEntries(paths.map((path) => [path, readDerivedScalar(document, path)])));
714
+ }
715
+ function copyDerivedSnapshot(snapshot, paths = Object.keys(snapshot)) {
716
+ return Object.freeze(Object.fromEntries(paths.map((path) => [path, cloneDerivedScalar(snapshot[path])])));
717
+ }
718
+
719
+ // src/plugins/derived-fields/lifecycle.ts
720
+ var watchedPaths = (plan) => [
721
+ ...new Set(plan.fields.flatMap((field) => field.mode === "always" ? plan.availablePaths : field.dependsOn))
722
+ ];
723
+ function derivedFieldsPlugin(schema) {
724
+ const registration = registerDerivedFields(schema);
725
+ const trackedPaths = [...registration.inputPaths, ...registration.fields.map((field) => field.targetPath)];
726
+ const states = /* @__PURE__ */ new WeakMap();
727
+ const stateFor = (document) => {
728
+ let state = states.get(document);
729
+ if (!state) {
730
+ state = { replacedPaths: /* @__PURE__ */ new Set(), assignedPaths: /* @__PURE__ */ new Set(), applyingResults: false };
731
+ states.set(document, state);
732
+ }
733
+ return state;
734
+ };
735
+ for (const path of trackedPaths) {
736
+ schema.path(path).set(function derivedFieldsObserveAssignment(value, _priorValue, _schemaType, options) {
737
+ if (this instanceof Document && !this.isNew && !options?._skipMarkModified && !isFullySelected(this, path)) {
738
+ const state = stateFor(this);
739
+ if (!state.applyingResults) state.assignedPaths.add(path);
740
+ }
741
+ return value;
742
+ });
743
+ }
744
+ const planFor = (document, state) => {
745
+ for (const path of state.assignedPaths) {
746
+ if (!document.$__.validationError?.errors[path] && !document.$__.primitiveAtomics?.[path]) {
747
+ document.markModified(path);
748
+ }
749
+ }
750
+ state.assignedPaths.clear();
751
+ const pendingWritePaths = new Set(
752
+ document.isNew ? [] : trackedPaths.filter(
753
+ (path) => path !== "_id" && document.$isDefault(path) && document.$__.selected?.[path] !== 0 && document.get(path, void 0, { getters: false }) != null
754
+ )
755
+ );
756
+ const plan = planDerivedFields(registration, document, {
757
+ knownReplacedPaths: state.replacedPaths,
758
+ pendingWritePaths
759
+ });
760
+ state.replacedPaths = new Set(document.isNew ? plan.availablePaths : plan.replacedPaths);
761
+ return plan;
762
+ };
763
+ const matches = (document, plan, checkpoint) => checkpoint !== void 0 && checkpoint.saveOptions === document.$__.saveOptions && checkpoint.isNew === document.isNew && // Restoring a target to its original persisted value can clear its dirty
764
+ // flag. It was still computed; only newly scheduled fields invalidate reuse.
765
+ plan.fields.every((field) => checkpoint.targets.includes(field.targetPath)) && isDeepStrictEqual(checkpoint.inputs, snapshotDerivedInputs(document, Object.keys(checkpoint.inputs))) && isDeepStrictEqual(checkpoint.results, snapshotDerivedInputs(document, checkpoint.targets));
766
+ const compute = async (document) => {
767
+ const state = stateFor(document);
768
+ state.checkpoint = void 0;
769
+ const plan = planFor(document, state);
770
+ const inputs = snapshotDerivedInputs(document, plan.availablePaths);
771
+ const staged = [];
772
+ for (const { targetPath, get } of plan.fields) {
773
+ const result = await get(copyDerivedSnapshot(inputs));
774
+ staged.push({ path: targetPath, value: cloneDerivedScalar(result) });
775
+ }
776
+ state.applyingResults = true;
777
+ try {
778
+ for (const { path, value } of staged) document.set(path, value);
779
+ } finally {
780
+ state.applyingResults = false;
781
+ }
782
+ const validationError = document.$__.validationError;
783
+ if (staged.some(({ path }) => validationError?.errors[path]?.name === "CastError")) throw validationError;
784
+ const targets = plan.fields.map((field) => field.targetPath);
785
+ state.checkpoint = {
786
+ saveOptions: document.$__.saveOptions,
787
+ isNew: document.isNew,
788
+ targets,
789
+ inputs: copyDerivedSnapshot(inputs, watchedPaths(plan)),
790
+ results: snapshotDerivedInputs(document, targets)
791
+ };
792
+ };
793
+ schema.pre("validate", async function derivedFieldsPreValidate() {
794
+ await compute(this);
795
+ });
796
+ schema.pre("save", async function derivedFieldsPreSave() {
797
+ const state = stateFor(this);
798
+ const plan = planFor(this, state);
799
+ if (this.$__.saveOptions && matches(this, plan, state.checkpoint)) return;
800
+ const options = this.$__.saveOptions;
801
+ const validate = options && "validateBeforeSave" in options ? !!options.validateBeforeSave : schema.options.validateBeforeSave;
802
+ if (validate) {
803
+ await this.validate(
804
+ void 0,
805
+ options && "validateModifiedOnly" in options ? { validateModifiedOnly: options.validateModifiedOnly } : void 0
806
+ );
807
+ } else {
808
+ await compute(this);
809
+ }
810
+ if (!matches(this, planFor(this, state), state.checkpoint)) {
811
+ throw new Error(
812
+ "derivedFieldsPlugin inputs or targets changed after computation; register source-mutating validation hooks before the plugin and keep getters/validators free of document mutations"
813
+ );
814
+ }
815
+ });
816
+ }
817
+
483
818
  // src/plugins/new-document.ts
484
819
  var stateKey = "newDocumentPlugin";
485
820
  function newDocumentPlugin(schema, options) {
@@ -520,8 +855,8 @@ var uniqueEmptiableString = (field, options) => (
520
855
  );
521
856
 
522
857
  // src/is.ts
523
- import { Types } from "mongoose";
524
- var { ObjectId } = Types;
858
+ import { Types as Types2 } from "mongoose";
859
+ var { ObjectId } = Types2;
525
860
  var CANONICAL_OBJECT_ID_STRING = /^[0-9a-f]{24}$/;
526
861
  function isObjectId(value) {
527
862
  try {
@@ -537,7 +872,10 @@ function isObjectId(value) {
537
872
  }
538
873
  }
539
874
  export {
875
+ DerivedFieldsDependencyError,
540
876
  cascadeDeletePlugin,
877
+ defineDerivedField,
878
+ derivedFieldsPlugin,
541
879
  isObjectId,
542
880
  modelFunctionPlugin,
543
881
  newDocumentPlugin,
package/llms.txt CHANGED
@@ -49,6 +49,44 @@ userSchema.plugin(modelFunctionPlugin, {
49
49
  // static + instance methods `fullName` and `fullNameById` are now available on the model
50
50
  ```
51
51
 
52
+ Persisted derived fields (see README "Persisted Derived Fields" for full contract):
53
+
54
+ ```ts
55
+ import { Schema } from 'mongoose';
56
+ import { defineDerivedField, derivedFieldsPlugin } from '@web-ts-toolkit/moo';
57
+
58
+ type User = { firstName: string; lastName: string; fullName: string };
59
+ const userSchema = new Schema<User>({
60
+ firstName: String,
61
+ lastName: String,
62
+ fullName: {
63
+ type: String,
64
+ index: true,
65
+ derived: defineDerivedField<User, 'fullName'>({
66
+ dependsOn: ['firstName', 'lastName'],
67
+ get: (doc) => `${doc.firstName ?? ''} ${doc.lastName ?? ''}`.trim(),
68
+ }),
69
+ },
70
+ }, { optimisticConcurrency: true });
71
+ // Register source mutators first, then this plugin, then compile the model.
72
+ userSchema.plugin(derivedFieldsPlugin);
73
+ ```
74
+
75
+ - Stored results are searchable/filterable/sortable/indexable with ordinary MongoDB queries; reads/property access never compute or persist.
76
+ - Existing explicit dependencies: no dependency modification/target assignment/pending default write => skip regardless of selection; work requested + all available => recompute; work requested + missing => reject before any getter. An omitted/missing target alone does not request repair.
77
+ - Actual pending non-null source/target defaults count as writes without modification flags (including '', 0, false); null/undefined defaults alone do not write. Pending target fallback defaults recompute. Applying defaults to excluded dependencies can queue writes and require rejection even on an otherwise unrelated save; defaults never prove an excluded input available.
78
+ - Available = selected (defaults usable, genuine absence without a default is known `undefined`, stored `null` is known) or explicitly fully replaced, including excluded null/undefined/default assignments retained on the same document. Unselected defaults/getters and relative `$inc()`/child modifications are not replacements. No automatic fetch.
79
+ - New documents retain their supplied/defaulted/absent input availability on later saves, including with constructor projection metadata.
80
+ - Bare getter shorthand recomputes each async validation/save and requires every supported non-derived input, including hidden fields and `_id`.
81
+ - Sync/async getters receive isolated readonly partial plain inputs, depopulated ids and copied Date/ObjectId/Decimal128 scalars, no virtuals/derived targets/managed version key/containers. Return `undefined` to clear; staged results use Mongoose setters/casting. Getters/validators must not mutate captured hydrated inputs.
82
+ - Root String/Number/Boolean/Date/ObjectId/Decimal128/BigInt only; reject nested/container/Mixed/Union/Binary/UUID/custom kinds, derived chains, immutable/virtual targets, unknown names, malformed options, and duplicate registration. Other ordinary fields coexist but are not getter inputs.
83
+ - Reject Mongoose-managed versionKey (default __v or custom) as a target/explicit dependency and omit it from every input view: Mongoose initializes/increments it after pre-save middleware. With versionKey:false, explicitly declared version-like scalar fields are ordinary supported targets/inputs.
84
+ - `DerivedFieldsDependencyError`: code `MOO_DERIVED_FIELDS_MISSING_DEPENDENCIES`, frozen path-only `issues: [{ targetPath, missingPaths }]`. Getter failures propagate; casting/validation use Mongoose errors. Rejection preserves application assignments in memory.
85
+ - Register source-mutating validation/save middleware before plugin. Normal validation sees results; earlier pre-save changes recompute/revalidate. Later pre-save mutations are outside guarantee; unstable validation-time mutations reject on save. `validateBeforeSave:false` computes/casts but skips required/custom validators.
86
+ - Guaranteed writes: hydrated save/Model.create; async document validation computes without persisting. Query updates/replacements/raw writes bypass. insertMany/bulk inserts may incidentally validate, not all-writer support; validateSync/middleware bypass/pathsToSave excluded.
87
+ - Backfill old rows/formula changes explicitly (load dependencies, mark target modified, save). Ordinary multi-writer Mongoose concurrency applies; optimisticConcurrency recommended for source/result invariants.
88
+ - All helper/error/types also available from `@web-ts-toolkit/moo/plugins` and `@web-ts-toolkit/moo/plugins/derived-fields`.
89
+
52
90
  New-document plugin:
53
91
 
54
92
  ```ts
@@ -146,8 +184,9 @@ userSchema.plugin(keycloakUserSyncPlugin, {
146
184
  - `@web-ts-toolkit/moo/is` — `isObjectId`
147
185
  - `@web-ts-toolkit/moo/schema` — `uniqueNullableString`, `uniqueEmptiableString`
148
186
  - `@web-ts-toolkit/moo/utils` — `isSchema`, `isObjectIdType`, `isReference`
149
- - `@web-ts-toolkit/moo/plugins` — non-Keycloak plugins (`modelFunctionPlugin`, `cascadeDeletePlugin`, `newDocumentPlugin`)
187
+ - `@web-ts-toolkit/moo/plugins` — non-Keycloak plugins (`modelFunctionPlugin`, `cascadeDeletePlugin`, `derivedFieldsPlugin`, `newDocumentPlugin`)
150
188
  - `@web-ts-toolkit/moo/plugins/cascade-delete` — `cascadeDeletePlugin`
189
+ - `@web-ts-toolkit/moo/plugins/derived-fields` — `derivedFieldsPlugin`, `defineDerivedField`, `DerivedFieldsDependencyError`, authoring types
151
190
  - `@web-ts-toolkit/moo/plugins/model-function` — `modelFunctionPlugin`
152
191
  - `@web-ts-toolkit/moo/plugins/new-document` — `newDocumentPlugin`
153
192
  - `@web-ts-toolkit/moo/plugins/keycloak-user-sync` — `createManagedKeycloakClient`, `keycloakUserSyncPlugin`
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/moo",
3
3
  "description": "Mongoose helpers for schema fields, ObjectId checks, and document plugins",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/moo",
5
- "version": "0.55.0",
5
+ "version": "0.56.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "mongoose",
@@ -51,6 +51,12 @@
51
51
  "require": "./plugins/cascade-delete.js",
52
52
  "default": "./plugins/cascade-delete.js"
53
53
  },
54
+ "./plugins/derived-fields": {
55
+ "types": "./plugins/derived-fields.d.ts",
56
+ "import": "./plugins/derived-fields.mjs",
57
+ "require": "./plugins/derived-fields.js",
58
+ "default": "./plugins/derived-fields.js"
59
+ },
54
60
  "./plugins/model-function": {
55
61
  "types": "./plugins/model-function.d.ts",
56
62
  "import": "./plugins/model-function.mjs",
@@ -83,7 +89,7 @@
83
89
  }
84
90
  },
85
91
  "dependencies": {
86
- "@web-ts-toolkit/utils": "0.55.0"
92
+ "@web-ts-toolkit/utils": "0.56.0"
87
93
  },
88
94
  "files": [
89
95
  "**/*",
@@ -0,0 +1,108 @@
1
+ import { Schema } from 'mongoose';
2
+
3
+ /**
4
+ * A partial plain persisted-model view, not a hydrated document. Only available,
5
+ * supported root scalar inputs are supplied; virtuals, populated documents,
6
+ * containers, derived targets, and Mongoose's managed version key are not inputs.
7
+ * Use the raw model type for `TModel`. Selected defaults are available; genuine
8
+ * selected absence is `undefined` and stored `null` is known. Defaults applied to
9
+ * unselected fields never prove availability, even when pending a write.
10
+ * Dates/ObjectIds/Decimal128s are copied per invocation; property getters/
11
+ * transforms are bypassed.
12
+ * Never mutate the hydrated document through a captured reference.
13
+ */
14
+ type DerivedFieldInput<TModel> = Readonly<Partial<TModel>>;
15
+ /** Sync/async computation with no document `this`; `undefined` clears the target. */
16
+ type DerivedFieldGetter<TModel, TValue> = (this: void, doc: DerivedFieldInput<TModel>) => TValue | undefined | Promise<TValue | undefined>;
17
+ /**
18
+ * Declare every source read by `get`. Existing documents skip when no dependency
19
+ * modification/default write or target assignment/default write requests work.
20
+ * Pending non-null defaults count even without Mongoose modification flags;
21
+ * null/undefined defaults alone do not write. Every requested computation requires
22
+ * all dependencies selected or fully replaced; excluded defaults are not replacements.
23
+ * The Mongoose-managed version key is not a supported dependency or target.
24
+ */
25
+ interface DerivedFieldDescriptor<TModel, TValue> {
26
+ readonly dependsOn: readonly Extract<keyof TModel, string>[];
27
+ readonly get: DerivedFieldGetter<TModel, TValue>;
28
+ }
29
+ /**
30
+ * The `derived` schema-path option. A bare getter recomputes on every operation
31
+ * and requires every supported non-derived schema input to be available.
32
+ */
33
+ type DerivedFieldDefinition<TModel, TValue> = DerivedFieldDescriptor<TModel, TValue> | DerivedFieldGetter<TModel, TValue>;
34
+ /**
35
+ * Contextually types a `derived` option without augmenting Mongoose globally.
36
+ * The second type argument is the target's key in the raw persisted model, so
37
+ * dependency names, getter input, and the target's result type are checked.
38
+ * Schema registration separately checks runtime path eligibility.
39
+ *
40
+ * @example
41
+ * type User = { firstName: string; lastName: string; fullName: string };
42
+ * const derived = defineDerivedField<User, 'fullName'>({
43
+ * dependsOn: ['firstName', 'lastName'],
44
+ * get: (doc) => `${doc.firstName ?? ''} ${doc.lastName ?? ''}`.trim(),
45
+ * });
46
+ */
47
+ declare function defineDerivedField<TModel, TTargetKey extends Extract<keyof TModel, string>>(definition: DerivedFieldDescriptor<TModel, TModel[TTargetKey]>): DerivedFieldDescriptor<TModel, TModel[TTargetKey]>;
48
+ declare function defineDerivedField<TModel, TTargetKey extends Extract<keyof TModel, string>>(definition: DerivedFieldGetter<TModel, TModel[TTargetKey]>): DerivedFieldGetter<TModel, TModel[TTargetKey]>;
49
+ declare function defineDerivedField<TModel, TTargetKey extends Extract<keyof TModel, string>>(definition: DerivedFieldDefinition<TModel, TModel[TTargetKey]>): DerivedFieldDefinition<TModel, TModel[TTargetKey]>;
50
+ /** One target's unavailable dependency paths; never includes source values. */
51
+ interface DerivedFieldDependencyIssue {
52
+ readonly targetPath: string;
53
+ readonly missingPaths: readonly string[];
54
+ }
55
+ /**
56
+ * Raised by dependency preflight before any getter runs. `issues` lists every
57
+ * scheduled target with missing inputs. No implicit database fetch is attempted.
58
+ */
59
+ declare class DerivedFieldsDependencyError extends Error {
60
+ /** Stable discriminator, including across separately bundled entrypoints. */
61
+ readonly code: "MOO_DERIVED_FIELDS_MISSING_DEPENDENCIES";
62
+ /** Frozen path-only issues; no source values or hydrated document are attached. */
63
+ readonly issues: readonly DerivedFieldDependencyIssue[];
64
+ constructor(issues: readonly DerivedFieldDependencyIssue[]);
65
+ }
66
+
67
+ /**
68
+ * Computes opt-in root scalar `derived` fields before asynchronous document
69
+ * validation and hydrated `save()` (including `Model.create()`). Results are
70
+ * staged, then assigned through Mongoose setters/casting; `undefined` clears a
71
+ * target. Getters receive separate frozen plain views with copied scalar values.
72
+ * Supported targets/dependencies: root String, Number, Boolean, Date, ObjectId,
73
+ * Decimal128, BigInt; containers, nested paths, and derived chains are rejected.
74
+ * Mongoose's managed version key (default `__v` or custom) is rejected as a
75
+ * target/dependency and omitted from inputs; with versionKey:false it is ordinary.
76
+ *
77
+ * Register after defining paths and source-mutating middleware, before compiling
78
+ * the model. Source-changing pre-validation hooks must precede this plugin for
79
+ * standalone validation to see their final values. Earlier pre-save changes are
80
+ * recomputed and, unless validation is disabled, revalidated. Changes made by
81
+ * later pre-save hooks are outside this guarantee. Validation hooks/validators
82
+ * must not change inputs after this plugin computes them: a save that still has
83
+ * such changes after revalidation rejects with an ordering error.
84
+ *
85
+ * No inputs are fetched. Explicit descriptors skip unless a dependency modification
86
+ * or target assignment requests work, including actual pending default writes.
87
+ * Mongoose writes non-null defaults (including '', 0, false) without modification
88
+ * flags; null/undefined defaults alone do not write. Pending defaults schedule work
89
+ * but do not prove excluded inputs available: all dependencies must be selected or
90
+ * fully replaced. Missing/projected-out targets without a write request still skip.
91
+ * Bare getters run on every validation/save and require every supported input.
92
+ * New documents retain their supplied/defaulted/absent input availability on
93
+ * later saves, including when constructed with projection metadata.
94
+ * A normal save reuses its own validation computation when inputs/results are
95
+ * unchanged. Separate validations/saves do not share that computation cache.
96
+ * Validation-disabled saves still compute and reject getter/casting failures,
97
+ * but intentionally skip required/custom validation.
98
+ *
99
+ * Query writes, replacements, insertMany/bulkWrite fast paths, raw-driver writes,
100
+ * validateSync(), middleware bypass, and restricted pathsToSave are outside the
101
+ * document-save contract. Some bulk insert paths invoke async document validation
102
+ * and therefore compute incidentally; that is not all-writer consistency.
103
+ * Backfill old rows/formula changes explicitly; ordinary Mongoose concurrent-write
104
+ * semantics apply (consider optimisticConcurrency for source/result invariants).
105
+ */
106
+ declare function derivedFieldsPlugin<TRawDocType>(schema: Schema<TRawDocType>): void;
107
+
108
+ export { type DerivedFieldDefinition, type DerivedFieldDependencyIssue, type DerivedFieldDescriptor, type DerivedFieldGetter, type DerivedFieldInput, DerivedFieldsDependencyError, defineDerivedField, derivedFieldsPlugin };
@@ -0,0 +1,108 @@
1
+ import { Schema } from 'mongoose';
2
+
3
+ /**
4
+ * A partial plain persisted-model view, not a hydrated document. Only available,
5
+ * supported root scalar inputs are supplied; virtuals, populated documents,
6
+ * containers, derived targets, and Mongoose's managed version key are not inputs.
7
+ * Use the raw model type for `TModel`. Selected defaults are available; genuine
8
+ * selected absence is `undefined` and stored `null` is known. Defaults applied to
9
+ * unselected fields never prove availability, even when pending a write.
10
+ * Dates/ObjectIds/Decimal128s are copied per invocation; property getters/
11
+ * transforms are bypassed.
12
+ * Never mutate the hydrated document through a captured reference.
13
+ */
14
+ type DerivedFieldInput<TModel> = Readonly<Partial<TModel>>;
15
+ /** Sync/async computation with no document `this`; `undefined` clears the target. */
16
+ type DerivedFieldGetter<TModel, TValue> = (this: void, doc: DerivedFieldInput<TModel>) => TValue | undefined | Promise<TValue | undefined>;
17
+ /**
18
+ * Declare every source read by `get`. Existing documents skip when no dependency
19
+ * modification/default write or target assignment/default write requests work.
20
+ * Pending non-null defaults count even without Mongoose modification flags;
21
+ * null/undefined defaults alone do not write. Every requested computation requires
22
+ * all dependencies selected or fully replaced; excluded defaults are not replacements.
23
+ * The Mongoose-managed version key is not a supported dependency or target.
24
+ */
25
+ interface DerivedFieldDescriptor<TModel, TValue> {
26
+ readonly dependsOn: readonly Extract<keyof TModel, string>[];
27
+ readonly get: DerivedFieldGetter<TModel, TValue>;
28
+ }
29
+ /**
30
+ * The `derived` schema-path option. A bare getter recomputes on every operation
31
+ * and requires every supported non-derived schema input to be available.
32
+ */
33
+ type DerivedFieldDefinition<TModel, TValue> = DerivedFieldDescriptor<TModel, TValue> | DerivedFieldGetter<TModel, TValue>;
34
+ /**
35
+ * Contextually types a `derived` option without augmenting Mongoose globally.
36
+ * The second type argument is the target's key in the raw persisted model, so
37
+ * dependency names, getter input, and the target's result type are checked.
38
+ * Schema registration separately checks runtime path eligibility.
39
+ *
40
+ * @example
41
+ * type User = { firstName: string; lastName: string; fullName: string };
42
+ * const derived = defineDerivedField<User, 'fullName'>({
43
+ * dependsOn: ['firstName', 'lastName'],
44
+ * get: (doc) => `${doc.firstName ?? ''} ${doc.lastName ?? ''}`.trim(),
45
+ * });
46
+ */
47
+ declare function defineDerivedField<TModel, TTargetKey extends Extract<keyof TModel, string>>(definition: DerivedFieldDescriptor<TModel, TModel[TTargetKey]>): DerivedFieldDescriptor<TModel, TModel[TTargetKey]>;
48
+ declare function defineDerivedField<TModel, TTargetKey extends Extract<keyof TModel, string>>(definition: DerivedFieldGetter<TModel, TModel[TTargetKey]>): DerivedFieldGetter<TModel, TModel[TTargetKey]>;
49
+ declare function defineDerivedField<TModel, TTargetKey extends Extract<keyof TModel, string>>(definition: DerivedFieldDefinition<TModel, TModel[TTargetKey]>): DerivedFieldDefinition<TModel, TModel[TTargetKey]>;
50
+ /** One target's unavailable dependency paths; never includes source values. */
51
+ interface DerivedFieldDependencyIssue {
52
+ readonly targetPath: string;
53
+ readonly missingPaths: readonly string[];
54
+ }
55
+ /**
56
+ * Raised by dependency preflight before any getter runs. `issues` lists every
57
+ * scheduled target with missing inputs. No implicit database fetch is attempted.
58
+ */
59
+ declare class DerivedFieldsDependencyError extends Error {
60
+ /** Stable discriminator, including across separately bundled entrypoints. */
61
+ readonly code: "MOO_DERIVED_FIELDS_MISSING_DEPENDENCIES";
62
+ /** Frozen path-only issues; no source values or hydrated document are attached. */
63
+ readonly issues: readonly DerivedFieldDependencyIssue[];
64
+ constructor(issues: readonly DerivedFieldDependencyIssue[]);
65
+ }
66
+
67
+ /**
68
+ * Computes opt-in root scalar `derived` fields before asynchronous document
69
+ * validation and hydrated `save()` (including `Model.create()`). Results are
70
+ * staged, then assigned through Mongoose setters/casting; `undefined` clears a
71
+ * target. Getters receive separate frozen plain views with copied scalar values.
72
+ * Supported targets/dependencies: root String, Number, Boolean, Date, ObjectId,
73
+ * Decimal128, BigInt; containers, nested paths, and derived chains are rejected.
74
+ * Mongoose's managed version key (default `__v` or custom) is rejected as a
75
+ * target/dependency and omitted from inputs; with versionKey:false it is ordinary.
76
+ *
77
+ * Register after defining paths and source-mutating middleware, before compiling
78
+ * the model. Source-changing pre-validation hooks must precede this plugin for
79
+ * standalone validation to see their final values. Earlier pre-save changes are
80
+ * recomputed and, unless validation is disabled, revalidated. Changes made by
81
+ * later pre-save hooks are outside this guarantee. Validation hooks/validators
82
+ * must not change inputs after this plugin computes them: a save that still has
83
+ * such changes after revalidation rejects with an ordering error.
84
+ *
85
+ * No inputs are fetched. Explicit descriptors skip unless a dependency modification
86
+ * or target assignment requests work, including actual pending default writes.
87
+ * Mongoose writes non-null defaults (including '', 0, false) without modification
88
+ * flags; null/undefined defaults alone do not write. Pending defaults schedule work
89
+ * but do not prove excluded inputs available: all dependencies must be selected or
90
+ * fully replaced. Missing/projected-out targets without a write request still skip.
91
+ * Bare getters run on every validation/save and require every supported input.
92
+ * New documents retain their supplied/defaulted/absent input availability on
93
+ * later saves, including when constructed with projection metadata.
94
+ * A normal save reuses its own validation computation when inputs/results are
95
+ * unchanged. Separate validations/saves do not share that computation cache.
96
+ * Validation-disabled saves still compute and reject getter/casting failures,
97
+ * but intentionally skip required/custom validation.
98
+ *
99
+ * Query writes, replacements, insertMany/bulkWrite fast paths, raw-driver writes,
100
+ * validateSync(), middleware bypass, and restricted pathsToSave are outside the
101
+ * document-save contract. Some bulk insert paths invoke async document validation
102
+ * and therefore compute incidentally; that is not all-writer consistency.
103
+ * Backfill old rows/formula changes explicitly; ordinary Mongoose concurrent-write
104
+ * semantics apply (consider optimisticConcurrency for source/result invariants).
105
+ */
106
+ declare function derivedFieldsPlugin<TRawDocType>(schema: Schema<TRawDocType>): void;
107
+
108
+ export { type DerivedFieldDefinition, type DerivedFieldDependencyIssue, type DerivedFieldDescriptor, type DerivedFieldGetter, type DerivedFieldInput, DerivedFieldsDependencyError, defineDerivedField, derivedFieldsPlugin };