@declaro/data 2.0.0-beta.12 → 2.0.0-beta.121

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 (130) 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 +13226 -0
  6. package/dist/node/index.cjs.map +93 -0
  7. package/dist/node/index.js +13205 -0
  8. package/dist/node/index.js.map +93 -0
  9. package/dist/ts/application/model-controller.d.ts +50 -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 +34 -0
  22. package/dist/ts/domain/events/event-types.d.ts.map +1 -0
  23. package/dist/ts/domain/events/mutation-event.d.ts +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 +110 -0
  36. package/dist/ts/domain/interfaces/repository.d.ts.map +1 -0
  37. package/dist/ts/domain/models/pagination.d.ts +28 -0
  38. package/dist/ts/domain/models/pagination.d.ts.map +1 -0
  39. package/dist/ts/domain/services/base-model-service.d.ts +23 -0
  40. package/dist/ts/domain/services/base-model-service.d.ts.map +1 -0
  41. package/dist/ts/domain/services/model-service-args.d.ts +9 -0
  42. package/dist/ts/domain/services/model-service-args.d.ts.map +1 -0
  43. package/dist/ts/domain/services/model-service.d.ts +72 -0
  44. package/dist/ts/domain/services/model-service.d.ts.map +1 -0
  45. package/dist/ts/domain/services/model-service.normalization.test.d.ts +2 -0
  46. package/dist/ts/domain/services/model-service.normalization.test.d.ts.map +1 -0
  47. package/dist/ts/domain/services/model-service.test.d.ts +2 -0
  48. package/dist/ts/domain/services/model-service.test.d.ts.map +1 -0
  49. package/dist/ts/domain/services/read-only-model-service.d.ts +76 -0
  50. package/dist/ts/domain/services/read-only-model-service.d.ts.map +1 -0
  51. package/dist/ts/domain/services/read-only-model-service.test.d.ts +2 -0
  52. package/dist/ts/domain/services/read-only-model-service.test.d.ts.map +1 -0
  53. package/dist/ts/index.d.ts +18 -0
  54. package/dist/ts/index.d.ts.map +1 -0
  55. package/dist/ts/shared/utils/schema-inference.d.ts +23 -0
  56. package/dist/ts/shared/utils/schema-inference.d.ts.map +1 -0
  57. package/dist/ts/shared/utils/schema-inheritance.d.ts +24 -0
  58. package/dist/ts/shared/utils/schema-inheritance.d.ts.map +1 -0
  59. package/dist/ts/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 +62 -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.trash.test.d.ts +2 -0
  76. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts.map +1 -0
  77. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts +2 -0
  78. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts.map +1 -0
  79. package/package.json +45 -42
  80. package/src/application/model-controller.test.ts +694 -0
  81. package/src/application/model-controller.ts +135 -0
  82. package/src/application/read-only-model-controller.test.ts +335 -0
  83. package/src/application/read-only-model-controller.ts +61 -0
  84. package/src/domain/events/domain-event.test.ts +82 -0
  85. package/src/domain/events/domain-event.ts +69 -0
  86. package/src/domain/events/event-types.ts +34 -0
  87. package/src/domain/events/mutation-event.test.ts +38 -0
  88. package/src/domain/events/mutation-event.ts +8 -0
  89. package/src/domain/events/query-event.test.ts +28 -0
  90. package/src/domain/events/query-event.ts +8 -0
  91. package/src/domain/events/request-event.test.ts +38 -0
  92. package/src/domain/events/request-event.ts +32 -0
  93. package/src/domain/interfaces/repository.ts +136 -0
  94. package/src/domain/models/pagination.ts +28 -0
  95. package/src/domain/services/base-model-service.ts +54 -0
  96. package/src/domain/services/model-service-args.ts +9 -0
  97. package/src/domain/services/model-service.normalization.test.ts +704 -0
  98. package/src/domain/services/model-service.test.ts +940 -0
  99. package/src/domain/services/model-service.ts +432 -0
  100. package/src/domain/services/read-only-model-service.test.ts +828 -0
  101. package/src/domain/services/read-only-model-service.ts +178 -0
  102. package/src/index.ts +17 -4
  103. package/src/shared/utils/schema-inference.ts +26 -0
  104. package/src/shared/utils/schema-inheritance.ts +28 -0
  105. package/src/test/mock/models/mock-book-models.ts +78 -0
  106. package/src/test/mock/repositories/mock-memory-repository.assign.test.ts +215 -0
  107. package/src/test/mock/repositories/mock-memory-repository.basic.test.ts +129 -0
  108. package/src/test/mock/repositories/mock-memory-repository.bulk-upsert.test.ts +159 -0
  109. package/src/test/mock/repositories/mock-memory-repository.count.test.ts +98 -0
  110. package/src/test/mock/repositories/mock-memory-repository.custom-lookup.test.ts +0 -0
  111. package/src/test/mock/repositories/mock-memory-repository.search.test.ts +265 -0
  112. package/src/test/mock/repositories/mock-memory-repository.trash.test.ts +736 -0
  113. package/src/test/mock/repositories/mock-memory-repository.ts +401 -0
  114. package/src/test/mock/repositories/mock-memory-repository.upsert.test.ts +108 -0
  115. package/dist/databaseConnection.d.ts +0 -24
  116. package/dist/datastoreAbstract.d.ts +0 -37
  117. package/dist/declaro-data.cjs +0 -1
  118. package/dist/declaro-data.mjs +0 -250
  119. package/dist/hydrateEntity.d.ts +0 -8
  120. package/dist/index.d.ts +0 -4
  121. package/dist/serverConnection.d.ts +0 -15
  122. package/dist/trackedStatus.d.ts +0 -15
  123. package/src/databaseConnection.ts +0 -137
  124. package/src/datastoreAbstract.ts +0 -190
  125. package/src/hydrateEntity.ts +0 -36
  126. package/src/placeholder.test.ts +0 -7
  127. package/src/serverConnection.ts +0 -74
  128. package/src/trackedStatus.ts +0 -35
  129. package/tsconfig.json +0 -10
  130. package/vite.config.ts +0 -23
