@declaro/data 2.0.0-beta.13 → 2.0.0-beta.130

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 (142) hide show
  1. package/{LICENSE → LICENSE.md} +1 -1
  2. package/README.md +0 -0
  3. package/dist/browser/index.js +26 -0
  4. package/dist/browser/index.js.map +93 -0
  5. package/dist/node/index.cjs +13360 -0
  6. package/dist/node/index.cjs.map +93 -0
  7. package/dist/node/index.js +13339 -0
  8. package/dist/node/index.js.map +93 -0
  9. package/dist/ts/application/model-controller.d.ts +60 -0
  10. package/dist/ts/application/model-controller.d.ts.map +1 -0
  11. package/dist/ts/application/model-controller.test.d.ts +2 -0
  12. package/dist/ts/application/model-controller.test.d.ts.map +1 -0
  13. package/dist/ts/application/read-only-model-controller.d.ts +24 -0
  14. package/dist/ts/application/read-only-model-controller.d.ts.map +1 -0
  15. package/dist/ts/application/read-only-model-controller.test.d.ts +2 -0
  16. package/dist/ts/application/read-only-model-controller.test.d.ts.map +1 -0
  17. package/dist/ts/domain/events/domain-event.d.ts +41 -0
  18. package/dist/ts/domain/events/domain-event.d.ts.map +1 -0
  19. package/dist/ts/domain/events/domain-event.test.d.ts +2 -0
  20. package/dist/ts/domain/events/domain-event.test.d.ts.map +1 -0
  21. package/dist/ts/domain/events/event-types.d.ts +34 -0
  22. package/dist/ts/domain/events/event-types.d.ts.map +1 -0
  23. package/dist/ts/domain/events/mutation-event.d.ts +9 -0
  24. package/dist/ts/domain/events/mutation-event.d.ts.map +1 -0
  25. package/dist/ts/domain/events/mutation-event.test.d.ts +2 -0
  26. package/dist/ts/domain/events/mutation-event.test.d.ts.map +1 -0
  27. package/dist/ts/domain/events/query-event.d.ts +8 -0
  28. package/dist/ts/domain/events/query-event.d.ts.map +1 -0
  29. package/dist/ts/domain/events/query-event.test.d.ts +2 -0
  30. package/dist/ts/domain/events/query-event.test.d.ts.map +1 -0
  31. package/dist/ts/domain/events/request-event.d.ts +26 -0
  32. package/dist/ts/domain/events/request-event.d.ts.map +1 -0
  33. package/dist/ts/domain/events/request-event.test.d.ts +2 -0
  34. package/dist/ts/domain/events/request-event.test.d.ts.map +1 -0
  35. package/dist/ts/domain/interfaces/repository.d.ts +110 -0
  36. package/dist/ts/domain/interfaces/repository.d.ts.map +1 -0
  37. package/dist/ts/domain/models/pagination.d.ts +28 -0
  38. package/dist/ts/domain/models/pagination.d.ts.map +1 -0
  39. package/dist/ts/domain/services/base-model-service.d.ts +23 -0
  40. package/dist/ts/domain/services/base-model-service.d.ts.map +1 -0
  41. package/dist/ts/domain/services/model-service-args.d.ts +9 -0
  42. package/dist/ts/domain/services/model-service-args.d.ts.map +1 -0
  43. package/dist/ts/domain/services/model-service.d.ts +99 -0
  44. package/dist/ts/domain/services/model-service.d.ts.map +1 -0
  45. package/dist/ts/domain/services/model-service.normalization.test.d.ts +2 -0
  46. package/dist/ts/domain/services/model-service.normalization.test.d.ts.map +1 -0
  47. package/dist/ts/domain/services/model-service.test.d.ts +2 -0
  48. package/dist/ts/domain/services/model-service.test.d.ts.map +1 -0
  49. package/dist/ts/domain/services/read-only-model-service.d.ts +84 -0
  50. package/dist/ts/domain/services/read-only-model-service.d.ts.map +1 -0
  51. package/dist/ts/domain/services/read-only-model-service.test.d.ts +2 -0
  52. package/dist/ts/domain/services/read-only-model-service.test.d.ts.map +1 -0
  53. package/dist/ts/index.d.ts +18 -0
  54. package/dist/ts/index.d.ts.map +1 -0
  55. package/dist/ts/shared/utils/schema-inference.d.ts +23 -0
  56. package/dist/ts/shared/utils/schema-inference.d.ts.map +1 -0
  57. package/dist/ts/shared/utils/schema-inheritance.d.ts +24 -0
  58. package/dist/ts/shared/utils/schema-inheritance.d.ts.map +1 -0
  59. package/dist/ts/shared/utils/schema-inheritance.test.d.ts +2 -0
  60. package/dist/ts/shared/utils/schema-inheritance.test.d.ts.map +1 -0
  61. package/dist/ts/shared/utils/test/animal-schema.d.ts +57 -0
  62. package/dist/ts/shared/utils/test/animal-schema.d.ts.map +1 -0
  63. package/dist/ts/shared/utils/test/animal-trait-schema.d.ts +55 -0
  64. package/dist/ts/shared/utils/test/animal-trait-schema.d.ts.map +1 -0
  65. package/dist/ts/shared/utils/test/elephant-schema.d.ts +30 -0
  66. package/dist/ts/shared/utils/test/elephant-schema.d.ts.map +1 -0
  67. package/dist/ts/shared/utils/test/elephant-trait-schema.d.ts +26 -0
  68. package/dist/ts/shared/utils/test/elephant-trait-schema.d.ts.map +1 -0
  69. package/dist/ts/test/mock/models/mock-book-models.d.ts +42 -0
  70. package/dist/ts/test/mock/models/mock-book-models.d.ts.map +1 -0
  71. package/dist/ts/test/mock/repositories/mock-memory-repository.assign.test.d.ts +2 -0
  72. package/dist/ts/test/mock/repositories/mock-memory-repository.assign.test.d.ts.map +1 -0
  73. package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts +2 -0
  74. package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts.map +1 -0
  75. package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts +2 -0
  76. package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts.map +1 -0
  77. package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts +2 -0
  78. package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts.map +1 -0
  79. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts +62 -0
  80. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts.map +1 -0
  81. package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts +2 -0
  82. package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts.map +1 -0
  83. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts +2 -0
  84. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts.map +1 -0
  85. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts +2 -0
  86. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts.map +1 -0
  87. package/package.json +45 -42
  88. package/src/application/model-controller.test.ts +694 -0
  89. package/src/application/model-controller.ts +186 -0
  90. package/src/application/read-only-model-controller.test.ts +335 -0
  91. package/src/application/read-only-model-controller.ts +79 -0
  92. package/src/domain/events/domain-event.test.ts +82 -0
  93. package/src/domain/events/domain-event.ts +69 -0
  94. package/src/domain/events/event-types.ts +34 -0
  95. package/src/domain/events/mutation-event.test.ts +390 -0
  96. package/src/domain/events/mutation-event.ts +16 -0
  97. package/src/domain/events/query-event.test.ts +228 -0
  98. package/src/domain/events/query-event.ts +14 -0
  99. package/src/domain/events/request-event.test.ts +38 -0
  100. package/src/domain/events/request-event.ts +47 -0
  101. package/src/domain/interfaces/repository.ts +136 -0
  102. package/src/domain/models/pagination.ts +28 -0
  103. package/src/domain/services/base-model-service.ts +54 -0
  104. package/src/domain/services/model-service-args.ts +9 -0
  105. package/src/domain/services/model-service.normalization.test.ts +704 -0
  106. package/src/domain/services/model-service.test.ts +1397 -0
  107. package/src/domain/services/model-service.ts +530 -0
  108. package/src/domain/services/read-only-model-service.test.ts +1058 -0
  109. package/src/domain/services/read-only-model-service.ts +203 -0
  110. package/src/index.ts +17 -4
  111. package/src/shared/utils/schema-inference.ts +26 -0
  112. package/src/shared/utils/schema-inheritance.test.ts +295 -0
  113. package/src/shared/utils/schema-inheritance.ts +28 -0
  114. package/src/shared/utils/test/animal-schema.ts +46 -0
  115. package/src/shared/utils/test/animal-trait-schema.ts +45 -0
  116. package/src/shared/utils/test/elephant-schema.ts +58 -0
  117. package/src/shared/utils/test/elephant-trait-schema.ts +53 -0
  118. package/src/test/mock/models/mock-book-models.ts +78 -0
  119. package/src/test/mock/repositories/mock-memory-repository.assign.test.ts +215 -0
  120. package/src/test/mock/repositories/mock-memory-repository.basic.test.ts +129 -0
  121. package/src/test/mock/repositories/mock-memory-repository.bulk-upsert.test.ts +159 -0
  122. package/src/test/mock/repositories/mock-memory-repository.count.test.ts +98 -0
  123. package/src/test/mock/repositories/mock-memory-repository.search.test.ts +265 -0
  124. package/src/test/mock/repositories/mock-memory-repository.trash.test.ts +736 -0
  125. package/src/test/mock/repositories/mock-memory-repository.ts +401 -0
  126. package/src/test/mock/repositories/mock-memory-repository.upsert.test.ts +108 -0
  127. package/dist/databaseConnection.d.ts +0 -24
  128. package/dist/datastoreAbstract.d.ts +0 -37
  129. package/dist/declaro-data.cjs +0 -1
  130. package/dist/declaro-data.mjs +0 -250
  131. package/dist/hydrateEntity.d.ts +0 -8
  132. package/dist/index.d.ts +0 -4
  133. package/dist/serverConnection.d.ts +0 -15
  134. package/dist/trackedStatus.d.ts +0 -15
  135. package/src/databaseConnection.ts +0 -137
  136. package/src/datastoreAbstract.ts +0 -190
  137. package/src/hydrateEntity.ts +0 -36
  138. package/src/placeholder.test.ts +0 -7
  139. package/src/serverConnection.ts +0 -74
  140. package/src/trackedStatus.ts +0 -35
  141. package/tsconfig.json +0 -10
  142. package/vite.config.ts +0 -23
