@declaro/data 2.0.0-beta.8 → 2.0.0-beta.81

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 +46 -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,327 @@
1
+ import { AuthValidator, getMockAuthSession, mockAuthConfig, MockAuthService } from '@declaro/auth'
2
+ import { EventManager, PermissionError } from '@declaro/core'
3
+ import { beforeEach, describe, expect, it } from 'bun:test'
4
+ import { ReadOnlyModelService } from '../domain/services/read-only-model-service'
5
+ import { MockBookSchema } from '../test/mock/models/mock-book-models'
6
+ import { MockMemoryRepository } from '../test/mock/repositories/mock-memory-repository'
7
+ import { ReadOnlyModelController } from './read-only-model-controller'
8
+
9
+ describe('ReadOnlyModelController', () => {
10
+ const namespace = 'books'
11
+ const mockSchema = MockBookSchema
12
+ const authService = new MockAuthService(mockAuthConfig)
13
+
14
+ let repository: MockMemoryRepository<typeof mockSchema>
15
+ let service: ReadOnlyModelService<typeof mockSchema>
16
+ let authValidator: AuthValidator
17
+ let invalidAuthValidator: AuthValidator
18
+
19
+ beforeEach(() => {
20
+ repository = new MockMemoryRepository({ schema: mockSchema })
21
+ authValidator = new AuthValidator(
22
+ getMockAuthSession({
23
+ claims: ['books::book.read:all'],
24
+ }),
25
+ authService,
26
+ )
27
+ invalidAuthValidator = new AuthValidator(
28
+ getMockAuthSession({
29
+ claims: ['authors::author.read:all'],
30
+ }),
31
+ authService,
32
+ )
33
+ service = new ReadOnlyModelService({
34
+ repository,
35
+ emitter: new EventManager(),
36
+ schema: mockSchema,
37
+ namespace,
38
+ })
39
+ })
40
+
41
+ it('should load a single record if permissions are valid', async () => {
42
+ const controller = new ReadOnlyModelController(service, authValidator)
43
+
44
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
45
+ await repository.create(input)
46
+
47
+ const record = await controller.load({ id: 42 })
48
+
49
+ expect(record).toEqual(input)
50
+ })
51
+
52
+ it('should throw PermissionError if permissions are invalid for load', async () => {
53
+ const controller = new ReadOnlyModelController(service, invalidAuthValidator)
54
+
55
+ await expect(controller.load({ id: 42 })).rejects.toThrow(PermissionError)
56
+ })
57
+
58
+ it('should load multiple records if permissions are valid', async () => {
59
+ const controller = new ReadOnlyModelController(service, authValidator)
60
+
61
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
62
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
63
+ await repository.create(input1)
64
+ await repository.create(input2)
65
+
66
+ const records = await controller.loadMany([{ id: 42 }, { id: 43 }])
67
+
68
+ expect(records).toEqual([input1, input2])
69
+ })
70
+
71
+ it('should throw PermissionError if permissions are invalid for loadMany', async () => {
72
+ const controller = new ReadOnlyModelController(service, invalidAuthValidator)
73
+
74
+ await expect(controller.loadMany([{ id: 42 }, { id: 43 }])).rejects.toThrow(PermissionError)
75
+ })
76
+
77
+ it('should search for records if permissions are valid', async () => {
78
+ const controller = new ReadOnlyModelController(service, authValidator)
79
+
80
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
81
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
82
+ await repository.create(input1)
83
+ await repository.create(input2)
84
+
85
+ const results = await controller.search({ text: 'Test' })
86
+
87
+ expect(results.results).toEqual([input1, input2])
88
+ expect(results.pagination.total).toBe(2)
89
+ })
90
+
91
+ it('should throw PermissionError if permissions are invalid for search', async () => {
92
+ const controller = new ReadOnlyModelController(service, invalidAuthValidator)
93
+
94
+ await expect(controller.search({ text: 'Test' })).rejects.toThrow(PermissionError)
95
+ })
96
+
97
+ it('should handle search with pagination options', async () => {
98
+ const controller = new ReadOnlyModelController(service, authValidator)
99
+
100
+ // Create 5 items
101
+ for (let i = 1; i <= 5; i++) {
102
+ await repository.create({
103
+ id: i,
104
+ title: `Test Book ${i}`,
105
+ author: `Author ${i}`,
106
+ publishedDate: new Date(),
107
+ })
108
+ }
109
+
110
+ const results = await controller.search(
111
+ {},
112
+ {
113
+ pagination: { page: 1, pageSize: 2 },
114
+ },
115
+ )
116
+
117
+ expect(results.results).toHaveLength(2)
118
+ expect(results.pagination.page).toBe(1)
119
+ expect(results.pagination.pageSize).toBe(2)
120
+ expect(results.pagination.total).toBe(5)
121
+ expect(results.pagination.totalPages).toBe(3)
122
+ })
123
+
124
+ it('should handle search with sort options', async () => {
125
+ const controller = new ReadOnlyModelController(service, authValidator)
126
+
127
+ const input1 = { id: 1, title: 'Z Book', author: 'Author A', publishedDate: new Date() }
128
+ const input2 = { id: 2, title: 'A Book', author: 'Author B', publishedDate: new Date() }
129
+ await repository.create(input1)
130
+ await repository.create(input2)
131
+
132
+ const results = await controller.search(
133
+ {},
134
+ {
135
+ sort: [{ title: 'asc' }],
136
+ },
137
+ )
138
+
139
+ expect(results.results.map((r) => r.title)).toEqual(['A Book', 'Z Book'])
140
+ expect(results.pagination.total).toBe(2)
141
+ })
142
+
143
+ it('should handle search with combined options', async () => {
144
+ const repositoryWithFilter = new MockMemoryRepository({
145
+ schema: mockSchema,
146
+ filter: (data, filters) => {
147
+ if (filters.text) {
148
+ return data.title.toLowerCase().includes(filters.text.toLowerCase())
149
+ }
150
+ return true
151
+ },
152
+ })
153
+
154
+ const serviceWithFilter = new ReadOnlyModelService({
155
+ repository: repositoryWithFilter,
156
+ emitter: new EventManager(),
157
+ namespace,
158
+ schema: mockSchema,
159
+ })
160
+
161
+ const controller = new ReadOnlyModelController(serviceWithFilter, authValidator)
162
+
163
+ await repositoryWithFilter.create({ title: 'Test Z Book', author: 'Author 1', publishedDate: new Date() })
164
+ await repositoryWithFilter.create({ title: 'Test A Book', author: 'Author 2', publishedDate: new Date() })
165
+ await repositoryWithFilter.create({ title: 'Other Book', author: 'Author 3', publishedDate: new Date() })
166
+
167
+ const results = await controller.search(
168
+ { text: 'Test' },
169
+ {
170
+ sort: [{ title: 'asc' }],
171
+ pagination: { page: 1, pageSize: 1 },
172
+ },
173
+ )
174
+
175
+ expect(results.results).toHaveLength(1)
176
+ expect(results.results[0].title).toBe('Test A Book')
177
+ expect(results.pagination.total).toBe(2)
178
+ expect(results.pagination.totalPages).toBe(2)
179
+ })
180
+
181
+ it('should count records if permissions are valid', async () => {
182
+ const controller = new ReadOnlyModelController(service, authValidator)
183
+
184
+ const input1 = { id: 1, title: 'Test Book 1', author: 'Author Name', publishedDate: new Date() }
185
+ const input2 = { id: 2, title: 'Test Book 2', author: 'Author Name', publishedDate: new Date() }
186
+ await repository.create(input1)
187
+ await repository.create(input2)
188
+
189
+ const count = await controller.count({})
190
+
191
+ expect(count).toBe(2)
192
+ })
193
+
194
+ it('should throw PermissionError if permissions are invalid for count', async () => {
195
+ const controller = new ReadOnlyModelController(service, invalidAuthValidator)
196
+
197
+ await expect(controller.count({})).rejects.toThrow(PermissionError)
198
+ })
199
+
200
+ describe('granular permission testing', () => {
201
+ it('should allow load with specific load permission', async () => {
202
+ const loadOnlyValidator = new AuthValidator(
203
+ getMockAuthSession({
204
+ claims: ['books::book.load:all'],
205
+ }),
206
+ authService,
207
+ )
208
+ const controller = new ReadOnlyModelController(service, loadOnlyValidator)
209
+
210
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
211
+ await repository.create(input)
212
+
213
+ const record = await controller.load({ id: 42 })
214
+
215
+ expect(record).toEqual(input)
216
+ })
217
+
218
+ it('should allow loadMany with specific loadMany permission', async () => {
219
+ const loadManyOnlyValidator = new AuthValidator(
220
+ getMockAuthSession({
221
+ claims: ['books::book.loadMany:all'],
222
+ }),
223
+ authService,
224
+ )
225
+ const controller = new ReadOnlyModelController(service, loadManyOnlyValidator)
226
+
227
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
228
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
229
+ await repository.create(input1)
230
+ await repository.create(input2)
231
+
232
+ const records = await controller.loadMany([{ id: 42 }, { id: 43 }])
233
+
234
+ expect(records).toEqual([input1, input2])
235
+ })
236
+
237
+ it('should allow search with specific search permission', async () => {
238
+ const searchOnlyValidator = new AuthValidator(
239
+ getMockAuthSession({
240
+ claims: ['books::book.search:all'],
241
+ }),
242
+ authService,
243
+ )
244
+ const controller = new ReadOnlyModelController(service, searchOnlyValidator)
245
+
246
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
247
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
248
+ await repository.create(input1)
249
+ await repository.create(input2)
250
+
251
+ const results = await controller.search({ text: 'Test' })
252
+
253
+ expect(results.results).toEqual([input1, input2])
254
+ expect(results.pagination.total).toBe(2)
255
+ })
256
+
257
+ it('should allow count with specific count permission', async () => {
258
+ const countOnlyValidator = new AuthValidator(
259
+ getMockAuthSession({
260
+ claims: ['books::book.count:all'],
261
+ }),
262
+ authService,
263
+ )
264
+ const controller = new ReadOnlyModelController(service, countOnlyValidator)
265
+
266
+ const input1 = { id: 1, title: 'Test Book 1', author: 'Author Name', publishedDate: new Date() }
267
+ const input2 = { id: 2, title: 'Test Book 2', author: 'Author Name', publishedDate: new Date() }
268
+ await repository.create(input1)
269
+ await repository.create(input2)
270
+
271
+ const count = await controller.count({})
272
+
273
+ expect(count).toBe(2)
274
+ })
275
+
276
+ it('should reject operations with wrong namespace permissions', async () => {
277
+ const wrongNamespaceValidator = new AuthValidator(
278
+ getMockAuthSession({
279
+ claims: ['users::user.read:all'], // Wrong namespace
280
+ }),
281
+ authService,
282
+ )
283
+ const controller = new ReadOnlyModelController(service, wrongNamespaceValidator)
284
+
285
+ await expect(controller.load({ id: 42 })).rejects.toThrow(PermissionError)
286
+ await expect(controller.loadMany([{ id: 42 }])).rejects.toThrow(PermissionError)
287
+ await expect(controller.search({})).rejects.toThrow(PermissionError)
288
+ await expect(controller.count({})).rejects.toThrow(PermissionError)
289
+ })
290
+
291
+ it('should reject operations with wrong resource permissions', async () => {
292
+ const wrongResourceValidator = new AuthValidator(
293
+ getMockAuthSession({
294
+ claims: ['books::author.read:all'], // Wrong resource
295
+ }),
296
+ authService,
297
+ )
298
+ const controller = new ReadOnlyModelController(service, wrongResourceValidator)
299
+
300
+ await expect(controller.load({ id: 42 })).rejects.toThrow(PermissionError)
301
+ await expect(controller.loadMany([{ id: 42 }])).rejects.toThrow(PermissionError)
302
+ await expect(controller.search({})).rejects.toThrow(PermissionError)
303
+ await expect(controller.count({})).rejects.toThrow(PermissionError)
304
+ })
305
+
306
+ it('should allow all read operations with general read permission', async () => {
307
+ // This is already tested in the main tests, but including here for completeness
308
+ const controller = new ReadOnlyModelController(service, authValidator) // authValidator has read:all
309
+
310
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
311
+ await repository.create(input)
312
+
313
+ // Test all operations work with general read permission
314
+ const loadResult = await controller.load({ id: 42 })
315
+ expect(loadResult).toEqual(input)
316
+
317
+ const loadManyResult = await controller.loadMany([{ id: 42 }])
318
+ expect(loadManyResult).toEqual([input])
319
+
320
+ const searchResult = await controller.search({})
321
+ expect(searchResult.results).toEqual([input])
322
+
323
+ const countResult = await controller.count({})
324
+ expect(countResult).toBe(1)
325
+ })
326
+ })
327
+ })
@@ -0,0 +1,61 @@
1
+ import type { AuthValidator } from '@declaro/auth'
2
+ import {} from '@declaro/auth'
3
+ import type { AnyModelSchema } from '@declaro/core'
4
+ import type { ILoadOptions, ISearchOptions, ReadOnlyModelService } from '../domain/services/read-only-model-service'
5
+ import type { InferDetail, InferFilters, InferLookup, InferSearchResults } from '../shared/utils/schema-inference'
6
+
7
+ export class ReadOnlyModelController<TSchema extends AnyModelSchema> {
8
+ constructor(
9
+ protected readonly service: ReadOnlyModelService<TSchema>,
10
+ protected readonly authValidator: AuthValidator,
11
+ ) {}
12
+
13
+ async load(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferDetail<TSchema>> {
14
+ this.authValidator.validatePermissions((v) =>
15
+ v.someOf([
16
+ this.service.getDescriptor('load', '*').toString(),
17
+ this.service.getDescriptor('read', '*').toString(),
18
+ ]),
19
+ )
20
+ return this.service.load(lookup, options)
21
+ }
22
+
23
+ async loadMany(lookups: InferLookup<TSchema>[], options?: ILoadOptions): Promise<InferDetail<TSchema>[]> {
24
+ this.authValidator.validatePermissions((v) =>
25
+ v.someOf([
26
+ this.service.getDescriptor('loadMany', '*').toString(),
27
+ this.service.getDescriptor('read', '*').toString(),
28
+ ]),
29
+ )
30
+ return this.service.loadMany(lookups, options)
31
+ }
32
+
33
+ async search(
34
+ input: InferFilters<TSchema>,
35
+ options?: ISearchOptions<TSchema>,
36
+ ): Promise<InferSearchResults<TSchema>> {
37
+ this.authValidator.validatePermissions((v) =>
38
+ v.someOf([
39
+ this.service.getDescriptor('search', '*').toString(),
40
+ this.service.getDescriptor('read', '*').toString(),
41
+ ]),
42
+ )
43
+ return this.service.search(input, options)
44
+ }
45
+
46
+ /**
47
+ * Count the number of records matching the given filters.
48
+ * @param input The filters to apply to the count operation.
49
+ * @param options Additional options for the count operation.
50
+ * @returns The count of matching records.
51
+ */
52
+ async count(input: InferFilters<TSchema>, options?: ISearchOptions<TSchema>): Promise<number> {
53
+ this.authValidator.validatePermissions((v) =>
54
+ v.someOf([
55
+ this.service.getDescriptor('count', '*').toString(),
56
+ this.service.getDescriptor('read', '*').toString(),
57
+ ]),
58
+ )
59
+ return this.service.count(input, options)
60
+ }
61
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { DomainEvent } from './domain-event'
3
+ import type { IDomainEventOptions } from './domain-event'
4
+
5
+ interface Book {
6
+ title: string
7
+ author: string
8
+ }
9
+
10
+ class MyEvent extends DomainEvent<Book> {
11
+ type = 'BOOK_CREATED' as const
12
+
13
+ constructor(options: IDomainEventOptions<Book>) {
14
+ super(options)
15
+ }
16
+ }
17
+
18
+ describe('DomainEvent', () => {
19
+ it('should create a domain event with default values', () => {
20
+ const book: Book = { title: '1984', author: 'George Orwell' }
21
+ const event = new MyEvent({ data: book })
22
+
23
+ expect(event.eventId?.length).toBeGreaterThanOrEqual(36) // UUID length
24
+ expect(event.data).toEqual(book)
25
+ expect(event.timestamp).toBeInstanceOf(Date)
26
+ expect(event.type).toBe('BOOK_CREATED')
27
+ expect(event.session).toBeUndefined() // session is optional and not set by default
28
+ expect(event.meta).toEqual({}) // meta should default to an empty object
29
+ })
30
+
31
+ it('should create a domain event with metadata', () => {
32
+ const book: Book = { title: '1984', author: 'George Orwell' }
33
+ const event = new MyEvent({ data: book, meta: { createdBy: 'user123' } })
34
+
35
+ expect(event.meta).toEqual({ createdBy: 'user123' })
36
+ })
37
+
38
+ it('should default timestamp to the current time', () => {
39
+ const book: Book = { title: '1984', author: 'George Orwell' }
40
+ const event = new MyEvent({ data: book })
41
+
42
+ const now = new Date()
43
+ expect(event.timestamp.getTime()).toBeLessThanOrEqual(now.getTime())
44
+ expect(event.timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 1000) // Allow 1 second margin
45
+ })
46
+
47
+ it('should instantiate with no input', () => {
48
+ const event = new DomainEvent()
49
+
50
+ expect(event.eventId).toBeDefined()
51
+ expect(event.timestamp).toBeInstanceOf(Date)
52
+ expect(event.type).toBe('UNKNOWN_EVENT')
53
+ expect(event.data).toBeUndefined()
54
+ expect(event.meta).toEqual({})
55
+ expect(event.session).toBeUndefined()
56
+ })
57
+
58
+ it('should create an event with a descriptor but no type', () => {
59
+ const book: Book = { title: '1984', author: 'George Orwell' }
60
+ const event = new DomainEvent({
61
+ data: book,
62
+ descriptor: { namespace: 'auth', resource: 'user', action: 'create', scope: 'admin' },
63
+ })
64
+
65
+ expect(event.type).toBe('auth::user.create:admin')
66
+ expect(event.descriptor.namespace).toBe('auth')
67
+ expect(event.descriptor.resource).toBe('user')
68
+ expect(event.descriptor.action).toBe('create')
69
+ expect(event.descriptor.scope).toBe('admin')
70
+ })
71
+
72
+ it('should create an event with a type but no descriptor', () => {
73
+ const book: Book = { title: '1984', author: 'George Orwell' }
74
+ const event = new DomainEvent({ data: book, meta: {}, type: 'books::book.create:own' })
75
+
76
+ expect(event.type).toBe('books::book.create:own')
77
+ expect(event.descriptor.namespace).toBe('books')
78
+ expect(event.descriptor.resource).toBe('book')
79
+ expect(event.descriptor.action).toBe('create')
80
+ expect(event.descriptor.scope).toBe('own')
81
+ })
82
+ })
@@ -0,0 +1,69 @@
1
+ import type { IAuthSession } from '@declaro/auth'
2
+ import { type IEvent, type IActionDescriptor, ActionDescriptor, type IActionDescriptorInput } from '@declaro/core'
3
+ import { v4 as uuid } from 'uuid'
4
+
5
+ export interface IDomainEvent<T, M = any> extends IEvent {
6
+ eventId: string
7
+ data?: T
8
+ meta?: M
9
+ timestamp: Date
10
+ descriptor: IActionDescriptor
11
+ session?: IAuthSession
12
+ }
13
+
14
+ export interface IDomainEventOptions<TData, TMeta = any> {
15
+ type?: string
16
+ eventId?: string
17
+ data?: TData
18
+ timestamp?: Date
19
+ descriptor?: IActionDescriptorInput
20
+ session?: IAuthSession
21
+ meta?: TMeta
22
+ }
23
+
24
+ export interface IDomainEventJSON<T, M = any> {
25
+ eventId: string
26
+ data?: T
27
+ meta?: M
28
+ timestamp: string // JSON-compatible format
29
+ type: string
30
+ session?: { id: string } // Simplified session representation
31
+ }
32
+
33
+ export class DomainEvent<T, M = any> implements IDomainEvent<T, M> {
34
+ readonly eventId: string
35
+ public data?: T
36
+ public timestamp: Date
37
+ public type: string = 'UNKNOWN_EVENT'
38
+ public descriptor: ActionDescriptor
39
+ public session?: IAuthSession
40
+ public meta: M
41
+
42
+ constructor(options: IDomainEventOptions<T, M> = {}) {
43
+ if (options.type) {
44
+ this.type = options.type
45
+ }
46
+ this.eventId = options.eventId ?? uuid()
47
+ this.timestamp = options.timestamp ?? new Date()
48
+ this.descriptor = options.descriptor
49
+ ? ActionDescriptor.fromJSON(options.descriptor)
50
+ : ActionDescriptor.fromString(this.type)
51
+ if (options.descriptor && this.type === 'UNKNOWN_EVENT') {
52
+ this.type = this.descriptor.toString()
53
+ }
54
+ this.data = options.data
55
+ this.meta = options.meta ?? ({} as M) // Ensure meta is always defined, defaulting to an empty object
56
+ this.session = options.session
57
+ }
58
+
59
+ toJSON(): IDomainEventJSON<T, M> {
60
+ return {
61
+ eventId: this.eventId,
62
+ data: this.data,
63
+ meta: this.meta,
64
+ timestamp: this.timestamp.toISOString(),
65
+ type: this.type,
66
+ session: this.session ? { id: this.session.id } : undefined,
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,21 @@
1
+ export enum ModelQueryEvent {
2
+ BeforeLoad = 'beforeLoad',
3
+ AfterLoad = 'afterLoad',
4
+ BeforeLoadMany = 'beforeLoadMany',
5
+ AfterLoadMany = 'afterLoadMany',
6
+ BeforeSearch = 'beforeSearch',
7
+ AfterSearch = 'afterSearch',
8
+ BeforeCount = 'beforeCount',
9
+ AfterCount = 'afterCount',
10
+ }
11
+
12
+ export enum ModelMutationAction {
13
+ BeforeCreate = 'beforeCreate',
14
+ AfterCreate = 'afterCreate',
15
+ BeforeUpdate = 'beforeUpdate',
16
+ AfterUpdate = 'afterUpdate',
17
+ BeforeRemove = 'beforeRemove',
18
+ AfterRemove = 'afterRemove',
19
+ BeforeRestore = 'beforeRestore',
20
+ AfterRestore = 'afterRestore',
21
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { MutationEvent } from './mutation-event'
3
+
4
+ describe('MutationEvent', () => {
5
+ it('should create a mutation event with input and descriptor', () => {
6
+ const input = { key: 'value' }
7
+ const descriptor = { namespace: 'test', action: 'create' }
8
+ const event = new MutationEvent(descriptor, input, {})
9
+
10
+ expect(event.descriptor.namespace).toBe('test')
11
+ expect(event.descriptor.action).toBe('create')
12
+ expect(event.meta.input).toEqual(input)
13
+ })
14
+
15
+ it('should update meta correctly', () => {
16
+ const input = { key: 'value' }
17
+ const descriptor = { namespace: 'test', action: 'create' }
18
+ const event = new MutationEvent(descriptor, input, {
19
+ foo: 'bar',
20
+ })
21
+
22
+ event.setMeta({
23
+ foo: 'baz',
24
+ })
25
+
26
+ expect(event.meta.foo).toBe('baz')
27
+ })
28
+
29
+ it('should set result correctly', () => {
30
+ const input = { key: 'value' }
31
+ const descriptor = { namespace: 'test', action: 'create' }
32
+ const event = new MutationEvent(descriptor, input, {})
33
+
34
+ const result = { success: true }
35
+ event.setResult(result)
36
+ expect(event.data).toEqual(result)
37
+ })
38
+ })
@@ -0,0 +1,8 @@
1
+ import type { IActionDescriptorInput } from '@declaro/core'
2
+ import { RequestEvent } from './request-event'
3
+
4
+ export class MutationEvent<TResult, TInput, TMeta = any> extends RequestEvent<TResult, TInput, TMeta> {
5
+ constructor(descriptor: IActionDescriptorInput, input: TInput, meta: TMeta = {} as TMeta) {
6
+ super(descriptor, input, meta)
7
+ }
8
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { QueryEvent } from './query-event'
3
+
4
+ describe('QueryEvent', () => {
5
+ describe('load action', () => {
6
+ it('should lookup a book with the given lookup', () => {
7
+ const params = { id: 42 }
8
+ const descriptor = { namespace: 'books', action: 'load' }
9
+ const query = new QueryEvent(descriptor, params, {})
10
+
11
+ expect(query.descriptor.namespace).toBe('books')
12
+ expect(query.descriptor.action).toBe('load')
13
+ expect(query.meta.input).toEqual(params)
14
+ })
15
+ })
16
+
17
+ describe('search action', () => {
18
+ it('should search for books with the given filters', () => {
19
+ const params = { text: '1984' }
20
+ const descriptor = { namespace: 'books', action: 'search' }
21
+ const query = new QueryEvent(descriptor, params, {})
22
+
23
+ expect(query.descriptor.namespace).toBe('books')
24
+ expect(query.descriptor.action).toBe('search')
25
+ expect(query.meta.input).toEqual(params)
26
+ })
27
+ })
28
+ })
@@ -0,0 +1,8 @@
1
+ import type { IActionDescriptorInput } from '@declaro/core'
2
+ import { RequestEvent } from './request-event'
3
+
4
+ export class QueryEvent<TResult, TParams, TMeta = any> extends RequestEvent<TResult, TParams, TMeta> {
5
+ constructor(descriptor: IActionDescriptorInput, params: TParams, meta: TMeta = {} as TMeta) {
6
+ super(descriptor, params, meta)
7
+ }
8
+ }