@woltz/rich-domain 1.2.4 → 1.3.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.
Files changed (94) hide show
  1. package/dist/aggregate-changes.d.ts +56 -14
  2. package/dist/aggregate-changes.d.ts.map +1 -1
  3. package/dist/aggregate-changes.js +103 -23
  4. package/dist/aggregate-changes.js.map +1 -1
  5. package/dist/base-entity.d.ts +1 -1
  6. package/dist/base-entity.d.ts.map +1 -1
  7. package/dist/base-entity.js +28 -13
  8. package/dist/base-entity.js.map +1 -1
  9. package/dist/change-tracker.d.ts +2 -1
  10. package/dist/change-tracker.d.ts.map +1 -1
  11. package/dist/change-tracker.js +61 -35
  12. package/dist/change-tracker.js.map +1 -1
  13. package/dist/criteria.d.ts +7 -15
  14. package/dist/criteria.d.ts.map +1 -1
  15. package/dist/criteria.js +105 -81
  16. package/dist/criteria.js.map +1 -1
  17. package/dist/domain-event-bus.js +4 -4
  18. package/dist/domain-event-bus.js.map +1 -1
  19. package/dist/domain-event.js +3 -0
  20. package/dist/domain-event.js.map +1 -1
  21. package/dist/entity-changes.js +1 -0
  22. package/dist/entity-changes.js.map +1 -1
  23. package/dist/entity-schema-registry.d.ts +137 -3
  24. package/dist/entity-schema-registry.d.ts.map +1 -1
  25. package/dist/entity-schema-registry.js +160 -7
  26. package/dist/entity-schema-registry.js.map +1 -1
  27. package/dist/exceptions.js +26 -1
  28. package/dist/exceptions.js.map +1 -1
  29. package/dist/id.js +2 -0
  30. package/dist/id.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/paginated-result.d.ts +4 -4
  34. package/dist/paginated-result.d.ts.map +1 -1
  35. package/dist/paginated-result.js +14 -19
  36. package/dist/paginated-result.js.map +1 -1
  37. package/dist/repository/unit-of-work.js +3 -7
  38. package/dist/repository/unit-of-work.js.map +1 -1
  39. package/dist/types/change-tracker.d.ts +30 -0
  40. package/dist/types/change-tracker.d.ts.map +1 -1
  41. package/dist/types/criteria.d.ts +1 -4
  42. package/dist/types/criteria.d.ts.map +1 -1
  43. package/dist/types/domain.d.ts +2 -1
  44. package/dist/types/domain.d.ts.map +1 -1
  45. package/dist/types/utils.d.ts +2 -2
  46. package/dist/utils/helpers.d.ts +1 -0
  47. package/dist/utils/helpers.d.ts.map +1 -1
  48. package/dist/utils/helpers.js +23 -0
  49. package/dist/utils/helpers.js.map +1 -1
  50. package/dist/validation-error.d.ts +15 -1
  51. package/dist/validation-error.d.ts.map +1 -1
  52. package/dist/validation-error.js +46 -3
  53. package/dist/validation-error.js.map +1 -1
  54. package/dist/value-object.d.ts +1 -1
  55. package/dist/value-object.d.ts.map +1 -1
  56. package/dist/value-object.js +30 -2
  57. package/dist/value-object.js.map +1 -1
  58. package/package.json +17 -3
  59. package/src/aggregate-changes.ts +133 -24
  60. package/src/base-entity.ts +22 -11
  61. package/src/change-tracker.ts +113 -54
  62. package/src/criteria.ts +151 -109
  63. package/src/entity-schema-registry.ts +256 -6
  64. package/src/index.ts +1 -1
  65. package/src/paginated-result.ts +21 -29
  66. package/src/types/change-tracker.ts +31 -0
  67. package/src/types/criteria.ts +1 -4
  68. package/src/types/domain.ts +2 -1
  69. package/src/types/utils.ts +2 -2
  70. package/src/utils/helpers.ts +28 -0
  71. package/src/validation-error.ts +54 -4
  72. package/src/value-object.ts +6 -1
  73. package/.versionrc.json +0 -21
  74. package/CHANGELOG.md +0 -163
  75. package/tests/aggregate-changes.test.ts +0 -284
  76. package/tests/criteria.test.ts +0 -716
  77. package/tests/depth/deep-tracking.test.ts +0 -554
  78. package/tests/domain-events.test.ts +0 -431
  79. package/tests/entity-equality.test.ts +0 -464
  80. package/tests/entity-schema-registry.test.ts +0 -382
  81. package/tests/entity-validation.test.ts +0 -252
  82. package/tests/history-tracker.spec.ts +0 -439
  83. package/tests/id.test.ts +0 -338
  84. package/tests/load-test/data.json +0 -347211
  85. package/tests/load-test/entities.ts +0 -97
  86. package/tests/load-test/generate-data.ts +0 -81
  87. package/tests/load-test/lead-to-domain.mapper.ts +0 -24
  88. package/tests/load-test/load.test.ts +0 -38
  89. package/tests/repository.test.ts +0 -635
  90. package/tests/to-json.test.ts +0 -99
  91. package/tests/utils.ts +0 -290
  92. package/tests/value-object-validation.test.ts +0 -219
  93. package/tests/value-objects.test.ts +0 -80
  94. package/tsconfig.json +0 -9
