@declaro/data 2.0.0-beta.9 → 2.0.0-beta.91

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 (109) hide show
  1. package/{LICENSE → LICENSE.md} +1 -1
  2. package/README.md +0 -0
  3. package/dist/browser/index.js +32 -0
  4. package/dist/browser/index.js.map +86 -0
  5. package/dist/node/index.cjs +11547 -0
  6. package/dist/node/index.cjs.map +86 -0
  7. package/dist/node/index.js +11526 -0
  8. package/dist/node/index.js.map +86 -0
  9. package/dist/ts/application/model-controller.d.ts +29 -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 +20 -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 +21 -0
  22. package/dist/ts/domain/events/event-types.d.ts.map +1 -0
  23. package/dist/ts/domain/events/mutation-event.d.ts +6 -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 +6 -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 +11 -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 +84 -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 +22 -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 +42 -0
  44. package/dist/ts/domain/services/model-service.d.ts.map +1 -0
  45. package/dist/ts/domain/services/model-service.test.d.ts +2 -0
  46. package/dist/ts/domain/services/model-service.test.d.ts.map +1 -0
  47. package/dist/ts/domain/services/read-only-model-service.d.ts +40 -0
  48. package/dist/ts/domain/services/read-only-model-service.d.ts.map +1 -0
  49. package/dist/ts/domain/services/read-only-model-service.test.d.ts +2 -0
  50. package/dist/ts/domain/services/read-only-model-service.test.d.ts.map +1 -0
  51. package/dist/ts/index.d.ts +18 -0
  52. package/dist/ts/index.d.ts.map +1 -0
  53. package/dist/ts/shared/utils/schema-inference.d.ts +23 -0
  54. package/dist/ts/shared/utils/schema-inference.d.ts.map +1 -0
  55. package/dist/ts/shared/utils/schema-inheritance.d.ts +24 -0
  56. package/dist/ts/shared/utils/schema-inheritance.d.ts.map +1 -0
  57. package/dist/ts/test/domain/services/model-service.test.d.ts +1 -0
  58. package/dist/ts/test/domain/services/model-service.test.d.ts.map +1 -0
  59. package/dist/ts/test/mock/models/mock-book-models.d.ts +42 -0
  60. package/dist/ts/test/mock/models/mock-book-models.d.ts.map +1 -0
  61. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts +36 -0
  62. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts.map +1 -0
  63. package/dist/ts/test/mock/repositories/mock-memory-repository.test.d.ts +2 -0
  64. package/dist/ts/test/mock/repositories/mock-memory-repository.test.d.ts.map +1 -0
  65. package/package.json +45 -42
  66. package/src/application/model-controller.test.ts +488 -0
  67. package/src/application/model-controller.ts +92 -0
  68. package/src/application/read-only-model-controller.test.ts +327 -0
  69. package/src/application/read-only-model-controller.ts +61 -0
  70. package/src/domain/events/domain-event.test.ts +82 -0
  71. package/src/domain/events/domain-event.ts +69 -0
  72. package/src/domain/events/event-types.ts +21 -0
  73. package/src/domain/events/mutation-event.test.ts +38 -0
  74. package/src/domain/events/mutation-event.ts +8 -0
  75. package/src/domain/events/query-event.test.ts +28 -0
  76. package/src/domain/events/query-event.ts +8 -0
  77. package/src/domain/events/request-event.test.ts +38 -0
  78. package/src/domain/events/request-event.ts +32 -0
  79. package/src/domain/interfaces/repository.ts +107 -0
  80. package/src/domain/models/pagination.ts +28 -0
  81. package/src/domain/services/base-model-service.ts +50 -0
  82. package/src/domain/services/model-service-args.ts +9 -0
  83. package/src/domain/services/model-service.test.ts +631 -0
  84. package/src/domain/services/model-service.ts +322 -0
  85. package/src/domain/services/read-only-model-service.test.ts +296 -0
  86. package/src/domain/services/read-only-model-service.ts +133 -0
  87. package/src/index.ts +17 -4
  88. package/src/shared/utils/schema-inference.ts +26 -0
  89. package/src/shared/utils/schema-inheritance.ts +28 -0
  90. package/src/test/domain/services/model-service.test.ts +0 -0
  91. package/src/test/mock/models/mock-book-models.ts +78 -0
  92. package/src/test/mock/repositories/mock-memory-repository.test.ts +715 -0
  93. package/src/test/mock/repositories/mock-memory-repository.ts +235 -0
  94. package/dist/databaseConnection.d.ts +0 -24
  95. package/dist/datastoreAbstract.d.ts +0 -37
  96. package/dist/declaro-data.cjs +0 -1
  97. package/dist/declaro-data.mjs +0 -250
  98. package/dist/hydrateEntity.d.ts +0 -8
  99. package/dist/index.d.ts +0 -4
  100. package/dist/serverConnection.d.ts +0 -15
  101. package/dist/trackedStatus.d.ts +0 -15
  102. package/src/databaseConnection.ts +0 -137
  103. package/src/datastoreAbstract.ts +0 -190
  104. package/src/hydrateEntity.ts +0 -36
  105. package/src/placeholder.test.ts +0 -7
  106. package/src/serverConnection.ts +0 -74
  107. package/src/trackedStatus.ts +0 -35
  108. package/tsconfig.json +0 -10
  109. package/vite.config.ts +0 -23
