@vytches/ddd-value-objects 0.31.0-alpha.0 → 0.31.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/LLMGUIDE.md CHANGED
@@ -55,13 +55,107 @@ const textId = EntityId.fromText('order-slug');
55
55
 
56
56
  ### `BaseValueObject<T>` method reference
57
57
 
58
- | Method | Description |
59
- | ----------------------------------- | -------------------------------------------------- |
60
- | `getValue(): T` | Returns the raw wrapped value |
61
- | `equals(other): boolean` | Structural equality by raw value (`===`) |
62
- | `toString(): string` | `String(value)` |
63
- | `toJSON(): string` | `JSON.stringify(value)` |
64
- | `abstract validate(value): boolean` | Must be implemented; called in subclass `create()` |
58
+ | Method | Description |
59
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
60
+ | `getValue(): T` | Returns the raw wrapped value |
61
+ | `equals(other): boolean` | `===` for primitives, `LibUtils.deepEqual` for objects — unless `getIdentityComponents()` is overridden on both sides, in which case identity components decide equality instead (VF-036) |
62
+ | `toString(): string` | `String(value)` |
63
+ | `toJSON(): unknown` | Returns the raw value (not a JSON string) |
64
+ | `abstract validate(value): boolean` | Must be implemented; called in subclass `create()` |
65
+ | `getIdentityComponents(): readonly unknown[] \| undefined` | Protected opt-in hook (VF-036). Default returns `undefined` (raw comparison, unchanged). Override to compare by a subset/projection of fields instead of the full raw value; if either side returns `undefined`, `equals()` falls back to the raw comparison above |
66
+
67
+ ## Partial-identity equality: `getIdentityComponents()` (VF-036)
68
+
69
+ **When to use it:** override this protected hook when only a subset of a value
70
+ object's `value` should participate in equality (e.g. exclude a display flag,
71
+ audit timestamp, or cache key from identity). Leave it unimplemented (default
72
+ returns `undefined`) when the full raw `value` should decide equality — that is
73
+ the common case.
74
+
75
+ ```ts compile-check
76
+ import { BaseValueObject } from '@vytches/ddd-value-objects';
77
+
78
+ interface MoneyProps {
79
+ amount: number;
80
+ currency: string;
81
+ displayFormat: 'symbol' | 'code';
82
+ }
83
+
84
+ class Money extends BaseValueObject<MoneyProps> {
85
+ validate(value: unknown): boolean {
86
+ const v = value as MoneyProps;
87
+ return v.amount >= 0 && v.currency.length === 3;
88
+ }
89
+
90
+ // string-literal discriminator ('Money') scopes equality to this type —
91
+ // see "Type-scoped equality" below; displayFormat is excluded on purpose.
92
+ protected override getIdentityComponents(): readonly unknown[] {
93
+ return ['Money', this.value.amount, this.value.currency];
94
+ }
95
+ }
96
+ ```
97
+
98
+ **Dispatch rule:** `equals()` calls the hook on both sides. Both defined →
99
+ compare components (same length, then element-wise, dispatching to nested
100
+ `.equals()` for value objects, `LibUtils.deepEqual` otherwise). Either side
101
+ `undefined` → unchanged raw comparison (`===` / `LibUtils.deepEqual`).
102
+
103
+ **Non-transitivity in mixed populations (collection hazard).** The fallback is
104
+ symmetric but not transitive: with A (no override), B (override, same raw
105
+ `value` as A), C (override, same components as B), `A.equals(B)` and
106
+ `B.equals(C)` can both be `true` while `A.equals(C)` is `false`. Code that
107
+ assumes an equivalence relation — `list.some(x => x.equals(y))`, de-duplication
108
+ by `.equals()` — breaks silently when raw-comparison and component-override
109
+ instances of the same base type are mixed. Migrate a whole hierarchy together
110
+ (see MIGRATION.md), never partially.
111
+
112
+ **`[]` footgun.** `[]` is defined-and-empty, not "opt out" — two `[]`- returning
113
+ instances are always equal to each other. Return `undefined` to opt out, never
114
+ `[]`.
115
+
116
+ **Fixed arity.** A class must always return the same number of components in the
117
+ same order. Comparison begins with a length check, so a conditional push
118
+ (`if (this.scope) parts.push(this.scope)`) makes two instances of one class
119
+ unequal because of an unset optional field rather than a real data difference.
120
+ Use a stable placeholder — `[this.tenant, this.scope ?? null, this.key]`. A
121
+ subclass that adds a component changes the arity of every comparison against its
122
+ parent; decide that deliberately.
123
+
124
+ **Undefined-because-uninitialized is a different trap than it sounds.** Only a
125
+ literal `undefined` returned _by the hook itself_ triggers the raw fallback; an
126
+ `undefined` _component value_ inside a defined array does not. The real trap is
127
+ a _conditional_ override that sometimes returns `undefined` — that silently and
128
+ intermittently downgrades the same class to raw comparison. Always return an
129
+ array once a class opts in.
130
+
131
+ **Throw propagation.** A throwing override propagates out of `equals()` uncaught
132
+ — no try/catch, by design (`equals()` is a hot path; the library logger is
133
+ diagnostics-only). `equals()` is no longer a total function for a subclass whose
134
+ override can throw.
135
+
136
+ **Frozen/readonly state only.** The constructor deep-freezes `value`, not any
137
+ subclass field assigned after `super(value)`. Components must derive only from
138
+ `value` or from subclass fields the subclass itself keeps immutable — deriving
139
+ from mutable state breaks equality stability over the instance's lifetime.
140
+
141
+ **Type-scoped equality idiom.** No type/`instanceof` check is performed —
142
+ cross-subclass equality with matching components stays `true`, same as raw
143
+ comparison today. To scope equality to one type, add a string literal as the
144
+ first component (`['Money', ...]`), never `this.constructor.name`
145
+ (minification-unsafe) or the class object itself (unsafe across duplicate
146
+ package copies).
147
+
148
+ **`toString()`/`toJSON()` stay value-based** regardless of this hook — both
149
+ always serialize the raw `value`. An override that narrows equality can desync
150
+ serialization from equality; keeping that coherent is the consumer's
151
+ responsibility.
152
+
153
+ > **Permanent note:** `getEqualityComponents()` — a similarly-named hook from an
154
+ > early (2025) documentation draft — was **never implemented** in any released
155
+ > version and will not be added as an alias/shim/fallback.
156
+ > `getIdentityComponents()` is the real hook, under a deliberately new name. Any
157
+ > existing `getEqualityComponents()` override has always been dead code; see
158
+ > root `MIGRATION.md` to migrate it.
65
159
 
66
160
  ## Patterns
67
161
 
@@ -224,6 +318,13 @@ cancelOrder(orderId); // OK
224
318
  Available factories: `createBrandedId<Tag>(entityId)`, `newBrandedId<Tag>()`,
225
319
  `brandedIdFromUUID<Tag>(uuid)`, `brandedIdFromText<Tag>(text)`.
226
320
 
321
+ ## Related Guides
322
+
323
+ - [`docs/guides/specification-vs-policy.md`](../../docs/guides/specification-vs-policy.md)
324
+ — "where do I validate what" decision tree: when a rule belongs in a VO's
325
+ `validate()` constructor invariant vs `Specification` /
326
+ `BusinessRuleValidator` / `PolicyBuilder`.
327
+
227
328
  ## Package Dependencies
228
329
 
229
330
  **Depends on:** `@vytches/ddd-contracts`, `@vytches/ddd-domain-primitives`,