@@ -0,0 +1,135 @@
1
+ import type { AuthValidator } from '@declaro/auth'
2
+ import { PermissionValidator, type AnyModelSchema } from '@declaro/core'
3
+ import type { ModelService, ICreateOptions, IUpdateOptions } from '../domain/services/model-service'
4
+ import type { InferDetail, InferFilters, InferInput, InferLookup, InferSummary } from '../shared/utils/schema-inference'
5
+ import { ReadOnlyModelController } from './read-only-model-controller'
6
+
7
+ export class ModelController<TSchema extends AnyModelSchema> extends ReadOnlyModelController<TSchema> {
8
+ constructor(protected readonly service: ModelService<TSchema>, protected readonly authValidator: AuthValidator) {
9
+ super(service, authValidator)
10
+ }
11
+
12
+ async create(input: InferInput<TSchema>): Promise<InferDetail<TSchema>> {
13
+ this.authValidator.validatePermissions((v) =>
14
+ v.someOf([
15
+ this.service.getDescriptor('create', '*').toString(),
16
+ this.service.getDescriptor('write', '*').toString(),
17
+ ]),
18
+ )
19
+ return this.service.create(input)
20
+ }
21
+
22
+ async update(lookup: InferLookup<TSchema>, input: InferInput<TSchema>): Promise<InferDetail<TSchema>> {
23
+ this.authValidator.validatePermissions((v) =>
24
+ v.someOf([
25
+ this.service.getDescriptor('update', '*').toString(),
26
+ this.service.getDescriptor('write', '*').toString(),
27
+ ]),
28
+ )
29
+ return this.service.update(lookup, input)
30
+ }
31
+
32
+ async remove(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
33
+ this.authValidator.validatePermissions((v) =>
34
+ v.someOf([
35
+ this.service.getDescriptor('remove', '*').toString(),
36
+ this.service.getDescriptor('write', '*').toString(),
37
+ ]),
38
+ )
39
+ return this.service.remove(lookup)
40
+ }
41
+
42
+ async restore(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
43
+ this.authValidator.validatePermissions((v) =>
44
+ v.someOf([
45
+ this.service.getDescriptor('restore', '*').toString(),
46
+ this.service.getDescriptor('write', '*').toString(),
47
+ ]),
48
+ )
49
+ return this.service.restore(lookup)
50
+ }
51
+
52
+ /**
53
+ * Upserts a record (creates if it doesn't exist, updates if it does).
54
+ * @param input The input data for the upsert operation.
55
+ * @param options Optional create or update options.
56
+ * @returns The upserted record.
57
+ */
58
+ async upsert(input: InferInput<TSchema>, options?: ICreateOptions | IUpdateOptions): Promise<InferDetail<TSchema>> {
59
+ // Create nested validator for (create AND update) permissions
60
+ const createAndUpdateValidator = PermissionValidator.create().allOf([
61
+ this.service.getDescriptor('create', '*').toString(),
62
+ this.service.getDescriptor('update', '*').toString(),
63
+ ])
64
+
65
+ this.authValidator.validatePermissions((v) =>
66
+ v.someOf([createAndUpdateValidator, this.service.getDescriptor('write', '*').toString()]),
67
+ )
68
+ return this.service.upsert(input, options)
69
+ }
70
+
71
+ /**
72
+ * Bulk upserts multiple records (creates if they don't exist, updates if they do).
73
+ * @param inputs Array of input data for the bulk upsert operation.
74
+ * @param options Optional create or update options.
75
+ * @returns Array of upserted records.
76
+ */
77
+ async bulkUpsert(
78
+ inputs: InferInput<TSchema>[],
79
+ options?: ICreateOptions | IUpdateOptions,
80
+ ): Promise<InferDetail<TSchema>[]> {
81
+ // Create nested validator for (create AND update) permissions
82
+ const createAndUpdateValidator = PermissionValidator.create().allOf([
83
+ this.service.getDescriptor('create', '*').toString(),
84
+ this.service.getDescriptor('update', '*').toString(),
85
+ ])
86
+
87
+ this.authValidator.validatePermissions((v) =>
88
+ v.someOf([createAndUpdateValidator, this.service.getDescriptor('write', '*').toString()]),
89
+ )
90
+ return this.service.bulkUpsert(inputs, options)
91
+ }
92
+
93
+ /**
94
+ * Permanently deletes a specific entity from the trash.
95
+ * Requires 'permanently-delete-from-trash', 'permanently-delete', or 'empty-trash' permission.
96
+ * @param lookup The lookup object containing entity identifiers
97
+ * @returns The permanently deleted entity summary
98
+ */
99
+ async permanentlyDeleteFromTrash(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
100
+ this.authValidator.validatePermissions((v) =>
101
+ v.someOf([
102
+ this.service.getDescriptor('permanently-delete-from-trash', '*').toString(),
103
+ this.service.getDescriptor('permanently-delete', '*').toString(),
104
+ this.service.getDescriptor('empty-trash', '*').toString(),
105
+ ]),
106
+ )
107
+ return this.service.permanentlyDeleteFromTrash(lookup)
108
+ }
109
+
110
+ /**
111
+ * Permanently deletes an entity without moving it to trash first.
112
+ * Requires 'permanently-delete' permission.
113
+ * @param lookup The lookup object containing entity identifiers
114
+ * @returns The permanently deleted entity summary
115
+ */
116
+ async permanentlyDelete(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
117
+ this.authValidator.validatePermissions((v) =>
118
+ v.someOf([this.service.getDescriptor('permanently-delete', '*').toString()]),
119
+ )
120
+ return this.service.permanentlyDelete(lookup)
121
+ }
122
+
123
+ /**
124
+ * Empties the trash by permanently deleting entities that have been marked as removed.
125
+ * Requires 'empty-trash' permission.
126
+ * @param filters Optional filters to apply when selecting entities to delete
127
+ * @returns The count of entities permanently deleted
128
+ */
129
+ async emptyTrash(filters?: InferFilters<TSchema>): Promise<number> {
130
+ this.authValidator.validatePermissions((v) =>
131
+ v.someOf([this.service.getDescriptor('empty-trash', '*').toString()]),
132
+ )
133
+ return this.service.emptyTrash(filters)
134
+ }
135
+ }
@@ -0,0 +1,335 @@
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
+ null,
26
+ authService,
27
+ )
28
+ invalidAuthValidator = new AuthValidator(
29
+ getMockAuthSession({
30
+ claims: ['authors::author.read:all'],
31
+ }),
32
+ null,
33
+ authService,
34
+ )
35
+ service = new ReadOnlyModelService({
36
+ repository,
37
+ emitter: new EventManager(),
38
+ schema: mockSchema,
39
+ namespace,
40
+ })
41
+ })
42
+
43
+ it('should load a single record if permissions are valid', async () => {
44
+ const controller = new ReadOnlyModelController(service, authValidator)
45
+
46
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
47
+ await repository.create(input)
48
+
49
+ const record = await controller.load({ id: 42 })
50
+
51
+ expect(record).toEqual(input)
52
+ })
53
+
54
+ it('should throw PermissionError if permissions are invalid for load', async () => {
55
+ const controller = new ReadOnlyModelController(service, invalidAuthValidator)
56
+
57
+ await expect(controller.load({ id: 42 })).rejects.toThrow(PermissionError)
58
+ })
59
+
60
+ it('should load multiple records if permissions are valid', async () => {
61
+ const controller = new ReadOnlyModelController(service, authValidator)
62
+
63
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
64
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
65
+ await repository.create(input1)
66
+ await repository.create(input2)
67
+
68
+ const records = await controller.loadMany([{ id: 42 }, { id: 43 }])
69
+
70
+ expect(records).toEqual([input1, input2])
71
+ })
72
+
73
+ it('should throw PermissionError if permissions are invalid for loadMany', async () => {
74
+ const controller = new ReadOnlyModelController(service, invalidAuthValidator)
75
+
76
+ await expect(controller.loadMany([{ id: 42 }, { id: 43 }])).rejects.toThrow(PermissionError)
77
+ })
78
+
79
+ it('should search for records if permissions are valid', async () => {
80
+ const controller = new ReadOnlyModelController(service, authValidator)
81
+
82
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
83
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
84
+ await repository.create(input1)
85
+ await repository.create(input2)
86
+
87
+ const results = await controller.search({ text: 'Test' })
88
+
89
+ expect(results.results).toEqual([input1, input2])
90
+ expect(results.pagination.total).toBe(2)
91
+ })
92
+
93
+ it('should throw PermissionError if permissions are invalid for search', async () => {
94
+ const controller = new ReadOnlyModelController(service, invalidAuthValidator)
95
+
96
+ await expect(controller.search({ text: 'Test' })).rejects.toThrow(PermissionError)
97
+ })
98
+
99
+ it('should handle search with pagination options', async () => {
100
+ const controller = new ReadOnlyModelController(service, authValidator)
101
+
102
+ // Create 5 items
103
+ for (let i = 1; i <= 5; i++) {
104
+ await repository.create({
105
+ id: i,
106
+ title: `Test Book ${i}`,
107
+ author: `Author ${i}`,
108
+ publishedDate: new Date(),
109
+ })
110
+ }
111
+
112
+ const results = await controller.search(
113
+ {},
114
+ {
115
+ pagination: { page: 1, pageSize: 2 },
116
+ },
117
+ )
118
+
119
+ expect(results.results).toHaveLength(2)
120
+ expect(results.pagination.page).toBe(1)
121
+ expect(results.pagination.pageSize).toBe(2)
122
+ expect(results.pagination.total).toBe(5)
123
+ expect(results.pagination.totalPages).toBe(3)
124
+ })
125
+
126
+ it('should handle search with sort options', async () => {
127
+ const controller = new ReadOnlyModelController(service, authValidator)
128
+
129
+ const input1 = { id: 1, title: 'Z Book', author: 'Author A', publishedDate: new Date() }
130
+ const input2 = { id: 2, title: 'A Book', author: 'Author B', publishedDate: new Date() }
131
+ await repository.create(input1)
132
+ await repository.create(input2)
133
+
134
+ const results = await controller.search(
135
+ {},
136
+ {
137
+ sort: [{ title: 'asc' }],
138
+ },
139
+ )
140
+
141
+ expect(results.results.map((r) => r.title)).toEqual(['A Book', 'Z Book'])
142
+ expect(results.pagination.total).toBe(2)
143
+ })
144
+
145
+ it('should handle search with combined options', async () => {
146
+ const repositoryWithFilter = new MockMemoryRepository({
147
+ schema: mockSchema,
148
+ filter: (data, filters) => {
149
+ if (filters.text) {
150
+ return data.title.toLowerCase().includes(filters.text.toLowerCase())
151
+ }
152
+ return true
153
+ },
154
+ })
155
+
156
+ const serviceWithFilter = new ReadOnlyModelService({
157
+ repository: repositoryWithFilter,
158
+ emitter: new EventManager(),
159
+ namespace,
160
+ schema: mockSchema,
161
+ })
162
+
163
+ const controller = new ReadOnlyModelController(serviceWithFilter, authValidator)
164
+
165
+ await repositoryWithFilter.create({ title: 'Test Z Book', author: 'Author 1', publishedDate: new Date() })
166
+ await repositoryWithFilter.create({ title: 'Test A Book', author: 'Author 2', publishedDate: new Date() })
167
+ await repositoryWithFilter.create({ title: 'Other Book', author: 'Author 3', publishedDate: new Date() })
168
+
169
+ const results = await controller.search(
170
+ { text: 'Test' },
171
+ {
172
+ sort: [{ title: 'asc' }],
173
+ pagination: { page: 1, pageSize: 1 },
174
+ },
175
+ )
176
+
177
+ expect(results.results).toHaveLength(1)
178
+ expect(results.results[0].title).toBe('Test A Book')
179
+ expect(results.pagination.total).toBe(2)
180
+ expect(results.pagination.totalPages).toBe(2)
181
+ })
182
+
183
+ it('should count records if permissions are valid', async () => {
184
+ const controller = new ReadOnlyModelController(service, authValidator)
185
+
186
+ const input1 = { id: 1, title: 'Test Book 1', author: 'Author Name', publishedDate: new Date() }
187
+ const input2 = { id: 2, title: 'Test Book 2', author: 'Author Name', publishedDate: new Date() }
188
+ await repository.create(input1)
189
+ await repository.create(input2)
190
+
191
+ const count = await controller.count({})
192
+
193
+ expect(count).toBe(2)
194
+ })
195
+
196
+ it('should throw PermissionError if permissions are invalid for count', async () => {
197
+ const controller = new ReadOnlyModelController(service, invalidAuthValidator)
198
+
199
+ await expect(controller.count({})).rejects.toThrow(PermissionError)
200
+ })
201
+
202
+ describe('granular permission testing', () => {
203
+ it('should allow load with specific load permission', async () => {
204
+ const loadOnlyValidator = new AuthValidator(
205
+ getMockAuthSession({
206
+ claims: ['books::book.load:all'],
207
+ }),
208
+ null,
209
+ authService,
210
+ )
211
+ const controller = new ReadOnlyModelController(service, loadOnlyValidator)
212
+
213
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
214
+ await repository.create(input)
215
+
216
+ const record = await controller.load({ id: 42 })
217
+
218
+ expect(record).toEqual(input)
219
+ })
220
+
221
+ it('should allow loadMany with specific loadMany permission', async () => {
222
+ const loadManyOnlyValidator = new AuthValidator(
223
+ getMockAuthSession({
224
+ claims: ['books::book.loadMany:all'],
225
+ }),
226
+ null,
227
+ authService,
228
+ )
229
+ const controller = new ReadOnlyModelController(service, loadManyOnlyValidator)
230
+
231
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
232
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
233
+ await repository.create(input1)
234
+ await repository.create(input2)
235
+
236
+ const records = await controller.loadMany([{ id: 42 }, { id: 43 }])
237
+
238
+ expect(records).toEqual([input1, input2])
239
+ })
240
+
241
+ it('should allow search with specific search permission', async () => {
242
+ const searchOnlyValidator = new AuthValidator(
243
+ getMockAuthSession({
244
+ claims: ['books::book.search:all'],
245
+ }),
246
+ null,
247
+ authService,
248
+ )
249
+ const controller = new ReadOnlyModelController(service, searchOnlyValidator)
250
+
251
+ const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
252
+ const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
253
+ await repository.create(input1)
254
+ await repository.create(input2)
255
+
256
+ const results = await controller.search({ text: 'Test' })
257
+
258
+ expect(results.results).toEqual([input1, input2])
259
+ expect(results.pagination.total).toBe(2)
260
+ })
261
+
262
+ it('should allow count with specific count permission', async () => {
263
+ const countOnlyValidator = new AuthValidator(
264
+ getMockAuthSession({
265
+ claims: ['books::book.count:all'],
266
+ }),
267
+ null,
268
+ authService,
269
+ )
270
+ const controller = new ReadOnlyModelController(service, countOnlyValidator)
271
+
272
+ const input1 = { id: 1, title: 'Test Book 1', author: 'Author Name', publishedDate: new Date() }
273
+ const input2 = { id: 2, title: 'Test Book 2', author: 'Author Name', publishedDate: new Date() }
274
+ await repository.create(input1)
275
+ await repository.create(input2)
276
+
277
+ const count = await controller.count({})
278
+
279
+ expect(count).toBe(2)
280
+ })
281
+
282
+ it('should reject operations with wrong namespace permissions', async () => {
283
+ const wrongNamespaceValidator = new AuthValidator(
284
+ getMockAuthSession({
285
+ claims: ['users::user.read:all'], // Wrong namespace
286
+ }),
287
+ null,
288
+ authService,
289
+ )
290
+ const controller = new ReadOnlyModelController(service, wrongNamespaceValidator)
291
+
292
+ await expect(controller.load({ id: 42 })).rejects.toThrow(PermissionError)
293
+ await expect(controller.loadMany([{ id: 42 }])).rejects.toThrow(PermissionError)
294
+ await expect(controller.search({})).rejects.toThrow(PermissionError)
295
+ await expect(controller.count({})).rejects.toThrow(PermissionError)
296
+ })
297
+
298
+ it('should reject operations with wrong resource permissions', async () => {
299
+ const wrongResourceValidator = new AuthValidator(
300
+ getMockAuthSession({
301
+ claims: ['books::author.read:all'], // Wrong resource
302
+ }),
303
+ null,
304
+ authService,
305
+ )
306
+ const controller = new ReadOnlyModelController(service, wrongResourceValidator)
307
+
308
+ await expect(controller.load({ id: 42 })).rejects.toThrow(PermissionError)
309
+ await expect(controller.loadMany([{ id: 42 }])).rejects.toThrow(PermissionError)
310
+ await expect(controller.search({})).rejects.toThrow(PermissionError)
311
+ await expect(controller.count({})).rejects.toThrow(PermissionError)
312
+ })
313
+
314
+ it('should allow all read operations with general read permission', async () => {
315
+ // This is already tested in the main tests, but including here for completeness
316
+ const controller = new ReadOnlyModelController(service, authValidator) // authValidator has read:all
317
+
318
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
319
+ await repository.create(input)
320
+
321
+ // Test all operations work with general read permission
322
+ const loadResult = await controller.load({ id: 42 })
323
+ expect(loadResult).toEqual(input)
324
+
325
+ const loadManyResult = await controller.loadMany([{ id: 42 }])
326
+ expect(loadManyResult).toEqual([input])
327
+
328
+ const searchResult = await controller.search({})
329
+ expect(searchResult.results).toEqual([input])
330
+
331
+ const countResult = await controller.count({})
332
+ expect(countResult).toBe(1)
333
+ })
334
+ })
335
+ })
@@ -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
+ }