@@ -0,0 +1,322 @@
1
+ import type { AnyModelSchema } from '@declaro/core'
2
+ import type { InferDetail, InferInput, InferLookup, InferSummary } from '../../shared/utils/schema-inference'
3
+ import { ModelMutationAction, ModelQueryEvent } from '../events/event-types'
4
+ import { MutationEvent } from '../events/mutation-event'
5
+ import type { IModelServiceArgs } from './model-service-args'
6
+ import { ReadOnlyModelService, type ILoadOptions } from './read-only-model-service'
7
+ import type { IActionOptions } from './base-model-service'
8
+
9
+ export interface ICreateOptions extends IActionOptions {}
10
+ export interface IUpdateOptions extends IActionOptions {}
11
+
12
+ export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelService<TSchema> {
13
+ constructor(args: IModelServiceArgs<TSchema>) {
14
+ super(args)
15
+ }
16
+
17
+ /**
18
+ * Removes a record by its lookup criteria.
19
+ * @param lookup The lookup criteria to find the record.
20
+ * @returns The removed record.
21
+ */
22
+ async remove(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferSummary<TSchema>> {
23
+ // Emit the before remove event
24
+ const beforeRemoveEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
25
+ this.getDescriptor(ModelMutationAction.BeforeRemove),
26
+ lookup,
27
+ )
28
+ await this.emitter.emitAsync(beforeRemoveEvent)
29
+
30
+ // Perform the removal
31
+ const result = await this.repository.remove(lookup, options)
32
+
33
+ // Emit the after remove event
34
+ const afterRemoveEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
35
+ this.getDescriptor(ModelMutationAction.AfterRemove),
36
+ lookup,
37
+ ).setResult(result)
38
+ await this.emitter.emitAsync(afterRemoveEvent)
39
+
40
+ // Return the results of the removal
41
+ return result
42
+ }
43
+
44
+ /**
45
+ * Restores a record by its lookup criteria.
46
+ * If a soft-deleted copy exists, it will be restored.
47
+ * @param lookup The lookup criteria to find the record to restore.
48
+ * @returns
49
+ */
50
+ async restore(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferSummary<TSchema>> {
51
+ // Emit the before restore event
52
+ const beforeRestoreEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
53
+ this.getDescriptor(ModelMutationAction.BeforeRestore),
54
+ lookup,
55
+ )
56
+ await this.emitter.emitAsync(beforeRestoreEvent)
57
+
58
+ // Perform the restore operation
59
+ const result = await this.repository.restore(lookup, options)
60
+
61
+ // Emit the after restore event
62
+ const afterRestoreEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
63
+ this.getDescriptor(ModelMutationAction.AfterRestore),
64
+ lookup,
65
+ ).setResult(result)
66
+ await this.emitter.emitAsync(afterRestoreEvent)
67
+
68
+ // Return the results of the restore operation
69
+ return result
70
+ }
71
+
72
+ async create(input: InferInput<TSchema>, options?: ICreateOptions): Promise<InferDetail<TSchema>> {
73
+ // Emit the before create event
74
+ const beforeCreateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
75
+ this.getDescriptor(ModelMutationAction.BeforeCreate),
76
+ input,
77
+ )
78
+ await this.emitter.emitAsync(beforeCreateEvent)
79
+
80
+ // Perform the creation
81
+ const result = await this.repository.create(input, options)
82
+
83
+ // Emit the after create event
84
+ const afterCreateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
85
+ this.getDescriptor(ModelMutationAction.AfterCreate),
86
+ input,
87
+ ).setResult(result)
88
+ await this.emitter.emitAsync(afterCreateEvent)
89
+
90
+ // Return the results of the creation
91
+ return result
92
+ }
93
+
94
+ async update(
95
+ lookup: InferLookup<TSchema>,
96
+ input: InferInput<TSchema>,
97
+ options?: IUpdateOptions,
98
+ ): Promise<InferDetail<TSchema>> {
99
+ // Emit the before update event
100
+ const beforeUpdateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
101
+ this.getDescriptor(ModelMutationAction.BeforeUpdate),
102
+ input,
103
+ )
104
+ await this.emitter.emitAsync(beforeUpdateEvent)
105
+
106
+ // Perform the update
107
+ const result = await this.repository.update(lookup, input, options)
108
+
109
+ // Emit the after update event
110
+ const afterUpdateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
111
+ this.getDescriptor(ModelMutationAction.AfterUpdate),
112
+ input,
113
+ ).setResult(result)
114
+ await this.emitter.emitAsync(afterUpdateEvent)
115
+
116
+ // Return the results of the update
117
+ return result
118
+ }
119
+
120
+ /**
121
+ * Upserts a record (creates if it doesn't exist, updates if it does).
122
+ * @param input The input data for the upsert operation.
123
+ * @param options Optional create or update options.
124
+ * @returns The upserted record.
125
+ */
126
+ async upsert(input: InferInput<TSchema>, options?: ICreateOptions | IUpdateOptions): Promise<InferDetail<TSchema>> {
127
+ const primaryKeyValue = this.getPrimaryKeyValue(input)
128
+
129
+ let beforeOperation: ModelMutationAction
130
+ let afterOperation: ModelMutationAction
131
+
132
+ if (primaryKeyValue === undefined) {
133
+ beforeOperation = ModelMutationAction.BeforeCreate
134
+ afterOperation = ModelMutationAction.AfterCreate
135
+ } else {
136
+ const existingItem = await this.load(
137
+ {
138
+ [this.entityMetadata.primaryKey]: primaryKeyValue,
139
+ } as InferLookup<TSchema>,
140
+ options,
141
+ )
142
+
143
+ if (existingItem) {
144
+ beforeOperation = ModelMutationAction.BeforeUpdate
145
+ afterOperation = ModelMutationAction.AfterUpdate
146
+ } else {
147
+ beforeOperation = ModelMutationAction.BeforeCreate
148
+ afterOperation = ModelMutationAction.AfterCreate
149
+ }
150
+ }
151
+
152
+ // Emit the before upsert event
153
+ const beforeUpsertEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
154
+ this.getDescriptor(beforeOperation),
155
+ input,
156
+ )
157
+ await this.emitter.emitAsync(beforeUpsertEvent)
158
+
159
+ // Perform the upsert operation
160
+ const result = await this.repository.upsert(input, options)
161
+
162
+ // Emit the after upsert event
163
+ const afterUpsertEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
164
+ this.getDescriptor(afterOperation),
165
+ input,
166
+ ).setResult(result)
167
+ await this.emitter.emitAsync(afterUpsertEvent)
168
+
169
+ // Return the results of the upsert operation
170
+ return result
171
+ }
172
+
173
+ /**
174
+ * Bulk upserts multiple records (creates if they don't exist, updates if they do).
175
+ * @param inputs Array of input data for the bulk upsert operation.
176
+ * @param options Optional create or update options.
177
+ * @returns Array of upserted records.
178
+ */
179
+ async bulkUpsert(
180
+ inputs: InferInput<TSchema>[],
181
+ options?: ICreateOptions | IUpdateOptions,
182
+ ): Promise<InferDetail<TSchema>[]> {
183
+ if (inputs.length === 0) {
184
+ return []
185
+ }
186
+
187
+ // Build a map of primary key to input and lookup info
188
+ type EntityInfo = {
189
+ input: InferInput<TSchema>
190
+ lookup: InferLookup<TSchema>
191
+ primaryKeyValue: string | number
192
+ existingEntity?: InferDetail<TSchema>
193
+ operation?: ModelMutationAction
194
+ }
195
+
196
+ const entityInfoMap = new Map<string | number, EntityInfo>()
197
+ const inputsWithoutPrimaryKey: InferInput<TSchema>[] = []
198
+
199
+ // Process each input and organize by primary key
200
+ for (const input of inputs) {
201
+ const primaryKeyValue = this.getPrimaryKeyValue(input)
202
+
203
+ if (primaryKeyValue !== undefined) {
204
+ const entityInfo: EntityInfo = {
205
+ input,
206
+ primaryKeyValue,
207
+ lookup: {
208
+ [this.entityMetadata.primaryKey]: primaryKeyValue,
209
+ } as InferLookup<TSchema>,
210
+ }
211
+ entityInfoMap.set(primaryKeyValue, entityInfo)
212
+ } else {
213
+ // Inputs without primary keys are always creates
214
+ inputsWithoutPrimaryKey.push(input)
215
+ }
216
+ }
217
+
218
+ // Extract lookups for existing entities
219
+ const lookups = Array.from(entityInfoMap.values()).map((info) => info.lookup)
220
+
221
+ // Load existing entities and update the map
222
+ if (lookups.length > 0) {
223
+ const existingEntities = await this.loadMany(lookups, options)
224
+ existingEntities.forEach((entity) => {
225
+ if (entity) {
226
+ const pkValue = this.getPrimaryKeyValue(entity)
227
+ if (pkValue !== undefined && entityInfoMap.has(pkValue)) {
228
+ const entityInfo = entityInfoMap.get(pkValue)!
229
+ entityInfo.existingEntity = entity
230
+ }
231
+ }
232
+ })
233
+ }
234
+
235
+ // Determine operation types and prepare before events
236
+ const beforeEvents: MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>[] = []
237
+
238
+ // Handle entities with primary keys
239
+ for (const entityInfo of entityInfoMap.values()) {
240
+ const operation = entityInfo.existingEntity
241
+ ? ModelMutationAction.BeforeUpdate
242
+ : ModelMutationAction.BeforeCreate
243
+
244
+ entityInfo.operation = operation
245
+
246
+ beforeEvents.push(
247
+ new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
248
+ this.getDescriptor(operation),
249
+ entityInfo.input,
250
+ ),
251
+ )
252
+ }
253
+
254
+ // Handle inputs without primary keys (always creates)
255
+ for (const input of inputsWithoutPrimaryKey) {
256
+ beforeEvents.push(
257
+ new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
258
+ this.getDescriptor(ModelMutationAction.BeforeCreate),
259
+ input,
260
+ ),
261
+ )
262
+ }
263
+
264
+ // Emit all before events
265
+ await Promise.all(beforeEvents.map((event) => this.emitter.emitAsync(event)))
266
+
267
+ // Perform the bulk upsert operation
268
+ const results = await this.repository.bulkUpsert(inputs, options)
269
+
270
+ // Create a map of result primary keys to results for matching
271
+ const resultsByPrimaryKey = new Map<string | number, InferDetail<TSchema>>()
272
+ const resultsWithoutPrimaryKey: InferDetail<TSchema>[] = []
273
+
274
+ for (const result of results) {
275
+ const pkValue = this.getPrimaryKeyValue(result)
276
+ if (pkValue !== undefined) {
277
+ resultsByPrimaryKey.set(pkValue, result)
278
+ } else {
279
+ resultsWithoutPrimaryKey.push(result)
280
+ }
281
+ }
282
+
283
+ // Prepare after events by matching results back to original inputs
284
+ const afterEvents: MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>[] = []
285
+ let resultsWithoutPkIndex = 0
286
+
287
+ // Handle entities with primary keys
288
+ for (const entityInfo of entityInfoMap.values()) {
289
+ const matchedResult = resultsByPrimaryKey.get(entityInfo.primaryKeyValue)!
290
+
291
+ const afterOperation =
292
+ entityInfo.operation === ModelMutationAction.BeforeCreate
293
+ ? ModelMutationAction.AfterCreate
294
+ : ModelMutationAction.AfterUpdate
295
+
296
+ afterEvents.push(
297
+ new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
298
+ this.getDescriptor(afterOperation),
299
+ entityInfo.input,
300
+ ).setResult(matchedResult),
301
+ )
302
+ }
303
+
304
+ // Handle inputs without primary keys (always creates)
305
+ for (const input of inputsWithoutPrimaryKey) {
306
+ const matchedResult = resultsWithoutPrimaryKey[resultsWithoutPkIndex++]
307
+
308
+ afterEvents.push(
309
+ new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
310
+ this.getDescriptor(ModelMutationAction.AfterCreate),
311
+ input,
312
+ ).setResult(matchedResult),
313
+ )
314
+ }
315
+
316
+ // Emit all after events
317
+ await Promise.all(afterEvents.map((event) => this.emitter.emitAsync(event)))
318
+
319
+ // Return the results
320
+ return results
321
+ }
322
+ }
@@ -0,0 +1,296 @@
1
+ import { describe, it, expect, beforeEach, spyOn, mock } from 'bun:test'
2
+ import { ReadOnlyModelService } from './read-only-model-service'
3
+ import { MockMemoryRepository } from '../../test/mock/repositories/mock-memory-repository'
4
+ import { MockBookSchema } from '../../test/mock/models/mock-book-models'
5
+ import { EventManager } from '@declaro/core'
6
+ import type { QueryEvent } from '../events/query-event'
7
+ import type { InferDetail, InferFilters, InferLookup, InferSearchResults } from '../../shared/utils/schema-inference'
8
+
9
+ describe('ReadOnlyModelService', () => {
10
+ const namespace = 'books'
11
+ const mockSchema = MockBookSchema
12
+
13
+ let repository: MockMemoryRepository<typeof mockSchema>
14
+ let emitter: EventManager
15
+ let service: ReadOnlyModelService<typeof mockSchema>
16
+
17
+ const beforeLoadSpy = mock(
18
+ (event: QueryEvent<InferDetail<typeof mockSchema>, InferLookup<typeof mockSchema>>) => {},
19
+ )
20
+ const afterLoadSpy = mock((event: QueryEvent<InferDetail<typeof mockSchema>, InferLookup<typeof mockSchema>>) => {})
21
+
22
+ const beforeLoadManySpy = mock(
23
+ (event: QueryEvent<InferDetail<typeof mockSchema>[], InferLookup<typeof mockSchema>[]>) => {},
24
+ )
25
+ const afterLoadManySpy = mock(
26
+ (event: QueryEvent<InferDetail<typeof mockSchema>[], InferLookup<typeof mockSchema>[]>) => {},
27
+ )
28
+
29
+ const beforeSearchSpy = mock(
30
+ (event: QueryEvent<InferSearchResults<typeof mockSchema>[], InferFilters<typeof mockSchema>[]>) => {},
31
+ )
32
+ const afterSearchSpy = mock(
33
+ (event: QueryEvent<InferSearchResults<typeof mockSchema>[], InferFilters<typeof mockSchema>[]>) => {},
34
+ )
35
+
36
+ beforeEach(() => {
37
+ repository = new MockMemoryRepository({ schema: mockSchema })
38
+ emitter = new EventManager()
39
+
40
+ beforeLoadSpy.mockClear()
41
+ afterLoadSpy.mockClear()
42
+ beforeLoadManySpy.mockClear()
43
+ afterLoadManySpy.mockClear()
44
+ beforeSearchSpy.mockClear()
45
+ afterSearchSpy.mockClear()
46
+
47
+ emitter.on<QueryEvent<InferDetail<typeof mockSchema>, InferLookup<typeof mockSchema>>>(
48
+ 'books::book.beforeLoad',
49
+ beforeLoadSpy,
50
+ )
51
+ emitter.on<QueryEvent<InferDetail<typeof mockSchema>, InferLookup<typeof mockSchema>>>(
52
+ 'books::book.afterLoad',
53
+ afterLoadSpy,
54
+ )
55
+
56
+ emitter.on<QueryEvent<InferDetail<typeof mockSchema>[], InferLookup<typeof mockSchema>[]>>(
57
+ 'books::book.beforeLoadMany',
58
+ beforeLoadManySpy,
59
+ )
60
+ emitter.on<QueryEvent<InferDetail<typeof mockSchema>[], InferLookup<typeof mockSchema>[]>>(
61
+ 'books::book.afterLoadMany',
62
+ afterLoadManySpy,
63
+ )
64
+
65
+ emitter.on<QueryEvent<InferSearchResults<typeof mockSchema>[], InferFilters<typeof mockSchema>[]>>(
66
+ 'books::book.beforeSearch',
67
+ beforeSearchSpy,
68
+ )
69
+ emitter.on<QueryEvent<InferSearchResults<typeof mockSchema>[], InferFilters<typeof mockSchema>[]>>(
70
+ 'books::book.afterSearch',
71
+ afterSearchSpy,
72
+ )
73
+
74
+ service = new ReadOnlyModelService({ repository, emitter, schema: mockSchema, namespace })
75
+ })
76
+
77
+ it('should load a single record', async () => {
78
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
79
+ await repository.create(input)
80
+
81
+ const record = await service.load({ id: 42 })
82
+
83
+ expect(record).toEqual(input)
84
+ })
85
+
86
+ it('should return null when loading a non-existent record', async () => {
87
+ const record = await service.load({ id: 999 })
88
+
89
+ expect(record).toBeNull()
90
+ })
91
+
92
+ it('should load multiple records', async () => {
93
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
94
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
95
+ await repository.create(input1)
96
+ await repository.create(input2)
97
+
98
+ const records = await service.loadMany([{ id: 42 }, { id: 43 }])
99
+
100
+ expect(records).toEqual([input1, input2])
101
+ })
102
+
103
+ it('should search for records', async () => {
104
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
105
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
106
+ await repository.create(input1)
107
+ await repository.create(input2)
108
+
109
+ const results = await service.search(
110
+ { text: 'Test' },
111
+ {
112
+ sort: [
113
+ {
114
+ title: 'asc',
115
+ },
116
+ {
117
+ author: 'desc',
118
+ },
119
+ ],
120
+ },
121
+ )
122
+
123
+ expect(results.results).toEqual([input1, input2])
124
+ expect(results.pagination.total).toBe(2)
125
+ })
126
+
127
+ it('should return empty results when searching for non-existent records', async () => {
128
+ const results = await service.search({ text: 'Non-existent' })
129
+
130
+ expect(results.results).toEqual([])
131
+ expect(results.pagination.total).toBe(0)
132
+ })
133
+
134
+ it('should handle pagination options correctly', async () => {
135
+ // Create 5 items
136
+ for (let i = 1; i <= 5; i++) {
137
+ await repository.create({
138
+ id: i,
139
+ title: `Test Book ${i}`,
140
+ author: `Author ${i}`,
141
+ publishedDate: new Date(),
142
+ })
143
+ }
144
+
145
+ // Test first page with pageSize 2
146
+ const page1 = await service.search(
147
+ {},
148
+ {
149
+ pagination: { page: 1, pageSize: 2 },
150
+ },
151
+ )
152
+ expect(page1.results).toHaveLength(2)
153
+ expect(page1.pagination.page).toBe(1)
154
+ expect(page1.pagination.pageSize).toBe(2)
155
+ expect(page1.pagination.total).toBe(5)
156
+ expect(page1.pagination.totalPages).toBe(3)
157
+
158
+ // Test second page
159
+ const page2 = await service.search(
160
+ {},
161
+ {
162
+ pagination: { page: 2, pageSize: 2 },
163
+ },
164
+ )
165
+ expect(page2.results).toHaveLength(2)
166
+ expect(page2.pagination.page).toBe(2)
167
+
168
+ // Test last page
169
+ const page3 = await service.search(
170
+ {},
171
+ {
172
+ pagination: { page: 3, pageSize: 2 },
173
+ },
174
+ )
175
+ expect(page3.results).toHaveLength(1)
176
+ expect(page3.pagination.page).toBe(3)
177
+ })
178
+
179
+ it('should handle sort options correctly', async () => {
180
+ const input1 = { id: 1, title: 'Z Book', author: 'Author A', publishedDate: new Date('2023-01-01') }
181
+ const input2 = { id: 2, title: 'A Book', author: 'Author B', publishedDate: new Date('2023-02-01') }
182
+ const input3 = { id: 3, title: 'M Book', author: 'Author C', publishedDate: new Date('2023-03-01') }
183
+
184
+ await repository.create(input1)
185
+ await repository.create(input2)
186
+ await repository.create(input3)
187
+
188
+ // Sort by title ascending
189
+ const titleAscResults = await service.search(
190
+ {},
191
+ {
192
+ sort: [{ title: 'asc' }],
193
+ },
194
+ )
195
+ expect(titleAscResults.results.map((r) => r.title)).toEqual(['A Book', 'M Book', 'Z Book'])
196
+
197
+ // Sort by title descending
198
+ const titleDescResults = await service.search(
199
+ {},
200
+ {
201
+ sort: [{ title: 'desc' }],
202
+ },
203
+ )
204
+ expect(titleDescResults.results.map((r) => r.title)).toEqual(['Z Book', 'M Book', 'A Book'])
205
+
206
+ // Sort by author ascending
207
+ const authorAscResults = await service.search(
208
+ {},
209
+ {
210
+ sort: [{ author: 'asc' }],
211
+ },
212
+ )
213
+ expect(authorAscResults.results.map((r) => r.author)).toEqual(['Author A', 'Author B', 'Author C'])
214
+ })
215
+
216
+ it('should handle combined filtering, sorting, and pagination', async () => {
217
+ const repositoryWithFilter = new MockMemoryRepository({
218
+ schema: mockSchema,
219
+ filter: (data, filters) => {
220
+ if (filters.text) {
221
+ return data.title.toLowerCase().includes(filters.text.toLowerCase())
222
+ }
223
+ return true
224
+ },
225
+ })
226
+
227
+ const serviceWithFilter = new ReadOnlyModelService({
228
+ repository: repositoryWithFilter,
229
+ emitter,
230
+ namespace,
231
+ schema: mockSchema,
232
+ })
233
+
234
+ await repositoryWithFilter.create({ title: 'Test Z Book', author: 'Author 1', publishedDate: new Date() })
235
+ await repositoryWithFilter.create({ title: 'Test A Book', author: 'Author 2', publishedDate: new Date() })
236
+ await repositoryWithFilter.create({ title: 'Other Book', author: 'Author 3', publishedDate: new Date() })
237
+ await repositoryWithFilter.create({ title: 'Test M Book', author: 'Author 4', publishedDate: new Date() })
238
+
239
+ const results = await serviceWithFilter.search(
240
+ { text: 'Test' },
241
+ {
242
+ sort: [{ title: 'asc' }],
243
+ pagination: { page: 1, pageSize: 2 },
244
+ },
245
+ )
246
+
247
+ expect(results.results).toHaveLength(2)
248
+ expect(results.results.map((r) => r.title)).toEqual(['Test A Book', 'Test M Book'])
249
+ expect(results.pagination.total).toBe(3) // 3 "Test" books total
250
+ expect(results.pagination.totalPages).toBe(2)
251
+ })
252
+
253
+ it('should trigger before and after events for load', async () => {
254
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
255
+ await repository.create(input)
256
+
257
+ const record = await service.load({ id: 42 })
258
+
259
+ expect(record).toEqual(input)
260
+ expect(beforeLoadSpy).toHaveBeenCalledTimes(1)
261
+ expect(beforeLoadSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeLoad' }))
262
+ expect(afterLoadSpy).toHaveBeenCalledTimes(1)
263
+ expect(afterLoadSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterLoad' }))
264
+ })
265
+
266
+ it('should trigger before and after events for loadMany', async () => {
267
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
268
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
269
+ await repository.create(input1)
270
+ await repository.create(input2)
271
+
272
+ const records = await service.loadMany([{ id: 42 }, { id: 43 }])
273
+
274
+ expect(records).toEqual([input1, input2])
275
+ expect(beforeLoadManySpy).toHaveBeenCalledTimes(1)
276
+ expect(beforeLoadManySpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeLoadMany' }))
277
+ expect(afterLoadManySpy).toHaveBeenCalledTimes(1)
278
+ expect(afterLoadManySpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterLoadMany' }))
279
+ })
280
+
281
+ it('should trigger before and after events for search', async () => {
282
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
283
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
284
+ await repository.create(input1)
285
+ await repository.create(input2)
286
+
287
+ const results = await service.search({ text: 'Test' })
288
+
289
+ expect(results.results).toEqual([input1, input2])
290
+ expect(results.pagination.total).toBe(2)
291
+ expect(beforeSearchSpy).toHaveBeenCalledTimes(1)
292
+ expect(beforeSearchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeSearch' }))
293
+ expect(afterSearchSpy).toHaveBeenCalledTimes(1)
294
+ expect(afterSearchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterSearch' }))
295
+ })
296
+ })