package/src/criteria.ts CHANGED
@@ -27,7 +27,7 @@ export class Criteria<T = any> {
27
27
  private _filters: Filter<FieldPath<T>, any>[] = [];
28
28
  private _orders: Order[] = [];
29
29
  private _pagination: Pagination = { page: 1, limit: 20, offset: 0 };
30
- private _search?: Search<T>;
30
+ private _search?: Search;
31
31
  private _adapter?: CriteriaAdapter<any, any>;
32
32
 
33
33
  private constructor() {}
@@ -155,11 +155,8 @@ export class Criteria<T = any> {
155
155
  return this.orderBy(field, "desc");
156
156
  }
157
157
 
158
- search<K extends FieldPath<T>>(fields: K[], value: string): this {
159
- this._search = {
160
- fields: fields.map(this.resolveFieldPath),
161
- value,
162
- };
158
+ search(value: string): this {
159
+ this._search = value;
163
160
  return this;
164
161
  }
165
162
 
@@ -167,13 +164,8 @@ export class Criteria<T = any> {
167
164
  return !!this._search;
168
165
  }
169
166
 
170
- getSearch() {
171
- return this._search
172
- ? {
173
- fields: this._search.fields.map(this.resolveFieldPath),
174
- value: this._search.value,
175
- }
176
- : undefined;
167
+ getSearch(): Search | undefined {
168
+ return this._search;
177
169
  }
178
170
 
179
171
  paginate(page: number, limit: number): this {
@@ -241,12 +233,7 @@ export class Criteria<T = any> {
241
233
  })),
242
234
  ];
243
235
  cloned._pagination = { ...this._pagination };
244
- cloned._search = this._search
245
- ? {
246
- fields: this._search.fields.map(this.resolveFieldPath),
247
- value: this._search.value,
248
- }
249
- : undefined;
236
+ cloned._search = this._search;
250
237
 
251
238
  if (this._adapter) {
252
239
  cloned.useAdapter(this._adapter);
@@ -268,12 +255,7 @@ export class Criteria<T = any> {
268
255
  direction: order.direction,
269
256
  })),
270
257
  pagination: this._pagination,
271
- search: this._search
272
- ? {
273
- fields: this._search.fields.map(this.resolveFieldPath),
274
- value: this._search.value,
275
- }
276
- : undefined,
258
+ search: this._search,
277
259
  };
278
260
  }
279
261
 
