@furystack/repository 10.1.11 → 11.0.1
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.
- package/CHANGELOG.md +21 -0
- package/README.md +74 -36
- package/esm/data-set-setting.d.ts +30 -44
- package/esm/data-set-setting.d.ts.map +1 -1
- package/esm/data-set.d.ts +17 -68
- package/esm/data-set.d.ts.map +1 -1
- package/esm/data-set.js +18 -69
- package/esm/data-set.js.map +1 -1
- package/esm/data-set.spec.d.ts +2 -0
- package/esm/data-set.spec.d.ts.map +1 -0
- package/esm/data-set.spec.js +348 -0
- package/esm/data-set.spec.js.map +1 -0
- package/esm/define-data-set.d.ts +71 -0
- package/esm/define-data-set.d.ts.map +1 -0
- package/esm/define-data-set.js +65 -0
- package/esm/define-data-set.js.map +1 -0
- package/esm/define-data-set.spec.d.ts +2 -0
- package/esm/define-data-set.spec.d.ts.map +1 -0
- package/esm/define-data-set.spec.js +134 -0
- package/esm/define-data-set.spec.js.map +1 -0
- package/esm/helpers.d.ts +12 -29
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +8 -28
- package/esm/helpers.js.map +1 -1
- package/esm/index.d.ts +2 -2
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +2 -2
- package/esm/index.js.map +1 -1
- package/package.json +5 -5
- package/src/data-set-setting.ts +32 -46
- package/src/data-set.spec.ts +397 -0
- package/src/data-set.ts +28 -72
- package/src/define-data-set.spec.ts +145 -0
- package/src/define-data-set.ts +122 -0
- package/src/helpers.ts +14 -33
- package/src/index.ts +2 -2
- package/esm/dataset.spec.d.ts +0 -2
- package/esm/dataset.spec.d.ts.map +0 -1
- package/esm/dataset.spec.js +0 -552
- package/esm/dataset.spec.js.map +0 -1
- package/esm/repository.d.ts +0 -15
- package/esm/repository.d.ts.map +0 -1
- package/esm/repository.js +0 -50
- package/esm/repository.js.map +0 -1
- package/esm/repository.spec.d.ts +0 -2
- package/esm/repository.spec.d.ts.map +0 -1
- package/esm/repository.spec.js +0 -18
- package/esm/repository.spec.js.map +0 -1
- package/src/dataset.spec.ts +0 -641
- package/src/repository.spec.ts +0 -19
- package/src/repository.ts +0 -50
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion -- false positives: discriminated `AuthorizationResult` mocks need explicit casts so vi.fn return types match the union (rule's tsutils-based check disagrees with tsc) */
|
|
2
|
+
import type { StoreToken, WithOptionalId } from '@furystack/core'
|
|
3
|
+
import { AuthorizationError, defineStore, InMemoryStore } from '@furystack/core'
|
|
4
|
+
import { createInjector, type Injector } from '@furystack/inject'
|
|
5
|
+
import { usingAsync } from '@furystack/utils'
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import type { AuthorizationResult, DataSetSettings } from './data-set-setting.js'
|
|
8
|
+
import type { DataSet } from './data-set.js'
|
|
9
|
+
import { defineDataSet, type DefineDataSetSettings } from './define-data-set.js'
|
|
10
|
+
import { getDataSetFor } from './helpers.js'
|
|
11
|
+
|
|
12
|
+
class TestClass {
|
|
13
|
+
declare id: number
|
|
14
|
+
declare value: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Token declared at module scope so TypeScript preserves the literal primary
|
|
18
|
+
// key. The explicit `StoreToken<TestClass, 'id'>` annotation keeps
|
|
19
|
+
// `TPrimaryKey` narrow when the token is later passed to `defineDataSet`;
|
|
20
|
+
// inferred return types from `defineStore` tend to widen back to
|
|
21
|
+
// `keyof TestClass` in downstream inference contexts.
|
|
22
|
+
const TestStore: StoreToken<TestClass, 'id'> = defineStore({
|
|
23
|
+
name: 'test/DataSetSpecStore',
|
|
24
|
+
model: TestClass,
|
|
25
|
+
primaryKey: 'id',
|
|
26
|
+
factory: () => new InMemoryStore({ model: TestClass, primaryKey: 'id' }),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Builds a fresh injector with a disposable {@link InMemoryStore} bound as the
|
|
31
|
+
* backing store for a {@link TestClass} dataset. Each call mints a new
|
|
32
|
+
* dataset token so the injector cache starts from scratch for every test.
|
|
33
|
+
*/
|
|
34
|
+
const withDataSet = async (
|
|
35
|
+
settings: DefineDataSetSettings<TestClass, 'id'> | undefined,
|
|
36
|
+
fn: (ctx: { i: Injector; dataSet: DataSet<TestClass, 'id'> }) => Promise<void>,
|
|
37
|
+
): Promise<void> => {
|
|
38
|
+
const TestDataSet = defineDataSet<TestClass, 'id', WithOptionalId<TestClass, 'id'>>({
|
|
39
|
+
name: `test/DataSet-${Math.random()}`,
|
|
40
|
+
store: TestStore,
|
|
41
|
+
settings,
|
|
42
|
+
})
|
|
43
|
+
await usingAsync(createInjector(), async (i) => {
|
|
44
|
+
const dataSet = getDataSetFor(i, TestDataSet)
|
|
45
|
+
await fn({ i, dataSet })
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('DataSet', () => {
|
|
50
|
+
describe('Authorizers', () => {
|
|
51
|
+
describe('Add', () => {
|
|
52
|
+
it('adds an entity when no settings are provided', async () => {
|
|
53
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
54
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
55
|
+
const result = await dataSet.get(i, 1)
|
|
56
|
+
expect(result?.value).toBe('asd')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('runs authorizeAdd and persists the entity on pass', async () => {
|
|
61
|
+
const authorizeAdd = vi.fn(async () => ({ isAllowed: true }) as AuthorizationResult)
|
|
62
|
+
await withDataSet({ authorizeAdd }, async ({ i, dataSet }) => {
|
|
63
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
64
|
+
expect(authorizeAdd).toHaveBeenCalled()
|
|
65
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('throws and does not persist when authorizeAdd fails', async () => {
|
|
70
|
+
const authorizeAdd = vi.fn(async () => ({ isAllowed: false, message: '...' }) as AuthorizationResult)
|
|
71
|
+
await withDataSet({ authorizeAdd }, async ({ i, dataSet }) => {
|
|
72
|
+
await expect(dataSet.add(i, { id: 1, value: 'asd' })).rejects.toBeInstanceOf(AuthorizationError)
|
|
73
|
+
expect(authorizeAdd).toHaveBeenCalled()
|
|
74
|
+
expect(await dataSet.get(i, 1)).toBeUndefined()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('applies modifyOnAdd before persisting', async () => {
|
|
79
|
+
const modifyOnAdd = vi.fn<NonNullable<DataSetSettings<TestClass, 'id'>['modifyOnAdd']>>(async ({ entity }) => ({
|
|
80
|
+
...entity,
|
|
81
|
+
value: entity.value.toUpperCase(),
|
|
82
|
+
}))
|
|
83
|
+
await withDataSet({ modifyOnAdd }, async ({ i, dataSet }) => {
|
|
84
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
85
|
+
const result = await dataSet.get(i, 1)
|
|
86
|
+
expect(modifyOnAdd).toHaveBeenCalled()
|
|
87
|
+
expect(result?.value).toBe('ASD')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('emits onEntityAdded after a successful add', async () => {
|
|
92
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
93
|
+
const listener = vi.fn()
|
|
94
|
+
dataSet.addListener('onEntityAdded', listener)
|
|
95
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
96
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ entity: { id: 1, value: 'asd' } }))
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('Update', () => {
|
|
102
|
+
it('updates an entity when no settings are provided', async () => {
|
|
103
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
104
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
105
|
+
await dataSet.update(i, 1, { id: 1, value: 'asd2' })
|
|
106
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd2')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('runs authorizeUpdate and persists on pass', async () => {
|
|
111
|
+
const authorizeUpdate = vi.fn(async () => ({ isAllowed: true }) as AuthorizationResult)
|
|
112
|
+
await withDataSet({ authorizeUpdate }, async ({ i, dataSet }) => {
|
|
113
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
114
|
+
await dataSet.update(i, 1, { id: 1, value: 'asd2' })
|
|
115
|
+
expect(authorizeUpdate).toHaveBeenCalled()
|
|
116
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd2')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('throws when authorizeUpdate fails and leaves the entity untouched', async () => {
|
|
121
|
+
const authorizeUpdate = vi.fn(async () => ({ isAllowed: false, message: '...' }) as AuthorizationResult)
|
|
122
|
+
await withDataSet({ authorizeUpdate }, async ({ i, dataSet }) => {
|
|
123
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
124
|
+
await expect(dataSet.update(i, 1, { id: 1, value: 'asd2' })).rejects.toBeInstanceOf(AuthorizationError)
|
|
125
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('runs authorizeUpdateEntity against the loaded entity and persists on pass', async () => {
|
|
130
|
+
const authorizeUpdateEntity = vi.fn(async () => ({ isAllowed: true }) as AuthorizationResult)
|
|
131
|
+
await withDataSet({ authorizeUpdateEntity }, async ({ i, dataSet }) => {
|
|
132
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
133
|
+
await dataSet.update(i, 1, { id: 1, value: 'asd2' })
|
|
134
|
+
expect(authorizeUpdateEntity).toHaveBeenCalled()
|
|
135
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd2')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('throws and leaves the entity untouched when authorizeUpdateEntity fails', async () => {
|
|
140
|
+
const authorizeUpdateEntity = vi.fn(async () => ({ isAllowed: false, message: '...' }) as AuthorizationResult)
|
|
141
|
+
await withDataSet({ authorizeUpdateEntity }, async ({ i, dataSet }) => {
|
|
142
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
143
|
+
await expect(dataSet.update(i, 1, { id: 1, value: 'asd2' })).rejects.toBeInstanceOf(AuthorizationError)
|
|
144
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('applies modifyOnUpdate before persisting', async () => {
|
|
149
|
+
const modifyOnUpdate = vi.fn<NonNullable<DataSetSettings<TestClass, 'id'>['modifyOnUpdate']>>(
|
|
150
|
+
async ({ entity }) => ({
|
|
151
|
+
...entity,
|
|
152
|
+
value: entity.value?.toUpperCase() ?? '',
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
await withDataSet({ modifyOnUpdate }, async ({ i, dataSet }) => {
|
|
156
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
157
|
+
await dataSet.update(i, 1, { id: 1, value: 'asd2' })
|
|
158
|
+
expect(modifyOnUpdate).toHaveBeenCalled()
|
|
159
|
+
expect((await dataSet.get(i, 1))?.value).toBe('ASD2')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('emits onEntityUpdated with the applied change', async () => {
|
|
164
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
165
|
+
const listener = vi.fn()
|
|
166
|
+
dataSet.addListener('onEntityUpdated', listener)
|
|
167
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
168
|
+
await dataSet.update(i, 1, { id: 1, value: 'asd2' })
|
|
169
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ change: { id: 1, value: 'asd2' }, id: 1 }))
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('Count', () => {
|
|
175
|
+
it('returns the count when no settings are provided', async () => {
|
|
176
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
177
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
178
|
+
expect(await dataSet.count(i)).toBe(1)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('returns the count when authorizeGet passes', async () => {
|
|
183
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
184
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
185
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
186
|
+
expect(await dataSet.count(i)).toBe(1)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('throws when authorizeGet fails', async () => {
|
|
191
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
192
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
193
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
194
|
+
await expect(dataSet.count(i)).rejects.toBeInstanceOf(AuthorizationError)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('filter', () => {
|
|
201
|
+
it('returns the unfiltered result when no settings are provided', async () => {
|
|
202
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
203
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
204
|
+
expect((await dataSet.find(i, {})).length).toBe(1)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('returns the unfiltered result when authorizeGet passes', async () => {
|
|
209
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
210
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
211
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
212
|
+
expect((await dataSet.find(i, {})).length).toBe(1)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('throws when authorizeGet fails', async () => {
|
|
217
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
218
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
219
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
220
|
+
await expect(dataSet.find(i, {})).rejects.toBeInstanceOf(AuthorizationError)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe('get', () => {
|
|
226
|
+
it('returns the entity when no settings are provided', async () => {
|
|
227
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
228
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
229
|
+
expect((await dataSet.get(i, 1))?.id).toBe(1)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('returns the entity when authorizeGet passes', async () => {
|
|
234
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
235
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
236
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
237
|
+
expect((await dataSet.get(i, 1))?.id).toBe(1)
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('throws when authorizeGet fails', async () => {
|
|
242
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
243
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
244
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
245
|
+
await expect(dataSet.get(i, 1)).rejects.toBeInstanceOf(AuthorizationError)
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('returns the entity when authorizeGetEntity passes', async () => {
|
|
250
|
+
const authorizeGetEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
251
|
+
await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => {
|
|
252
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
253
|
+
expect((await dataSet.get(i, 1))?.id).toBe(1)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('throws when authorizeGetEntity fails', async () => {
|
|
258
|
+
const authorizeGetEntity = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
259
|
+
await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => {
|
|
260
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
261
|
+
await expect(dataSet.get(i, 1)).rejects.toBeInstanceOf(AuthorizationError)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('receives the full entity in authorizeGetEntity even when select is used', async () => {
|
|
266
|
+
const authorizeGetEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
267
|
+
await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => {
|
|
268
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
269
|
+
const result = await dataSet.get(i, 1, ['value'])
|
|
270
|
+
expect(authorizeGetEntity).toHaveBeenCalledTimes(1)
|
|
271
|
+
const firstCall = authorizeGetEntity.mock.calls[0] as unknown as [{ entity: TestClass }]
|
|
272
|
+
expect(firstCall[0].entity).toEqual({ id: 1, value: 'asd' })
|
|
273
|
+
expect(result).toEqual({ value: 'asd' })
|
|
274
|
+
expect(result).not.toHaveProperty('id')
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('returns undefined without calling authorizeGetEntity when the entity does not exist', async () => {
|
|
279
|
+
const authorizeGetEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
280
|
+
await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => {
|
|
281
|
+
const result = await dataSet.get(i, 999)
|
|
282
|
+
expect(result).toBeUndefined()
|
|
283
|
+
expect(authorizeGetEntity).not.toHaveBeenCalled()
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('remove', () => {
|
|
289
|
+
it('removes the entity when no settings are provided', async () => {
|
|
290
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
291
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
292
|
+
await dataSet.remove(i, 1)
|
|
293
|
+
expect(await dataSet.count(i)).toBe(0)
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('removes the entity when authorizeRemove passes', async () => {
|
|
298
|
+
const authorizeRemove = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
299
|
+
await withDataSet({ authorizeRemove }, async ({ i, dataSet }) => {
|
|
300
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
301
|
+
await dataSet.remove(i, 1)
|
|
302
|
+
expect(await dataSet.count(i)).toBe(0)
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('throws and preserves the entity when authorizeRemove fails', async () => {
|
|
307
|
+
const authorizeRemove = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
308
|
+
await withDataSet({ authorizeRemove }, async ({ i, dataSet }) => {
|
|
309
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
310
|
+
await expect(dataSet.remove(i, 1)).rejects.toBeInstanceOf(AuthorizationError)
|
|
311
|
+
expect(await dataSet.count(i)).toBe(1)
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('removes the entity when authorizeRemoveEntity passes', async () => {
|
|
316
|
+
const authorizeRemoveEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
317
|
+
await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => {
|
|
318
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
319
|
+
await dataSet.remove(i, 1)
|
|
320
|
+
expect(await dataSet.count(i)).toBe(0)
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('throws and preserves the entity when authorizeRemoveEntity fails', async () => {
|
|
325
|
+
const authorizeRemoveEntity = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
326
|
+
await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => {
|
|
327
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
328
|
+
await expect(dataSet.remove(i, 1)).rejects.toBeInstanceOf(AuthorizationError)
|
|
329
|
+
expect(await dataSet.count(i)).toBe(1)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('emits onEntityRemoved after a successful remove', async () => {
|
|
334
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
335
|
+
const listener = vi.fn()
|
|
336
|
+
dataSet.addListener('onEntityRemoved', listener)
|
|
337
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
338
|
+
await dataSet.remove(i, 1)
|
|
339
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ key: 1 }))
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('removes multiple entities at once', async () => {
|
|
344
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
345
|
+
await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' }, { id: 3, value: 'c' })
|
|
346
|
+
await dataSet.remove(i, 1, 3)
|
|
347
|
+
expect(await dataSet.count(i)).toBe(1)
|
|
348
|
+
expect((await dataSet.get(i, 2))?.value).toBe('b')
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('emits onEntityRemoved for each removed key', async () => {
|
|
353
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
354
|
+
const removedKeys: number[] = []
|
|
355
|
+
dataSet.addListener('onEntityRemoved', ({ key }) => {
|
|
356
|
+
removedKeys.push(key)
|
|
357
|
+
})
|
|
358
|
+
await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' })
|
|
359
|
+
await dataSet.remove(i, 1, 2)
|
|
360
|
+
expect(removedKeys).toEqual([1, 2])
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('authorizes each entity when authorizeRemoveEntity is set', async () => {
|
|
365
|
+
const authorizeRemoveEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
366
|
+
await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => {
|
|
367
|
+
await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' })
|
|
368
|
+
await dataSet.remove(i, 1, 2)
|
|
369
|
+
expect(authorizeRemoveEntity).toHaveBeenCalledTimes(2)
|
|
370
|
+
expect(await dataSet.count(i)).toBe(0)
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('does not remove anything when authorizeRemoveEntity fails for one entity (all-or-nothing)', async () => {
|
|
375
|
+
const authorizeRemoveEntity = vi.fn(async ({ entity }: { entity: TestClass }) => {
|
|
376
|
+
if (entity.id === 2) {
|
|
377
|
+
return { isAllowed: false, message: 'forbidden' } as AuthorizationResult
|
|
378
|
+
}
|
|
379
|
+
return { isAllowed: true } as AuthorizationResult
|
|
380
|
+
})
|
|
381
|
+
await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => {
|
|
382
|
+
await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' }, { id: 3, value: 'c' })
|
|
383
|
+
await expect(dataSet.remove(i, 1, 2, 3)).rejects.toBeInstanceOf(AuthorizationError)
|
|
384
|
+
expect(await dataSet.count(i)).toBe(3)
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('no-ops when called without keys', async () => {
|
|
389
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
390
|
+
const listener = vi.fn()
|
|
391
|
+
dataSet.addListener('onEntityRemoved', listener)
|
|
392
|
+
await dataSet.remove(i)
|
|
393
|
+
expect(listener).not.toHaveBeenCalled()
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
})
|
package/src/data-set.ts
CHANGED
|
@@ -1,34 +1,36 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CreateResult,
|
|
3
|
+
FilterType,
|
|
4
|
+
FindOptions,
|
|
5
|
+
PartialResult,
|
|
6
|
+
PhysicalStore,
|
|
7
|
+
WithOptionalId,
|
|
8
|
+
} from '@furystack/core'
|
|
2
9
|
import { AuthorizationError, selectFields } from '@furystack/core'
|
|
3
10
|
import type { Injector } from '@furystack/inject'
|
|
4
11
|
import { EventHub, type ListenerErrorPayload } from '@furystack/utils'
|
|
5
12
|
import type { DataSetSettings } from './data-set-setting.js'
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
15
|
+
* Authorization-enforcing wrapper around a {@link PhysicalStore}. The
|
|
16
|
+
* recommended write gateway for application code — `furystack/no-direct-store-token`
|
|
17
|
+
* enforces this. Each mutation runs the relevant `authorize*` and
|
|
18
|
+
* `modify*` callbacks from {@link DataSetSettings}, persists, then emits
|
|
19
|
+
* `onEntityAdded` / `onEntityUpdated` / `onEntityRemoved` (consumed by
|
|
20
|
+
* entity sync, audit logs).
|
|
10
21
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* All mutating methods require an `injector` parameter that provides the caller's context (e.g. the current
|
|
17
|
-
* user's identity). For server-side or background operations that don't originate from an HTTP request,
|
|
18
|
-
* use {@link useSystemIdentityContext} to create a scoped child injector with elevated privileges.
|
|
22
|
+
* Mutating methods take an `injector` parameter to surface caller identity
|
|
23
|
+
* to the authorizers. For server-side / background work without an HTTP
|
|
24
|
+
* request, wrap the injector with `useSystemIdentityContext` from
|
|
25
|
+
* `@furystack/core`.
|
|
19
26
|
*
|
|
20
27
|
* @example
|
|
21
28
|
* ```ts
|
|
22
|
-
* import { useSystemIdentityContext } from '@furystack/core'
|
|
23
|
-
* import { getDataSetFor } from '@furystack/repository'
|
|
24
|
-
* import { usingAsync } from '@furystack/utils'
|
|
25
|
-
*
|
|
26
|
-
* // Server-side write with an elevated identity
|
|
27
29
|
* await usingAsync(
|
|
28
30
|
* useSystemIdentityContext({ injector, username: 'background-job' }),
|
|
29
31
|
* async (systemInjector) => {
|
|
30
|
-
* const dataSet = getDataSetFor(systemInjector,
|
|
31
|
-
* await dataSet.add(systemInjector,
|
|
32
|
+
* const dataSet = getDataSetFor(systemInjector, UserDataSet)
|
|
33
|
+
* await dataSet.add(systemInjector, { username: 'alice', roles: [] })
|
|
32
34
|
* },
|
|
33
35
|
* )
|
|
34
36
|
* ```
|
|
@@ -42,20 +44,8 @@ export class DataSet<T, TPrimaryKey extends keyof T, TWritableData = WithOptiona
|
|
|
42
44
|
}>
|
|
43
45
|
implements Disposable
|
|
44
46
|
{
|
|
45
|
-
/**
|
|
46
|
-
* Primary key of the contained entity
|
|
47
|
-
*/
|
|
48
47
|
public primaryKey: TPrimaryKey
|
|
49
48
|
|
|
50
|
-
/**
|
|
51
|
-
* Adds an entity to the DataSet.
|
|
52
|
-
* Runs authorization checks, applies modification hooks, persists to the physical store,
|
|
53
|
-
* and emits an `onEntityAdded` event for each created entity.
|
|
54
|
-
* @param injector The injector from the caller's context. For server-side or background operations
|
|
55
|
-
* without an HTTP request, use a child injector with an elevated {@link IdentityContext}.
|
|
56
|
-
* @param entities The entities to add
|
|
57
|
-
* @returns The CreateResult with the created entities
|
|
58
|
-
*/
|
|
59
49
|
public async add(injector: Injector, ...entities: TWritableData[]): Promise<CreateResult<T>> {
|
|
60
50
|
await Promise.all(
|
|
61
51
|
entities.map(async (entity) => {
|
|
@@ -81,15 +71,6 @@ export class DataSet<T, TPrimaryKey extends keyof T, TWritableData = WithOptiona
|
|
|
81
71
|
return createResult
|
|
82
72
|
}
|
|
83
73
|
|
|
84
|
-
/**
|
|
85
|
-
* Updates an entity in the store.
|
|
86
|
-
* Runs authorization checks, applies modification hooks, persists to the physical store,
|
|
87
|
-
* and emits an `onEntityUpdated` event.
|
|
88
|
-
* @param injector The injector from the caller's context. For server-side or background operations
|
|
89
|
-
* without an HTTP request, use a child injector with an elevated {@link IdentityContext}.
|
|
90
|
-
* @param id The identifier of the entity
|
|
91
|
-
* @param change The update
|
|
92
|
-
*/
|
|
93
74
|
public async update(injector: Injector, id: T[TPrimaryKey], change: Partial<T>): Promise<void> {
|
|
94
75
|
if (this.settings.authorizeUpdate) {
|
|
95
76
|
const result = await this.settings.authorizeUpdate({ injector, change })
|
|
@@ -113,12 +94,6 @@ export class DataSet<T, TPrimaryKey extends keyof T, TWritableData = WithOptiona
|
|
|
113
94
|
this.emit('onEntityUpdated', { injector, change: parsed, id })
|
|
114
95
|
}
|
|
115
96
|
|
|
116
|
-
/**
|
|
117
|
-
* Returns a Promise with the entity count
|
|
118
|
-
* @param injector The Injector from the context
|
|
119
|
-
* @param filter The Filter that will be applied
|
|
120
|
-
* @returns the Count
|
|
121
|
-
*/
|
|
122
97
|
public async count(injector: Injector, filter?: FilterType<T>): Promise<number> {
|
|
123
98
|
if (this.settings.authorizeGet) {
|
|
124
99
|
const result = await this.settings.authorizeGet({ injector })
|
|
@@ -129,12 +104,6 @@ export class DataSet<T, TPrimaryKey extends keyof T, TWritableData = WithOptiona
|
|
|
129
104
|
return await this.settings.physicalStore.count(filter)
|
|
130
105
|
}
|
|
131
106
|
|
|
132
|
-
/**
|
|
133
|
-
* Returns a filtered subset of the entity
|
|
134
|
-
* @param injector The Injector from the context
|
|
135
|
-
* @param filter The Filter definition
|
|
136
|
-
* @returns A result with the current items
|
|
137
|
-
*/
|
|
138
107
|
public async find<TFields extends Array<keyof T>>(
|
|
139
108
|
injector: Injector,
|
|
140
109
|
filter: FindOptions<T, TFields>,
|
|
@@ -149,13 +118,6 @@ export class DataSet<T, TPrimaryKey extends keyof T, TWritableData = WithOptiona
|
|
|
149
118
|
return this.settings.physicalStore.find(parsedFilter)
|
|
150
119
|
}
|
|
151
120
|
|
|
152
|
-
/**
|
|
153
|
-
* Returns an entity based on its primary key
|
|
154
|
-
* @param injector The injector from the context
|
|
155
|
-
* @param key The identifier of the entity
|
|
156
|
-
* @param select A field list used for projection
|
|
157
|
-
* @returns An item with the current unique key or Undefined
|
|
158
|
-
*/
|
|
159
121
|
public async get<TSelect extends Array<keyof T>>(
|
|
160
122
|
injector: Injector,
|
|
161
123
|
key: T[TPrimaryKey],
|
|
@@ -172,30 +134,24 @@ export class DataSet<T, TPrimaryKey extends keyof T, TWritableData = WithOptiona
|
|
|
172
134
|
if (!fullEntity) {
|
|
173
135
|
return undefined
|
|
174
136
|
}
|
|
175
|
-
const result = await this.settings.authorizeGetEntity({ injector, entity: fullEntity
|
|
137
|
+
const result = await this.settings.authorizeGetEntity({ injector, entity: fullEntity })
|
|
176
138
|
if (!result.isAllowed) {
|
|
177
139
|
throw new AuthorizationError(result.message)
|
|
178
140
|
}
|
|
179
141
|
if (select) {
|
|
180
142
|
return selectFields(fullEntity as T & object, ...select)
|
|
181
143
|
}
|
|
182
|
-
return fullEntity
|
|
144
|
+
return fullEntity
|
|
183
145
|
}
|
|
184
146
|
return await this.settings.physicalStore.get(key, select)
|
|
185
147
|
}
|
|
186
148
|
|
|
187
149
|
/**
|
|
188
|
-
* Removes
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* are authorized. Keys that don't match any entity are silently forwarded to the
|
|
194
|
-
* physical store's `remove()` call.
|
|
195
|
-
* @param injector The injector from the caller's context. For server-side or background operations
|
|
196
|
-
* without an HTTP request, use a child injector with an elevated {@link IdentityContext}.
|
|
197
|
-
* @param keys The primary keys of the entities to remove
|
|
198
|
-
* @returns A promise that will be resolved / rejected based on the remove success
|
|
150
|
+
* Removes by primary key. Pre-load `authorizeRemove` and per-entity
|
|
151
|
+
* `authorizeRemoveEntity` are all-or-nothing — any rejection aborts the
|
|
152
|
+
* whole batch before any persist call. When `authorizeRemoveEntity` is
|
|
153
|
+
* configured, missing keys are silently forwarded to the physical store
|
|
154
|
+
* (no entity to authorize).
|
|
199
155
|
*/
|
|
200
156
|
public async remove(injector: Injector, ...keys: Array<T[TPrimaryKey]>): Promise<void> {
|
|
201
157
|
if (keys.length === 0) {
|
|
@@ -213,7 +169,7 @@ export class DataSet<T, TPrimaryKey extends keyof T, TWritableData = WithOptiona
|
|
|
213
169
|
})
|
|
214
170
|
await Promise.all(
|
|
215
171
|
entities.map(async (entity) => {
|
|
216
|
-
const removeResult = await this.settings.authorizeRemoveEntity!({ injector, entity
|
|
172
|
+
const removeResult = await this.settings.authorizeRemoveEntity!({ injector, entity })
|
|
217
173
|
if (!removeResult.isAllowed) {
|
|
218
174
|
throw new AuthorizationError(removeResult.message)
|
|
219
175
|
}
|