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

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 (127) 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 +13094 -0
  6. package/dist/node/index.cjs.map +93 -0
  7. package/dist/node/index.js +13073 -0
  8. package/dist/node/index.js.map +93 -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 +50 -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.assign.test.d.ts +2 -0
  62. package/dist/ts/test/mock/repositories/mock-memory-repository.assign.test.d.ts.map +1 -0
  63. package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts +2 -0
  64. package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts.map +1 -0
  65. package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts +2 -0
  66. package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts.map +1 -0
  67. package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts +2 -0
  68. package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts.map +1 -0
  69. package/dist/ts/test/mock/repositories/mock-memory-repository.custom-lookup.test.d.ts +1 -0
  70. package/dist/ts/test/mock/repositories/mock-memory-repository.custom-lookup.test.d.ts.map +1 -0
  71. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts +44 -0
  72. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts.map +1 -0
  73. package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts +2 -0
  74. package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts.map +1 -0
  75. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts +2 -0
  76. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts.map +1 -0
  77. package/package.json +45 -42
  78. package/src/application/model-controller.test.ts +503 -0
  79. package/src/application/model-controller.ts +92 -0
  80. package/src/application/read-only-model-controller.test.ts +335 -0
  81. package/src/application/read-only-model-controller.ts +61 -0
  82. package/src/domain/events/domain-event.test.ts +82 -0
  83. package/src/domain/events/domain-event.ts +69 -0
  84. package/src/domain/events/event-types.ts +21 -0
  85. package/src/domain/events/mutation-event.test.ts +38 -0
  86. package/src/domain/events/mutation-event.ts +8 -0
  87. package/src/domain/events/query-event.test.ts +28 -0
  88. package/src/domain/events/query-event.ts +8 -0
  89. package/src/domain/events/request-event.test.ts +38 -0
  90. package/src/domain/events/request-event.ts +32 -0
  91. package/src/domain/interfaces/repository.ts +107 -0
  92. package/src/domain/models/pagination.ts +28 -0
  93. package/src/domain/services/base-model-service.ts +50 -0
  94. package/src/domain/services/model-service-args.ts +9 -0
  95. package/src/domain/services/model-service.test.ts +876 -0
  96. package/src/domain/services/model-service.ts +345 -0
  97. package/src/domain/services/read-only-model-service.test.ts +296 -0
  98. package/src/domain/services/read-only-model-service.ts +133 -0
  99. package/src/index.ts +17 -4
  100. package/src/shared/utils/schema-inference.ts +26 -0
  101. package/src/shared/utils/schema-inheritance.ts +28 -0
  102. package/src/test/domain/services/model-service.test.ts +0 -0
  103. package/src/test/mock/models/mock-book-models.ts +78 -0
  104. package/src/test/mock/repositories/mock-memory-repository.assign.test.ts +215 -0
  105. package/src/test/mock/repositories/mock-memory-repository.basic.test.ts +129 -0
  106. package/src/test/mock/repositories/mock-memory-repository.bulk-upsert.test.ts +159 -0
  107. package/src/test/mock/repositories/mock-memory-repository.count.test.ts +98 -0
  108. package/src/test/mock/repositories/mock-memory-repository.custom-lookup.test.ts +0 -0
  109. package/src/test/mock/repositories/mock-memory-repository.search.test.ts +265 -0
  110. package/src/test/mock/repositories/mock-memory-repository.ts +301 -0
  111. package/src/test/mock/repositories/mock-memory-repository.upsert.test.ts +108 -0
  112. package/dist/databaseConnection.d.ts +0 -24
  113. package/dist/datastoreAbstract.d.ts +0 -37
  114. package/dist/declaro-data.cjs +0 -1
  115. package/dist/declaro-data.mjs +0 -250
  116. package/dist/hydrateEntity.d.ts +0 -8
  117. package/dist/index.d.ts +0 -4
  118. package/dist/serverConnection.d.ts +0 -15
  119. package/dist/trackedStatus.d.ts +0 -15
  120. package/src/databaseConnection.ts +0 -137
  121. package/src/datastoreAbstract.ts +0 -190
  122. package/src/hydrateEntity.ts +0 -36
  123. package/src/placeholder.test.ts +0 -7
  124. package/src/serverConnection.ts +0 -74
  125. package/src/trackedStatus.ts +0 -35
  126. package/tsconfig.json +0 -10
  127. package/vite.config.ts +0 -23
