@declaro/data 2.0.0-beta.14 → 2.0.0-beta.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/{LICENSE → LICENSE.md} +1 -1
  2. package/README.md +0 -0
  3. package/dist/browser/index.js +26 -0
  4. package/dist/browser/index.js.map +93 -0
  5. package/dist/node/index.cjs +13372 -0
  6. package/dist/node/index.cjs.map +93 -0
  7. package/dist/node/index.js +13351 -0
  8. package/dist/node/index.js.map +93 -0
  9. package/dist/ts/application/model-controller.d.ts +60 -0
  10. package/dist/ts/application/model-controller.d.ts.map +1 -0
  11. package/dist/ts/application/model-controller.test.d.ts +2 -0
  12. package/dist/ts/application/model-controller.test.d.ts.map +1 -0
  13. package/dist/ts/application/read-only-model-controller.d.ts +24 -0
  14. package/dist/ts/application/read-only-model-controller.d.ts.map +1 -0
  15. package/dist/ts/application/read-only-model-controller.test.d.ts +2 -0
  16. package/dist/ts/application/read-only-model-controller.test.d.ts.map +1 -0
  17. package/dist/ts/domain/events/domain-event.d.ts +41 -0
  18. package/dist/ts/domain/events/domain-event.d.ts.map +1 -0
  19. package/dist/ts/domain/events/domain-event.test.d.ts +2 -0
  20. package/dist/ts/domain/events/domain-event.test.d.ts.map +1 -0
  21. package/dist/ts/domain/events/event-types.d.ts +37 -0
  22. package/dist/ts/domain/events/event-types.d.ts.map +1 -0
  23. package/dist/ts/domain/events/mutation-event.d.ts +41 -0
  24. package/dist/ts/domain/events/mutation-event.d.ts.map +1 -0
  25. package/dist/ts/domain/events/mutation-event.test.d.ts +2 -0
  26. package/dist/ts/domain/events/mutation-event.test.d.ts.map +1 -0
  27. package/dist/ts/domain/events/query-event.d.ts +8 -0
  28. package/dist/ts/domain/events/query-event.d.ts.map +1 -0
  29. package/dist/ts/domain/events/query-event.test.d.ts +2 -0
  30. package/dist/ts/domain/events/query-event.test.d.ts.map +1 -0
  31. package/dist/ts/domain/events/request-event.d.ts +26 -0
  32. package/dist/ts/domain/events/request-event.d.ts.map +1 -0
  33. package/dist/ts/domain/events/request-event.test.d.ts +2 -0
  34. package/dist/ts/domain/events/request-event.test.d.ts.map +1 -0
  35. package/dist/ts/domain/interfaces/repository.d.ts +110 -0
  36. package/dist/ts/domain/interfaces/repository.d.ts.map +1 -0
  37. package/dist/ts/domain/models/pagination.d.ts +28 -0
  38. package/dist/ts/domain/models/pagination.d.ts.map +1 -0
  39. package/dist/ts/domain/services/base-model-service.d.ts +23 -0
  40. package/dist/ts/domain/services/base-model-service.d.ts.map +1 -0
  41. package/dist/ts/domain/services/model-service-args.d.ts +9 -0
  42. package/dist/ts/domain/services/model-service-args.d.ts.map +1 -0
  43. package/dist/ts/domain/services/model-service.d.ts +99 -0
  44. package/dist/ts/domain/services/model-service.d.ts.map +1 -0
  45. package/dist/ts/domain/services/model-service.normalization.test.d.ts +2 -0
  46. package/dist/ts/domain/services/model-service.normalization.test.d.ts.map +1 -0
  47. package/dist/ts/domain/services/model-service.test.d.ts +2 -0
  48. package/dist/ts/domain/services/model-service.test.d.ts.map +1 -0
  49. package/dist/ts/domain/services/read-only-model-service.d.ts +90 -0
  50. package/dist/ts/domain/services/read-only-model-service.d.ts.map +1 -0
  51. package/dist/ts/domain/services/read-only-model-service.test.d.ts +2 -0
  52. package/dist/ts/domain/services/read-only-model-service.test.d.ts.map +1 -0
  53. package/dist/ts/index.d.ts +18 -0
  54. package/dist/ts/index.d.ts.map +1 -0
  55. package/dist/ts/shared/utils/schema-inference.d.ts +23 -0
  56. package/dist/ts/shared/utils/schema-inference.d.ts.map +1 -0
  57. package/dist/ts/shared/utils/schema-inheritance.d.ts +24 -0
  58. package/dist/ts/shared/utils/schema-inheritance.d.ts.map +1 -0
  59. package/dist/ts/shared/utils/schema-inheritance.test.d.ts +2 -0
  60. package/dist/ts/shared/utils/schema-inheritance.test.d.ts.map +1 -0
  61. package/dist/ts/shared/utils/test/animal-schema.d.ts +57 -0
  62. package/dist/ts/shared/utils/test/animal-schema.d.ts.map +1 -0
  63. package/dist/ts/shared/utils/test/animal-trait-schema.d.ts +55 -0
  64. package/dist/ts/shared/utils/test/animal-trait-schema.d.ts.map +1 -0
  65. package/dist/ts/shared/utils/test/elephant-schema.d.ts +30 -0
  66. package/dist/ts/shared/utils/test/elephant-schema.d.ts.map +1 -0
  67. package/dist/ts/shared/utils/test/elephant-trait-schema.d.ts +26 -0
  68. package/dist/ts/shared/utils/test/elephant-trait-schema.d.ts.map +1 -0
  69. package/dist/ts/test/mock/models/mock-book-models.d.ts +42 -0
  70. package/dist/ts/test/mock/models/mock-book-models.d.ts.map +1 -0
  71. package/dist/ts/test/mock/repositories/mock-memory-repository.assign.test.d.ts +2 -0
  72. package/dist/ts/test/mock/repositories/mock-memory-repository.assign.test.d.ts.map +1 -0
  73. package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts +2 -0
  74. package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts.map +1 -0
  75. package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts +2 -0
  76. package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts.map +1 -0
  77. package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts +2 -0
  78. package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts.map +1 -0
  79. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts +62 -0
  80. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts.map +1 -0
  81. package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts +2 -0
  82. package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts.map +1 -0
  83. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts +2 -0
  84. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts.map +1 -0
  85. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts +2 -0
  86. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts.map +1 -0
  87. package/package.json +46 -42
  88. package/src/application/model-controller.test.ts +694 -0
  89. package/src/application/model-controller.ts +186 -0
  90. package/src/application/read-only-model-controller.test.ts +335 -0
  91. package/src/application/read-only-model-controller.ts +79 -0
  92. package/src/domain/events/domain-event.test.ts +82 -0
  93. package/src/domain/events/domain-event.ts +69 -0
  94. package/src/domain/events/event-types.ts +37 -0
  95. package/src/domain/events/mutation-event.test.ts +390 -0
  96. package/src/domain/events/mutation-event.ts +53 -0
  97. package/src/domain/events/query-event.test.ts +228 -0
  98. package/src/domain/events/query-event.ts +14 -0
  99. package/src/domain/events/request-event.test.ts +38 -0
  100. package/src/domain/events/request-event.ts +47 -0
  101. package/src/domain/interfaces/repository.ts +136 -0
  102. package/src/domain/models/pagination.ts +28 -0
  103. package/src/domain/services/base-model-service.ts +54 -0
  104. package/src/domain/services/model-service-args.ts +9 -0
  105. package/src/domain/services/model-service.normalization.test.ts +704 -0
  106. package/src/domain/services/model-service.test.ts +1616 -0
  107. package/src/domain/services/model-service.ts +597 -0
  108. package/src/domain/services/read-only-model-service.test.ts +1130 -0
  109. package/src/domain/services/read-only-model-service.ts +211 -0
  110. package/src/index.ts +17 -4
  111. package/src/shared/utils/schema-inference.ts +26 -0
  112. package/src/shared/utils/schema-inheritance.test.ts +295 -0
  113. package/src/shared/utils/schema-inheritance.ts +28 -0
  114. package/src/shared/utils/test/animal-schema.ts +46 -0
  115. package/src/shared/utils/test/animal-trait-schema.ts +45 -0
  116. package/src/shared/utils/test/elephant-schema.ts +58 -0
  117. package/src/shared/utils/test/elephant-trait-schema.ts +53 -0
  118. package/src/test/mock/models/mock-book-models.ts +78 -0
  119. package/src/test/mock/repositories/mock-memory-repository.assign.test.ts +215 -0
  120. package/src/test/mock/repositories/mock-memory-repository.basic.test.ts +129 -0
  121. package/src/test/mock/repositories/mock-memory-repository.bulk-upsert.test.ts +159 -0
  122. package/src/test/mock/repositories/mock-memory-repository.count.test.ts +98 -0
  123. package/src/test/mock/repositories/mock-memory-repository.search.test.ts +265 -0
  124. package/src/test/mock/repositories/mock-memory-repository.trash.test.ts +736 -0
  125. package/src/test/mock/repositories/mock-memory-repository.ts +401 -0
  126. package/src/test/mock/repositories/mock-memory-repository.upsert.test.ts +108 -0
  127. package/dist/databaseConnection.d.ts +0 -24
  128. package/dist/datastoreAbstract.d.ts +0 -37
  129. package/dist/declaro-data.cjs +0 -1
  130. package/dist/declaro-data.mjs +0 -250
  131. package/dist/hydrateEntity.d.ts +0 -8
  132. package/dist/index.d.ts +0 -4
  133. package/dist/serverConnection.d.ts +0 -15
  134. package/dist/trackedStatus.d.ts +0 -15
  135. package/src/databaseConnection.ts +0 -137
  136. package/src/datastoreAbstract.ts +0 -190
  137. package/src/hydrateEntity.ts +0 -36
  138. package/src/placeholder.test.ts +0 -7
  139. package/src/serverConnection.ts +0 -74
  140. package/src/trackedStatus.ts +0 -35
  141. package/tsconfig.json +0 -10
  142. package/vite.config.ts +0 -23