@@ -0,0 +1,1397 @@
1
+ import { EventManager } from '@declaro/core'
2
+ import { beforeEach, describe, expect, it, mock } from 'bun:test'
3
+ import { MockBookSchema } from '../../test/mock/models/mock-book-models'
4
+ import { MockMemoryRepository } from '../../test/mock/repositories/mock-memory-repository'
5
+ import { ModelService } from './model-service'
6
+
7
+ describe('ModelService', () => {
8
+ const namespace = 'books'
9
+ const mockSchema = MockBookSchema
10
+
11
+ let repository: MockMemoryRepository<typeof mockSchema>
12
+ let emitter: EventManager
13
+ let service: ModelService<typeof mockSchema>
14
+
15
+ beforeEach(() => {
16
+ repository = new MockMemoryRepository({ schema: mockSchema })
17
+ emitter = new EventManager()
18
+ service = new ModelService({ repository, emitter, schema: mockSchema, namespace })
19
+ })
20
+
21
+ const beforeCreateSpy = mock((event) => {})
22
+ const afterCreateSpy = mock((event) => {})
23
+ const beforeUpdateSpy = mock((event) => {})
24
+ const afterUpdateSpy = mock((event) => {})
25
+ const beforeRemoveSpy = mock((event) => {})
26
+ const afterRemoveSpy = mock((event) => {})
27
+ const beforeRestoreSpy = mock((event) => {})
28
+ const afterRestoreSpy = mock((event) => {})
29
+
30
+ beforeEach(() => {
31
+ emitter.on('books::book.beforeCreate', beforeCreateSpy)
32
+ emitter.on('books::book.afterCreate', afterCreateSpy)
33
+ emitter.on('books::book.beforeUpdate', beforeUpdateSpy)
34
+ emitter.on('books::book.afterUpdate', afterUpdateSpy)
35
+ emitter.on('books::book.beforeRemove', beforeRemoveSpy)
36
+ emitter.on('books::book.afterRemove', afterRemoveSpy)
37
+ emitter.on('books::book.beforeRestore', beforeRestoreSpy)
38
+ emitter.on('books::book.afterRestore', afterRestoreSpy)
39
+
40
+ beforeCreateSpy.mockClear()
41
+ afterCreateSpy.mockClear()
42
+ beforeUpdateSpy.mockClear()
43
+ afterUpdateSpy.mockClear()
44
+ beforeRemoveSpy.mockClear()
45
+ afterRemoveSpy.mockClear()
46
+ beforeRestoreSpy.mockClear()
47
+ afterRestoreSpy.mockClear()
48
+ })
49
+
50
+ it('should create a record', async () => {
51
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
52
+ const createdRecord = await service.create(input)
53
+
54
+ expect(createdRecord).toEqual(input)
55
+ })
56
+
57
+ it('should update a record', async () => {
58
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
59
+ await repository.create(input)
60
+
61
+ const updatedInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
62
+ const updatedRecord = await service.update({ id: 42 }, updatedInput)
63
+
64
+ expect(updatedRecord).toEqual({ id: 42, ...updatedInput })
65
+ })
66
+
67
+ it('should remove a record', async () => {
68
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
69
+ await repository.create(input)
70
+
71
+ const removedRecord = await service.remove({ id: 42 })
72
+
73
+ expect(removedRecord).toEqual(input)
74
+ expect(await repository.load({ id: 42 })).toBeNull()
75
+ })
76
+
77
+ it('should restore a record', async () => {
78
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
79
+ await repository.create(input)
80
+ await repository.remove({ id: 42 })
81
+
82
+ const restoredRecord = await service.restore({ id: 42 })
83
+
84
+ expect(restoredRecord).toEqual(input)
85
+ expect(await repository.load({ id: 42 })).toEqual(input)
86
+ })
87
+
88
+ it('should trigger before and after events for create', async () => {
89
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
90
+ const createdRecord = await service.create(input)
91
+
92
+ expect(createdRecord).toEqual(input)
93
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
94
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
95
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
96
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
97
+ })
98
+
99
+ it('should trigger before and after events for update', async () => {
100
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
101
+ await repository.create(input)
102
+
103
+ const updatedInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
104
+ const updatedRecord = await service.update({ id: 42 }, updatedInput)
105
+
106
+ expect(updatedRecord).toEqual({ id: 42, ...updatedInput })
107
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
108
+ expect(beforeUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeUpdate' }))
109
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
110
+ expect(afterUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterUpdate' }))
111
+ })
112
+
113
+ it('should trigger before and after events for remove', async () => {
114
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
115
+ await repository.create(input)
116
+
117
+ const removedRecord = await service.remove({ id: 42 })
118
+
119
+ expect(removedRecord).toEqual(input)
120
+ expect(beforeRemoveSpy).toHaveBeenCalledTimes(1)
121
+ expect(beforeRemoveSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeRemove' }))
122
+ expect(afterRemoveSpy).toHaveBeenCalledTimes(1)
123
+ expect(afterRemoveSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterRemove' }))
124
+ })
125
+
126
+ it('should trigger before and after events for restore', async () => {
127
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
128
+ await repository.create(input)
129
+ await repository.remove({ id: 42 })
130
+
131
+ const restoredRecord = await service.restore({ id: 42 })
132
+
133
+ expect(restoredRecord).toEqual(input)
134
+ expect(beforeRestoreSpy).toHaveBeenCalledTimes(1)
135
+ expect(beforeRestoreSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeRestore' }))
136
+ expect(afterRestoreSpy).toHaveBeenCalledTimes(1)
137
+ expect(afterRestoreSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterRestore' }))
138
+ })
139
+
140
+ it('should throw an error when attempting to remove a non-existent record', async () => {
141
+ await expect(service.remove({ id: 999 })).rejects.toThrow('Item not found')
142
+ })
143
+
144
+ it('should throw an error when attempting to update a non-existent record', async () => {
145
+ const updatedInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
146
+ await expect(service.update({ id: 999 }, updatedInput)).rejects.toThrow('Item not found')
147
+ })
148
+
149
+ describe('upsert functionality', () => {
150
+ it('should create a new record when no existing record with primary key exists', async () => {
151
+ const input = { id: 42, title: 'New Book', author: 'Author Name', publishedDate: new Date() }
152
+
153
+ const upsertedRecord = await service.upsert(input)
154
+
155
+ expect(upsertedRecord).toEqual(input)
156
+ expect(await repository.load({ id: 42 })).toEqual(input)
157
+ })
158
+
159
+ it('should update an existing record when primary key matches', async () => {
160
+ // Create initial record
161
+ const initial = {
162
+ id: 42,
163
+ title: 'Original Book',
164
+ author: 'Original Author',
165
+ publishedDate: new Date('2023-01-01'),
166
+ }
167
+ await repository.create(initial)
168
+
169
+ // Upsert with same ID but different data
170
+ const update = {
171
+ id: 42,
172
+ title: 'Updated Book',
173
+ author: 'Updated Author',
174
+ publishedDate: new Date('2023-12-01'),
175
+ }
176
+ const upsertedRecord = await service.upsert(update)
177
+
178
+ expect(upsertedRecord).toEqual(update)
179
+ expect(await repository.load({ id: 42 })).toEqual(update)
180
+ })
181
+
182
+ it('should create a new record when upserting without primary key', async () => {
183
+ const input = { title: 'Book Without ID', author: 'Author Name', publishedDate: new Date() }
184
+
185
+ const upsertedRecord = await service.upsert(input)
186
+
187
+ expect(upsertedRecord.id).toBeDefined()
188
+ expect(upsertedRecord.title).toBe(input.title)
189
+ expect(upsertedRecord.author).toBe(input.author)
190
+ expect(await repository.load({ id: upsertedRecord.id })).toEqual(upsertedRecord)
191
+ })
192
+
193
+ it('should create a new record when primary key is null', async () => {
194
+ const input = {
195
+ id: undefined,
196
+ title: 'Book With Undefined ID',
197
+ author: 'Author Name',
198
+ publishedDate: new Date(),
199
+ }
200
+
201
+ const upsertedRecord = await service.upsert(input)
202
+
203
+ expect(upsertedRecord.id).toBeDefined()
204
+ expect(upsertedRecord.id).not.toBeNull()
205
+ expect(upsertedRecord.title).toBe(input.title)
206
+ expect(upsertedRecord.author).toBe(input.author)
207
+ })
208
+
209
+ it('should trigger create events when upserting a new record', async () => {
210
+ const input = { id: 42, title: 'New Book', author: 'Author Name', publishedDate: new Date() }
211
+
212
+ const upsertedRecord = await service.upsert(input)
213
+
214
+ expect(upsertedRecord).toEqual(input)
215
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
216
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
217
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
218
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
219
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
220
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
221
+ })
222
+
223
+ it('should trigger update events when upserting an existing record', async () => {
224
+ // Create initial record
225
+ const initial = { id: 42, title: 'Original Book', author: 'Original Author', publishedDate: new Date() }
226
+ await repository.create(initial)
227
+
228
+ // Clear create spies since they were called during setup
229
+ beforeCreateSpy.mockClear()
230
+ afterCreateSpy.mockClear()
231
+
232
+ // Upsert existing record
233
+ const update = { id: 42, title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
234
+ const upsertedRecord = await service.upsert(update)
235
+
236
+ expect(upsertedRecord).toEqual(update)
237
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
238
+ expect(beforeUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeUpdate' }))
239
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
240
+ expect(afterUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterUpdate' }))
241
+ expect(beforeCreateSpy).not.toHaveBeenCalled()
242
+ expect(afterCreateSpy).not.toHaveBeenCalled()
243
+ })
244
+
245
+ it('should trigger create events when upserting with non-existent primary key', async () => {
246
+ // Try to upsert with an ID that doesn't exist
247
+ const input = { id: 999, title: 'Non-existent Book', author: 'Author Name', publishedDate: new Date() }
248
+
249
+ const upsertedRecord = await service.upsert(input)
250
+
251
+ expect(upsertedRecord).toEqual(input)
252
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
253
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
254
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
255
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
256
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
257
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
258
+ })
259
+
260
+ it('should handle multiple upserts correctly', async () => {
261
+ // First upsert (create)
262
+ const input1 = { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() }
263
+ const result1 = await service.upsert(input1)
264
+ expect(result1).toEqual(input1)
265
+ expect(await repository.load({ id: 1 })).toEqual(input1)
266
+
267
+ // Second upsert (update)
268
+ const input2 = { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date() }
269
+ const result2 = await service.upsert(input2)
270
+ expect(result2).toEqual(input2)
271
+ expect(await repository.load({ id: 1 })).toEqual(input2)
272
+
273
+ // Third upsert (create new)
274
+ const input3 = { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() }
275
+ const result3 = await service.upsert(input3)
276
+ expect(result3).toEqual(input3)
277
+ expect(await repository.load({ id: 2 })).toEqual(input3)
278
+ })
279
+
280
+ it('should work with auto-generated IDs', async () => {
281
+ const input1 = { title: 'Auto ID Book 1', author: 'Author 1', publishedDate: new Date() }
282
+ const input2 = { title: 'Auto ID Book 2', author: 'Author 2', publishedDate: new Date() }
283
+
284
+ const result1 = await service.upsert(input1)
285
+ const result2 = await service.upsert(input2)
286
+
287
+ expect(result1.id).toBeDefined()
288
+ expect(result2.id).toBeDefined()
289
+ expect(result1.id).not.toBe(result2.id)
290
+ expect(result1.title).toBe(input1.title)
291
+ expect(result2.title).toBe(input2.title)
292
+ })
293
+ })
294
+
295
+ describe('bulkUpsert functionality', () => {
296
+ it('should handle empty input array', async () => {
297
+ const results = await service.bulkUpsert([])
298
+ expect(results).toEqual([])
299
+ })
300
+
301
+ it('should create multiple new records with explicit IDs', async () => {
302
+ const inputs = [
303
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date('2023-01-01') },
304
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date('2023-02-01') },
305
+ { id: 3, title: 'Book 3', author: 'Author 3', publishedDate: new Date('2023-03-01') },
306
+ ]
307
+
308
+ const results = await service.bulkUpsert(inputs)
309
+
310
+ expect(results).toEqual(inputs)
311
+ expect(results).toHaveLength(3)
312
+
313
+ // Verify all records were created
314
+ for (const input of inputs) {
315
+ const loaded = await repository.load({ id: input.id })
316
+ expect(loaded).toEqual(input)
317
+ }
318
+ })
319
+
320
+ it('should update multiple existing records', async () => {
321
+ // Create initial records
322
+ const initialRecords = [
323
+ { id: 1, title: 'Original Book 1', author: 'Original Author 1', publishedDate: new Date('2023-01-01') },
324
+ { id: 2, title: 'Original Book 2', author: 'Original Author 2', publishedDate: new Date('2023-02-01') },
325
+ { id: 3, title: 'Original Book 3', author: 'Original Author 3', publishedDate: new Date('2023-03-01') },
326
+ ]
327
+
328
+ for (const record of initialRecords) {
329
+ await repository.create(record)
330
+ }
331
+
332
+ // Clear spies from creation
333
+ beforeCreateSpy.mockClear()
334
+ afterCreateSpy.mockClear()
335
+
336
+ // Update all records
337
+ const updateInputs = [
338
+ { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date('2023-06-01') },
339
+ { id: 2, title: 'Updated Book 2', author: 'Updated Author 2', publishedDate: new Date('2023-07-01') },
340
+ { id: 3, title: 'Updated Book 3', author: 'Updated Author 3', publishedDate: new Date('2023-08-01') },
341
+ ]
342
+
343
+ const results = await service.bulkUpsert(updateInputs)
344
+
345
+ expect(results).toEqual(updateInputs)
346
+ expect(results).toHaveLength(3)
347
+
348
+ // Verify all records were updated
349
+ for (const input of updateInputs) {
350
+ const loaded = await repository.load({ id: input.id })
351
+ expect(loaded).toEqual(input)
352
+ }
353
+ })
354
+
355
+ it('should handle mixed create and update operations', async () => {
356
+ // Create some initial records
357
+ const initialRecords = [
358
+ { id: 1, title: 'Existing Book 1', author: 'Existing Author 1', publishedDate: new Date('2023-01-01') },
359
+ { id: 3, title: 'Existing Book 3', author: 'Existing Author 3', publishedDate: new Date('2023-03-01') },
360
+ ]
361
+
362
+ for (const record of initialRecords) {
363
+ await repository.create(record)
364
+ }
365
+
366
+ // Clear spies from creation
367
+ beforeCreateSpy.mockClear()
368
+ afterCreateSpy.mockClear()
369
+
370
+ // Mix of updates and creates
371
+ const mixedInputs = [
372
+ { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date('2023-06-01') }, // Update
373
+ { id: 2, title: 'New Book 2', author: 'New Author 2', publishedDate: new Date('2023-07-01') }, // Create
374
+ { id: 3, title: 'Updated Book 3', author: 'Updated Author 3', publishedDate: new Date('2023-08-01') }, // Update
375
+ { id: 4, title: 'New Book 4', author: 'New Author 4', publishedDate: new Date('2023-09-01') }, // Create
376
+ ]
377
+
378
+ const results = await service.bulkUpsert(mixedInputs)
379
+
380
+ expect(results).toEqual(mixedInputs)
381
+ expect(results).toHaveLength(4)
382
+
383
+ // Verify all records exist with correct data
384
+ for (const input of mixedInputs) {
385
+ const loaded = await repository.load({ id: input.id })
386
+ expect(loaded).toEqual(input)
387
+ }
388
+ })
389
+
390
+ it('should handle records without primary keys (auto-generated IDs)', async () => {
391
+ const inputsWithoutIds = [
392
+ { title: 'Auto Book 1', author: 'Auto Author 1', publishedDate: new Date('2023-01-01') },
393
+ { title: 'Auto Book 2', author: 'Auto Author 2', publishedDate: new Date('2023-02-01') },
394
+ ]
395
+
396
+ const results = await service.bulkUpsert(inputsWithoutIds)
397
+
398
+ expect(results).toHaveLength(2)
399
+ expect(results[0].id).toBeDefined()
400
+ expect(results[1].id).toBeDefined()
401
+ expect(results[0].id).not.toBe(results[1].id)
402
+ expect(results[0].title).toBe(inputsWithoutIds[0].title)
403
+ expect(results[1].title).toBe(inputsWithoutIds[1].title)
404
+
405
+ // Verify records were created
406
+ for (const result of results) {
407
+ const loaded = await repository.load({ id: result.id })
408
+ expect(loaded).toEqual(result)
409
+ }
410
+ })
411
+
412
+ it('should handle mixed records with and without primary keys', async () => {
413
+ // Create an existing record with a higher ID to avoid conflicts with auto-generated IDs
414
+ const existingRecord = {
415
+ id: 100,
416
+ title: 'Existing Book',
417
+ author: 'Existing Author',
418
+ publishedDate: new Date('2023-01-01'),
419
+ }
420
+ await repository.create(existingRecord)
421
+
422
+ // Clear spies from creation
423
+ beforeCreateSpy.mockClear()
424
+ afterCreateSpy.mockClear()
425
+
426
+ const mixedInputs = [
427
+ {
428
+ id: 100,
429
+ title: 'Updated Existing Book',
430
+ author: 'Updated Author',
431
+ publishedDate: new Date('2023-06-01'),
432
+ }, // Update
433
+ { id: 200, title: 'New Book with ID', author: 'New Author', publishedDate: new Date('2023-07-01') }, // Create with ID
434
+ { title: 'Auto Book 1', author: 'Auto Author 1', publishedDate: new Date('2023-08-01') }, // Create without ID
435
+ { title: 'Auto Book 2', author: 'Auto Author 2', publishedDate: new Date('2023-09-01') }, // Create without ID
436
+ ]
437
+
438
+ const results = await service.bulkUpsert(mixedInputs)
439
+
440
+ expect(results).toHaveLength(4)
441
+
442
+ // Results should be in the same order as inputs (repository preserves order)
443
+ expect(results[0].id).toBe(100)
444
+ expect(results[0].title).toBe('Updated Existing Book')
445
+
446
+ expect(results[1].id).toBe(200)
447
+ expect(results[1].title).toBe('New Book with ID')
448
+
449
+ expect(results[2].id).toBeDefined()
450
+ expect(results[2].title).toBe('Auto Book 1')
451
+
452
+ expect(results[3].id).toBeDefined()
453
+ expect(results[3].title).toBe('Auto Book 2')
454
+
455
+ // Verify all records exist in repository
456
+ const loadedRecord1 = await repository.load({ id: 100 })
457
+ expect(loadedRecord1?.title).toBe('Updated Existing Book')
458
+
459
+ const loadedRecord2 = await repository.load({ id: 200 })
460
+ expect(loadedRecord2?.title).toBe('New Book with ID')
461
+
462
+ const loadedRecord3 = await repository.load({ id: results[2].id })
463
+ expect(loadedRecord3?.title).toBe('Auto Book 1')
464
+
465
+ const loadedRecord4 = await repository.load({ id: results[3].id })
466
+ expect(loadedRecord4?.title).toBe('Auto Book 2')
467
+ })
468
+
469
+ it('should trigger correct before and after events for bulk create operations', async () => {
470
+ const inputs = [
471
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() },
472
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() },
473
+ ]
474
+
475
+ await service.bulkUpsert(inputs)
476
+
477
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(2)
478
+ expect(afterCreateSpy).toHaveBeenCalledTimes(2)
479
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
480
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
481
+
482
+ // Verify event details
483
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
484
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
485
+ })
486
+
487
+ it('should trigger correct before and after events for bulk update operations', async () => {
488
+ // Create initial records
489
+ const initialRecords = [
490
+ { id: 1, title: 'Original Book 1', author: 'Original Author 1', publishedDate: new Date() },
491
+ { id: 2, title: 'Original Book 2', author: 'Original Author 2', publishedDate: new Date() },
492
+ ]
493
+
494
+ for (const record of initialRecords) {
495
+ await repository.create(record)
496
+ }
497
+
498
+ // Clear spies from creation
499
+ beforeCreateSpy.mockClear()
500
+ afterCreateSpy.mockClear()
501
+
502
+ // Update records
503
+ const updateInputs = [
504
+ { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date() },
505
+ { id: 2, title: 'Updated Book 2', author: 'Updated Author 2', publishedDate: new Date() },
506
+ ]
507
+
508
+ await service.bulkUpsert(updateInputs)
509
+
510
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(2)
511
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(2)
512
+ expect(beforeCreateSpy).not.toHaveBeenCalled()
513
+ expect(afterCreateSpy).not.toHaveBeenCalled()
514
+
515
+ // Verify event details
516
+ expect(beforeUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeUpdate' }))
517
+ expect(afterUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterUpdate' }))
518
+ })
519
+
520
+ it('should trigger correct before and after events for mixed operations', async () => {
521
+ // Create one existing record
522
+ const existingRecord = {
523
+ id: 1,
524
+ title: 'Existing Book',
525
+ author: 'Existing Author',
526
+ publishedDate: new Date(),
527
+ }
528
+ await repository.create(existingRecord)
529
+
530
+ // Clear spies from creation
531
+ beforeCreateSpy.mockClear()
532
+ afterCreateSpy.mockClear()
533
+
534
+ // Mix of update and create
535
+ const mixedInputs = [
536
+ { id: 1, title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }, // Update
537
+ { id: 2, title: 'New Book', author: 'New Author', publishedDate: new Date() }, // Create
538
+ ]
539
+
540
+ await service.bulkUpsert(mixedInputs)
541
+
542
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
543
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
544
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
545
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
546
+ })
547
+
548
+ it('should trigger events for records without primary keys', async () => {
549
+ const inputsWithoutIds = [
550
+ { title: 'Auto Book 1', author: 'Auto Author 1', publishedDate: new Date() },
551
+ { title: 'Auto Book 2', author: 'Auto Author 2', publishedDate: new Date() },
552
+ ]
553
+
554
+ await service.bulkUpsert(inputsWithoutIds)
555
+
556
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(2)
557
+ expect(afterCreateSpy).toHaveBeenCalledTimes(2)
558
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
559
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
560
+ })
561
+
562
+ it('should handle large batches efficiently', async () => {
563
+ const batchSize = 100
564
+ const inputs = Array.from({ length: batchSize }, (_, index) => ({
565
+ id: index + 1,
566
+ title: `Book ${index + 1}`,
567
+ author: `Author ${index + 1}`,
568
+ publishedDate: new Date(`2023-${String((index % 12) + 1).padStart(2, '0')}-01`),
569
+ }))
570
+
571
+ const startTime = Date.now()
572
+ const results = await service.bulkUpsert(inputs)
573
+ const endTime = Date.now()
574
+
575
+ expect(results).toHaveLength(batchSize)
576
+ expect(results).toEqual(inputs)
577
+
578
+ // Verify events were triggered for all items
579
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(batchSize)
580
+ expect(afterCreateSpy).toHaveBeenCalledTimes(batchSize)
581
+
582
+ // Basic performance check - should complete in reasonable time
583
+ expect(endTime - startTime).toBeLessThan(5000) // Less than 5 seconds
584
+
585
+ // Verify a few random records
586
+ const randomIndexes = [0, Math.floor(batchSize / 2), batchSize - 1]
587
+ for (const index of randomIndexes) {
588
+ const loaded = await repository.load({ id: inputs[index].id })
589
+ expect(loaded).toEqual(inputs[index])
590
+ }
591
+ })
592
+
593
+ it('should maintain data integrity when bulk upserting duplicate primary keys in input', async () => {
594
+ // Input with duplicate IDs - last one should win
595
+ const inputsWithDuplicates = [
596
+ { id: 1, title: 'First Book 1', author: 'First Author 1', publishedDate: new Date('2023-01-01') },
597
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date('2023-02-01') },
598
+ { id: 1, title: 'Last Book 1', author: 'Last Author 1', publishedDate: new Date('2023-03-01') }, // Duplicate ID
599
+ ]
600
+
601
+ const results = await service.bulkUpsert(inputsWithDuplicates)
602
+
603
+ expect(results).toHaveLength(3)
604
+
605
+ // The repository should handle duplicates according to its implementation
606
+ // In our mock implementation, it processes them sequentially
607
+ const finalRecord1 = await repository.load({ id: 1 })
608
+ expect(finalRecord1?.title).toBe('Last Book 1') // Last write wins
609
+
610
+ const record2 = await repository.load({ id: 2 })
611
+ expect(record2?.title).toBe('Book 2')
612
+ })
613
+
614
+ it('should preserve order of results matching input order', async () => {
615
+ const inputs = [
616
+ { id: 3, title: 'Book 3', author: 'Author 3', publishedDate: new Date('2023-03-01') },
617
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date('2023-01-01') },
618
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date('2023-02-01') },
619
+ ]
620
+
621
+ const results = await service.bulkUpsert(inputs)
622
+
623
+ expect(results).toHaveLength(3)
624
+ expect(results[0].id).toBe(3)
625
+ expect(results[1].id).toBe(1)
626
+ expect(results[2].id).toBe(2)
627
+ expect(results).toEqual(inputs)
628
+ })
629
+ })
630
+
631
+ describe('Trash Functionality', () => {
632
+ const beforeEmptyTrashSpy = mock((event) => {})
633
+ const afterEmptyTrashSpy = mock((event) => {})
634
+ const beforePermanentlyDeleteFromTrashSpy = mock((event) => {})
635
+ const afterPermanentlyDeleteFromTrashSpy = mock((event) => {})
636
+ const beforePermanentlyDeleteSpy = mock((event) => {})
637
+ const afterPermanentlyDeleteSpy = mock((event) => {})
638
+
639
+ beforeEach(() => {
640
+ emitter.on('books::book.beforeEmptyTrash', beforeEmptyTrashSpy)
641
+ emitter.on('books::book.afterEmptyTrash', afterEmptyTrashSpy)
642
+ emitter.on('books::book.beforePermanentlyDeleteFromTrash', beforePermanentlyDeleteFromTrashSpy)
643
+ emitter.on('books::book.afterPermanentlyDeleteFromTrash', afterPermanentlyDeleteFromTrashSpy)
644
+ emitter.on('books::book.beforePermanentlyDelete', beforePermanentlyDeleteSpy)
645
+ emitter.on('books::book.afterPermanentlyDelete', afterPermanentlyDeleteSpy)
646
+
647
+ beforeEmptyTrashSpy.mockClear()
648
+ afterEmptyTrashSpy.mockClear()
649
+ beforePermanentlyDeleteFromTrashSpy.mockClear()
650
+ afterPermanentlyDeleteFromTrashSpy.mockClear()
651
+ beforePermanentlyDeleteSpy.mockClear()
652
+ afterPermanentlyDeleteSpy.mockClear()
653
+ })
654
+
655
+ describe('emptyTrash', () => {
656
+ it('should permanently delete all items from trash', async () => {
657
+ // Create and remove multiple items
658
+ const book1 = await repository.create({
659
+ title: 'Book 1',
660
+ author: 'Author 1',
661
+ publishedDate: new Date(),
662
+ })
663
+ const book2 = await repository.create({
664
+ title: 'Book 2',
665
+ author: 'Author 2',
666
+ publishedDate: new Date(),
667
+ })
668
+ const book3 = await repository.create({
669
+ title: 'Book 3',
670
+ author: 'Author 3',
671
+ publishedDate: new Date(),
672
+ })
673
+
674
+ await repository.remove({ id: book1.id })
675
+ await repository.remove({ id: book2.id })
676
+ await repository.remove({ id: book3.id })
677
+
678
+ // Empty trash
679
+ const count = await service.emptyTrash()
680
+
681
+ expect(count).toBe(3)
682
+
683
+ // Verify items are no longer in trash
684
+ const inTrash1 = await repository.load({ id: book1.id }, { removedOnly: true })
685
+ const inTrash2 = await repository.load({ id: book2.id }, { removedOnly: true })
686
+ const inTrash3 = await repository.load({ id: book3.id }, { removedOnly: true })
687
+
688
+ expect(inTrash1).toBeNull()
689
+ expect(inTrash2).toBeNull()
690
+ expect(inTrash3).toBeNull()
691
+ })
692
+
693
+ it('should permanently delete filtered items from trash', async () => {
694
+ const repositoryWithFilter = new MockMemoryRepository({
695
+ schema: mockSchema,
696
+ filter: (data, filters) => {
697
+ if (filters.text) {
698
+ return data.title.toLowerCase().includes(filters.text.toLowerCase())
699
+ }
700
+ return true
701
+ },
702
+ })
703
+
704
+ const serviceWithFilter = new ModelService({
705
+ repository: repositoryWithFilter,
706
+ emitter,
707
+ schema: mockSchema,
708
+ namespace,
709
+ })
710
+
711
+ const book1 = await repositoryWithFilter.create({
712
+ title: 'Test Book 1',
713
+ author: 'Author 1',
714
+ publishedDate: new Date(),
715
+ })
716
+ const book2 = await repositoryWithFilter.create({
717
+ title: 'Test Book 2',
718
+ author: 'Author 2',
719
+ publishedDate: new Date(),
720
+ })
721
+ const book3 = await repositoryWithFilter.create({
722
+ title: 'Other Book',
723
+ author: 'Author 3',
724
+ publishedDate: new Date(),
725
+ })
726
+
727
+ await repositoryWithFilter.remove({ id: book1.id })
728
+ await repositoryWithFilter.remove({ id: book2.id })
729
+ await repositoryWithFilter.remove({ id: book3.id })
730
+
731
+ // Empty trash with filter
732
+ const count = await serviceWithFilter.emptyTrash({ text: 'Test' })
733
+
734
+ expect(count).toBe(2)
735
+
736
+ // Verify only filtered items were deleted
737
+ const inTrash1 = await repositoryWithFilter.load({ id: book1.id }, { removedOnly: true })
738
+ const inTrash2 = await repositoryWithFilter.load({ id: book2.id }, { removedOnly: true })
739
+ const inTrash3 = await repositoryWithFilter.load({ id: book3.id }, { removedOnly: true })
740
+
741
+ expect(inTrash1).toBeNull()
742
+ expect(inTrash2).toBeNull()
743
+ expect(inTrash3).not.toBeNull()
744
+ expect(inTrash3?.title).toBe('Other Book')
745
+ })
746
+
747
+ it('should return 0 when trash is empty', async () => {
748
+ const count = await service.emptyTrash()
749
+ expect(count).toBe(0)
750
+ })
751
+
752
+ it('should trigger before and after events for emptyTrash', async () => {
753
+ const book = await repository.create({
754
+ title: 'Book to Remove',
755
+ author: 'Author',
756
+ publishedDate: new Date(),
757
+ })
758
+ await repository.remove({ id: book.id })
759
+
760
+ await service.emptyTrash()
761
+
762
+ expect(beforeEmptyTrashSpy).toHaveBeenCalledTimes(1)
763
+ expect(beforeEmptyTrashSpy).toHaveBeenCalledWith(
764
+ expect.objectContaining({ type: 'books::book.beforeEmptyTrash' }),
765
+ )
766
+ expect(afterEmptyTrashSpy).toHaveBeenCalledTimes(1)
767
+ expect(afterEmptyTrashSpy).toHaveBeenCalledWith(
768
+ expect.objectContaining({ type: 'books::book.afterEmptyTrash' }),
769
+ )
770
+ })
771
+
772
+ it('should not affect non-removed items', async () => {
773
+ await repository.create({ title: 'Active Book 1', author: 'Author 1', publishedDate: new Date() })
774
+ await repository.create({ title: 'Active Book 2', author: 'Author 2', publishedDate: new Date() })
775
+
776
+ const book3 = await repository.create({
777
+ title: 'Book to Remove',
778
+ author: 'Author 3',
779
+ publishedDate: new Date(),
780
+ })
781
+ await repository.remove({ id: book3.id })
782
+
783
+ // Empty trash
784
+ await service.emptyTrash()
785
+
786
+ // Verify active items are still there
787
+ const results = await repository.search({})
788
+ expect(results.results).toHaveLength(2)
789
+ })
790
+ })
791
+
792
+ describe('permanentlyDeleteFromTrash', () => {
793
+ it('should permanently delete a removed item from trash', async () => {
794
+ const book = await repository.create({
795
+ title: 'Book to Delete',
796
+ author: 'Author',
797
+ publishedDate: new Date(),
798
+ })
799
+
800
+ // Remove the book
801
+ await repository.remove({ id: book.id })
802
+
803
+ // Verify it's in trash
804
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
805
+ expect(inTrash).not.toBeNull()
806
+
807
+ // Permanently delete from trash
808
+ const deleted = await service.permanentlyDeleteFromTrash({ id: book.id })
809
+
810
+ expect(deleted.id).toBe(book.id)
811
+ expect(deleted.title).toBe('Book to Delete')
812
+
813
+ // Verify it's no longer in trash
814
+ const afterDelete = await repository.load({ id: book.id }, { removedOnly: true })
815
+ expect(afterDelete).toBeNull()
816
+
817
+ // Verify it's not in main data either
818
+ const inMain = await repository.load({ id: book.id })
819
+ expect(inMain).toBeNull()
820
+ })
821
+
822
+ it('should throw error when trying to delete non-existent item from trash', async () => {
823
+ await expect(service.permanentlyDeleteFromTrash({ id: 999 })).rejects.toThrow()
824
+ })
825
+
826
+ it('should throw error when trying to delete active item from trash', async () => {
827
+ const book = await repository.create({
828
+ title: 'Active Book',
829
+ author: 'Author',
830
+ publishedDate: new Date(),
831
+ })
832
+
833
+ // Should fail because item is not in trash
834
+ await expect(service.permanentlyDeleteFromTrash({ id: book.id })).rejects.toThrow()
835
+ })
836
+
837
+ it('should trigger before and after events for permanentlyDeleteFromTrash', async () => {
838
+ const book = await repository.create({
839
+ title: 'Book to Delete',
840
+ author: 'Author',
841
+ publishedDate: new Date(),
842
+ })
843
+ await repository.remove({ id: book.id })
844
+
845
+ await service.permanentlyDeleteFromTrash({ id: book.id })
846
+
847
+ expect(beforePermanentlyDeleteFromTrashSpy).toHaveBeenCalledTimes(1)
848
+ expect(beforePermanentlyDeleteFromTrashSpy).toHaveBeenCalledWith(
849
+ expect.objectContaining({ type: 'books::book.beforePermanentlyDeleteFromTrash' }),
850
+ )
851
+ expect(afterPermanentlyDeleteFromTrashSpy).toHaveBeenCalledTimes(1)
852
+ expect(afterPermanentlyDeleteFromTrashSpy).toHaveBeenCalledWith(
853
+ expect.objectContaining({ type: 'books::book.afterPermanentlyDeleteFromTrash' }),
854
+ )
855
+ })
856
+ })
857
+
858
+ describe('permanentlyDelete', () => {
859
+ it('should permanently delete a removed item', async () => {
860
+ const book = await repository.create({
861
+ title: 'Book to Delete',
862
+ author: 'Author',
863
+ publishedDate: new Date(),
864
+ })
865
+
866
+ // Remove the book first
867
+ await repository.remove({ id: book.id })
868
+
869
+ // Verify it's in trash
870
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
871
+ expect(inTrash).not.toBeNull()
872
+
873
+ // Permanently delete
874
+ const deleted = await service.permanentlyDelete({ id: book.id })
875
+
876
+ expect(deleted.id).toBe(book.id)
877
+ expect(deleted.title).toBe('Book to Delete')
878
+
879
+ // Verify it's no longer anywhere
880
+ const afterDelete = await repository.load({ id: book.id }, { removedOnly: true })
881
+ expect(afterDelete).toBeNull()
882
+
883
+ const inMain = await repository.load({ id: book.id })
884
+ expect(inMain).toBeNull()
885
+ })
886
+
887
+ it('should permanently delete an active item', async () => {
888
+ const book = await repository.create({
889
+ title: 'Active Book to Delete',
890
+ author: 'Author',
891
+ publishedDate: new Date(),
892
+ })
893
+
894
+ // Verify it exists
895
+ const exists = await repository.load({ id: book.id })
896
+ expect(exists).not.toBeNull()
897
+
898
+ // Permanently delete without removing first
899
+ const deleted = await service.permanentlyDelete({ id: book.id })
900
+
901
+ expect(deleted.id).toBe(book.id)
902
+ expect(deleted.title).toBe('Active Book to Delete')
903
+
904
+ // Verify it's no longer in main data
905
+ const afterDelete = await repository.load({ id: book.id })
906
+ expect(afterDelete).toBeNull()
907
+
908
+ // Verify it's not in trash either
909
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
910
+ expect(inTrash).toBeNull()
911
+ })
912
+
913
+ it('should throw error when trying to delete non-existent item', async () => {
914
+ await expect(service.permanentlyDelete({ id: 999 })).rejects.toThrow()
915
+ })
916
+
917
+ it('should trigger before and after events for permanentlyDelete', async () => {
918
+ const book = await repository.create({
919
+ title: 'Book to Delete',
920
+ author: 'Author',
921
+ publishedDate: new Date(),
922
+ })
923
+
924
+ await service.permanentlyDelete({ id: book.id })
925
+
926
+ expect(beforePermanentlyDeleteSpy).toHaveBeenCalledTimes(1)
927
+ expect(beforePermanentlyDeleteSpy).toHaveBeenCalledWith(
928
+ expect.objectContaining({ type: 'books::book.beforePermanentlyDelete' }),
929
+ )
930
+ expect(afterPermanentlyDeleteSpy).toHaveBeenCalledTimes(1)
931
+ expect(afterPermanentlyDeleteSpy).toHaveBeenCalledWith(
932
+ expect.objectContaining({ type: 'books::book.afterPermanentlyDelete' }),
933
+ )
934
+ })
935
+ })
936
+ })
937
+
938
+ describe('detailsToInput', () => {
939
+ it('should convert a detail object to an input by picking only input fields', async () => {
940
+ const detail = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
941
+ const input = await service.detailsToInput(detail)
942
+
943
+ expect(input.title).toBe('Test Book')
944
+ expect(input.author).toBe('Author Name')
945
+ expect(input.publishedDate).toEqual(new Date('2023-06-15'))
946
+ })
947
+
948
+ it('should exclude fields not present in the input model', async () => {
949
+ const detailWithExtras = {
950
+ id: 42,
951
+ title: 'Test Book',
952
+ author: 'Author Name',
953
+ publishedDate: new Date('2023-06-15'),
954
+ extraField: 'should be excluded',
955
+ anotherExtra: 123,
956
+ }
957
+ const input = await service.detailsToInput(detailWithExtras)
958
+
959
+ expect(input.title).toBe('Test Book')
960
+ expect(input.author).toBe('Author Name')
961
+ expect((input as any).extraField).toBeUndefined()
962
+ expect((input as any).anotherExtra).toBeUndefined()
963
+ })
964
+
965
+ it('should coerce values through the input schema', async () => {
966
+ // publishedDate uses z.coerce.date(), so a string should be coerced to a Date
967
+ const detailWithStringDate = {
968
+ id: 42,
969
+ title: 'Test Book',
970
+ author: 'Author Name',
971
+ publishedDate: '2023-06-15T00:00:00.000Z',
972
+ }
973
+ const input = await service.detailsToInput(detailWithStringDate)
974
+
975
+ expect(input.publishedDate).toBeInstanceOf(Date)
976
+ expect(input.publishedDate).toEqual(new Date('2023-06-15T00:00:00.000Z'))
977
+ })
978
+
979
+ it('should include the primary key field when present in the source', async () => {
980
+ const detail = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
981
+ const input = await service.detailsToInput(detail)
982
+
983
+ // id is optional in the input model but should be included if present
984
+ expect(input.id).toBe(42)
985
+ })
986
+
987
+ it('should work with partial source data that satisfies required input fields', async () => {
988
+ const minimalDetail = {
989
+ title: 'Minimal Book',
990
+ author: 'Some Author',
991
+ publishedDate: new Date(),
992
+ }
993
+ const input = await service.detailsToInput(minimalDetail)
994
+
995
+ expect(input.title).toBe('Minimal Book')
996
+ expect(input.author).toBe('Some Author')
997
+ expect(input.id).toBeUndefined()
998
+ })
999
+
1000
+ it('should produce a valid input that can be used with create', async () => {
1001
+ const detail = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1002
+ const input = await service.detailsToInput(detail)
1003
+
1004
+ // Remove the primary key and create a new record
1005
+ delete (input as any).id
1006
+ const created = await service.create(input)
1007
+
1008
+ expect(created.title).toBe('Test Book')
1009
+ expect(created.author).toBe('Author Name')
1010
+ expect(created.id).toBeDefined()
1011
+ expect(created.id).not.toBe(42)
1012
+ })
1013
+ })
1014
+
1015
+ describe('duplicate', () => {
1016
+ it('should create a duplicate of an existing record with a new primary key', async () => {
1017
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1018
+ await repository.create(original)
1019
+
1020
+ const duplicate = await service.duplicate({ id: 42 })
1021
+
1022
+ expect(duplicate.title).toBe('Original Book')
1023
+ expect(duplicate.author).toBe('Author Name')
1024
+ expect(duplicate.publishedDate).toEqual(new Date('2023-06-15'))
1025
+ expect(duplicate.id).toBeDefined()
1026
+ expect(duplicate.id).not.toBe(42)
1027
+ })
1028
+
1029
+ it('should apply overrides to the duplicated record', async () => {
1030
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1031
+ await repository.create(original)
1032
+
1033
+ const duplicate = await service.duplicate({ id: 42 }, { title: 'Duplicated Book' })
1034
+
1035
+ expect(duplicate.title).toBe('Duplicated Book')
1036
+ expect(duplicate.author).toBe('Author Name')
1037
+ expect(duplicate.id).toBeDefined()
1038
+ expect(duplicate.id).not.toBe(42)
1039
+ })
1040
+
1041
+ it('should throw an error when trying to duplicate a non-existent record', async () => {
1042
+ await expect(service.duplicate({ id: 999 })).rejects.toThrow()
1043
+ })
1044
+
1045
+ it('should trigger create events for the duplicate', async () => {
1046
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date() }
1047
+ await repository.create(original)
1048
+
1049
+ beforeCreateSpy.mockClear()
1050
+ afterCreateSpy.mockClear()
1051
+
1052
+ await service.duplicate({ id: 42 })
1053
+
1054
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
1055
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
1056
+ })
1057
+
1058
+ it('should not modify the original record', async () => {
1059
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1060
+ await repository.create(original)
1061
+
1062
+ await service.duplicate({ id: 42 }, { title: 'Modified Title' })
1063
+
1064
+ const loaded = await repository.load({ id: 42 })
1065
+ expect(loaded?.title).toBe('Original Book')
1066
+ expect(loaded?.author).toBe('Author Name')
1067
+ })
1068
+
1069
+ it('should allow overriding multiple fields', async () => {
1070
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1071
+ await repository.create(original)
1072
+
1073
+ const newDate = new Date('2024-01-01')
1074
+ const duplicate = await service.duplicate({ id: 42 }, {
1075
+ title: 'New Title',
1076
+ author: 'New Author',
1077
+ publishedDate: newDate,
1078
+ })
1079
+
1080
+ expect(duplicate.title).toBe('New Title')
1081
+ expect(duplicate.author).toBe('New Author')
1082
+ expect(duplicate.publishedDate).toEqual(newDate)
1083
+ expect(duplicate.id).not.toBe(42)
1084
+ })
1085
+
1086
+ it('should create independent records that can be modified separately', async () => {
1087
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1088
+ await repository.create(original)
1089
+
1090
+ const duplicate = await service.duplicate({ id: 42 })
1091
+
1092
+ // Update the duplicate
1093
+ await service.update({ id: duplicate.id }, { title: 'Updated Duplicate', author: 'Updated Author', publishedDate: new Date() })
1094
+
1095
+ // Verify original is unchanged
1096
+ const loadedOriginal = await repository.load({ id: 42 })
1097
+ expect(loadedOriginal?.title).toBe('Original Book')
1098
+
1099
+ // Verify duplicate was updated
1100
+ const loadedDuplicate = await repository.load({ id: duplicate.id })
1101
+ expect(loadedDuplicate?.title).toBe('Updated Duplicate')
1102
+ })
1103
+
1104
+ it('should respect doNotDispatchEvents option', async () => {
1105
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date() }
1106
+ await repository.create(original)
1107
+
1108
+ beforeCreateSpy.mockClear()
1109
+ afterCreateSpy.mockClear()
1110
+
1111
+ await service.duplicate({ id: 42 }, undefined, { doNotDispatchEvents: true })
1112
+
1113
+ expect(beforeCreateSpy).not.toHaveBeenCalled()
1114
+ expect(afterCreateSpy).not.toHaveBeenCalled()
1115
+ })
1116
+ })
1117
+
1118
+ describe('doNotDispatchEvents option', () => {
1119
+ const beforeLoadSpy = mock(() => {})
1120
+ const afterLoadSpy = mock(() => {})
1121
+ const beforeLoadManySpy = mock(() => {})
1122
+ const afterLoadManySpy = mock(() => {})
1123
+
1124
+ beforeEach(() => {
1125
+ repository = new MockMemoryRepository({ schema: mockSchema })
1126
+ emitter = new EventManager()
1127
+
1128
+ beforeLoadSpy.mockClear()
1129
+ afterLoadSpy.mockClear()
1130
+ beforeLoadManySpy.mockClear()
1131
+ afterLoadManySpy.mockClear()
1132
+ beforeCreateSpy.mockClear()
1133
+ afterCreateSpy.mockClear()
1134
+ beforeUpdateSpy.mockClear()
1135
+ afterUpdateSpy.mockClear()
1136
+
1137
+ emitter.on('books::book.beforeLoad', beforeLoadSpy)
1138
+ emitter.on('books::book.afterLoad', afterLoadSpy)
1139
+ emitter.on('books::book.beforeLoadMany', beforeLoadManySpy)
1140
+ emitter.on('books::book.afterLoadMany', afterLoadManySpy)
1141
+ emitter.on('books::book.beforeCreate', beforeCreateSpy)
1142
+ emitter.on('books::book.afterCreate', afterCreateSpy)
1143
+ emitter.on('books::book.beforeUpdate', beforeUpdateSpy)
1144
+ emitter.on('books::book.afterUpdate', afterUpdateSpy)
1145
+
1146
+ service = new ModelService({ repository, emitter, schema: mockSchema, namespace })
1147
+ })
1148
+
1149
+ describe('upsert', () => {
1150
+ it('should not dispatch any events when doNotDispatchEvents is true', async () => {
1151
+ const input = {
1152
+ id: 1,
1153
+ title: 'Existing Book',
1154
+ author: 'Author',
1155
+ publishedDate: new Date(),
1156
+ }
1157
+ await repository.create(input)
1158
+
1159
+ const updatedInput = {
1160
+ id: 1,
1161
+ title: 'Updated Book',
1162
+ author: 'Updated Author',
1163
+ publishedDate: new Date(),
1164
+ }
1165
+
1166
+ await service.upsert(updatedInput, { doNotDispatchEvents: true })
1167
+
1168
+ // Load events should not be dispatched due to propagation
1169
+ expect(beforeLoadSpy).not.toHaveBeenCalled()
1170
+ expect(afterLoadSpy).not.toHaveBeenCalled()
1171
+
1172
+ // Update events should not be dispatched either
1173
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
1174
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
1175
+ })
1176
+
1177
+ it('should not dispatch load events even when doNotDispatchEvents is false', async () => {
1178
+ const input = {
1179
+ id: 2,
1180
+ title: 'Existing Book',
1181
+ author: 'Author',
1182
+ publishedDate: new Date(),
1183
+ }
1184
+ await repository.create(input)
1185
+
1186
+ const updatedInput = {
1187
+ id: 2,
1188
+ title: 'Updated Book',
1189
+ author: 'Updated Author',
1190
+ publishedDate: new Date(),
1191
+ }
1192
+
1193
+ await service.upsert(updatedInput, { doNotDispatchEvents: false })
1194
+
1195
+ // Load events should NOT be dispatched (forced internally)
1196
+ expect(beforeLoadSpy).not.toHaveBeenCalled()
1197
+ expect(afterLoadSpy).not.toHaveBeenCalled()
1198
+
1199
+ // Update events should be dispatched
1200
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
1201
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
1202
+ })
1203
+
1204
+ it('should not dispatch load events even when doNotDispatchEvents is not specified', async () => {
1205
+ const input = {
1206
+ id: 3,
1207
+ title: 'Existing Book',
1208
+ author: 'Author',
1209
+ publishedDate: new Date(),
1210
+ }
1211
+ await repository.create(input)
1212
+
1213
+ const updatedInput = {
1214
+ id: 3,
1215
+ title: 'Updated Book',
1216
+ author: 'Updated Author',
1217
+ publishedDate: new Date(),
1218
+ }
1219
+
1220
+ await service.upsert(updatedInput)
1221
+
1222
+ // Load events should NOT be dispatched (forced internally)
1223
+ expect(beforeLoadSpy).not.toHaveBeenCalled()
1224
+ expect(afterLoadSpy).not.toHaveBeenCalled()
1225
+
1226
+ // Update events should be dispatched
1227
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
1228
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
1229
+ })
1230
+ })
1231
+
1232
+ describe('bulkUpsert', () => {
1233
+ it('should not dispatch any events when doNotDispatchEvents is true', async () => {
1234
+ const input1 = {
1235
+ id: 1,
1236
+ title: 'Existing Book 1',
1237
+ author: 'Author 1',
1238
+ publishedDate: new Date(),
1239
+ }
1240
+ const input2 = {
1241
+ id: 2,
1242
+ title: 'Existing Book 2',
1243
+ author: 'Author 2',
1244
+ publishedDate: new Date(),
1245
+ }
1246
+ await repository.create(input1)
1247
+ await repository.create(input2)
1248
+
1249
+ const updatedInputs = [
1250
+ {
1251
+ id: 1,
1252
+ title: 'Updated Book 1',
1253
+ author: 'Updated Author 1',
1254
+ publishedDate: new Date(),
1255
+ },
1256
+ {
1257
+ id: 2,
1258
+ title: 'Updated Book 2',
1259
+ author: 'Updated Author 2',
1260
+ publishedDate: new Date(),
1261
+ },
1262
+ ]
1263
+
1264
+ await service.bulkUpsert(updatedInputs, { doNotDispatchEvents: true })
1265
+
1266
+ // LoadMany events should not be dispatched due to propagation
1267
+ expect(beforeLoadManySpy).not.toHaveBeenCalled()
1268
+ expect(afterLoadManySpy).not.toHaveBeenCalled()
1269
+
1270
+ // Update events should not be dispatched either
1271
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
1272
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
1273
+ })
1274
+
1275
+ it('should not dispatch loadMany events even when doNotDispatchEvents is false', async () => {
1276
+ const input1 = {
1277
+ id: 3,
1278
+ title: 'Existing Book 3',
1279
+ author: 'Author 3',
1280
+ publishedDate: new Date(),
1281
+ }
1282
+ const input2 = {
1283
+ id: 4,
1284
+ title: 'Existing Book 4',
1285
+ author: 'Author 4',
1286
+ publishedDate: new Date(),
1287
+ }
1288
+ await repository.create(input1)
1289
+ await repository.create(input2)
1290
+
1291
+ const updatedInputs = [
1292
+ {
1293
+ id: 3,
1294
+ title: 'Updated Book 3',
1295
+ author: 'Updated Author 3',
1296
+ publishedDate: new Date(),
1297
+ },
1298
+ {
1299
+ id: 4,
1300
+ title: 'Updated Book 4',
1301
+ author: 'Updated Author 4',
1302
+ publishedDate: new Date(),
1303
+ },
1304
+ ]
1305
+
1306
+ await service.bulkUpsert(updatedInputs, { doNotDispatchEvents: false })
1307
+
1308
+ // LoadMany events should NOT be dispatched (forced internally)
1309
+ expect(beforeLoadManySpy).not.toHaveBeenCalled()
1310
+ expect(afterLoadManySpy).not.toHaveBeenCalled()
1311
+
1312
+ // Update events should be dispatched (2 updates)
1313
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(2)
1314
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(2)
1315
+ })
1316
+
1317
+ it('should not dispatch loadMany events even when doNotDispatchEvents is not specified', async () => {
1318
+ const input1 = {
1319
+ id: 5,
1320
+ title: 'Existing Book 5',
1321
+ author: 'Author 5',
1322
+ publishedDate: new Date(),
1323
+ }
1324
+ const input2 = {
1325
+ id: 6,
1326
+ title: 'Existing Book 6',
1327
+ author: 'Author 6',
1328
+ publishedDate: new Date(),
1329
+ }
1330
+ await repository.create(input1)
1331
+ await repository.create(input2)
1332
+
1333
+ const updatedInputs = [
1334
+ {
1335
+ id: 5,
1336
+ title: 'Updated Book 5',
1337
+ author: 'Updated Author 5',
1338
+ publishedDate: new Date(),
1339
+ },
1340
+ {
1341
+ id: 6,
1342
+ title: 'Updated Book 6',
1343
+ author: 'Updated Author 6',
1344
+ publishedDate: new Date(),
1345
+ },
1346
+ ]
1347
+
1348
+ await service.bulkUpsert(updatedInputs)
1349
+
1350
+ // LoadMany events should NOT be dispatched (forced internally)
1351
+ expect(beforeLoadManySpy).not.toHaveBeenCalled()
1352
+ expect(afterLoadManySpy).not.toHaveBeenCalled()
1353
+
1354
+ // Update events should be dispatched (2 updates)
1355
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(2)
1356
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(2)
1357
+ })
1358
+
1359
+ it('should not dispatch any events with doNotDispatchEvents for mixed create and update operations', async () => {
1360
+ const existingInput = {
1361
+ id: 7,
1362
+ title: 'Existing Book',
1363
+ author: 'Author',
1364
+ publishedDate: new Date(),
1365
+ }
1366
+ await repository.create(existingInput)
1367
+
1368
+ const inputs = [
1369
+ {
1370
+ id: 7,
1371
+ title: 'Updated Book',
1372
+ author: 'Updated Author',
1373
+ publishedDate: new Date(),
1374
+ },
1375
+ {
1376
+ id: 8,
1377
+ title: 'New Book',
1378
+ author: 'New Author',
1379
+ publishedDate: new Date(),
1380
+ },
1381
+ ]
1382
+
1383
+ await service.bulkUpsert(inputs, { doNotDispatchEvents: true })
1384
+
1385
+ // LoadMany events should not be dispatched
1386
+ expect(beforeLoadManySpy).not.toHaveBeenCalled()
1387
+ expect(afterLoadManySpy).not.toHaveBeenCalled()
1388
+
1389
+ // Neither create nor update events should be dispatched
1390
+ expect(beforeCreateSpy).not.toHaveBeenCalled()
1391
+ expect(afterCreateSpy).not.toHaveBeenCalled()
1392
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
1393
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
1394
+ })
1395
+ })
1396
+ })
1397
+ })