@@ -282,7 +264,7 @@ export class Criteria<T = any> {
282
264
  filters?: TypedFilter<T>[];
283
265
  orders?: TypedOrder<T>[];
284
266
  pagination?: Pagination;
285
- search?: { fields: FieldPath<T>[]; value: string };
267
+ search?: Search;
286
268
  },
287
269
  adapter?: CriteriaAdapter<any, any>
288
270
  ): Criteria<T> {
@@ -307,11 +289,7 @@ export class Criteria<T = any> {
307
289
  })),
308
290
  ];
309
291
  if (obj.pagination) criteria._pagination = { ...obj.pagination };
310
- if (obj.search)
311
- criteria._search = {
312
- ...obj.search,
313
- fields: obj.search.fields.map(criteria.resolveFieldPath),
314
- };
292
+ if (obj.search) criteria._search = obj.search;
315
293
 
316
294
  return criteria;
317
295
  }
@@ -337,10 +315,12 @@ export class Criteria<T = any> {
337
315
  return field;
338
316
  }
339
317
 
340
- static fromQueryParams<T>(
341
- query: Record<string, any>,
318
+ static fromQueryParams<T = any>(
319
+ query: Record<string, any> | undefined,
342
320
  adapter?: CriteriaAdapter<any, any>
343
321
  ): Criteria<T> {
322
+ if (!query) return Criteria.create<T>();
323
+
344
324
  const criteria = Criteria.create<T>();
345
325
 
346
326
  if (adapter) {
@@ -354,80 +334,95 @@ export class Criteria<T = any> {
354
334
  if (key === "limit") {
355
335
  continue;
356
336
  }
357
- if (key === "sort") {
358
- continue;
359
- }
360
337
 
361
- const [field, operatorWithQuantifier] = key.split(":");
338
+ if (key === "filters") {
339
+ const filters: Record<string, any> = criteria.parseFilterValue(value);
362
340
 
363
- if (!operatorWithQuantifier || !field) continue;
341
+ for (let [filterKey, filterValue] of Object.entries(filters)) {
342
+ const [field, operatorWithQuantifier] = filterKey.split(":");
364
343
 
365
- const [operatorRaw, quantifierRaw] = operatorWithQuantifier.split("@");
366
- const operator = isOperator(operatorRaw) ? operatorRaw : null;
367
- if (!operator) {
368
- throw new InvalidCriteriaError(`Invalid filter operator`, operatorRaw);
369
- }
344
+ if (!operatorWithQuantifier || !field) continue;
370
345
 
371
- const validQuantifiers = ["some", "every", "none"];
372
- const quantifier =
373
- quantifierRaw && validQuantifiers.includes(quantifierRaw)
374
- ? (quantifierRaw as CriteriaOptions["quantifier"])
375
- : undefined;
376
-
377
- if (quantifierRaw && !quantifier) {
378
- throw new InvalidCriteriaError(
379
- `Invalid quantifier. Valid values: ${validQuantifiers.join(", ")}`,
380
- quantifierRaw
381
- );
382
- }
346
+ const [operatorRaw, quantifierRaw] =
347
+ operatorWithQuantifier.split("@");
348
+ const operator = isOperator(operatorRaw) ? operatorRaw : null;
349
+ if (!operator) {
350
+ throw new InvalidCriteriaError(
351
+ `Invalid filter operator`,
352
+ operatorRaw
353
+ );
354
+ }
383
355
 
384
- const options: CriteriaOptions | undefined = quantifier
385
- ? { quantifier }
386
- : undefined;
356
+ const validQuantifiers = ["some", "every", "none"];
357
+ const quantifier =
358
+ quantifierRaw && validQuantifiers.includes(quantifierRaw)
359
+ ? (quantifierRaw as CriteriaOptions["quantifier"])
360
+ : undefined;
361
+
362
+ if (quantifierRaw && !quantifier) {
363
+ throw new InvalidCriteriaError(
364
+ `Invalid quantifier. Valid values: ${validQuantifiers.join(
365
+ ", "
366
+ )}`,
367
+ quantifierRaw
368
+ );
369
+ }
387
370
 
388
- let parsedValue: any = value;
371
+ const options: CriteriaOptions | undefined = quantifier
372
+ ? { quantifier }
373
+ : undefined;
389
374
 
390
- const resolvedField = criteria.resolveFieldPath(field as FieldPath<T>);
375
+ let parsedValue: any = filterValue;
391
376
 
392
- if (operator === "between") {
393
- parsedValue = value
394
- .split(",")
395
- .map((v: any) => parseQueryValue(v.trim()));
396
- if (parsedValue.length === 2) {
397
- criteria.where(
398
- resolvedField,
399
- "between" as OperatorsForType<PathValue<T, FieldPath<T>>>,
400
- [parsedValue[0], parsedValue[1]] as [
401
- PathValue<T, FieldPath<T>>,
402
- PathValue<T, FieldPath<T>>
403
- ],
404
- options
377
+ const resolvedField = criteria.resolveFieldPath(
378
+ field as FieldPath<T>
405
379
  );
406
- }
407
- continue;
408
- }
409
380
 
410
- if (operator === "in" || operator === "notIn") {
411
- parsedValue = value.split(",").map(parseQueryValue);
412
- criteria.where(
413
- field as any,
414
- operator as OperatorsForType<PathValue<T, FieldPath<T>>>,
415
- parsedValue,
416
- options
417
- );
418
- continue;
419
- }
381
+ if (operator === "between") {
382
+ parsedValue = criteria
383
+ .parseFilterValue(filterValue)
384
+ .map((v: any) => parseQueryValue(v.trim()));
385
+
386
+ if (parsedValue.length === 2) {
387
+ criteria.where(
388
+ resolvedField,
389
+ "between" as OperatorsForType<PathValue<T, FieldPath<T>>>,
390
+ [parsedValue[0], parsedValue[1]] as [
391
+ PathValue<T, FieldPath<T>>,
392
+ PathValue<T, FieldPath<T>>
393
+ ],
394
+ options
395
+ );
396
+ }
397
+ continue;
398
+ }
420
399
 
421
- const parsedFinalValue = parseQueryValue(value);
400
+ if (operator === "in" || operator === "notIn") {
401
+ parsedValue = criteria
402
+ .parseFilterValue(filterValue)
403
+ .map(parseQueryValue);
404
+
405
+ criteria.where(
406
+ field as any,
407
+ operator as OperatorsForType<PathValue<T, FieldPath<T>>>,
408
+ parsedValue,
409
+ options
410
+ );
411
+ continue;
412
+ }
422
413
 
423
- criteria.validateOperator(operator, parsedFinalValue);
414
+ const parsedFinalValue = parseQueryValue(filterValue);
424
415
 
425
- criteria.where(
426
- field as FieldPath<T>,
427
- operator as OperatorsForType<PathValue<T, FieldPath<T>>>,
428
- parsedFinalValue,
429
- options
430
- );
416
+ criteria.validateOperator(operator, parsedFinalValue);
417
+
418
+ criteria.where(
419
+ field as FieldPath<T>,
420
+ operator as OperatorsForType<PathValue<T, FieldPath<T>>>,
421
+ parsedFinalValue,
422
+ options
423
+ );
424
+ }
425
+ }
431
426
  }
432
427
 
433
428
  const page = query.page ? parseInt(query.page) : undefined;
@@ -437,24 +432,60 @@ export class Criteria<T = any> {
437
432
  criteria.paginate(page, limit);
438
433
  }
439
434
 
435
+ // 1. orderBy=["field:asc","field2:desc"]
440
436
  if (query.orderBy) {
441
- const sortParts = query.orderBy.split(",");
442
- sortParts.forEach((part: string) => {
443
- const [field, direction] = part.split(":");
444
- criteria.orderBy(
445
- field as FieldPath<T>,
446
- (direction as OrderDirection) || "asc"
447
- );
448
- });
437
+ const orderByValue = query.orderBy;
438
+
439
+ if (
440
+ typeof orderByValue === "string" &&
441
+ orderByValue.trim().startsWith("[")
442
+ ) {
443
+ try {
444
+ const orderArray = JSON.parse(orderByValue);
445
+ if (Array.isArray(orderArray)) {
446
+ orderArray.forEach((item: string) => {
447
+ const [field, direction] = item.split(":");
448
+ criteria.orderBy(
449
+ field as FieldPath<T>,
450
+ (direction as OrderDirection) || "asc"
451
+ );
452
+ });
453
+ }
454
+ } catch {
455
+ throw new InvalidCriteriaError(
456
+ "Invalid JSON array format for orderBy",
457
+ orderByValue
458
+ );
459
+ }
460
+ } else if (Array.isArray(orderByValue)) {
461
+ orderByValue.forEach((item: string) => {
462
+ const [field, direction] = item.split(":");
463
+ criteria.orderBy(
464
+ field as FieldPath<T>,
465
+ (direction as OrderDirection) || "asc"
466
+ );
467
+ });
468
+ }
469
+ // 2. orderBy="field:asc,field2:desc"
470
+ else if (typeof orderByValue === "string" && orderByValue.includes(":")) {
471
+ const sortParts = orderByValue.split(",");
472
+ sortParts.forEach((part: string) => {
473
+ const [field, direction] = part.split(":");
474
+ criteria.orderBy(
475
+ field as FieldPath<T>,
476
+ (direction as OrderDirection) || "asc"
477
+ );
478
+ });
479
+ }
480
+ // 3. orderBy="field" + orderDirection="asc"
481
+ else {
482
+ const direction = (query.orderDirection as OrderDirection) || "asc";
483
+ criteria.orderBy(orderByValue as FieldPath<T>, direction);
484
+ }
449
485
  }
450
486
 
451
- if (query.search && query.searchFields) {
452
- const fields = (query.searchFields as string)
453
- .split(",")
454
- .filter(Boolean) as FieldPath<T>[];
455
-
456
- const resolvedFields = fields.map(criteria.resolveFieldPath);
457
- criteria.search(resolvedFields, query.search as string);
487
+ if (query.search && typeof query.search === "string") {
488
+ criteria.search(query.search);
458
489
  }
459
490
 
460
491
  return criteria;
@@ -476,4 +507,15 @@ export class Criteria<T = any> {
476
507
  );
477
508
  }
478
509
  }
510
+
511
+ private parseFilterValue(value: any) {
512
+ if (typeof value === "string") {
513
+ try {
514
+ return JSON.parse(value);
515
+ } catch {
516
+ throw new InvalidCriteriaError(`Invalid filter value`, value);
517
+ }
518
+ }
519
+ return parseQueryValue(value);
520
+ }
479
521
  }
@@ -1,6 +1,47 @@
1
1
  import { Entity } from "./entity";
2
2
  import { ValueObject } from "./value-object";
3
3
  import { Id } from "./id";
4
+ import { ConfigurationError } from "./exceptions";
5
+ import { levenshteinDistance } from "./utils/helpers";
6
+
7
+ /**
8
+ * Type of collection relationship.
9
+ * - 'owned': Parent owns the children (1:N). Delete parent = delete children.
10
+ * - 'reference': Parent references existing entities (N:N). Delete parent = unlink only.
11
+ */
12
+ export type CollectionType = "owned" | "reference";
13
+
14
+ /**
15
+ * Configuration for a collection (1:N or N:N relationship).
16
+ */
17
+ export interface CollectionConfig {
18
+ /**
19
+ * Type of relationship.
20
+ * - 'owned': Children are created/deleted with the parent (default for 1:N)
21
+ * - 'reference': Only the link is created/removed (for N:N)
22
+ * @default 'owned'
23
+ */
24
+ type: CollectionType;
25
+
26
+ /**
27
+ * Target entity name (required for 'reference' type).
28
+ * @example 'Tag'
29
+ */
30
+ entity?: string;
31
+
32
+ /**
33
+ * Junction table configuration (optional, for ORMs that need it like Drizzle).
34
+ * Prisma handles this automatically, so it's optional.
35
+ */
36
+ junction?: {
37
+ /** Junction table name (e.g., 'post_tags', '_PostToTag') */
38
+ table: string;
39
+ /** FK field pointing to the source entity (e.g., 'post_id') */
40
+ sourceKey: string;
41
+ /** FK field pointing to the target entity (e.g., 'tag_id') */
42
+ targetKey: string;
43
+ };
44
+ }
4
45
 
5
46
  /**
6
47
  * Mapping schema for a domain entity.
@@ -17,7 +58,7 @@ export interface EntitySchema {
17
58
  */
18
59
  fields?: Record<string, string>;
19
60
  /**
20
- * FK configuration for parent relation.
61
+ * FK configuration for parent relation (1:N owned).
21
62
  */
22
63
  parentFk?: {
23
64
  /** Name of the FK field in the database (e.g., 'author_id') */
@@ -25,6 +66,18 @@ export interface EntitySchema {
25
66
  /** Name of the parent entity (e.g., 'User') */
26
67
  parentEntity: string;
27
68
  };
69
+ /**
70
+ * Collection configurations for this entity's relations.
71
+ * Key is the property name in the domain entity.
72
+ * @example
73
+ * ```typescript
74
+ * collections: {
75
+ * comments: { type: 'owned' },
76
+ * tags: { type: 'reference', entity: 'Tag' }
77
+ * }
78
+ * ```
79
+ */
80
+ collections?: Record<string, CollectionConfig>;
28
81
  }
29
82
 
30
83
  /**
@@ -50,11 +103,15 @@ export interface MappedEntityData {
50
103
  * table: 'blog_posts',
51
104
  * fields: { content: 'post_content' },
52
105
  * parentFk: { field: 'author_id', parentEntity: 'User' },
106
+ * collections: {
107
+ * comments: { type: 'owned' },
108
+ * tags: { type: 'reference', entity: 'Tag' }
109
+ * }
53
110
  * });
54
111
  *
55
112
  * const table = registry.getTable('Post'); // 'blog_posts'
56
- * const mapped = registry.mapFields('User', { email: 'test@test.com' });
57
- * // { user_email: 'test@test.com' }
113
+ * const tagConfig = registry.getCollectionConfig('Post', 'tags');
114
+ * // { type: 'reference', entity: 'Tag' }
58
115
  * ```
59
116
  */
60
117
  export class EntitySchemaRegistry {
@@ -93,14 +150,31 @@ export class EntitySchemaRegistry {
93
150
  getSchema(entity: string): EntitySchema {
94
151
  const schema = this.schemas.get(entity);
95
152
  if (!schema) {
96
- throw new Error(
153
+ throw new ConfigurationError(
97
154
  `EntitySchemaRegistry: No schema registered for entity '${entity}'. ` +
98
- `Available entities: ${Array.from(this.schemas.keys()).join(", ") || "none"}`
155
+ `Available entities: ${
156
+ Array.from(this.schemas.keys()).join(", ") || "none"
157
+ }`
99
158
  );
100
159
  }
101
160
  return schema;
102
161
  }
103
162
 
163
+ /**
164
+ * Gets all registered schemas.
165
+ */
166
+ getAllSchemas(): EntitySchema[] {
167
+ return Array.from(this.schemas.values());
168
+ }
169
+
170
+ /**
171
+ * Tries to get the schema of an entity, returns null if not found.
172
+ * @param entity - Entity name.
173
+ */
174
+ tryGetSchema(entity: string): EntitySchema | null {
175
+ return this.schemas.get(entity) ?? null;
176
+ }
177
+
104
178
  /**
105
179
  * Checks if an entity is registered.
106
180
  * @param entity - Entity name.
@@ -212,6 +286,104 @@ export class EntitySchemaRegistry {
212
286
  return schema.parentFk?.field ?? null;
213
287
  }
214
288
 
289
+ /**
290
+ * Gets the collection configuration for a specific field.
291
+ *
292
+ * @param entity - Parent entity name (e.g., 'Post')
293
+ * @param fieldName - Collection field name (e.g., 'tags')
294
+ * @returns CollectionConfig or null if not configured
295
+ *
296
+ * @example
297
+ * ```typescript
298
+ * const config = registry.getCollectionConfig('Post', 'tags');
299
+ * if (config?.type === 'reference') {
300
+ * // Handle N:N relation - use connect/disconnect
301
+ * } else {
302
+ * // Handle 1:N relation - use create/delete
303
+ * }
304
+ * ```
305
+ */
306
+ getCollectionConfig(
307
+ entity: string,
308
+ fieldName: string
309
+ ): CollectionConfig | null {
310
+ const schema = this.tryGetSchema(entity);
311
+ if (!schema?.collections) return null;
312
+ return schema.collections[fieldName] ?? null;
313
+ }
314
+
315
+ /**
316
+ * Checks if a collection is a reference type (N:N).
317
+ *
318
+ * @param entity - Parent entity name
319
+ * @param fieldName - Collection field name
320
+ * @returns true if the collection is a reference (N:N), false otherwise
321
+ *
322
+ * @example
323
+ * ```typescript
324
+ * if (registry.isReferenceCollection('Post', 'tags')) {
325
+ * // Use connect/disconnect instead of create/delete
326
+ * }
327
+ * ```
328
+ */
329
+ isReferenceCollection(entity: string, fieldName: string): boolean {
330
+ const config = this.getCollectionConfig(entity, fieldName);
331
+ return config?.type === "reference";
332
+ }
333
+
334
+ /**
335
+ * Checks if a collection is owned (1:N).
336
+ * Returns true if explicitly configured as 'owned' or if not configured at all.
337
+ *
338
+ * @param entity - Parent entity name
339
+ * @param fieldName - Collection field name
340
+ * @returns true if the collection is owned (1:N), false if reference
341
+ */
342
+ isOwnedCollection(entity: string, fieldName: string): boolean {
343
+ const config = this.getCollectionConfig(entity, fieldName);
344
+ // Default to owned if not configured
345
+ return config?.type !== "reference";
346
+ }
347
+
348
+ /**
349
+ * Gets all collections configured for an entity.
350
+ *
351
+ * @param entity - Entity name
352
+ * @returns Record of field names to collection configs, or empty object
353
+ */
354
+ getCollections(entity: string): Record<string, CollectionConfig> {
355
+ const schema = this.tryGetSchema(entity);
356
+ return schema?.collections ?? {};
357
+ }
358
+
359
+ /**
360
+ * Gets all reference (N:N) collections for an entity.
361
+ *
362
+ * @param entity - Entity name
363
+ * @returns Array of field names that are reference collections
364
+ */
365
+ getReferenceCollections(entity: string): string[] {
366
+ const collections = this.getCollections(entity);
367
+ return Object.entries(collections)
368
+ .filter(([_, config]) => config.type === "reference")
369
+ .map(([field]) => field);
370
+ }
371
+
372
+ /**
373
+ * Gets the junction table configuration for a reference collection.
374
+ *
375
+ * @param entity - Parent entity name
376
+ * @param fieldName - Collection field name
377
+ * @returns Junction config or null
378
+ */
379
+ getJunctionConfig(
380
+ entity: string,
381
+ fieldName: string
382
+ ): CollectionConfig["junction"] | null {
383
+ const config = this.getCollectionConfig(entity, fieldName);
384
+ return config?.junction ?? null;
385
+ }
386
+
215
387
  /**
216
388
  * Lists all registered entities.
217
389
  */
@@ -234,7 +406,12 @@ export class EntitySchemaRegistry {
234
406
  if (Array.isArray(value)) return true;
235
407
  if (value instanceof Entity) return true;
236
408
  if (value instanceof ValueObject) return true;
237
- if (typeof value === 'object' && value.id && typeof value.id === 'object' && 'value' in value.id) {
409
+ if (
410
+ typeof value === "object" &&
411
+ value.id &&
412
+ typeof value.id === "object" &&
413
+ "value" in value.id
414
+ ) {
238
415
  return true;
239
416
  }
240
417
  return false;
@@ -252,4 +429,77 @@ export class EntitySchemaRegistry {
252
429
  }
253
430
  return value;
254
431
  }
432
+
433
+ /**
434
+ * Validates that a relation field exists in the entity's collections.
435
+ *
436
+ * @param entity - Parent entity name
437
+ * @param relationField - Relation field to validate
438
+ * @throws ConfigurationError if the field doesn't exist
439
+ *
440
+ */
441
+ public validateRelationField(entity: string, relationField: string): void {
442
+ const schema = this.tryGetSchema(entity);
443
+
444
+ const uuidPattern =
445
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
446
+
447
+ if (uuidPattern.test(entity)) {
448
+ throw new ConfigurationError(
449
+ `EntitySchemaRegistry: Received an ID '${entity}' instead of an entity name. ` +
450
+ `This usually means 'parentEntity' is not being set correctly in the ChangeTracker. ` +
451
+ `Check that addDelete/addCreate are receiving the entity NAME (e.g., 'Post'), not the ID.`
452
+ );
453
+ }
454
+
455
+ if (!schema) {
456
+ throw new ConfigurationError(
457
+ `EntitySchemaRegistry: Cannot validate relation '${relationField}' - ` +
458
+ `entity '${entity}' is not registered. ` +
459
+ `Available entities: ${
460
+ this.getRegisteredEntities().join(", ") || "none"
461
+ }`
462
+ );
463
+ }
464
+
465
+ const collections = schema.collections ?? {};
466
+ const availableCollections = Object.keys(collections);
467
+
468
+ if (availableCollections.length === 0) {
469
+ return;
470
+ }
471
+
472
+ if (!collections[relationField]) {
473
+ const suggestions = this.findSimilarNames(
474
+ relationField,
475
+ availableCollections
476
+ );
477
+ const suggestionText =
478
+ suggestions.length > 0
479
+ ? ` Did you mean: '${suggestions.join("' or '")}'?`
480
+ : "";
481
+
482
+ throw new ConfigurationError(
483
+ `EntitySchemaRegistry: Unknown relation '${relationField}' for entity '${entity}'. ` +
484
+ `Available collections: ${availableCollections.join(
485
+ ", "
486
+ )}.${suggestionText}`
487
+ );
488
+ }
489
+ }
490
+
491
+ private findSimilarNames(input: string, candidates: string[]): string[] {
492
+ return candidates
493
+ .map((candidate) => ({
494
+ name: candidate,
495
+ distance: levenshteinDistance(
496
+ input.toLowerCase(),
497
+ candidate.toLowerCase()
498
+ ),
499
+ }))
500
+ .filter(({ distance }) => distance <= 3)
501
+ .sort((a, b) => a.distance - b.distance)
502
+ .slice(0, 2)
503
+ .map(({ name }) => name);
504
+ }
255
505
  }
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ export { ValueObject } from "./value-object";
11
11
  export { Mapper } from "./mapper";
12
12
  export { EntitySchemaRegistry } from "./entity-schema-registry";
13
13
  export { AggregateChanges } from "./aggregate-changes";
14
- export {
14
+ export type {
15
15
  DomainEventHandler,
16
16
  EntityHooks,
17
17
  Filter,