package/README.md CHANGED
@@ -17,20 +17,20 @@ pnpm add @vytches/ddd-value-objects
17
17
 
18
18
  ## What's included
19
19
 
20
- | Export | Kind | Description |
21
- | ------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
22
- | `BaseValueObject<T>` | class | Abstract base for value objects; deep-freezes internal value; implements `equals()` via structural comparison |
23
- | `ValueObjectValidator<T>` | interface | `validate(value: T): boolean` — implement to add invariant checking |
24
- | `EntityId<T>` | class | Enhanced entity identifier extending the contracts `EntityId`; adds UUID/integer/text validation, `create()`, `fromString()`, `fromNumber()` |
25
- | `EntityIdFactory` | class | **Deprecated** — will be removed in v1.0.0; use `EntityId.create()` directly |
26
- | `BrandedId<Tag>` | type | Compile-time branded `EntityId<string>` — prevents mixing IDs across aggregate types |
27
- | `createBrandedId<Tag>(id)` | function | Casts an `EntityId` to `BrandedId<Tag>` |
28
- | `newBrandedId<Tag>()` | function | Creates a new UUID-based `BrandedId<Tag>` |
29
- | `brandedIdFromUUID<Tag>(uuid)` | function | Creates a `BrandedId<Tag>` from an existing UUID string |
30
- | `brandedIdFromText<Tag>(text)` | function | Creates a `BrandedId<Tag>` from a text string |
31
- | `IEntityId<T>` | interface | Re-exported from `@vytches/ddd-contracts` |
32
- | `IEntityIdFactory` | interface | Re-exported from `@vytches/ddd-contracts` |
33
- | `IdType` | type | Re-exported from `@vytches/ddd-contracts` — `'uuid' \| 'integer' \| 'text'` |
20
+ | Export | Kind | Description |
21
+ | ------------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
22
+ | `BaseValueObject<T>` | class | Abstract base for value objects; deep-freezes internal value; implements `equals()` via structural comparison, with an opt-in [`getIdentityComponents()`](#partial-identity-equality-with-getidentitycomponents) hook for partial-identity comparison |
23
+ | `ValueObjectValidator<T>` | interface | `validate(value: T): boolean` — implement to add invariant checking |
24
+ | `EntityId<T>` | class | Enhanced entity identifier extending the contracts `EntityId`; adds UUID/integer/text validation, `create()`, `fromString()`, `fromNumber()` |
25
+ | `EntityIdFactory` | class | **Deprecated** — will be removed in v1.0.0; use `EntityId.create()` directly |
26
+ | `BrandedId<Tag>` | type | Compile-time branded `EntityId<string>` — prevents mixing IDs across aggregate types |
27
+ | `createBrandedId<Tag>(id)` | function | Casts an `EntityId` to `BrandedId<Tag>` |
28
+ | `newBrandedId<Tag>()` | function | Creates a new UUID-based `BrandedId<Tag>` |
29
+ | `brandedIdFromUUID<Tag>(uuid)` | function | Creates a `BrandedId<Tag>` from an existing UUID string |
30
+ | `brandedIdFromText<Tag>(text)` | function | Creates a `BrandedId<Tag>` from a text string |
31
+ | `IEntityId<T>` | interface | Re-exported from `@vytches/ddd-contracts` |
32
+ | `IEntityIdFactory` | interface | Re-exported from `@vytches/ddd-contracts` |
33
+ | `IdType` | type | Re-exported from `@vytches/ddd-contracts` — `'uuid' \| 'integer' \| 'text'` |
34
34
 
35
35
  ## Note on bundled domain examples
36
36
 
@@ -121,6 +121,289 @@ shipOrder(orderId); // OK
121
121
  shipOrder(customerId); // TypeScript compile error!
122
122
  ```
123
123
 
124
+ ## Keeping a specific validation error with `getInvalidValueMessage()`
125
+
126
+ _Since VF-023 — the constructor validates, see
127
+ [`CHANGELOG.md`](./CHANGELOG.md)._
128
+
129
+ `BaseValueObject`'s constructor calls `validate()` and throws on failure, before
130
+ the subclass constructor body runs. `validate()` returns a boolean, so any value
131
+ object whose real validation produces a _rich_ error — which field, which rule,
132
+ which expected checksum — has to swallow that error to satisfy the signature.
133
+ What reaches the caller is then the generic default:
134
+
135
+ ```
136
+ Error: Invalid value for Regon
137
+ ```
138
+
139
+ Override `getInvalidValueMessage()` to put the specific message back. It is
140
+ called only on the failing path, so re-running the detailed validation there
141
+ costs nothing in the happy case:
142
+
143
+ ```typescript
144
+ export abstract class NationalIdentifier extends BaseValueObject<IdentifierProps> {
145
+ /** Throws a rich, typed error. Called by validate() and, on failure, here. */
146
+ protected abstract assertValid(value: string): void;
147
+
148
+ validate(props: IdentifierProps): boolean {
149
+ try {
150
+ this.assertValid(props.value);
151
+ return props.locale === this.expectedLocale;
152
+ } catch {
153
+ return false; // the detail is lost here — recovered below
154
+ }
155
+ }
156
+
157
+ protected override getInvalidValueMessage(props: IdentifierProps): string {
158
+ try {
159
+ this.assertValid(props.value);
160
+ } catch (error) {
161
+ // "REGON has invalid checksum: expected 5, got 6"
162
+ return error instanceof Error ? error.message : String(error);
163
+ }
164
+ return `${this.typeName} requires locale '${this.expectedLocale}', got '${props.locale}'`;
165
+ }
166
+ }
167
+ ```
168
+
169
+ Two constraints apply, both from the undefined-during-`super()` trap: the
170
+ override may read only its `value` parameter and state the base constructor has
171
+ already set (`this.value` is assigned before the throw), never a field
172
+ initialized in the subclass constructor — that field is still `undefined` here.
173
+
174
+ A `Result`-returning factory keeps the typed error by re-wrapping what the
175
+ constructor threw:
176
+
177
+ ```typescript
178
+ static create(value: string): Result<Regon, IdentifierValidationError> {
179
+ try {
180
+ return Result.ok(new Regon({ value, locale: 'pl-PL' }));
181
+ } catch (error) {
182
+ if (error instanceof IdentifierValidationError) return Result.fail(error);
183
+ // Generic Error from the base constructor — message already specific,
184
+ // thanks to getInvalidValueMessage() above.
185
+ return Result.fail(
186
+ new IdentifierValidationError(
187
+ error instanceof Error ? error.message : 'Unknown error',
188
+ 'REGON'
189
+ )
190
+ );
191
+ }
192
+ }
193
+ ```
194
+
195
+ **Migration note.** Before VF-023 the common shape was `new X(props)` followed
196
+ by `x.assertValid(...)` in the factory — the constructor did not validate, so
197
+ the factory's own call produced the error. That second call is now unreachable:
198
+ the base class throws first. A factory relying on it silently changes its error
199
+ type from the typed one to a plain `Error`.
200
+
201
+ ## Partial-identity equality with `getIdentityComponents()`
202
+
203
+ _Since VF-036 — additive minor, see [`CHANGELOG.md`](./CHANGELOG.md)._
204
+
205
+ By default, `equals()` compares the **entire** stored value: `===` for
206
+ primitives, `LibUtils.deepEqual` for objects. Override the protected
207
+ `getIdentityComponents()` hook when only a subset of a value object's state
208
+ should participate in equality — for example, a `Money` value object that
209
+ carries a display-formatting flag that must not affect equality, or a value
210
+ object that should compare a derived/normalized projection of its state rather
211
+ than the raw stored shape.
212
+
213
+ ```ts compile-check
214
+ import { BaseValueObject } from '@vytches/ddd-value-objects';
215
+
216
+ interface MoneyProps {
217
+ amount: number;
218
+ currency: string;
219
+ displayFormat: 'symbol' | 'code';
220
+ }
221
+
222
+ class Money extends BaseValueObject<MoneyProps> {
223
+ constructor(props: MoneyProps) {
224
+ super(props);
225
+ }
226
+
227
+ validate(value: MoneyProps): boolean {
228
+ return (
229
+ typeof value === 'object' &&
230
+ value !== null &&
231
+ value.amount >= 0 &&
232
+ value.currency.length === 3
233
+ );
234
+ }
235
+
236
+ // 'Money' is a type-scoped discriminator (see below); displayFormat is
237
+ // intentionally excluded from identity.
238
+ protected override getIdentityComponents(): readonly unknown[] {
239
+ return ['Money', this.value.amount, this.value.currency];
240
+ }
241
+ }
242
+
243
+ const a = new Money({ amount: 10, currency: 'USD', displayFormat: 'symbol' });
244
+ const b = new Money({ amount: 10, currency: 'USD', displayFormat: 'code' });
245
+
246
+ a.equals(b); // true — displayFormat is not an identity component
247
+ ```
248
+
249
+ ### When to reach for this instead of full-value equality
250
+
251
+ Use `getIdentityComponents()` when a value object's raw stored value carries
252
+ fields that are _incidental_ to identity — audit metadata, cache keys,
253
+ presentation flags — while the _value_ the domain actually cares about is
254
+ narrower. If every field of `value` matters for equality, leave the hook
255
+ unimplemented; the default `undefined` return keeps the unmodified raw
256
+ comparison.
257
+
258
+ ### The asymmetric fallback — and why it breaks transitivity in mixed populations
259
+
260
+ `equals()` calls `getIdentityComponents()` on **both** sides being compared.
261
+ Component comparison only runs if **both** sides return a defined array; if
262
+ **either** side returns `undefined`, `equals()` falls back to the unchanged raw
263
+ comparison. This fallback is symmetric — `a.equals(b)` and `b.equals(a)` always
264
+ agree, since both directions see the same two results — but it is deliberately
265
+ **not transitive** once a raw-comparison instance and a component-override
266
+ instance coexist in the same population:
267
+
268
+ - `A` — no override, raw comparison.
269
+ - `B` — component override, whose raw `value` happens to equal `A`'s.
270
+ - `C` — component override, whose components match `B`'s.
271
+
272
+ `A.equals(B)` can be `true` (raw fallback matches), `B.equals(C)` can be `true`
273
+ (components match), yet `A.equals(C)` can be `false` (raw fallback, different
274
+ `value`). This is an accepted, test-pinned limitation — it is not "fixed" with
275
+ `instanceof`/type gating.
276
+
277
+ **Collection-level consequence:** code such as `list.some(x => x.equals(y))` or
278
+ de-duplication by `.equals()` implicitly assumes equality is an equivalence
279
+ relation — reflexive, symmetric, and **transitive**. Mixing component-identity
280
+ subclasses with raw-comparison subclasses of the same base type inside one
281
+ collection breaks that assumption silently. Migrate a whole class hierarchy to
282
+ `getIdentityComponents()` together, or not at all — see the root
283
+ [`MIGRATION.md`](../../MIGRATION.md) for the atomic-codemod guidance.
284
+
285
+ ### The empty-array footgun
286
+
287
+ Returning `[]` is **defined-and-empty**, not "opt out." Two value objects that
288
+ both return `[]` are equal to each other regardless of what their `value` holds
289
+ (same length — zero — so the element-wise comparison is vacuously true for
290
+ both). To opt out of component comparison, return `undefined` (the base
291
+ default); never return `[]` to mean "not applicable."
292
+
293
+ ### The fixed-arity rule
294
+
295
+ A class must **always return the same number of components**, in the same order.
296
+ Comparison starts with a length check, so a conditional push —
297
+ `if (this.scope) parts.push(this.scope)` — makes two instances of the same class
298
+ unequal purely because one had an optional field set. That reads as a data
299
+ difference when it is actually an arity difference. Push a stable placeholder
300
+ instead:
301
+
302
+ ```ts compile-check
303
+ import { BaseValueObject } from '@vytches/ddd-value-objects';
304
+
305
+ interface GrantProps {
306
+ tenant: string;
307
+ scope?: string;
308
+ key: string;
309
+ }
310
+
311
+ class GrantRef extends BaseValueObject<GrantProps> {
312
+ validate(value: GrantProps): boolean {
313
+ return typeof value === 'object' && value !== null;
314
+ }
315
+
316
+ protected override getIdentityComponents(): readonly unknown[] {
317
+ // Always three slots, whatever is populated. Never push conditionally.
318
+ return [this.value.tenant, this.value.scope ?? null, this.value.key];
319
+ }
320
+ }
321
+ ```
322
+
323
+ The same applies across a hierarchy: if a subclass adds a component, it has
324
+ changed the arity of every comparison against its parent — a design decision to
325
+ make deliberately rather than discover.
326
+
327
+ ### The "sometimes undefined" downgrade trap
328
+
329
+ Only a **literal `undefined`/omitted return from the hook itself** triggers the
330
+ raw-comparison fallback — a component _value_ inside a returned array being
331
+ `undefined` (e.g. an unset optional field) does not; the array is still
332
+ "defined" and comparison proceeds element-wise. The trap is the reverse case: a
333
+ _conditional_ override — "return components once some field is set, otherwise
334
+ return `undefined`" — will silently and intermittently downgrade the very same
335
+ class to raw comparison across different instances or different points in an
336
+ instance's lifecycle. Prefer an override that always returns an array once a
337
+ class opts in.
338
+
339
+ ### Throw propagation — `equals()` is no longer total
340
+
341
+ A `getIdentityComponents()` override that throws propagates straight out of
342
+ `equals()`, uncaught. This is deliberate: `equals()` is a hot path, and this
343
+ library's internal logger is diagnostics-only and not meant to intercept
344
+ consumer logic (see the logging-removal note in the root `MIGRATION.md`), so
345
+ silently swallowing the throw into a wrong-but-quiet `false`/`true` would hide a
346
+ real bug in the override. Once a subclass overrides this hook with logic that
347
+ can throw, `equals()` is no longer a total function for that subclass.
348
+
349
+ ### Components must come from frozen or readonly state
350
+
351
+ The base constructor deep-freezes the `value` passed to `super()` — but it does
352
+ **not** freeze any additional fields a subclass declares and assigns after
353
+ `super(value)`; those remain ordinary mutable class fields unless the subclass
354
+ itself keeps them readonly/frozen. Derive components only from state that cannot
355
+ change after construction (the frozen `value`, or subclass fields the subclass
356
+ itself keeps immutable). Deriving a component from mutable state breaks the
357
+ invariant that a value object's equality is stable for its lifetime.
358
+
359
+ ### Type-scoped equality: the sanctioned idiom
360
+
361
+ `getIdentityComponents()` performs no type or `instanceof` check — two different
362
+ subclasses returning matching components are still considered equal, exactly as
363
+ cross-subclass raw comparison behaves today. If equality should be scoped to a
364
+ specific type, add a **string-literal discriminator** as the first component (as
365
+ in the `Money` example above):
366
+
367
+ ```ts compile-check
368
+ import { BaseValueObject } from '@vytches/ddd-value-objects';
369
+
370
+ class OrderNumber extends BaseValueObject<string> {
371
+ validate(value: unknown): boolean {
372
+ return typeof value === 'string' && value.length > 0;
373
+ }
374
+
375
+ protected override getIdentityComponents(): readonly unknown[] {
376
+ return ['OrderNumber', this.value];
377
+ }
378
+ }
379
+ ```
380
+
381
+ Do **not** use `this.constructor.name` (unsafe under minification, which can
382
+ rename classes inconsistently) and do **not** use the class object itself
383
+ (unsafe across duplicate package copies — the same hazard `instanceof` has).
384
+
385
+ ### `toString()`/`toJSON()` stay value-based
386
+
387
+ Overriding `getIdentityComponents()` does not change `toString()` or `toJSON()`
388
+ — both continue to serialize the raw `value`, unfiltered. If your override
389
+ narrows equality to a subset of fields, serialization may now surface
390
+ information that equality ignores (or the reverse). Keeping any resulting
391
+ equals/serialization desync coherent is the consumer's responsibility.
392
+
393
+ > **A permanent note: `getEqualityComponents` was never real.** An early (2025)
394
+ > documentation draft described a similarly-named `getEqualityComponents()`
395
+ > hook. It was **never implemented** in any released version of this library —
396
+ > every `equals()` call has always used the raw `value`-based comparison
397
+ > described above. The name was removed from docs in a later accuracy pass, but
398
+ > some consumers had already written overrides against it, which were (and
399
+ > always had been) dead code. `getIdentityComponents()` is the real, supported
400
+ > hook, under a deliberately different name. `getEqualityComponents` will
401
+ > **not** be added as an alias, shim, or runtime-detected fallback — doing so
402
+ > would silently activate every dormant consumer override on upgrade, which is
403
+ > exactly the behavioral break this design avoids. If you are holding a dead
404
+ > `getEqualityComponents` override, see the root
405
+ > [`MIGRATION.md`](../../MIGRATION.md).
406
+
124
407
  ## Package boundaries
125
408
 
126
409
  `@vytches/ddd-value-objects` depends on:
@@ -1,8 +1,28 @@
1
1
  export interface ValueObjectValidator<T> {
2
2
  validate(value: T): boolean;
3
3
  }
4
+ /**
5
+ * VF-036 (D3): module-private brand used to recognize "this is a
6
+ * `BaseValueObject`" without `instanceof`. `instanceof` breaks silently
7
+ * across duplicate package copies (e.g. a failed `pnpm dedupe`, or two
8
+ * versions of this library resolved by a bundler/monorepo tool) because
9
+ * each copy has its own distinct class reference. `Symbol.for` reads from
10
+ * the *global* symbol registry, so every copy of this module — regardless
11
+ * of which physical `node_modules` install produced it — resolves the same
12
+ * symbol and agrees on the brand. The symbol is intentionally not exported:
13
+ * consumers never need to reference it directly, only the boolean brand
14
+ * check performed internally by `componentEquals`.
15
+ */
16
+ declare const VALUE_OBJECT_BRAND: unique symbol;
4
17
  export declare abstract class BaseValueObject<T> implements ValueObjectValidator<T> {
5
18
  protected readonly value: T;
19
+ /**
20
+ * VF-036 (D3): marks every instance with the module-private brand symbol
21
+ * so `componentEquals` can recognize nested value objects without
22
+ * `instanceof`. Declared as an instance property (not on the prototype)
23
+ * so `in` works uniformly regardless of how a subclass is constructed.
24
+ */
25
+ protected readonly [VALUE_OBJECT_BRAND] = true;
6
26
  /**
7
27
  * VF-023 (D-1, AC1, BREAKING): validates `value` via the polymorphic
8
28
  * `validate()` hook and throws synchronously on failure. This replaces
@@ -24,9 +44,141 @@ export declare abstract class BaseValueObject<T> implements ValueObjectValidator
24
44
  * is ever reached.
25
45
  */
26
46
  constructor(value: T);
47
+ /**
48
+ * VF-036 (D1/D4): opt-in hook letting a subclass declare which fields
49
+ * make up its *identity*, so `equals()` compares those fields instead of
50
+ * the raw `value` this class stores. Ships under the name
51
+ * `getIdentityComponents` — an earlier, never-implemented name for a
52
+ * similar idea (`getEqualityComponents`) circulated in docs and MUST NOT
53
+ * be revived as an alias, shim, or runtime-detected fallback; it never
54
+ * existed in source and never will.
55
+ *
56
+ * ## When component identity beats full-value equality
57
+ * The default raw comparison (`this.value === valueObject.value`, then
58
+ * `LibUtils.deepEqual` for objects) treats the *entire* stored value as
59
+ * significant. Override this hook when only a subset of fields should
60
+ * participate — e.g. a `Money` VO whose `value` also carries a
61
+ * display-formatting flag that must not affect equality, or a VO that
62
+ * wants to compare by a derived/normalized projection of its state
63
+ * rather than the raw stored shape.
64
+ *
65
+ * ## The asymmetric fallback, and why it is not transitive
66
+ * The dispatch rule in `equals()` is: if BOTH sides return a defined
67
+ * array, compare components; if EITHER side returns `undefined`, run the
68
+ * completely unchanged raw comparison. This rule is symmetric (`a.equals(b)`
69
+ * and `b.equals(a)` agree, since both directions see the same two
70
+ * `getIdentityComponents()` results) but deliberately **not transitive**:
71
+ * given A (raw comparison, no override), B (component override, happens
72
+ * to have the same raw `value` as A), and C (component override, same
73
+ * components as B), `A.equals(B)` may be `true` (raw fallback matches),
74
+ * `B.equals(C)` may be `true` (components match), yet `A.equals(C)` can
75
+ * be `false` (raw fallback, different `value`). This is an accepted,
76
+ * documented, test-pinned limitation — do not "fix" it by adding
77
+ * type/instanceof gating; see D6. It matters most for collection code
78
+ * such as `list.some(x => x.equals(y))` or de-duplication by `.equals`:
79
+ * such code implicitly assumes an equivalence relation, and mixing
80
+ * component-identity subclasses with raw-comparison subclasses in the
81
+ * same collection breaks that assumption.
82
+ *
83
+ * ## The empty-array footgun
84
+ * Returning `[]` is **defined-and-empty**, not "opt out". An empty array
85
+ * means "this VO has no identity-relevant fields", so it is equal to
86
+ * every other VO whose `getIdentityComponents()` also returns `[]`
87
+ * (same length: zero, vacuously equal element-wise) — regardless of what
88
+ * their respective `value`s hold. To opt out, return `undefined` (the
89
+ * base default), not `[]`.
90
+ *
91
+ * ## The undefined-because-field-not-initialized trap
92
+ * `undefined` silently downgrades to the raw comparison. If an override
93
+ * reads a subclass field that has not been initialized yet (see the
94
+ * undefined-during-`super()` trap documented on the constructor) and
95
+ * that read produces `undefined` as a *component value* (not as the
96
+ * hook's own return value), the surrounding array is still "defined" —
97
+ * only a *literal* `undefined`/omitted return from the override itself
98
+ * triggers the fallback. Conversely, a conditional override that
99
+ * sometimes returns `undefined` (e.g. "only compare by components once
100
+ * some field is set") will silently and intermittently fall back to raw
101
+ * comparison for the same class — prefer always returning an array once
102
+ * a class opts in.
103
+ *
104
+ * ## Throw propagation
105
+ * A throwing override propagates out of `equals()` uncaught (D5).
106
+ * `equals()` is a hot path and this library's logger is
107
+ * diagnostics-only, so there is no try/catch here — catching would
108
+ * silently convert a loud consumer bug (e.g. a component accessor that
109
+ * dereferences something absent) into a wrong-but-quiet `false`/`true`
110
+ * answer. `equals()` stops being a total function for classes that
111
+ * override this hook with logic that can throw.
112
+ *
113
+ * ## Fixed arity
114
+ * A class must always return the same number of components on every
115
+ * call (do not vary the array length based on instance state). Two
116
+ * defined arrays of different lengths are treated as unequal without
117
+ * inspecting elements, so a class whose arity drifts will get
118
+ * inconsistent, order-of-comparison-dependent results.
119
+ *
120
+ * ## Components must come from frozen/readonly state
121
+ * The constructor deep-freezes the `value` passed to `super()`, but it
122
+ * does **not** freeze any additional fields a subclass declares and
123
+ * assigns after `super(value)` — those are ordinary mutable class
124
+ * fields unless the subclass itself makes them readonly/frozen.
125
+ * Components must be derived only from state that cannot change after
126
+ * construction (the frozen `value`, or subclass fields the subclass
127
+ * itself keeps immutable); deriving a component from mutable state
128
+ * breaks the invariant that a VO's equality is stable for its lifetime.
129
+ *
130
+ * ## Type-scoped equality: the sanctioned idiom
131
+ * This hook performs no type or `instanceof` check (D6) — cross-subclass
132
+ * equality behaves exactly as it does today, and two different
133
+ * subclasses returning matching components are considered equal. For
134
+ * consumers who want type-scoped equality, the sanctioned opt-in is a
135
+ * string literal as the first component (e.g. `['Money', this.amount,
136
+ * this.currency]`), NOT `this.constructor.name` (unsafe under
137
+ * minification, which can rename classes inconsistently) and NOT the
138
+ * class object itself (unsafe across duplicate package copies, the same
139
+ * hazard `instanceof` has — see the brand-symbol note above).
140
+ *
141
+ * ## toString()/toJSON() stay value-based
142
+ * Overriding this hook does not change `toString()` or `toJSON()` — both
143
+ * continue to reflect the raw `value`. If an override narrows equality
144
+ * to a subset of fields, `toString()`/`toJSON()` may now serialize
145
+ * information that equality ignores (or vice versa); keeping any
146
+ * resulting equals/hash/serialization desync coherent is the
147
+ * consumer's responsibility.
148
+ *
149
+ * @returns `undefined` (the base default) to opt out and keep the raw
150
+ * `value`-based comparison; a readonly array of identity-relevant
151
+ * values to opt in to component-wise comparison.
152
+ */
153
+ protected getIdentityComponents(): readonly unknown[] | undefined;
27
154
  /**
28
155
  * Compares this value object with another for equality.
29
- * Uses deep structural comparison for object values, === for primitives.
156
+ *
157
+ * VF-036 (D1/D2): first consults `getIdentityComponents()` on both
158
+ * sides. If BOTH return a defined array, equality is decided by that
159
+ * array alone: same length, then element-wise via `componentEquals`
160
+ * (which itself dispatches to nested `.equals()` for branded value
161
+ * objects, `LibUtils.deepEqual` otherwise). If EITHER side returns
162
+ * `undefined`, this method falls through to the exact, unmodified raw
163
+ * comparison that existed before VF-036 — see the block below and the
164
+ * `getIdentityComponents` doc comment for the full rationale, including
165
+ * why this fallback is symmetric but not transitive.
166
+ *
167
+ * The hook is consulted for primitive `T` as well as object `T`; nothing
168
+ * about this dispatch depends on `typeof this.value`.
169
+ *
170
+ * HARD CONSTRAINT: there is intentionally no unconditional
171
+ * `this === valueObject` shortcut at the top of this method — the only
172
+ * reflexivity shortcut this class takes lives inside the raw-comparison
173
+ * branch below (`this.value === valueObject.value`, which briefly
174
+ * predates any `getIdentityComponents` check on that branch only because
175
+ * neither side opted in). When neither side returns components, the
176
+ * executed code path is bit-for-bit identical to the pre-VF-036
177
+ * behavior, which is what keeps this a non-breaking, additive minor
178
+ * release rather than a breaking major.
179
+ *
180
+ * Uses deep structural comparison for object values, === for primitives,
181
+ * in the fallback branch.
30
182
  *
31
183
  * VF-023 (D-3, AC2/AC3, BREAKING): deep comparison now uses
32
184
  * `LibUtils.deepEqual` instead of `JSON.stringify` equality. This fixes
@@ -79,3 +231,4 @@ export declare abstract class BaseValueObject<T> implements ValueObjectValidator
79
231
  */
80
232
  protected getInvalidValueMessage(value: T): string;
81
233
  }
234
+ export {};
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("@vytches/ddd-utils"),s=require("@vytches/ddd-contracts"),r=require("@vytches/ddd-domain-primitives");class l{constructor(t){if(this.value=t!==null&&typeof t=="object"?e.LibUtils.deepFreeze(t):t,!this.validate(this.value))throw new Error(this.getInvalidValueMessage(this.value))}equals(t){return t?this.value===t.value?!0:typeof this.value=="object"&&this.value!==null?e.LibUtils.deepEqual(this.value,t.value):!1:!1}toString(){return String(this.value)}toJSON(){return this.value}getValue(){return this.value}getInvalidValueMessage(t){return`Invalid value for ${this.constructor.name}`}}class i extends s.EntityId{constructor(t,n){super(t,n)}validate(t){switch(this.getType()){case"uuid":return e.LibUtils.isValidUUID(t);case"integer":return e.LibUtils.isValidInteger(t);case"bigint":return e.LibUtils.isValidBigInt(t);case"text":return e.LibUtils.isValidTextId(t);default:throw new r.InvalidParameterError(`Unsupported IdType: ${this.getType()}`)}}static create(){return new i(e.LibUtils.getUUID(),"uuid")}static createWithRandomUUID(){return i.create()}static fromUUID(t){if(!e.LibUtils.hasValue(t))throw r.MissingValueError.withValue("entity identifier");if(!e.LibUtils.isValidUUID(t))throw r.InvalidParameterError.withParameter("entity identifier");return new i(t,"uuid")}static fromInteger(t){if(!e.LibUtils.isValidInteger(t))throw r.InvalidParameterError.withParameter("entity identifier must be a non-negative integer");return new i(t.toString(),"integer")}static fromBigInt(t){const n=e.LibUtils.normalizeIdToString(t);if(!e.LibUtils.isValidBigInt(n))throw r.InvalidParameterError.withParameter("entity identifier must be a valid bigint");return new i(n,"bigint")}static fromText(t){if(!e.LibUtils.hasValue(t))throw r.MissingValueError.withValue("entity identifier");if(!e.LibUtils.isValidTextId(t))throw r.InvalidParameterError.withParameter("entity identifier contains invalid characters");return new i(t,"text")}static tryFromUUID(t){return e.LibUtils.hasValue(t)?e.LibUtils.isValidUUID(t)?e.Result.ok(new i(t,"uuid")):e.Result.fail(r.InvalidParameterError.withParameter("entity identifier")):e.Result.fail(r.MissingValueError.withValue("entity identifier"))}static tryFromInteger(t){return e.LibUtils.isValidInteger(t)?e.Result.ok(new i(t.toString(),"integer")):e.Result.fail(r.InvalidParameterError.withParameter("entity identifier must be a non-negative integer"))}static tryFromBigInt(t){const n=e.LibUtils.normalizeIdToString(t);return e.LibUtils.isValidBigInt(n)?e.Result.ok(new i(n,"bigint")):e.Result.fail(r.InvalidParameterError.withParameter("entity identifier must be a valid bigint"))}static tryFromText(t){return e.LibUtils.hasValue(t)?e.LibUtils.isValidTextId(t)?e.Result.ok(new i(t,"text")):e.Result.fail(r.InvalidParameterError.withParameter("entity identifier contains invalid characters")):e.Result.fail(r.MissingValueError.withValue("entity identifier"))}}function u(a){return a}function d(){return i.create()}function o(a){return i.fromUUID(a)}function f(a){return i.fromText(a)}exports.BaseValueObject=l;exports.EntityId=i;exports.brandedIdFromText=f;exports.brandedIdFromUUID=o;exports.createBrandedId=u;exports.newBrandedId=d;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("@vytches/ddd-utils"),d=require("@vytches/ddd-contracts"),r=require("@vytches/ddd-domain-primitives");var u;const o=Symbol.for("@vytches/ddd.valueObject");function f(n,t){return typeof n=="object"&&n!==null&&o in n&&typeof t=="object"&&t!==null&&o in t?n.equals(t):e.LibUtils.deepEqual(n,t,new WeakMap)}u=o;class c{constructor(t){if(this[u]=!0,this.value=t!==null&&typeof t=="object"?e.LibUtils.deepFreeze(t):t,!this.validate(this.value))throw new Error(this.getInvalidValueMessage(this.value))}getIdentityComponents(){}equals(t){if(!t)return!1;const a=this.getIdentityComponents(),l=t.getIdentityComponents();if(a!==void 0&&l!==void 0){if(a.length!==l.length)return!1;for(let s=0;s<a.length;s++)if(!f(a[s],l[s]))return!1;return!0}return this.value===t.value?!0:typeof this.value=="object"&&this.value!==null?e.LibUtils.deepEqual(this.value,t.value):!1}toString(){return String(this.value)}toJSON(){return this.value}getValue(){return this.value}getInvalidValueMessage(t){return`Invalid value for ${this.constructor.name}`}}class i extends d.EntityId{constructor(t,a){super(t,a)}validate(t){switch(this.getType()){case"uuid":return e.LibUtils.isValidUUID(t);case"integer":return e.LibUtils.isValidInteger(t);case"bigint":return e.LibUtils.isValidBigInt(t);case"text":return e.LibUtils.isValidTextId(t);default:throw new r.InvalidParameterError(`Unsupported IdType: ${this.getType()}`)}}static create(){return new i(e.LibUtils.getUUID(),"uuid")}static createWithRandomUUID(){return i.create()}static fromUUID(t){if(!e.LibUtils.hasValue(t))throw r.MissingValueError.withValue("entity identifier");if(!e.LibUtils.isValidUUID(t))throw r.InvalidParameterError.withParameter("entity identifier");return new i(t,"uuid")}static fromInteger(t){if(!e.LibUtils.isValidInteger(t))throw r.InvalidParameterError.withParameter("entity identifier must be a non-negative integer");return new i(t.toString(),"integer")}static fromBigInt(t){const a=e.LibUtils.normalizeIdToString(t);if(!e.LibUtils.isValidBigInt(a))throw r.InvalidParameterError.withParameter("entity identifier must be a valid bigint");return new i(a,"bigint")}static fromText(t){if(!e.LibUtils.hasValue(t))throw r.MissingValueError.withValue("entity identifier");if(!e.LibUtils.isValidTextId(t))throw r.InvalidParameterError.withParameter("entity identifier contains invalid characters");return new i(t,"text")}static tryFromUUID(t){return e.LibUtils.hasValue(t)?e.LibUtils.isValidUUID(t)?e.Result.ok(new i(t,"uuid")):e.Result.fail(r.InvalidParameterError.withParameter("entity identifier")):e.Result.fail(r.MissingValueError.withValue("entity identifier"))}static tryFromInteger(t){return e.LibUtils.isValidInteger(t)?e.Result.ok(new i(t.toString(),"integer")):e.Result.fail(r.InvalidParameterError.withParameter("entity identifier must be a non-negative integer"))}static tryFromBigInt(t){const a=e.LibUtils.normalizeIdToString(t);return e.LibUtils.isValidBigInt(a)?e.Result.ok(new i(a,"bigint")):e.Result.fail(r.InvalidParameterError.withParameter("entity identifier must be a valid bigint"))}static tryFromText(t){return e.LibUtils.hasValue(t)?e.LibUtils.isValidTextId(t)?e.Result.ok(new i(t,"text")):e.Result.fail(r.InvalidParameterError.withParameter("entity identifier contains invalid characters")):e.Result.fail(r.MissingValueError.withValue("entity identifier"))}}function g(n){return n}function h(){return i.create()}function m(n){return i.fromUUID(n)}function U(n){return i.fromText(n)}exports.BaseValueObject=c;exports.EntityId=i;exports.brandedIdFromText=U;exports.brandedIdFromUUID=m;exports.createBrandedId=g;exports.newBrandedId=h;
package/dist/index.js CHANGED
@@ -1,7 +1,15 @@
1
- import { LibUtils as e, Result as r } from "@vytches/ddd-utils";
2
- import { EntityId as o } from "@vytches/ddd-contracts";
3
- import { InvalidParameterError as n, MissingValueError as u } from "@vytches/ddd-domain-primitives";
4
- class h {
1
+ import { LibUtils as e, Result as a } from "@vytches/ddd-utils";
2
+ import { EntityId as h } from "@vytches/ddd-contracts";
3
+ import { InvalidParameterError as s, MissingValueError as u } from "@vytches/ddd-domain-primitives";
4
+ var f;
5
+ const d = Symbol.for("@vytches/ddd.valueObject");
6
+ function c(r, t) {
7
+ return typeof r == "object" && r !== null && d in r && typeof t == "object" && t !== null && d in t ? r.equals(
8
+ t
9
+ ) : e.deepEqual(r, t, /* @__PURE__ */ new WeakMap());
10
+ }
11
+ f = d;
12
+ class I {
5
13
  /**
6
14
  * VF-023 (D-1, AC1, BREAKING): validates `value` via the polymorphic
7
15
  * `validate()` hook and throws synchronously on failure. This replaces
@@ -23,12 +31,145 @@ class h {
23
31
  * is ever reached.
24
32
  */
25
33
  constructor(t) {
26
- if (this.value = t !== null && typeof t == "object" ? e.deepFreeze(t) : t, !this.validate(this.value))
34
+ if (this[f] = !0, this.value = t !== null && typeof t == "object" ? e.deepFreeze(t) : t, !this.validate(this.value))
27
35
  throw new Error(this.getInvalidValueMessage(this.value));
28
36
  }
37
+ /**
38
+ * VF-036 (D1/D4): opt-in hook letting a subclass declare which fields
39
+ * make up its *identity*, so `equals()` compares those fields instead of
40
+ * the raw `value` this class stores. Ships under the name
41
+ * `getIdentityComponents` — an earlier, never-implemented name for a
42
+ * similar idea (`getEqualityComponents`) circulated in docs and MUST NOT
43
+ * be revived as an alias, shim, or runtime-detected fallback; it never
44
+ * existed in source and never will.
45
+ *
46
+ * ## When component identity beats full-value equality
47
+ * The default raw comparison (`this.value === valueObject.value`, then
48
+ * `LibUtils.deepEqual` for objects) treats the *entire* stored value as
49
+ * significant. Override this hook when only a subset of fields should
50
+ * participate — e.g. a `Money` VO whose `value` also carries a
51
+ * display-formatting flag that must not affect equality, or a VO that
52
+ * wants to compare by a derived/normalized projection of its state
53
+ * rather than the raw stored shape.
54
+ *
55
+ * ## The asymmetric fallback, and why it is not transitive
56
+ * The dispatch rule in `equals()` is: if BOTH sides return a defined
57
+ * array, compare components; if EITHER side returns `undefined`, run the
58
+ * completely unchanged raw comparison. This rule is symmetric (`a.equals(b)`
59
+ * and `b.equals(a)` agree, since both directions see the same two
60
+ * `getIdentityComponents()` results) but deliberately **not transitive**:
61
+ * given A (raw comparison, no override), B (component override, happens
62
+ * to have the same raw `value` as A), and C (component override, same
63
+ * components as B), `A.equals(B)` may be `true` (raw fallback matches),
64
+ * `B.equals(C)` may be `true` (components match), yet `A.equals(C)` can
65
+ * be `false` (raw fallback, different `value`). This is an accepted,
66
+ * documented, test-pinned limitation — do not "fix" it by adding
67
+ * type/instanceof gating; see D6. It matters most for collection code
68
+ * such as `list.some(x => x.equals(y))` or de-duplication by `.equals`:
69
+ * such code implicitly assumes an equivalence relation, and mixing
70
+ * component-identity subclasses with raw-comparison subclasses in the
71
+ * same collection breaks that assumption.
72
+ *
73
+ * ## The empty-array footgun
74
+ * Returning `[]` is **defined-and-empty**, not "opt out". An empty array
75
+ * means "this VO has no identity-relevant fields", so it is equal to
76
+ * every other VO whose `getIdentityComponents()` also returns `[]`
77
+ * (same length: zero, vacuously equal element-wise) — regardless of what
78
+ * their respective `value`s hold. To opt out, return `undefined` (the
79
+ * base default), not `[]`.
80
+ *
81
+ * ## The undefined-because-field-not-initialized trap
82
+ * `undefined` silently downgrades to the raw comparison. If an override
83
+ * reads a subclass field that has not been initialized yet (see the
84
+ * undefined-during-`super()` trap documented on the constructor) and
85
+ * that read produces `undefined` as a *component value* (not as the
86
+ * hook's own return value), the surrounding array is still "defined" —
87
+ * only a *literal* `undefined`/omitted return from the override itself
88
+ * triggers the fallback. Conversely, a conditional override that
89
+ * sometimes returns `undefined` (e.g. "only compare by components once
90
+ * some field is set") will silently and intermittently fall back to raw
91
+ * comparison for the same class — prefer always returning an array once
92
+ * a class opts in.
93
+ *
94
+ * ## Throw propagation
95
+ * A throwing override propagates out of `equals()` uncaught (D5).
96
+ * `equals()` is a hot path and this library's logger is
97
+ * diagnostics-only, so there is no try/catch here — catching would
98
+ * silently convert a loud consumer bug (e.g. a component accessor that
99
+ * dereferences something absent) into a wrong-but-quiet `false`/`true`
100
+ * answer. `equals()` stops being a total function for classes that
101
+ * override this hook with logic that can throw.
102
+ *
103
+ * ## Fixed arity
104
+ * A class must always return the same number of components on every
105
+ * call (do not vary the array length based on instance state). Two
106
+ * defined arrays of different lengths are treated as unequal without
107
+ * inspecting elements, so a class whose arity drifts will get
108
+ * inconsistent, order-of-comparison-dependent results.
109
+ *
110
+ * ## Components must come from frozen/readonly state
111
+ * The constructor deep-freezes the `value` passed to `super()`, but it
112
+ * does **not** freeze any additional fields a subclass declares and
113
+ * assigns after `super(value)` — those are ordinary mutable class
114
+ * fields unless the subclass itself makes them readonly/frozen.
115
+ * Components must be derived only from state that cannot change after
116
+ * construction (the frozen `value`, or subclass fields the subclass
117
+ * itself keeps immutable); deriving a component from mutable state
118
+ * breaks the invariant that a VO's equality is stable for its lifetime.
119
+ *
120
+ * ## Type-scoped equality: the sanctioned idiom
121
+ * This hook performs no type or `instanceof` check (D6) — cross-subclass
122
+ * equality behaves exactly as it does today, and two different
123
+ * subclasses returning matching components are considered equal. For
124
+ * consumers who want type-scoped equality, the sanctioned opt-in is a
125
+ * string literal as the first component (e.g. `['Money', this.amount,
126
+ * this.currency]`), NOT `this.constructor.name` (unsafe under
127
+ * minification, which can rename classes inconsistently) and NOT the
128
+ * class object itself (unsafe across duplicate package copies, the same
129
+ * hazard `instanceof` has — see the brand-symbol note above).
130
+ *
131
+ * ## toString()/toJSON() stay value-based
132
+ * Overriding this hook does not change `toString()` or `toJSON()` — both
133
+ * continue to reflect the raw `value`. If an override narrows equality
134
+ * to a subset of fields, `toString()`/`toJSON()` may now serialize
135
+ * information that equality ignores (or vice versa); keeping any
136
+ * resulting equals/hash/serialization desync coherent is the
137
+ * consumer's responsibility.
138
+ *
139
+ * @returns `undefined` (the base default) to opt out and keep the raw
140
+ * `value`-based comparison; a readonly array of identity-relevant
141
+ * values to opt in to component-wise comparison.
142
+ */
143
+ getIdentityComponents() {
144
+ }
29
145
  /**
30
146
  * Compares this value object with another for equality.
31
- * Uses deep structural comparison for object values, === for primitives.
147
+ *
148
+ * VF-036 (D1/D2): first consults `getIdentityComponents()` on both
149
+ * sides. If BOTH return a defined array, equality is decided by that
150
+ * array alone: same length, then element-wise via `componentEquals`
151
+ * (which itself dispatches to nested `.equals()` for branded value
152
+ * objects, `LibUtils.deepEqual` otherwise). If EITHER side returns
153
+ * `undefined`, this method falls through to the exact, unmodified raw
154
+ * comparison that existed before VF-036 — see the block below and the
155
+ * `getIdentityComponents` doc comment for the full rationale, including
156
+ * why this fallback is symmetric but not transitive.
157
+ *
158
+ * The hook is consulted for primitive `T` as well as object `T`; nothing
159
+ * about this dispatch depends on `typeof this.value`.
160
+ *
161
+ * HARD CONSTRAINT: there is intentionally no unconditional
162
+ * `this === valueObject` shortcut at the top of this method — the only
163
+ * reflexivity shortcut this class takes lives inside the raw-comparison
164
+ * branch below (`this.value === valueObject.value`, which briefly
165
+ * predates any `getIdentityComponents` check on that branch only because
166
+ * neither side opted in). When neither side returns components, the
167
+ * executed code path is bit-for-bit identical to the pre-VF-036
168
+ * behavior, which is what keeps this a non-breaking, additive minor
169
+ * release rather than a breaking major.
170
+ *
171
+ * Uses deep structural comparison for object values, === for primitives,
172
+ * in the fallback branch.
32
173
  *
33
174
  * VF-023 (D-3, AC2/AC3, BREAKING): deep comparison now uses
34
175
  * `LibUtils.deepEqual` instead of `JSON.stringify` equality. This fixes
@@ -44,7 +185,18 @@ class h {
44
185
  * @returns True if they are equal, false otherwise
45
186
  */
46
187
  equals(t) {
47
- return t ? this.value === t.value ? !0 : typeof this.value == "object" && this.value !== null ? e.deepEqual(this.value, t.value) : !1 : !1;
188
+ if (!t)
189
+ return !1;
190
+ const n = this.getIdentityComponents(), l = t.getIdentityComponents();
191
+ if (n !== void 0 && l !== void 0) {
192
+ if (n.length !== l.length)
193
+ return !1;
194
+ for (let o = 0; o < n.length; o++)
195
+ if (!c(n[o], l[o]))
196
+ return !1;
197
+ return !0;
198
+ }
199
+ return this.value === t.value ? !0 : typeof this.value == "object" && this.value !== null ? e.deepEqual(this.value, t.value) : !1;
48
200
  }
49
201
  /**
50
202
  * Returns a string representation of the value object
@@ -85,9 +237,9 @@ class h {
85
237
  return `Invalid value for ${this.constructor.name}`;
86
238
  }
87
239
  }
88
- class i extends o {
89
- constructor(t, a) {
90
- super(t, a);
240
+ class i extends h {
241
+ constructor(t, n) {
242
+ super(t, n);
91
243
  }
92
244
  validate(t) {
93
245
  switch (this.getType()) {
@@ -100,7 +252,7 @@ class i extends o {
100
252
  case "text":
101
253
  return e.isValidTextId(t);
102
254
  default:
103
- throw new n(`Unsupported IdType: ${this.getType()}`);
255
+ throw new s(`Unsupported IdType: ${this.getType()}`);
104
256
  }
105
257
  }
106
258
  /**
@@ -130,25 +282,25 @@ class i extends o {
130
282
  if (!e.hasValue(t))
131
283
  throw u.withValue("entity identifier");
132
284
  if (!e.isValidUUID(t))
133
- throw n.withParameter("entity identifier");
285
+ throw s.withParameter("entity identifier");
134
286
  return new i(t, "uuid");
135
287
  }
136
288
  static fromInteger(t) {
137
289
  if (!e.isValidInteger(t))
138
- throw n.withParameter("entity identifier must be a non-negative integer");
290
+ throw s.withParameter("entity identifier must be a non-negative integer");
139
291
  return new i(t.toString(), "integer");
140
292
  }
141
293
  static fromBigInt(t) {
142
- const a = e.normalizeIdToString(t);
143
- if (!e.isValidBigInt(a))
144
- throw n.withParameter("entity identifier must be a valid bigint");
145
- return new i(a, "bigint");
294
+ const n = e.normalizeIdToString(t);
295
+ if (!e.isValidBigInt(n))
296
+ throw s.withParameter("entity identifier must be a valid bigint");
297
+ return new i(n, "bigint");
146
298
  }
147
299
  static fromText(t) {
148
300
  if (!e.hasValue(t))
149
301
  throw u.withValue("entity identifier");
150
302
  if (!e.isValidTextId(t))
151
- throw n.withParameter("entity identifier contains invalid characters");
303
+ throw s.withParameter("entity identifier contains invalid characters");
152
304
  return new i(t, "text");
153
305
  }
154
306
  // --- Result-returning factory methods (non-throwing) ---
@@ -159,7 +311,7 @@ class i extends o {
159
311
  * @since 0.24.0
160
312
  */
161
313
  static tryFromUUID(t) {
162
- return e.hasValue(t) ? e.isValidUUID(t) ? r.ok(new i(t, "uuid")) : r.fail(n.withParameter("entity identifier")) : r.fail(u.withValue("entity identifier"));
314
+ return e.hasValue(t) ? e.isValidUUID(t) ? a.ok(new i(t, "uuid")) : a.fail(s.withParameter("entity identifier")) : a.fail(u.withValue("entity identifier"));
163
315
  }
164
316
  /**
165
317
  * Create EntityId from integer, returning Result instead of throwing.
@@ -168,8 +320,8 @@ class i extends o {
168
320
  * @since 0.24.0
169
321
  */
170
322
  static tryFromInteger(t) {
171
- return e.isValidInteger(t) ? r.ok(new i(t.toString(), "integer")) : r.fail(
172
- n.withParameter("entity identifier must be a non-negative integer")
323
+ return e.isValidInteger(t) ? a.ok(new i(t.toString(), "integer")) : a.fail(
324
+ s.withParameter("entity identifier must be a non-negative integer")
173
325
  );
174
326
  }
175
327
  /**
@@ -179,9 +331,9 @@ class i extends o {
179
331
  * @since 0.24.0
180
332
  */
181
333
  static tryFromBigInt(t) {
182
- const a = e.normalizeIdToString(t);
183
- return e.isValidBigInt(a) ? r.ok(new i(a, "bigint")) : r.fail(
184
- n.withParameter("entity identifier must be a valid bigint")
334
+ const n = e.normalizeIdToString(t);
335
+ return e.isValidBigInt(n) ? a.ok(new i(n, "bigint")) : a.fail(
336
+ s.withParameter("entity identifier must be a valid bigint")
185
337
  );
186
338
  }
187
339
  /**
@@ -191,28 +343,28 @@ class i extends o {
191
343
  * @since 0.24.0
192
344
  */
193
345
  static tryFromText(t) {
194
- return e.hasValue(t) ? e.isValidTextId(t) ? r.ok(new i(t, "text")) : r.fail(
195
- n.withParameter("entity identifier contains invalid characters")
196
- ) : r.fail(u.withValue("entity identifier"));
346
+ return e.hasValue(t) ? e.isValidTextId(t) ? a.ok(new i(t, "text")) : a.fail(
347
+ s.withParameter("entity identifier contains invalid characters")
348
+ ) : a.fail(u.withValue("entity identifier"));
197
349
  }
198
350
  }
199
- function c(s) {
200
- return s;
351
+ function V(r) {
352
+ return r;
201
353
  }
202
- function g() {
354
+ function p() {
203
355
  return i.create();
204
356
  }
205
- function m(s) {
206
- return i.fromUUID(s);
357
+ function y(r) {
358
+ return i.fromUUID(r);
207
359
  }
208
- function w(s) {
209
- return i.fromText(s);
360
+ function U(r) {
361
+ return i.fromText(r);
210
362
  }
211
363
  export {
212
- h as BaseValueObject,
364
+ I as BaseValueObject,
213
365
  i as EntityId,
214
- w as brandedIdFromText,
215
- m as brandedIdFromUUID,
216
- c as createBrandedId,
217
- g as newBrandedId
366
+ U as brandedIdFromText,
367
+ y as brandedIdFromUUID,
368
+ V as createBrandedId,
369
+ p as newBrandedId
218
370
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vytches/ddd-value-objects",
3
- "version": "0.31.0-alpha.0",
3
+ "version": "0.31.1",
4
4
  "description": "Enhanced value objects and EntityId implementations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -34,9 +34,9 @@
34
34
  "directory": "packages/value-objects"
35
35
  },
36
36
  "dependencies": {
37
- "@vytches/ddd-contracts": "0.31.0-alpha.0",
38
- "@vytches/ddd-utils": "0.31.0-alpha.0",
39
- "@vytches/ddd-domain-primitives": "0.31.0-alpha.0"
37
+ "@vytches/ddd-domain-primitives": "0.31.1",
38
+ "@vytches/ddd-utils": "0.31.1",
39
+ "@vytches/ddd-contracts": "0.31.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "typescript": "^5.0.0",