@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 +108 -7
- package/README.md +297 -14
- package/dist/base-value-object.d.ts +154 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +191 -39
- package/package.json +4 -4
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
|
|
59
|
-
|
|
|
60
|
-
| `getValue(): T`
|
|
61
|
-
| `equals(other): boolean`
|
|
62
|
-
| `toString(): string`
|
|
63
|
-
| `toJSON():
|
|
64
|
-
| `abstract validate(value): boolean`
|
|
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
|
-
*
|
|
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"),
|
|
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
|
|
2
|
-
import { EntityId as
|
|
3
|
-
import { InvalidParameterError as
|
|
4
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
89
|
-
constructor(t,
|
|
90
|
-
super(t,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
143
|
-
if (!e.isValidBigInt(
|
|
144
|
-
throw
|
|
145
|
-
return new i(
|
|
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
|
|
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) ?
|
|
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) ?
|
|
172
|
-
|
|
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
|
|
183
|
-
return e.isValidBigInt(
|
|
184
|
-
|
|
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) ?
|
|
195
|
-
|
|
196
|
-
) :
|
|
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
|
|
200
|
-
return
|
|
351
|
+
function V(r) {
|
|
352
|
+
return r;
|
|
201
353
|
}
|
|
202
|
-
function
|
|
354
|
+
function p() {
|
|
203
355
|
return i.create();
|
|
204
356
|
}
|
|
205
|
-
function
|
|
206
|
-
return i.fromUUID(
|
|
357
|
+
function y(r) {
|
|
358
|
+
return i.fromUUID(r);
|
|
207
359
|
}
|
|
208
|
-
function
|
|
209
|
-
return i.fromText(
|
|
360
|
+
function U(r) {
|
|
361
|
+
return i.fromText(r);
|
|
210
362
|
}
|
|
211
363
|
export {
|
|
212
|
-
|
|
364
|
+
I as BaseValueObject,
|
|
213
365
|
i as EntityId,
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
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-
|
|
38
|
-
"@vytches/ddd-utils": "0.31.
|
|
39
|
-
"@vytches/ddd-
|
|
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",
|