@@ -0,0 +1,345 @@
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
+ * Normalizes input data before processing. This method can be overridden by subclasses
19
+ * to implement custom input normalization logic (e.g., trimming strings, setting defaults, etc.).
20
+ * By default, this method returns the input unchanged.
21
+ * @param input The input data to normalize.
22
+ * @returns The normalized input data.
23
+ */
24
+ protected async normalizeInput(input: InferInput<TSchema>): Promise<InferInput<TSchema>> {
25
+ return input
26
+ }
27
+
28
+ /**
29
+ * Removes a record by its lookup criteria.
30
+ * @param lookup The lookup criteria to find the record.
31
+ * @returns The removed record.
32
+ */
33
+ async remove(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferSummary<TSchema>> {
34
+ // Emit the before remove event
35
+ const beforeRemoveEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
36
+ this.getDescriptor(ModelMutationAction.BeforeRemove),
37
+ lookup,
38
+ )
39
+ await this.emitter.emitAsync(beforeRemoveEvent)
40
+
41
+ // Perform the removal
42
+ const result = await this.repository.remove(lookup, options)
43
+
44
+ // Emit the after remove event
45
+ const afterRemoveEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
46
+ this.getDescriptor(ModelMutationAction.AfterRemove),
47
+ lookup,
48
+ ).setResult(result)
49
+ await this.emitter.emitAsync(afterRemoveEvent)
50
+
51
+ // Return the results of the removal
52
+ return result
53
+ }
54
+
55
+ /**
56
+ * Restores a record by its lookup criteria.
57
+ * If a soft-deleted copy exists, it will be restored.
58
+ * @param lookup The lookup criteria to find the record to restore.
59
+ * @returns
60
+ */
61
+ async restore(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferSummary<TSchema>> {
62
+ // Emit the before restore event
63
+ const beforeRestoreEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
64
+ this.getDescriptor(ModelMutationAction.BeforeRestore),
65
+ lookup,
66
+ )
67
+ await this.emitter.emitAsync(beforeRestoreEvent)
68
+
69
+ // Perform the restore operation
70
+ const result = await this.repository.restore(lookup, options)
71
+
72
+ // Emit the after restore event
73
+ const afterRestoreEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
74
+ this.getDescriptor(ModelMutationAction.AfterRestore),
75
+ lookup,
76
+ ).setResult(result)
77
+ await this.emitter.emitAsync(afterRestoreEvent)
78
+
79
+ // Return the results of the restore operation
80
+ return result
81
+ }
82
+
83
+ async create(input: InferInput<TSchema>, options?: ICreateOptions): Promise<InferDetail<TSchema>> {
84
+ // Normalize the input data
85
+ const normalizedInput = await this.normalizeInput(input)
86
+
87
+ // Emit the before create event
88
+ const beforeCreateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
89
+ this.getDescriptor(ModelMutationAction.BeforeCreate),
90
+ normalizedInput,
91
+ )
92
+ await this.emitter.emitAsync(beforeCreateEvent)
93
+
94
+ // Perform the creation
95
+ const result = await this.repository.create(normalizedInput, options)
96
+
97
+ // Emit the after create event
98
+ const afterCreateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
99
+ this.getDescriptor(ModelMutationAction.AfterCreate),
100
+ normalizedInput,
101
+ ).setResult(result)
102
+ await this.emitter.emitAsync(afterCreateEvent)
103
+
104
+ // Return the results of the creation
105
+ return result
106
+ }
107
+
108
+ async update(
109
+ lookup: InferLookup<TSchema>,
110
+ input: InferInput<TSchema>,
111
+ options?: IUpdateOptions,
112
+ ): Promise<InferDetail<TSchema>> {
113
+ // Normalize the input data
114
+ const normalizedInput = await this.normalizeInput(input)
115
+
116
+ // Emit the before update event
117
+ const beforeUpdateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
118
+ this.getDescriptor(ModelMutationAction.BeforeUpdate),
119
+ normalizedInput,
120
+ )
121
+ await this.emitter.emitAsync(beforeUpdateEvent)
122
+
123
+ // Perform the update
124
+ const result = await this.repository.update(lookup, normalizedInput, options)
125
+
126
+ // Emit the after update event
127
+ const afterUpdateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
128
+ this.getDescriptor(ModelMutationAction.AfterUpdate),
129
+ normalizedInput,
130
+ ).setResult(result)
131
+ await this.emitter.emitAsync(afterUpdateEvent)
132
+
133
+ // Return the results of the update
134
+ return result
135
+ }
136
+
137
+ /**
138
+ * Upserts a record (creates if it doesn't exist, updates if it does).
139
+ * @param input The input data for the upsert operation.
140
+ * @param options Optional create or update options.
141
+ * @returns The upserted record.
142
+ */
143
+ async upsert(input: InferInput<TSchema>, options?: ICreateOptions | IUpdateOptions): Promise<InferDetail<TSchema>> {
144
+ // Normalize the input data
145
+ const normalizedInput = await this.normalizeInput(input)
146
+
147
+ const primaryKeyValue = this.getPrimaryKeyValue(normalizedInput)
148
+
149
+ let beforeOperation: ModelMutationAction
150
+ let afterOperation: ModelMutationAction
151
+
152
+ if (primaryKeyValue === undefined) {
153
+ beforeOperation = ModelMutationAction.BeforeCreate
154
+ afterOperation = ModelMutationAction.AfterCreate
155
+ } else {
156
+ const existingItem = await this.load(
157
+ {
158
+ [this.entityMetadata.primaryKey]: primaryKeyValue,
159
+ } as InferLookup<TSchema>,
160
+ options,
161
+ )
162
+
163
+ if (existingItem) {
164
+ beforeOperation = ModelMutationAction.BeforeUpdate
165
+ afterOperation = ModelMutationAction.AfterUpdate
166
+ } else {
167
+ beforeOperation = ModelMutationAction.BeforeCreate
168
+ afterOperation = ModelMutationAction.AfterCreate
169
+ }
170
+ }
171
+
172
+ // Emit the before upsert event
173
+ const beforeUpsertEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
174
+ this.getDescriptor(beforeOperation),
175
+ normalizedInput,
176
+ )
177
+ await this.emitter.emitAsync(beforeUpsertEvent)
178
+
179
+ // Perform the upsert operation
180
+ const result = await this.repository.upsert(normalizedInput, options)
181
+
182
+ // Emit the after upsert event
183
+ const afterUpsertEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
184
+ this.getDescriptor(afterOperation),
185
+ normalizedInput,
186
+ ).setResult(result)
187
+ await this.emitter.emitAsync(afterUpsertEvent)
188
+
189
+ // Return the results of the upsert operation
190
+ return result
191
+ }
192
+
193
+ /**
194
+ * Bulk upserts multiple records (creates if they don't exist, updates if they do).
195
+ * @param inputs Array of input data for the bulk upsert operation.
196
+ * @param options Optional create or update options.
197
+ * @returns Array of upserted records.
198
+ */
199
+ async bulkUpsert(
200
+ inputs: InferInput<TSchema>[],
201
+ options?: ICreateOptions | IUpdateOptions,
202
+ ): Promise<InferDetail<TSchema>[]> {
203
+ if (inputs.length === 0) {
204
+ return []
205
+ }
206
+
207
+ // Normalize all input data in parallel using Promise.all
208
+ const normalizedInputs = await Promise.all(inputs.map((input) => this.normalizeInput(input)))
209
+
210
+ // Build a map of primary key to input and lookup info
211
+ type EntityInfo = {
212
+ input: InferInput<TSchema>
213
+ lookup: InferLookup<TSchema>
214
+ primaryKeyValue: string | number
215
+ existingEntity?: InferDetail<TSchema>
216
+ operation?: ModelMutationAction
217
+ }
218
+
219
+ const entityInfoMap = new Map<string | number, EntityInfo>()
220
+ const inputsWithoutPrimaryKey: InferInput<TSchema>[] = []
221
+
222
+ // Process each normalized input and organize by primary key
223
+ for (const input of normalizedInputs) {
224
+ const primaryKeyValue = this.getPrimaryKeyValue(input)
225
+
226
+ if (primaryKeyValue !== undefined) {
227
+ const entityInfo: EntityInfo = {
228
+ input,
229
+ primaryKeyValue,
230
+ lookup: {
231
+ [this.entityMetadata.primaryKey]: primaryKeyValue,
232
+ } as InferLookup<TSchema>,
233
+ }
234
+ entityInfoMap.set(primaryKeyValue, entityInfo)
235
+ } else {
236
+ // Inputs without primary keys are always creates
237
+ inputsWithoutPrimaryKey.push(input)
238
+ }
239
+ }
240
+
241
+ // Extract lookups for existing entities
242
+ const lookups = Array.from(entityInfoMap.values()).map((info) => info.lookup)
243
+
244
+ // Load existing entities and update the map
245
+ if (lookups.length > 0) {
246
+ const existingEntities = await this.loadMany(lookups, options)
247
+ existingEntities.forEach((entity) => {
248
+ if (entity) {
249
+ const pkValue = this.getPrimaryKeyValue(entity)
250
+ if (pkValue !== undefined && entityInfoMap.has(pkValue)) {
251
+ const entityInfo = entityInfoMap.get(pkValue)!
252
+ entityInfo.existingEntity = entity
253
+ }
254
+ }
255
+ })
256
+ }
257
+
258
+ // Determine operation types and prepare before events
259
+ const beforeEvents: MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>[] = []
260
+
261
+ // Handle entities with primary keys
262
+ for (const entityInfo of entityInfoMap.values()) {
263
+ const operation = entityInfo.existingEntity
264
+ ? ModelMutationAction.BeforeUpdate
265
+ : ModelMutationAction.BeforeCreate
266
+
267
+ entityInfo.operation = operation
268
+
269
+ beforeEvents.push(
270
+ new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
271
+ this.getDescriptor(operation),
272
+ entityInfo.input,
273
+ ),
274
+ )
275
+ }
276
+
277
+ // Handle inputs without primary keys (always creates)
278
+ for (const input of inputsWithoutPrimaryKey) {
279
+ beforeEvents.push(
280
+ new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
281
+ this.getDescriptor(ModelMutationAction.BeforeCreate),
282
+ input,
283
+ ),
284
+ )
285
+ }
286
+
287
+ // Emit all before events
288
+ await Promise.all(beforeEvents.map((event) => this.emitter.emitAsync(event)))
289
+
290
+ // Perform the bulk upsert operation with normalized inputs
291
+ const results = await this.repository.bulkUpsert(normalizedInputs, options)
292
+
293
+ // Create a map of result primary keys to results for matching
294
+ const resultsByPrimaryKey = new Map<string | number, InferDetail<TSchema>>()
295
+ const resultsWithoutPrimaryKey: InferDetail<TSchema>[] = []
296
+
297
+ for (const result of results) {
298
+ const pkValue = this.getPrimaryKeyValue(result)
299
+ if (pkValue !== undefined) {
300
+ resultsByPrimaryKey.set(pkValue, result)
301
+ } else {
302
+ resultsWithoutPrimaryKey.push(result)
303
+ }
304
+ }
305
+
306
+ // Prepare after events by matching results back to original inputs
307
+ const afterEvents: MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>[] = []
308
+ let resultsWithoutPkIndex = 0
309
+
310
+ // Handle entities with primary keys
311
+ for (const entityInfo of entityInfoMap.values()) {
312
+ const matchedResult = resultsByPrimaryKey.get(entityInfo.primaryKeyValue)!
313
+
314
+ const afterOperation =
315
+ entityInfo.operation === ModelMutationAction.BeforeCreate
316
+ ? ModelMutationAction.AfterCreate
317
+ : ModelMutationAction.AfterUpdate
318
+
319
+ afterEvents.push(
320
+ new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
321
+ this.getDescriptor(afterOperation),
322
+ entityInfo.input,
323
+ ).setResult(matchedResult),
324
+ )
325
+ }
326
+
327
+ // Handle inputs without primary keys (always creates)
328
+ for (const input of inputsWithoutPrimaryKey) {
329
+ const matchedResult = resultsWithoutPrimaryKey[resultsWithoutPkIndex++]
330
+
331
+ afterEvents.push(
332
+ new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
333
+ this.getDescriptor(ModelMutationAction.AfterCreate),
334
+ input,
335
+ ).setResult(matchedResult),
336
+ )
337
+ }
338
+
339
+ // Emit all after events
340
+ await Promise.all(afterEvents.map((event) => this.emitter.emitAsync(event)))
341
+
342
+ // Return the results
343
+ return results
344
+ }
345
+ }
@@ -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
+ })