@declaro/data 2.0.0-beta.110 → 2.0.0-beta.111

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.
@@ -9,6 +9,23 @@ export interface ISearchOptions<TSchema extends AnyModelSchema> extends IActionO
9
9
  sort?: InferSort<TSchema>;
10
10
  }
11
11
  export declare class ReadOnlyModelService<TSchema extends AnyModelSchema> extends BaseModelService<TSchema> {
12
+ /**
13
+ * Normalize the detail data to match the expected schema.
14
+ * WARNING: This method is called once per detail in load operations.
15
+ * Any intensive operations or queries should be avoided here, and done via bulk operations in the respective methods such as `loadMany` instead.
16
+ * @param detail The detail data to normalize.
17
+ * @returns The normalized detail data.
18
+ */
19
+ normalizeDetail(detail: InferDetail<TSchema>): Promise<InferDetail<TSchema>>;
20
+ /**
21
+ * Normalize the summary data to match the expected schema.
22
+ * WARNING: This method is called once per summary in search results, often in parallel.
23
+ * Any intensive operations or queries should be avoided here, and done via bulk operations in the respective methods such as `search` instead.
24
+ *
25
+ * @param summary The summary data to normalize.
26
+ * @returns The normalized summary data.
27
+ */
28
+ normalizeSummary(summary: InferDetail<TSchema>): Promise<InferDetail<TSchema>>;
12
29
  /**
13
30
  * Load a single record by its lookup criteria.
14
31
  * @param lookup The lookup criteria to find the record.
@@ -1 +1 @@
1
- {"version":3,"file":"read-only-model-service.d.ts","sourceRoot":"","sources":["../../../../src/domain/services/read-only-model-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AACnD,OAAO,KAAK,EACR,WAAW,EACX,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,SAAS,EACZ,MAAM,qCAAqC,CAAA;AAG5C,OAAO,EAAE,gBAAgB,EAAE,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAC5E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAG5D,MAAM,WAAW,YAAa,SAAQ,cAAc;CAAG;AACvD,MAAM,WAAW,cAAc,CAAC,OAAO,SAAS,cAAc,CAAE,SAAQ,cAAc;IAClF,UAAU,CAAC,EAAE,gBAAgB,CAAA;IAC7B,IAAI,CAAC,EAAE,SAAS,CAAC,OAAO,CAAC,CAAA;CAC5B;AAED,qBAAa,oBAAoB,CAAC,OAAO,SAAS,cAAc,CAAE,SAAQ,gBAAgB,CAAC,OAAO,CAAC;IAC/F;;;;;OAKG;IACG,IAAI,CAAC,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;IAqB/F;;;;;OAKG;IACG,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,OAAO,CAAC,EAAE,CAAC;IAqBxG;;;;;OAKG;IACG,MAAM,CACR,OAAO,EAAE,YAAY,CAAC,OAAO,CAAC,EAC9B,OAAO,CAAC,EAAE,cAAc,CAAC,OAAO,CAAC,GAClC,OAAO,CAAC,kBAAkB,CAAC,OAAO,CAAC,CAAC;IAsBvC;;;;OAIG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,cAAc,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;CAqBlG"}
1
+ {"version":3,"file":"read-only-model-service.d.ts","sourceRoot":"","sources":["../../../../src/domain/services/read-only-model-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAS,MAAM,eAAe,CAAA;AAC1D,OAAO,KAAK,EACR,WAAW,EACX,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,SAAS,EACZ,MAAM,qCAAqC,CAAA;AAG5C,OAAO,EAAE,gBAAgB,EAAE,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAC5E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAG5D,MAAM,WAAW,YAAa,SAAQ,cAAc;CAAG;AACvD,MAAM,WAAW,cAAc,CAAC,OAAO,SAAS,cAAc,CAAE,SAAQ,cAAc;IAClF,UAAU,CAAC,EAAE,gBAAgB,CAAA;IAC7B,IAAI,CAAC,EAAE,SAAS,CAAC,OAAO,CAAC,CAAA;CAC5B;AAED,qBAAa,oBAAoB,CAAC,OAAO,SAAS,cAAc,CAAE,SAAQ,gBAAgB,CAAC,OAAO,CAAC;IAC/F;;;;;;OAMG;IACG,eAAe,CAAC,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;IAalF;;;;;;;OAOG;IACG,gBAAgB,CAAC,OAAO,EAAE,WAAW,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;IAapF;;;;;OAKG;IACG,IAAI,CAAC,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;IAqB/F;;;;;OAKG;IACG,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,OAAO,CAAC,EAAE,CAAC;IAqBxG;;;;;OAKG;IACG,MAAM,CACR,OAAO,EAAE,YAAY,CAAC,OAAO,CAAC,EAC9B,OAAO,CAAC,EAAE,cAAc,CAAC,OAAO,CAAC,GAClC,OAAO,CAAC,kBAAkB,CAAC,OAAO,CAAC,CAAC;IAyBvC;;;;OAIG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,cAAc,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;CAqBlG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@declaro/data",
3
- "version": "2.0.0-beta.110",
3
+ "version": "2.0.0-beta.111",
4
4
  "description": "A data-mapper framework for managing application data across integrated systems.",
5
5
  "main": "dist/node/index.cjs",
6
6
  "module": "dist/node/index.js",
@@ -22,9 +22,9 @@
22
22
  "@declaro/zod": "^2.0.0-beta.51"
23
23
  },
24
24
  "devDependencies": {
25
- "@declaro/auth": "^2.0.0-beta.110",
26
- "@declaro/core": "^2.0.0-beta.110",
27
- "@declaro/zod": "^2.0.0-beta.110",
25
+ "@declaro/auth": "^2.0.0-beta.111",
26
+ "@declaro/core": "^2.0.0-beta.111",
27
+ "@declaro/zod": "^2.0.0-beta.111",
28
28
  "crypto-browserify": "^3.12.1",
29
29
  "typescript": "^5.8.3",
30
30
  "uuid": "^11.1.0",
@@ -43,5 +43,5 @@
43
43
  "require": "./dist/node/index.cjs",
44
44
  "browser": "./dist/browser/index.js"
45
45
  },
46
- "gitHead": "3cae222af9b009c585bb824012814b9ca3e62638"
46
+ "gitHead": "a4506956d649e65c945a96482df1156be7510d85"
47
47
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach } from 'bun:test'
2
2
  import { ModelService } from './model-service'
3
3
  import { MockMemoryRepository } from '../../test/mock/repositories/mock-memory-repository'
4
- import { MockBookSchema } from '../../test/mock/models/mock-book-models'
4
+ import { MockBookSchema, type MockBookInput } from '../../test/mock/models/mock-book-models'
5
5
  import { EventManager } from '@declaro/core'
6
6
  import { mock } from 'bun:test'
7
7
 
@@ -635,12 +635,12 @@ describe('ModelService', () => {
635
635
  return this.normalizeInput(input)
636
636
  }
637
637
 
638
- protected async normalizeInput(input: any) {
638
+ protected async normalizeInput(input: MockBookInput): Promise<MockBookInput> {
639
639
  return {
640
640
  ...input,
641
641
  title: input.title?.trim(),
642
642
  author: input.author?.trim(),
643
- normalizedAt: new Date('2023-01-01'),
643
+ publishedDate: new Date('2023-01-01'),
644
644
  }
645
645
  }
646
646
  }
@@ -665,7 +665,7 @@ describe('ModelService', () => {
665
665
 
666
666
  expect(createdItem.title).toBe('Test Book')
667
667
  expect(createdItem.author).toBe('Author Name')
668
- expect((createdItem as any).normalizedAt).toEqual(new Date('2023-01-01'))
668
+ expect(createdItem.publishedDate).toEqual(new Date('2023-01-01'))
669
669
  })
670
670
 
671
671
  it('should use custom normalizeInput method for update operation', async () => {
@@ -677,7 +677,7 @@ describe('ModelService', () => {
677
677
 
678
678
  expect(updatedItem.title).toBe('Updated Book')
679
679
  expect(updatedItem.author).toBe('Updated Author')
680
- expect((updatedItem as any).normalizedAt).toEqual(new Date('2023-01-01'))
680
+ expect(updatedItem.publishedDate).toEqual(new Date('2023-01-01'))
681
681
  })
682
682
 
683
683
  it('should use custom normalizeInput method for upsert operation', async () => {
@@ -686,7 +686,7 @@ describe('ModelService', () => {
686
686
 
687
687
  expect(upsertedItem.title).toBe('Test Book')
688
688
  expect(upsertedItem.author).toBe('Author Name')
689
- expect((upsertedItem as any).normalizedAt).toEqual(new Date('2023-01-01'))
689
+ expect(upsertedItem.publishedDate).toEqual(new Date('2023-01-01'))
690
690
 
691
691
  // Upsert again with different data
692
692
  const updateInput = {
@@ -699,7 +699,7 @@ describe('ModelService', () => {
699
699
 
700
700
  expect(updatedItem.title).toBe('Updated Book')
701
701
  expect(updatedItem.author).toBe('Updated Author')
702
- expect((updatedItem as any).normalizedAt).toEqual(new Date('2023-01-01'))
702
+ expect(updatedItem.publishedDate).toEqual(new Date('2023-01-01'))
703
703
  })
704
704
 
705
705
  it('should use custom normalizeInput method for bulkUpsert operation', async () => {
@@ -714,15 +714,15 @@ describe('ModelService', () => {
714
714
  expect(results).toHaveLength(3)
715
715
  expect(results[0].title).toBe('Book One')
716
716
  expect(results[0].author).toBe('Author One')
717
- expect((results[0] as any).normalizedAt).toEqual(new Date('2023-01-01'))
717
+ expect(results[0].publishedDate).toEqual(new Date('2023-01-01'))
718
718
 
719
719
  expect(results[1].title).toBe('Book Two')
720
720
  expect(results[1].author).toBe('Author Two')
721
- expect((results[1] as any).normalizedAt).toEqual(new Date('2023-01-01'))
721
+ expect(results[1].publishedDate).toEqual(new Date('2023-01-01'))
722
722
 
723
723
  expect(results[2].title).toBe('Book Three')
724
724
  expect(results[2].author).toBe('Author Three')
725
- expect((results[2] as any).normalizedAt).toEqual(new Date('2023-01-01'))
725
+ expect(results[2].publishedDate).toEqual(new Date('2023-01-01'))
726
726
  })
727
727
 
728
728
  it('should preserve events order with normalized input in create operation', async () => {
@@ -735,61 +735,11 @@ describe('ModelService', () => {
735
735
 
736
736
  expect(beforeCreateCall.meta.input.title).toBe('Test Book')
737
737
  expect(beforeCreateCall.meta.input.author).toBe('Author Name')
738
- expect(beforeCreateCall.meta.input.normalizedAt).toEqual(new Date('2023-01-01'))
738
+ expect(beforeCreateCall.meta.input.publishedDate).toEqual(new Date('2023-01-01'))
739
739
 
740
740
  expect(afterCreateCall.meta.input.title).toBe('Test Book')
741
741
  expect(afterCreateCall.meta.input.author).toBe('Author Name')
742
- expect(afterCreateCall.meta.input.normalizedAt).toEqual(new Date('2023-01-01'))
743
- })
744
-
745
- it('should handle complex async normalization logic', async () => {
746
- class ComplexNormalizationService extends ModelService<typeof mockSchema> {
747
- protected async normalizeInput(input: any) {
748
- // Simulate async operations like database lookups, API calls, etc.
749
- await new Promise((resolve) => setTimeout(resolve, 10))
750
-
751
- const normalized = { ...input }
752
-
753
- // Complex normalization logic
754
- if (normalized.title) {
755
- normalized.title = normalized.title
756
- .trim()
757
- .replace(/\s+/g, ' ')
758
- .toLowerCase()
759
- .replace(/\b\w/g, (l: string) => l.toUpperCase()) // Title case
760
- }
761
-
762
- if (normalized.author) {
763
- normalized.author = normalized.author.trim()
764
- }
765
-
766
- // Add metadata
767
- normalized.processedAt = new Date('2023-01-01')
768
- normalized.version = (normalized.version || 0) + 1
769
-
770
- return normalized
771
- }
772
- }
773
-
774
- const complexService = new ComplexNormalizationService({
775
- repository,
776
- emitter,
777
- schema: mockSchema,
778
- namespace,
779
- })
780
-
781
- const input = {
782
- title: ' the great book ',
783
- author: ' John Doe ',
784
- publishedDate: new Date(),
785
- }
786
-
787
- const result = await complexService.create(input)
788
-
789
- expect(result.title).toBe('The Great Book')
790
- expect(result.author).toBe('John Doe')
791
- expect((result as any).processedAt).toEqual(new Date('2023-01-01'))
792
- expect((result as any).version).toBe(1)
742
+ expect(afterCreateCall.meta.input.publishedDate).toEqual(new Date('2023-01-01'))
793
743
  })
794
744
 
795
745
  it('should call normalizeInput method exactly once per input during bulkUpsert with Promise.all', async () => {
@@ -873,4 +823,199 @@ describe('ModelService', () => {
873
823
  expect(processingTimes).toHaveLength(3)
874
824
  })
875
825
  })
826
+
827
+ describe('Response Normalization', () => {
828
+ class TestRepository extends MockMemoryRepository<typeof mockSchema> {
829
+ constructor() {
830
+ super({
831
+ schema: mockSchema,
832
+ })
833
+ }
834
+
835
+ async create(input: MockBookInput): Promise<any> {
836
+ const record = await super.create(input)
837
+ // Return with publishedDate as string to test normalization
838
+ record.publishedDate = '2024-01-01' as any
839
+ return record
840
+ }
841
+
842
+ async update(lookup: any, input: MockBookInput): Promise<any> {
843
+ const record = await super.update(lookup, input)
844
+ // Return with publishedDate as string to test normalization
845
+ record.publishedDate = '2024-01-01' as any
846
+ return record
847
+ }
848
+
849
+ async upsert(input: MockBookInput): Promise<any> {
850
+ const record = await super.upsert(input)
851
+ // Return with publishedDate as string to test normalization
852
+ record.publishedDate = '2024-01-01' as any
853
+ return record
854
+ }
855
+
856
+ async bulkUpsert(inputs: MockBookInput[]): Promise<any[]> {
857
+ const records = await super.bulkUpsert(inputs)
858
+ // Return with publishedDate as string to test normalization
859
+ for (const record of records) {
860
+ record.publishedDate = '2024-01-01' as any
861
+ }
862
+ return records
863
+ }
864
+
865
+ async remove(lookup: any): Promise<any> {
866
+ const record = await super.remove(lookup)
867
+ // Return with publishedDate as string to test normalization
868
+ if (record) {
869
+ record.publishedDate = '2024-01-01' as any
870
+ }
871
+ return record
872
+ }
873
+
874
+ async restore(lookup: any): Promise<any> {
875
+ const record = await super.restore(lookup)
876
+ // Return with publishedDate as string to test normalization
877
+ if (record) {
878
+ record.publishedDate = '2024-01-01' as any
879
+ }
880
+ return record
881
+ }
882
+ }
883
+
884
+ class TestService extends ModelService<typeof mockSchema> {}
885
+
886
+ let testRepository: TestRepository
887
+ let testService: TestService
888
+
889
+ beforeEach(() => {
890
+ testRepository = new TestRepository()
891
+ emitter = new EventManager()
892
+
893
+ testService = new TestService({
894
+ repository: testRepository,
895
+ emitter,
896
+ schema: mockSchema,
897
+ namespace,
898
+ })
899
+ })
900
+
901
+ it('should normalize details in the create response', async () => {
902
+ const input = { id: 200, title: 'Create Test', author: 'Creator', publishedDate: new Date() }
903
+ const record = await testService.create(input)
904
+
905
+ const expectedDate = new Date('2024-01-01')
906
+ const actualDate = record.publishedDate
907
+
908
+ expect(actualDate).toEqual(expectedDate)
909
+ expect(actualDate).toBeInstanceOf(Date)
910
+ })
911
+
912
+ it('should normalize details in the update response', async () => {
913
+ const input = { id: 201, title: 'Update Test', author: 'Updater', publishedDate: new Date() }
914
+ await testRepository.create(input)
915
+
916
+ const updatedInput = { title: 'Updated Test', author: 'Updated Author', publishedDate: new Date() }
917
+ const record = await testService.update({ id: 201 }, updatedInput)
918
+
919
+ const expectedDate = new Date('2024-01-01')
920
+ const actualDate = record.publishedDate
921
+
922
+ expect(actualDate).toEqual(expectedDate)
923
+ expect(actualDate).toBeInstanceOf(Date)
924
+ })
925
+
926
+ it('should normalize details in the upsert response when creating', async () => {
927
+ const input = { id: 202, title: 'Upsert Create Test', author: 'Upserter', publishedDate: new Date() }
928
+ const record = await testService.upsert(input)
929
+
930
+ const expectedDate = new Date('2024-01-01')
931
+ const actualDate = record.publishedDate
932
+
933
+ expect(actualDate).toEqual(expectedDate)
934
+ expect(actualDate).toBeInstanceOf(Date)
935
+ })
936
+
937
+ it('should normalize details in the upsert response when updating', async () => {
938
+ const input = { id: 203, title: 'Upsert Update Test', author: 'Upserter', publishedDate: new Date() }
939
+ await testRepository.create(input)
940
+
941
+ const updatedInput = {
942
+ id: 203,
943
+ title: 'Updated Upsert Test',
944
+ author: 'Updated Upserter',
945
+ publishedDate: new Date(),
946
+ }
947
+ const record = await testService.upsert(updatedInput)
948
+
949
+ const expectedDate = new Date('2024-01-01')
950
+ const actualDate = record.publishedDate
951
+
952
+ expect(actualDate).toEqual(expectedDate)
953
+ expect(actualDate).toBeInstanceOf(Date)
954
+ })
955
+
956
+ it('should normalize details in the bulkUpsert response', async () => {
957
+ const input1 = { id: 204, title: 'Bulk Test 1', author: 'Bulk Author 1', publishedDate: new Date() }
958
+ const input2 = { id: 205, title: 'Bulk Test 2', author: 'Bulk Author 2', publishedDate: new Date() }
959
+
960
+ const records = await testService.bulkUpsert([input1, input2])
961
+
962
+ const expectedDate = new Date('2024-01-01')
963
+
964
+ for (const record of records) {
965
+ const actualDate = record.publishedDate
966
+ expect(actualDate).toEqual(expectedDate)
967
+ expect(actualDate).toBeInstanceOf(Date)
968
+ }
969
+ })
970
+
971
+ it('should normalize details in the bulkUpsert response with mixed create and update', async () => {
972
+ const existingInput = { id: 206, title: 'Existing', author: 'Existing Author', publishedDate: new Date() }
973
+ await testRepository.create(existingInput)
974
+
975
+ const updateInput = {
976
+ id: 206,
977
+ title: 'Updated Existing',
978
+ author: 'Updated Author',
979
+ publishedDate: new Date(),
980
+ }
981
+ const createInput = { id: 207, title: 'New Record', author: 'New Author', publishedDate: new Date() }
982
+
983
+ const records = await testService.bulkUpsert([updateInput, createInput])
984
+
985
+ const expectedDate = new Date('2024-01-01')
986
+
987
+ for (const record of records) {
988
+ const actualDate = record.publishedDate
989
+ expect(actualDate).toEqual(expectedDate)
990
+ expect(actualDate).toBeInstanceOf(Date)
991
+ }
992
+ })
993
+
994
+ it('should normalize summaries in the remove response', async () => {
995
+ const input = { id: 208, title: 'Remove Test', author: 'Remover', publishedDate: new Date() }
996
+ await testRepository.create(input)
997
+
998
+ const record = await testService.remove({ id: 208 })
999
+
1000
+ const expectedDate = new Date('2024-01-01')
1001
+ const actualDate = record.publishedDate
1002
+
1003
+ expect(actualDate).toEqual(expectedDate)
1004
+ expect(actualDate).toBeInstanceOf(Date)
1005
+ })
1006
+
1007
+ it('should normalize summaries in the restore response', async () => {
1008
+ const input = { id: 209, title: 'Restore Test', author: 'Restorer', publishedDate: new Date() }
1009
+ await testRepository.create(input)
1010
+ await testRepository.remove({ id: 209 })
1011
+
1012
+ const record = await testService.restore({ id: 209 })
1013
+
1014
+ const expectedDate = new Date('2024-01-01')
1015
+ const actualDate = record.publishedDate
1016
+
1017
+ expect(actualDate).toEqual(expectedDate)
1018
+ expect(actualDate).toBeInstanceOf(Date)
1019
+ })
1020
+ })
876
1021
  })
@@ -49,7 +49,7 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
49
49
  await this.emitter.emitAsync(afterRemoveEvent)
50
50
 
51
51
  // Return the results of the removal
52
- return result
52
+ return await this.normalizeSummary(result)
53
53
  }
54
54
 
55
55
  /**
@@ -77,7 +77,7 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
77
77
  await this.emitter.emitAsync(afterRestoreEvent)
78
78
 
79
79
  // Return the results of the restore operation
80
- return result
80
+ return await this.normalizeSummary(result)
81
81
  }
82
82
 
83
83
  async create(input: InferInput<TSchema>, options?: ICreateOptions): Promise<InferDetail<TSchema>> {
@@ -102,7 +102,7 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
102
102
  await this.emitter.emitAsync(afterCreateEvent)
103
103
 
104
104
  // Return the results of the creation
105
- return result
105
+ return await this.normalizeDetail(result)
106
106
  }
107
107
 
108
108
  async update(
@@ -131,7 +131,7 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
131
131
  await this.emitter.emitAsync(afterUpdateEvent)
132
132
 
133
133
  // Return the results of the update
134
- return result
134
+ return await this.normalizeDetail(result)
135
135
  }
136
136
 
137
137
  /**
@@ -187,7 +187,7 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
187
187
  await this.emitter.emitAsync(afterUpsertEvent)
188
188
 
189
189
  // Return the results of the upsert operation
190
- return result
190
+ return await this.normalizeDetail(result)
191
191
  }
192
192
 
193
193
  /**
@@ -340,6 +340,6 @@ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelS
340
340
  await Promise.all(afterEvents.map((event) => this.emitter.emitAsync(event)))
341
341
 
342
342
  // Return the results
343
- return results
343
+ return await Promise.all(results.map((result) => this.normalizeDetail(result)))
344
344
  }
345
345
  }
@@ -1,7 +1,7 @@
1
- import { describe, it, expect, beforeEach, spyOn, mock } from 'bun:test'
2
- import { ReadOnlyModelService } from './read-only-model-service'
1
+ import { describe, it, expect, beforeEach, spyOn, mock, beforeAll } from 'bun:test'
2
+ import { ReadOnlyModelService, type ILoadOptions } from './read-only-model-service'
3
3
  import { MockMemoryRepository } from '../../test/mock/repositories/mock-memory-repository'
4
- import { MockBookSchema } from '../../test/mock/models/mock-book-models'
4
+ import { MockBookSchema, type MockBookDetail, type MockBookLookup } from '../../test/mock/models/mock-book-models'
5
5
  import { EventManager } from '@declaro/core'
6
6
  import type { QueryEvent } from '../events/query-event'
7
7
  import type { InferDetail, InferFilters, InferLookup, InferSearchResults } from '../../shared/utils/schema-inference'
@@ -293,4 +293,104 @@ describe('ReadOnlyModelService', () => {
293
293
  expect(afterSearchSpy).toHaveBeenCalledTimes(1)
294
294
  expect(afterSearchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterSearch' }))
295
295
  })
296
+
297
+ describe('Response Normalization', () => {
298
+ class TestRepository extends MockMemoryRepository<typeof mockSchema> {
299
+ constructor() {
300
+ super({
301
+ schema: mockSchema,
302
+ })
303
+ }
304
+
305
+ async load(input: MockBookLookup): Promise<MockBookDetail | null> {
306
+ const record = await super.load(input)
307
+
308
+ if (record) {
309
+ record.publishedDate = '2024-01-01' as any
310
+ }
311
+
312
+ return record
313
+ }
314
+
315
+ async loadMany(inputs: MockBookLookup[]): Promise<MockBookDetail[]> {
316
+ const records = await super.loadMany(inputs)
317
+
318
+ for (const record of records) {
319
+ record.publishedDate = '2024-01-01' as any
320
+ }
321
+
322
+ return records
323
+ }
324
+
325
+ async search(
326
+ filters: InferFilters<typeof mockSchema>,
327
+ options?: ILoadOptions,
328
+ ): Promise<InferSearchResults<typeof mockSchema>> {
329
+ const results = await super.search(filters, options)
330
+
331
+ for (const record of results.results) {
332
+ record.publishedDate = '2024-01-01' as any
333
+ }
334
+
335
+ return results
336
+ }
337
+ }
338
+ class TestService extends ReadOnlyModelService<typeof mockSchema> {}
339
+
340
+ let testService: TestService
341
+
342
+ beforeEach(() => {
343
+ repository = new TestRepository()
344
+ emitter = new EventManager()
345
+
346
+ testService = new TestService({ repository, emitter, schema: mockSchema, namespace })
347
+ })
348
+
349
+ it('should normalize details in the load response', async () => {
350
+ const input = { id: 100, title: 'Normalization Test', author: 'Normalizer', publishedDate: new Date() }
351
+ await repository.create(input)
352
+
353
+ const record = await testService.load({ id: 100 })
354
+
355
+ const expectedDate = new Date('2024-01-01')
356
+ const actualDate = record.publishedDate
357
+
358
+ expect(actualDate).toEqual(expectedDate)
359
+ expect(actualDate).toBeInstanceOf(Date)
360
+ })
361
+
362
+ it('should normalize details in the loadMany response', async () => {
363
+ const input1 = { id: 101, title: 'Normalization Test 1', author: 'Normalizer 1', publishedDate: new Date() }
364
+ const input2 = { id: 102, title: 'Normalization Test 2', author: 'Normalizer 2', publishedDate: new Date() }
365
+ await repository.create(input1)
366
+ await repository.create(input2)
367
+
368
+ const records = await testService.loadMany([{ id: 101 }, { id: 102 }])
369
+
370
+ const expectedDate = new Date('2024-01-01')
371
+
372
+ for (const record of records) {
373
+ const actualDate = record.publishedDate
374
+ expect(actualDate).toEqual(expectedDate)
375
+ expect(actualDate).toBeInstanceOf(Date)
376
+ }
377
+ })
378
+
379
+ it('should normalize details in the search response', async () => {
380
+ const input1 = { id: 103, title: 'Normalization Test 3', author: 'Normalizer 3', publishedDate: new Date() }
381
+ const input2 = { id: 104, title: 'Normalization Test 4', author: 'Normalizer 4', publishedDate: new Date() }
382
+ await repository.create(input1)
383
+ await repository.create(input2)
384
+
385
+ const results = await testService.search({ text: 'Normalization' })
386
+
387
+ const expectedDate = new Date('2024-01-01')
388
+
389
+ for (const record of results.results) {
390
+ const actualDate = record.publishedDate
391
+ expect(actualDate).toEqual(expectedDate)
392
+ expect(actualDate).toBeInstanceOf(Date)
393
+ }
394
+ })
395
+ })
296
396
  })
@@ -1,4 +1,4 @@
1
- import type { AnyModelSchema } from '@declaro/core'
1
+ import type { AnyModelSchema, Model } from '@declaro/core'
2
2
  import type {
3
3
  InferDetail,
4
4
  InferFilters,
@@ -19,6 +19,47 @@ export interface ISearchOptions<TSchema extends AnyModelSchema> extends IActionO
19
19
  }
20
20
 
21
21
  export class ReadOnlyModelService<TSchema extends AnyModelSchema> extends BaseModelService<TSchema> {
22
+ /**
23
+ * Normalize the detail data to match the expected schema.
24
+ * WARNING: This method is called once per detail in load operations.
25
+ * Any intensive operations or queries should be avoided here, and done via bulk operations in the respective methods such as `loadMany` instead.
26
+ * @param detail The detail data to normalize.
27
+ * @returns The normalized detail data.
28
+ */
29
+ async normalizeDetail(detail: InferDetail<TSchema>): Promise<InferDetail<TSchema>> {
30
+ const detailModel = this.schema.definition.detail as Model<any, any>
31
+ if (detailModel) {
32
+ const validation = await detailModel.validate(detail, { strict: false })
33
+ if (validation.issues) {
34
+ console.warn(`${detailModel.labels.singularLabel} shape did not match the expected schema`)
35
+ } else {
36
+ return validation.value
37
+ }
38
+ }
39
+ return detail
40
+ }
41
+
42
+ /**
43
+ * Normalize the summary data to match the expected schema.
44
+ * WARNING: This method is called once per summary in search results, often in parallel.
45
+ * Any intensive operations or queries should be avoided here, and done via bulk operations in the respective methods such as `search` instead.
46
+ *
47
+ * @param summary The summary data to normalize.
48
+ * @returns The normalized summary data.
49
+ */
50
+ async normalizeSummary(summary: InferDetail<TSchema>): Promise<InferDetail<TSchema>> {
51
+ const summaryModel = this.schema.definition.summary as Model<any, any>
52
+ if (summaryModel) {
53
+ const validation = await summaryModel.validate(summary, { strict: false })
54
+ if (validation.issues) {
55
+ console.warn(`${summaryModel.labels.singularLabel} shape did not match the expected schema`)
56
+ } else {
57
+ return validation.value
58
+ }
59
+ }
60
+ return summary
61
+ }
62
+
22
63
  /**
23
64
  * Load a single record by its lookup criteria.
24
65
  * @param lookup The lookup criteria to find the record.
@@ -43,7 +84,7 @@ export class ReadOnlyModelService<TSchema extends AnyModelSchema> extends BaseMo
43
84
  ).setResult(details)
44
85
  await this.emitter.emitAsync(afterLoadEvent)
45
86
 
46
- return details
87
+ return await this.normalizeDetail(details)
47
88
  }
48
89
 
49
90
  /**
@@ -70,7 +111,7 @@ export class ReadOnlyModelService<TSchema extends AnyModelSchema> extends BaseMo
70
111
  ).setResult(details)
71
112
  await this.emitter.emitAsync(afterLoadManyEvent)
72
113
 
73
- return details
114
+ return await Promise.all(details.map((detail) => this.normalizeDetail(detail)))
74
115
  }
75
116
 
76
117
  /**
@@ -101,7 +142,10 @@ export class ReadOnlyModelService<TSchema extends AnyModelSchema> extends BaseMo
101
142
  await this.emitter.emitAsync(afterSearchEvent)
102
143
 
103
144
  // Return the search results
104
- return results
145
+ return {
146
+ ...results,
147
+ results: await Promise.all(results.results.map((detail) => this.normalizeSummary(detail))),
148
+ }
105
149
  }
106
150
 
107
151
  /**
@@ -1 +0,0 @@
1
- //# sourceMappingURL=model-service.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"model-service.test.d.ts","sourceRoot":"","sources":["../../../../../src/test/domain/services/model-service.test.ts"],"names":[],"mappings":""}
File without changes