@@ -0,0 +1,186 @@
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 { ILoadOptions } from '../domain/services/read-only-model-service'
5
+ import type { InferDetail, InferFilters, InferInput, InferLookup, InferSummary } from '../shared/utils/schema-inference'
6
+ import { ReadOnlyModelController } from './read-only-model-controller'
7
+
8
+ export class ModelController<TSchema extends AnyModelSchema> extends ReadOnlyModelController<TSchema> {
9
+ constructor(
10
+ protected readonly service: ModelService<TSchema>,
11
+ protected readonly authValidator: AuthValidator,
12
+ ) {
13
+ super(service, authValidator)
14
+ }
15
+
16
+ async createPermissions(input: InferInput<TSchema>, options?: ICreateOptions): Promise<PermissionValidator> {
17
+ return PermissionValidator.create().someOf([
18
+ this.service.getDescriptor('create', '*').toString(),
19
+ this.service.getDescriptor('write', '*').toString(),
20
+ ])
21
+ }
22
+
23
+ async create(input: InferInput<TSchema>, options?: ICreateOptions): Promise<InferDetail<TSchema>> {
24
+ const permissions = await this.createPermissions(input, options)
25
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
26
+ return this.service.create(input, options)
27
+ }
28
+
29
+ async updatePermissions(
30
+ lookup: InferLookup<TSchema>,
31
+ input: InferInput<TSchema>,
32
+ options?: IUpdateOptions,
33
+ ): Promise<PermissionValidator> {
34
+ return PermissionValidator.create().someOf([
35
+ this.service.getDescriptor('update', '*').toString(),
36
+ this.service.getDescriptor('write', '*').toString(),
37
+ ])
38
+ }
39
+
40
+ async update(
41
+ lookup: InferLookup<TSchema>,
42
+ input: InferInput<TSchema>,
43
+ options?: IUpdateOptions,
44
+ ): Promise<InferDetail<TSchema>> {
45
+ const permissions = await this.updatePermissions(lookup, input, options)
46
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
47
+ return this.service.update(lookup, input, options)
48
+ }
49
+
50
+ async removePermissions(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<PermissionValidator> {
51
+ return PermissionValidator.create().someOf([
52
+ this.service.getDescriptor('remove', '*').toString(),
53
+ this.service.getDescriptor('write', '*').toString(),
54
+ ])
55
+ }
56
+
57
+ async remove(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferSummary<TSchema>> {
58
+ const permissions = await this.removePermissions(lookup, options)
59
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
60
+ return this.service.remove(lookup, options)
61
+ }
62
+
63
+ async restorePermissions(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<PermissionValidator> {
64
+ return PermissionValidator.create().someOf([
65
+ this.service.getDescriptor('restore', '*').toString(),
66
+ this.service.getDescriptor('write', '*').toString(),
67
+ ])
68
+ }
69
+
70
+ async restore(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferSummary<TSchema>> {
71
+ const permissions = await this.restorePermissions(lookup, options)
72
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
73
+ return this.service.restore(lookup, options)
74
+ }
75
+
76
+ async upsertPermissions(
77
+ input: InferInput<TSchema>,
78
+ options?: ICreateOptions | IUpdateOptions,
79
+ ): Promise<PermissionValidator> {
80
+ // Create nested validator for (create AND update) permissions
81
+ const createAndUpdateValidator = PermissionValidator.create().allOf([
82
+ this.service.getDescriptor('create', '*').toString(),
83
+ this.service.getDescriptor('update', '*').toString(),
84
+ ])
85
+
86
+ return PermissionValidator.create().someOf([
87
+ createAndUpdateValidator,
88
+ this.service.getDescriptor('write', '*').toString(),
89
+ ])
90
+ }
91
+
92
+ /**
93
+ * Upserts a record (creates if it doesn't exist, updates if it does).
94
+ * @param input The input data for the upsert operation.
95
+ * @param options Optional create or update options.
96
+ * @returns The upserted record.
97
+ */
98
+ async upsert(input: InferInput<TSchema>, options?: ICreateOptions | IUpdateOptions): Promise<InferDetail<TSchema>> {
99
+ const permissions = await this.upsertPermissions(input, options)
100
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
101
+ return this.service.upsert(input, options)
102
+ }
103
+
104
+ async bulkUpsertPermissions(
105
+ inputs: InferInput<TSchema>[],
106
+ options?: ICreateOptions | IUpdateOptions,
107
+ ): Promise<PermissionValidator> {
108
+ // Create nested validator for (create AND update) permissions
109
+ const createAndUpdateValidator = PermissionValidator.create().allOf([
110
+ this.service.getDescriptor('create', '*').toString(),
111
+ this.service.getDescriptor('update', '*').toString(),
112
+ ])
113
+
114
+ return PermissionValidator.create().someOf([
115
+ createAndUpdateValidator,
116
+ this.service.getDescriptor('write', '*').toString(),
117
+ ])
118
+ }
119
+
120
+ /**
121
+ * Bulk upserts multiple records (creates if they don't exist, updates if they do).
122
+ * @param inputs Array of input data for the bulk upsert operation.
123
+ * @param options Optional create or update options.
124
+ * @returns Array of upserted records.
125
+ */
126
+ async bulkUpsert(
127
+ inputs: InferInput<TSchema>[],
128
+ options?: ICreateOptions | IUpdateOptions,
129
+ ): Promise<InferDetail<TSchema>[]> {
130
+ const permissions = await this.bulkUpsertPermissions(inputs, options)
131
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
132
+ return this.service.bulkUpsert(inputs, options)
133
+ }
134
+
135
+ async permanentlyDeleteFromTrashPermissions(lookup: InferLookup<TSchema>): Promise<PermissionValidator> {
136
+ return PermissionValidator.create().someOf([
137
+ this.service.getDescriptor('permanently-delete-from-trash', '*').toString(),
138
+ this.service.getDescriptor('permanently-delete', '*').toString(),
139
+ this.service.getDescriptor('empty-trash', '*').toString(),
140
+ ])
141
+ }
142
+
143
+ /**
144
+ * Permanently deletes a specific entity from the trash.
145
+ * Requires 'permanently-delete-from-trash', 'permanently-delete', or 'empty-trash' permission.
146
+ * @param lookup The lookup object containing entity identifiers
147
+ * @returns The permanently deleted entity summary
148
+ */
149
+ async permanentlyDeleteFromTrash(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
150
+ const permissions = await this.permanentlyDeleteFromTrashPermissions(lookup)
151
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
152
+ return this.service.permanentlyDeleteFromTrash(lookup)
153
+ }
154
+
155
+ async permanentlyDeletePermissions(lookup: InferLookup<TSchema>): Promise<PermissionValidator> {
156
+ return PermissionValidator.create().someOf([this.service.getDescriptor('permanently-delete', '*').toString()])
157
+ }
158
+
159
+ /**
160
+ * Permanently deletes an entity without moving it to trash first.
161
+ * Requires 'permanently-delete' permission.
162
+ * @param lookup The lookup object containing entity identifiers
163
+ * @returns The permanently deleted entity summary
164
+ */
165
+ async permanentlyDelete(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
166
+ const permissions = await this.permanentlyDeletePermissions(lookup)
167
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
168
+ return this.service.permanentlyDelete(lookup)
169
+ }
170
+
171
+ async emptyTrashPermissions(filters?: InferFilters<TSchema>): Promise<PermissionValidator> {
172
+ return PermissionValidator.create().someOf([this.service.getDescriptor('empty-trash', '*').toString()])
173
+ }
174
+
175
+ /**
176
+ * Empties the trash by permanently deleting entities that have been marked as removed.
177
+ * Requires 'empty-trash' permission.
178
+ * @param filters Optional filters to apply when selecting entities to delete
179
+ * @returns The count of entities permanently deleted
180
+ */
181
+ async emptyTrash(filters?: InferFilters<TSchema>): Promise<number> {
182
+ const permissions = await this.emptyTrashPermissions(filters)
183
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
184
+ return this.service.emptyTrash(filters)
185
+ }
186
+ }
@@ -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,79 @@
1
+ import type { AuthValidator } from '@declaro/auth'
2
+ import {} from '@declaro/auth'
3
+ import { PermissionValidator, 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 loadPermissions(lookup: InferLookup<TSchema>): Promise<PermissionValidator> {
14
+ return PermissionValidator.create().someOf([
15
+ this.service.getDescriptor('load', '*').toString(),
16
+ this.service.getDescriptor('read', '*').toString(),
17
+ ])
18
+ }
19
+
20
+ async load(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferDetail<TSchema>> {
21
+ const permissions = await this.loadPermissions(lookup)
22
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
23
+ return this.service.load(lookup, options)
24
+ }
25
+
26
+ async loadManyPermissions(lookups: InferLookup<TSchema>[]): Promise<PermissionValidator> {
27
+ return PermissionValidator.create().someOf([
28
+ this.service.getDescriptor('loadMany', '*').toString(),
29
+ this.service.getDescriptor('read', '*').toString(),
30
+ ])
31
+ }
32
+
33
+ async loadMany(lookups: InferLookup<TSchema>[], options?: ILoadOptions): Promise<InferDetail<TSchema>[]> {
34
+ const permissions = await this.loadManyPermissions(lookups)
35
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
36
+ return this.service.loadMany(lookups, options)
37
+ }
38
+
39
+ async searchPermissions(
40
+ input: InferFilters<TSchema>,
41
+ options?: ISearchOptions<TSchema>,
42
+ ): Promise<PermissionValidator> {
43
+ return PermissionValidator.create().someOf([
44
+ this.service.getDescriptor('search', '*').toString(),
45
+ this.service.getDescriptor('read', '*').toString(),
46
+ ])
47
+ }
48
+
49
+ async search(
50
+ input: InferFilters<TSchema>,
51
+ options?: ISearchOptions<TSchema>,
52
+ ): Promise<InferSearchResults<TSchema>> {
53
+ const permissions = await this.searchPermissions(input, options)
54
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
55
+ return this.service.search(input, options)
56
+ }
57
+
58
+ async countPermissions(
59
+ input: InferFilters<TSchema>,
60
+ options?: ISearchOptions<TSchema>,
61
+ ): Promise<PermissionValidator> {
62
+ return PermissionValidator.create().someOf([
63
+ this.service.getDescriptor('count', '*').toString(),
64
+ this.service.getDescriptor('read', '*').toString(),
65
+ ])
66
+ }
67
+
68
+ /**
69
+ * Count the number of records matching the given filters.
70
+ * @param input The filters to apply to the count operation.
71
+ * @param options Additional options for the count operation.
72
+ * @returns The count of matching records.
73
+ */
74
+ async count(input: InferFilters<TSchema>, options?: ISearchOptions<TSchema>): Promise<number> {
75
+ const permissions = await this.countPermissions(input, options)
76
+ this.authValidator.validatePermissions((v) => v.extend(permissions))
77
+ return this.service.count(input, options)
78
+ }
79
+ }
@@ -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
+ })