@furystack/repository 10.1.10 → 11.0.0
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 +16 -0
- package/README.md +74 -36
- 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 +89 -0
- package/esm/define-data-set.d.ts.map +1 -0
- package/esm/define-data-set.js +75 -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 +21 -22
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +17 -21
- 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 +6 -6
- package/src/data-set.spec.ts +396 -0
- package/src/define-data-set.spec.ts +145 -0
- package/src/define-data-set.ts +140 -0
- package/src/helpers.ts +23 -26
- 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,396 @@
|
|
|
1
|
+
import type { StoreToken, WithOptionalId } from '@furystack/core'
|
|
2
|
+
import { AuthorizationError, defineStore, InMemoryStore } from '@furystack/core'
|
|
3
|
+
import { createInjector, type Injector } from '@furystack/inject'
|
|
4
|
+
import { usingAsync } from '@furystack/utils'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import type { AuthorizationResult, DataSetSettings } from './data-set-setting.js'
|
|
7
|
+
import type { DataSet } from './data-set.js'
|
|
8
|
+
import { defineDataSet, type DefineDataSetSettings } from './define-data-set.js'
|
|
9
|
+
import { getDataSetFor } from './helpers.js'
|
|
10
|
+
|
|
11
|
+
class TestClass {
|
|
12
|
+
declare id: number
|
|
13
|
+
declare value: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Token declared at module scope so TypeScript preserves the literal primary
|
|
17
|
+
// key. The explicit `StoreToken<TestClass, 'id'>` annotation keeps
|
|
18
|
+
// `TPrimaryKey` narrow when the token is later passed to `defineDataSet`;
|
|
19
|
+
// inferred return types from `defineStore` tend to widen back to
|
|
20
|
+
// `keyof TestClass` in downstream inference contexts.
|
|
21
|
+
const TestStore: StoreToken<TestClass, 'id'> = defineStore({
|
|
22
|
+
name: 'test/DataSetSpecStore',
|
|
23
|
+
model: TestClass,
|
|
24
|
+
primaryKey: 'id',
|
|
25
|
+
factory: () => new InMemoryStore({ model: TestClass, primaryKey: 'id' }),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Builds a fresh injector with a disposable {@link InMemoryStore} bound as the
|
|
30
|
+
* backing store for a {@link TestClass} dataset. Each call mints a new
|
|
31
|
+
* dataset token so the injector cache starts from scratch for every test.
|
|
32
|
+
*/
|
|
33
|
+
const withDataSet = async (
|
|
34
|
+
settings: DefineDataSetSettings<TestClass, 'id'> | undefined,
|
|
35
|
+
fn: (ctx: { i: Injector; dataSet: DataSet<TestClass, 'id'> }) => Promise<void>,
|
|
36
|
+
): Promise<void> => {
|
|
37
|
+
const TestDataSet = defineDataSet<TestClass, 'id', WithOptionalId<TestClass, 'id'>>({
|
|
38
|
+
name: `test/DataSet-${Math.random()}`,
|
|
39
|
+
store: TestStore,
|
|
40
|
+
settings,
|
|
41
|
+
})
|
|
42
|
+
await usingAsync(createInjector(), async (i) => {
|
|
43
|
+
const dataSet = getDataSetFor(i, TestDataSet)
|
|
44
|
+
await fn({ i, dataSet })
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('DataSet', () => {
|
|
49
|
+
describe('Authorizers', () => {
|
|
50
|
+
describe('Add', () => {
|
|
51
|
+
it('adds an entity when no settings are provided', async () => {
|
|
52
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
53
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
54
|
+
const result = await dataSet.get(i, 1)
|
|
55
|
+
expect(result?.value).toBe('asd')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('runs authorizeAdd and persists the entity on pass', async () => {
|
|
60
|
+
const authorizeAdd = vi.fn(async () => ({ isAllowed: true }) as AuthorizationResult)
|
|
61
|
+
await withDataSet({ authorizeAdd }, async ({ i, dataSet }) => {
|
|
62
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
63
|
+
expect(authorizeAdd).toHaveBeenCalled()
|
|
64
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('throws and does not persist when authorizeAdd fails', async () => {
|
|
69
|
+
const authorizeAdd = vi.fn(async () => ({ isAllowed: false, message: '...' }) as AuthorizationResult)
|
|
70
|
+
await withDataSet({ authorizeAdd }, async ({ i, dataSet }) => {
|
|
71
|
+
await expect(dataSet.add(i, { id: 1, value: 'asd' })).rejects.toBeInstanceOf(AuthorizationError)
|
|
72
|
+
expect(authorizeAdd).toHaveBeenCalled()
|
|
73
|
+
expect(await dataSet.get(i, 1)).toBeUndefined()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('applies modifyOnAdd before persisting', async () => {
|
|
78
|
+
const modifyOnAdd = vi.fn<NonNullable<DataSetSettings<TestClass, 'id'>['modifyOnAdd']>>(async ({ entity }) => ({
|
|
79
|
+
...entity,
|
|
80
|
+
value: entity.value.toUpperCase(),
|
|
81
|
+
}))
|
|
82
|
+
await withDataSet({ modifyOnAdd }, async ({ i, dataSet }) => {
|
|
83
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
84
|
+
const result = await dataSet.get(i, 1)
|
|
85
|
+
expect(modifyOnAdd).toHaveBeenCalled()
|
|
86
|
+
expect(result?.value).toBe('ASD')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('emits onEntityAdded after a successful add', async () => {
|
|
91
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
92
|
+
const listener = vi.fn()
|
|
93
|
+
dataSet.addListener('onEntityAdded', listener)
|
|
94
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
95
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ entity: { id: 1, value: 'asd' } }))
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('Update', () => {
|
|
101
|
+
it('updates an entity when no settings are provided', async () => {
|
|
102
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
103
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
104
|
+
await dataSet.update(i, 1, { id: 1, value: 'asd2' })
|
|
105
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd2')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('runs authorizeUpdate and persists on pass', async () => {
|
|
110
|
+
const authorizeUpdate = vi.fn(async () => ({ isAllowed: true }) as AuthorizationResult)
|
|
111
|
+
await withDataSet({ authorizeUpdate }, async ({ i, dataSet }) => {
|
|
112
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
113
|
+
await dataSet.update(i, 1, { id: 1, value: 'asd2' })
|
|
114
|
+
expect(authorizeUpdate).toHaveBeenCalled()
|
|
115
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd2')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('throws when authorizeUpdate fails and leaves the entity untouched', async () => {
|
|
120
|
+
const authorizeUpdate = vi.fn(async () => ({ isAllowed: false, message: '...' }) as AuthorizationResult)
|
|
121
|
+
await withDataSet({ authorizeUpdate }, async ({ i, dataSet }) => {
|
|
122
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
123
|
+
await expect(dataSet.update(i, 1, { id: 1, value: 'asd2' })).rejects.toBeInstanceOf(AuthorizationError)
|
|
124
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('runs authorizeUpdateEntity against the loaded entity and persists on pass', async () => {
|
|
129
|
+
const authorizeUpdateEntity = vi.fn(async () => ({ isAllowed: true }) as AuthorizationResult)
|
|
130
|
+
await withDataSet({ authorizeUpdateEntity }, async ({ i, dataSet }) => {
|
|
131
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
132
|
+
await dataSet.update(i, 1, { id: 1, value: 'asd2' })
|
|
133
|
+
expect(authorizeUpdateEntity).toHaveBeenCalled()
|
|
134
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd2')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('throws and leaves the entity untouched when authorizeUpdateEntity fails', async () => {
|
|
139
|
+
const authorizeUpdateEntity = vi.fn(async () => ({ isAllowed: false, message: '...' }) as AuthorizationResult)
|
|
140
|
+
await withDataSet({ authorizeUpdateEntity }, async ({ i, dataSet }) => {
|
|
141
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
142
|
+
await expect(dataSet.update(i, 1, { id: 1, value: 'asd2' })).rejects.toBeInstanceOf(AuthorizationError)
|
|
143
|
+
expect((await dataSet.get(i, 1))?.value).toBe('asd')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('applies modifyOnUpdate before persisting', async () => {
|
|
148
|
+
const modifyOnUpdate = vi.fn<NonNullable<DataSetSettings<TestClass, 'id'>['modifyOnUpdate']>>(
|
|
149
|
+
async ({ entity }) => ({
|
|
150
|
+
...entity,
|
|
151
|
+
value: entity.value?.toUpperCase() ?? '',
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
await withDataSet({ modifyOnUpdate }, async ({ i, dataSet }) => {
|
|
155
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
156
|
+
await dataSet.update(i, 1, { id: 1, value: 'asd2' })
|
|
157
|
+
expect(modifyOnUpdate).toHaveBeenCalled()
|
|
158
|
+
expect((await dataSet.get(i, 1))?.value).toBe('ASD2')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('emits onEntityUpdated with the applied change', async () => {
|
|
163
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
164
|
+
const listener = vi.fn()
|
|
165
|
+
dataSet.addListener('onEntityUpdated', listener)
|
|
166
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
167
|
+
await dataSet.update(i, 1, { id: 1, value: 'asd2' })
|
|
168
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ change: { id: 1, value: 'asd2' }, id: 1 }))
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('Count', () => {
|
|
174
|
+
it('returns the count when no settings are provided', async () => {
|
|
175
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
176
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
177
|
+
expect(await dataSet.count(i)).toBe(1)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('returns the count when authorizeGet passes', async () => {
|
|
182
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
183
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
184
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
185
|
+
expect(await dataSet.count(i)).toBe(1)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('throws when authorizeGet fails', async () => {
|
|
190
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
191
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
192
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
193
|
+
await expect(dataSet.count(i)).rejects.toBeInstanceOf(AuthorizationError)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('filter', () => {
|
|
200
|
+
it('returns the unfiltered result when no settings are provided', async () => {
|
|
201
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
202
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
203
|
+
expect((await dataSet.find(i, {})).length).toBe(1)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('returns the unfiltered result when authorizeGet passes', async () => {
|
|
208
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
209
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
210
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
211
|
+
expect((await dataSet.find(i, {})).length).toBe(1)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('throws when authorizeGet fails', async () => {
|
|
216
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
217
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
218
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
219
|
+
await expect(dataSet.find(i, {})).rejects.toBeInstanceOf(AuthorizationError)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('get', () => {
|
|
225
|
+
it('returns the entity when no settings are provided', async () => {
|
|
226
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
227
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
228
|
+
expect((await dataSet.get(i, 1))?.id).toBe(1)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('returns the entity when authorizeGet passes', async () => {
|
|
233
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
234
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
235
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
236
|
+
expect((await dataSet.get(i, 1))?.id).toBe(1)
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('throws when authorizeGet fails', async () => {
|
|
241
|
+
const authorizeGet = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
242
|
+
await withDataSet({ authorizeGet }, async ({ i, dataSet }) => {
|
|
243
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
244
|
+
await expect(dataSet.get(i, 1)).rejects.toBeInstanceOf(AuthorizationError)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('returns the entity when authorizeGetEntity passes', async () => {
|
|
249
|
+
const authorizeGetEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
250
|
+
await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => {
|
|
251
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
252
|
+
expect((await dataSet.get(i, 1))?.id).toBe(1)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('throws when authorizeGetEntity fails', async () => {
|
|
257
|
+
const authorizeGetEntity = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
258
|
+
await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => {
|
|
259
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
260
|
+
await expect(dataSet.get(i, 1)).rejects.toBeInstanceOf(AuthorizationError)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('receives the full entity in authorizeGetEntity even when select is used', async () => {
|
|
265
|
+
const authorizeGetEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
266
|
+
await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => {
|
|
267
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
268
|
+
const result = await dataSet.get(i, 1, ['value'])
|
|
269
|
+
expect(authorizeGetEntity).toHaveBeenCalledTimes(1)
|
|
270
|
+
const firstCall = authorizeGetEntity.mock.calls[0] as unknown as [{ entity: TestClass }]
|
|
271
|
+
expect(firstCall[0].entity).toEqual({ id: 1, value: 'asd' })
|
|
272
|
+
expect(result).toEqual({ value: 'asd' })
|
|
273
|
+
expect(result).not.toHaveProperty('id')
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('returns undefined without calling authorizeGetEntity when the entity does not exist', async () => {
|
|
278
|
+
const authorizeGetEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
279
|
+
await withDataSet({ authorizeGetEntity }, async ({ i, dataSet }) => {
|
|
280
|
+
const result = await dataSet.get(i, 999)
|
|
281
|
+
expect(result).toBeUndefined()
|
|
282
|
+
expect(authorizeGetEntity).not.toHaveBeenCalled()
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
describe('remove', () => {
|
|
288
|
+
it('removes the entity when no settings are provided', async () => {
|
|
289
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
290
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
291
|
+
await dataSet.remove(i, 1)
|
|
292
|
+
expect(await dataSet.count(i)).toBe(0)
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('removes the entity when authorizeRemove passes', async () => {
|
|
297
|
+
const authorizeRemove = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
298
|
+
await withDataSet({ authorizeRemove }, async ({ i, dataSet }) => {
|
|
299
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
300
|
+
await dataSet.remove(i, 1)
|
|
301
|
+
expect(await dataSet.count(i)).toBe(0)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('throws and preserves the entity when authorizeRemove fails', async () => {
|
|
306
|
+
const authorizeRemove = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
307
|
+
await withDataSet({ authorizeRemove }, async ({ i, dataSet }) => {
|
|
308
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
309
|
+
await expect(dataSet.remove(i, 1)).rejects.toBeInstanceOf(AuthorizationError)
|
|
310
|
+
expect(await dataSet.count(i)).toBe(1)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('removes the entity when authorizeRemoveEntity passes', async () => {
|
|
315
|
+
const authorizeRemoveEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
316
|
+
await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => {
|
|
317
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
318
|
+
await dataSet.remove(i, 1)
|
|
319
|
+
expect(await dataSet.count(i)).toBe(0)
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('throws and preserves the entity when authorizeRemoveEntity fails', async () => {
|
|
324
|
+
const authorizeRemoveEntity = vi.fn(async () => ({ isAllowed: false, message: ':(' }) as AuthorizationResult)
|
|
325
|
+
await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => {
|
|
326
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
327
|
+
await expect(dataSet.remove(i, 1)).rejects.toBeInstanceOf(AuthorizationError)
|
|
328
|
+
expect(await dataSet.count(i)).toBe(1)
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('emits onEntityRemoved after a successful remove', async () => {
|
|
333
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
334
|
+
const listener = vi.fn()
|
|
335
|
+
dataSet.addListener('onEntityRemoved', listener)
|
|
336
|
+
await dataSet.add(i, { id: 1, value: 'asd' })
|
|
337
|
+
await dataSet.remove(i, 1)
|
|
338
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ key: 1 }))
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('removes multiple entities at once', async () => {
|
|
343
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
344
|
+
await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' }, { id: 3, value: 'c' })
|
|
345
|
+
await dataSet.remove(i, 1, 3)
|
|
346
|
+
expect(await dataSet.count(i)).toBe(1)
|
|
347
|
+
expect((await dataSet.get(i, 2))?.value).toBe('b')
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('emits onEntityRemoved for each removed key', async () => {
|
|
352
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
353
|
+
const removedKeys: number[] = []
|
|
354
|
+
dataSet.addListener('onEntityRemoved', ({ key }) => {
|
|
355
|
+
removedKeys.push(key)
|
|
356
|
+
})
|
|
357
|
+
await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' })
|
|
358
|
+
await dataSet.remove(i, 1, 2)
|
|
359
|
+
expect(removedKeys).toEqual([1, 2])
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('authorizes each entity when authorizeRemoveEntity is set', async () => {
|
|
364
|
+
const authorizeRemoveEntity = vi.fn(async () => ({ isAllowed: true, message: '' }) as AuthorizationResult)
|
|
365
|
+
await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => {
|
|
366
|
+
await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' })
|
|
367
|
+
await dataSet.remove(i, 1, 2)
|
|
368
|
+
expect(authorizeRemoveEntity).toHaveBeenCalledTimes(2)
|
|
369
|
+
expect(await dataSet.count(i)).toBe(0)
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('does not remove anything when authorizeRemoveEntity fails for one entity (all-or-nothing)', async () => {
|
|
374
|
+
const authorizeRemoveEntity = vi.fn(async ({ entity }: { entity: TestClass }) => {
|
|
375
|
+
if (entity.id === 2) {
|
|
376
|
+
return { isAllowed: false, message: 'forbidden' } as AuthorizationResult
|
|
377
|
+
}
|
|
378
|
+
return { isAllowed: true } as AuthorizationResult
|
|
379
|
+
})
|
|
380
|
+
await withDataSet({ authorizeRemoveEntity }, async ({ i, dataSet }) => {
|
|
381
|
+
await dataSet.add(i, { id: 1, value: 'a' }, { id: 2, value: 'b' }, { id: 3, value: 'c' })
|
|
382
|
+
await expect(dataSet.remove(i, 1, 2, 3)).rejects.toBeInstanceOf(AuthorizationError)
|
|
383
|
+
expect(await dataSet.count(i)).toBe(3)
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('no-ops when called without keys', async () => {
|
|
388
|
+
await withDataSet(undefined, async ({ i, dataSet }) => {
|
|
389
|
+
const listener = vi.fn()
|
|
390
|
+
dataSet.addListener('onEntityRemoved', listener)
|
|
391
|
+
await dataSet.remove(i)
|
|
392
|
+
expect(listener).not.toHaveBeenCalled()
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { defineStore, InMemoryStore } from '@furystack/core'
|
|
2
|
+
import { createInjector } from '@furystack/inject'
|
|
3
|
+
import { usingAsync } from '@furystack/utils'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { DataSet } from './data-set.js'
|
|
6
|
+
import { defineDataSet } from './define-data-set.js'
|
|
7
|
+
|
|
8
|
+
class Test {
|
|
9
|
+
declare id: number
|
|
10
|
+
declare value: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Store tokens are declared at module scope so TypeScript keeps the literal
|
|
14
|
+
// primary-key narrowing. Wrapping `defineStore` in an arrow function would
|
|
15
|
+
// widen the inferred `TPrimaryKey` back to `keyof Test` in the return type.
|
|
16
|
+
const BasicStore = defineStore({
|
|
17
|
+
name: 'test/BasicStore',
|
|
18
|
+
model: Test,
|
|
19
|
+
primaryKey: 'id',
|
|
20
|
+
factory: () => new InMemoryStore({ model: Test, primaryKey: 'id' }),
|
|
21
|
+
})
|
|
22
|
+
const SingletonStore = defineStore({
|
|
23
|
+
name: 'test/SingletonStore',
|
|
24
|
+
model: Test,
|
|
25
|
+
primaryKey: 'id',
|
|
26
|
+
factory: () => new InMemoryStore({ model: Test, primaryKey: 'id' }),
|
|
27
|
+
})
|
|
28
|
+
const MetaStore = defineStore({
|
|
29
|
+
name: 'test/MetaStore',
|
|
30
|
+
model: Test,
|
|
31
|
+
primaryKey: 'id',
|
|
32
|
+
factory: () => new InMemoryStore({ model: Test, primaryKey: 'id' }),
|
|
33
|
+
})
|
|
34
|
+
const HooksStore = defineStore({
|
|
35
|
+
name: 'test/HooksStore',
|
|
36
|
+
model: Test,
|
|
37
|
+
primaryKey: 'id',
|
|
38
|
+
factory: () => new InMemoryStore({ model: Test, primaryKey: 'id' }),
|
|
39
|
+
})
|
|
40
|
+
const EventsStore = defineStore({
|
|
41
|
+
name: 'test/EventsStore',
|
|
42
|
+
model: Test,
|
|
43
|
+
primaryKey: 'id',
|
|
44
|
+
factory: () => new InMemoryStore({ model: Test, primaryKey: 'id' }),
|
|
45
|
+
})
|
|
46
|
+
const DisposeStore = defineStore({
|
|
47
|
+
name: 'test/DisposeStore',
|
|
48
|
+
model: Test,
|
|
49
|
+
primaryKey: 'id',
|
|
50
|
+
factory: () => new InMemoryStore({ model: Test, primaryKey: 'id' }),
|
|
51
|
+
})
|
|
52
|
+
const RebindStore = defineStore({
|
|
53
|
+
name: 'test/RebindStore',
|
|
54
|
+
model: Test,
|
|
55
|
+
primaryKey: 'id',
|
|
56
|
+
factory: () => new InMemoryStore({ model: Test, primaryKey: 'id' }),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const modifyOnAddSpy = vi.fn()
|
|
60
|
+
const BasicDataSet = defineDataSet({ name: 'test/BasicDataSet', store: BasicStore })
|
|
61
|
+
const SingletonDataSet = defineDataSet({ name: 'test/SingletonDataSet', store: SingletonStore })
|
|
62
|
+
const MetaDataSet = defineDataSet({ name: 'test/MetaDataSet', store: MetaStore })
|
|
63
|
+
// The `settings` callback participates in bidirectional inference against the
|
|
64
|
+
// surrounding generics. Even with `NoInfer` on the settings position, inline
|
|
65
|
+
// callbacks currently widen `TPrimaryKey` to `keyof T` because TypeScript has
|
|
66
|
+
// to contextually type the callback before it can commit to the store-driven
|
|
67
|
+
// inference. The runtime value of `token.primaryKey` is still correct; only
|
|
68
|
+
// the entity parameter is typed as the wider `WithOptionalId<Test, keyof Test>`.
|
|
69
|
+
// The non-null assertion on `entity.value` reflects that widening.
|
|
70
|
+
const HooksDataSet = defineDataSet({
|
|
71
|
+
name: 'test/HooksDataSet',
|
|
72
|
+
store: HooksStore,
|
|
73
|
+
settings: {
|
|
74
|
+
modifyOnAdd: async ({ entity }) => {
|
|
75
|
+
modifyOnAddSpy()
|
|
76
|
+
return { ...entity, value: entity.value!.toUpperCase() }
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
const EventsDataSet = defineDataSet({ name: 'test/EventsDataSet', store: EventsStore })
|
|
81
|
+
const DisposeDataSet = defineDataSet({ name: 'test/DisposeDataSet', store: DisposeStore })
|
|
82
|
+
const RebindDataSet = defineDataSet({ name: 'test/RebindDataSet', store: RebindStore })
|
|
83
|
+
|
|
84
|
+
describe('defineDataSet', () => {
|
|
85
|
+
it('resolves to a DataSet backed by the supplied store', async () => {
|
|
86
|
+
await usingAsync(createInjector(), async (i) => {
|
|
87
|
+
const ds = i.get(BasicDataSet)
|
|
88
|
+
expect(ds).toBeInstanceOf(DataSet)
|
|
89
|
+
expect(ds.settings.physicalStore).toBe(i.get(BasicStore))
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('caches the DataSet as a singleton across resolutions', async () => {
|
|
94
|
+
await usingAsync(createInjector(), async (i) => {
|
|
95
|
+
expect(i.get(SingletonDataSet)).toBe(i.get(SingletonDataSet))
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('mirrors the model and primary key from the backing store token', () => {
|
|
100
|
+
expect(MetaDataSet.model).toBe(Test)
|
|
101
|
+
expect(MetaDataSet.primaryKey).toBe('id')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('applies the configured authorizers and hooks', async () => {
|
|
105
|
+
modifyOnAddSpy.mockClear()
|
|
106
|
+
await usingAsync(createInjector(), async (i) => {
|
|
107
|
+
const ds = i.get(HooksDataSet)
|
|
108
|
+
await ds.add(i, { id: 1, value: 'asd' })
|
|
109
|
+
const stored = await ds.get(i, 1)
|
|
110
|
+
expect(modifyOnAddSpy).toHaveBeenCalledTimes(1)
|
|
111
|
+
expect(stored?.value).toBe('ASD')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('emits change events through the DataSet EventHub', async () => {
|
|
116
|
+
await usingAsync(createInjector(), async (i) => {
|
|
117
|
+
const added = vi.fn()
|
|
118
|
+
const ds = i.get(EventsDataSet)
|
|
119
|
+
ds.addListener('onEntityAdded', added)
|
|
120
|
+
await ds.add(i, { id: 1, value: 'x' })
|
|
121
|
+
expect(added).toHaveBeenCalledWith(expect.objectContaining({ entity: { id: 1, value: 'x' } }))
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('clears DataSet listeners when the injector is disposed', async () => {
|
|
126
|
+
const i = createInjector()
|
|
127
|
+
const ds = i.get(DisposeDataSet)
|
|
128
|
+
const listener = vi.fn()
|
|
129
|
+
ds.addListener('onEntityAdded', listener)
|
|
130
|
+
await i[Symbol.asyncDispose]()
|
|
131
|
+
// After disposal the DataSet's event subscriptions are cleared;
|
|
132
|
+
// emitting directly would be a no-op.
|
|
133
|
+
ds.emit('onEntityAdded', { injector: i, entity: { id: 1, value: 'x' } })
|
|
134
|
+
expect(listener).not.toHaveBeenCalled()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('allows rebinding the backing store for tests', async () => {
|
|
138
|
+
await usingAsync(createInjector(), async (i) => {
|
|
139
|
+
const replacement = new InMemoryStore({ model: Test, primaryKey: 'id' })
|
|
140
|
+
i.bind(RebindStore, () => replacement)
|
|
141
|
+
const ds = i.get(RebindDataSet)
|
|
142
|
+
expect(ds.settings.physicalStore).toBe(replacement)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Constructable, PhysicalStore, StoreToken, WithOptionalId } from '@furystack/core'
|
|
2
|
+
import type { ServiceContext, Token } from '@furystack/inject'
|
|
3
|
+
import { defineService } from '@furystack/inject'
|
|
4
|
+
import type { DataSetSettings } from './data-set-setting.js'
|
|
5
|
+
import { DataSet } from './data-set.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves a {@link StoreToken} to a {@link PhysicalStore} whose
|
|
9
|
+
* `TWritableData` generic matches the owning data set. `StoreToken` defaults
|
|
10
|
+
* `TWritableData` to `WithOptionalId`, while a {@link DataSet} may narrow it
|
|
11
|
+
* (e.g. to strip system-owned fields). The write surface of `PhysicalStore`
|
|
12
|
+
* is contravariant in `TWritableData` — a store that accepts
|
|
13
|
+
* `WithOptionalId<T, TPK>` also accepts any narrower shape — but TypeScript
|
|
14
|
+
* cannot express that with the default generic, so the cast is localized
|
|
15
|
+
* here with this explanation rather than scattered at call sites.
|
|
16
|
+
*/
|
|
17
|
+
const resolvePhysicalStore = <T, TPrimaryKey extends keyof T, TWritableData>(
|
|
18
|
+
ctx: ServiceContext<'singleton'>,
|
|
19
|
+
store: StoreToken<T, TPrimaryKey>,
|
|
20
|
+
): PhysicalStore<T, TPrimaryKey, TWritableData> =>
|
|
21
|
+
ctx.inject(store) as unknown as PhysicalStore<T, TPrimaryKey, TWritableData>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A DI token that resolves to a {@link DataSet} and carries its model and
|
|
25
|
+
* primary key metadata (mirrored from the backing {@link StoreToken}).
|
|
26
|
+
*
|
|
27
|
+
* Consumers can `injector.get(token)` to obtain the dataset, or read the
|
|
28
|
+
* `model` / `primaryKey` fields to drive reflection-style tooling such as
|
|
29
|
+
* entity sync and OpenAPI generation.
|
|
30
|
+
*/
|
|
31
|
+
export type DataSetToken<T, TPrimaryKey extends keyof T, TWritableData = WithOptionalId<T, TPrimaryKey>> = Token<
|
|
32
|
+
DataSet<T, TPrimaryKey, TWritableData>,
|
|
33
|
+
'singleton'
|
|
34
|
+
> & {
|
|
35
|
+
readonly model: Constructable<T>
|
|
36
|
+
readonly primaryKey: TPrimaryKey
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Settings accepted by {@link defineDataSet}. A subset of
|
|
41
|
+
* {@link DataSetSettings}: the physical store is supplied by the store token
|
|
42
|
+
* and must not be passed here.
|
|
43
|
+
*/
|
|
44
|
+
export type DefineDataSetSettings<
|
|
45
|
+
T,
|
|
46
|
+
TPrimaryKey extends keyof T,
|
|
47
|
+
TWritableData = WithOptionalId<T, TPrimaryKey>,
|
|
48
|
+
> = Omit<DataSetSettings<T, TPrimaryKey, TWritableData>, 'physicalStore'>
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Options accepted by {@link defineDataSet}.
|
|
52
|
+
*
|
|
53
|
+
* `settings` is wrapped in `NoInfer` so the `store` token alone fixes the
|
|
54
|
+
* generic parameters. Without it, an inline callback inside `settings`
|
|
55
|
+
* (e.g. a `modifyOnAdd` arrow) triggers bidirectional inference where
|
|
56
|
+
* TypeScript widens `TPrimaryKey` back to `keyof T` to satisfy the
|
|
57
|
+
* callback's contextual typing.
|
|
58
|
+
*/
|
|
59
|
+
export type DefineDataSetOptions<T, TPrimaryKey extends keyof T, TWritableData = WithOptionalId<T, TPrimaryKey>> = {
|
|
60
|
+
/**
|
|
61
|
+
* Human-readable identifier used for debug/readability. Token identity is
|
|
62
|
+
* established by the returned {@link DataSetToken} object reference.
|
|
63
|
+
*/
|
|
64
|
+
name: string
|
|
65
|
+
/**
|
|
66
|
+
* The store this data set is backed by. Its `model` and `primaryKey` are
|
|
67
|
+
* propagated onto the returned token.
|
|
68
|
+
*/
|
|
69
|
+
store: StoreToken<T, TPrimaryKey>
|
|
70
|
+
/**
|
|
71
|
+
* Authorizers, modification hooks and filter post-processors. Optional —
|
|
72
|
+
* a data set without settings performs no authorization and forwards
|
|
73
|
+
* operations straight to the physical store.
|
|
74
|
+
*/
|
|
75
|
+
settings?: NoInfer<DefineDataSetSettings<T, TPrimaryKey, TWritableData>>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Defines a {@link DataSet} as a first-class DI token backed by a
|
|
80
|
+
* {@link StoreToken}.
|
|
81
|
+
*
|
|
82
|
+
* The returned token resolves to a singleton `DataSet`. On the first
|
|
83
|
+
* resolution the factory:
|
|
84
|
+
*
|
|
85
|
+
* 1. Resolves the backing physical store through the injector.
|
|
86
|
+
* 2. Constructs a {@link DataSet} with the supplied {@link DefineDataSetSettings}
|
|
87
|
+
* and that store.
|
|
88
|
+
* 3. Registers a disposal callback that clears the DataSet's event
|
|
89
|
+
* subscriptions when the owning injector is disposed.
|
|
90
|
+
*
|
|
91
|
+
* The token also carries the `model` and `primaryKey` of the backing store so
|
|
92
|
+
* that downstream tooling (entity sync, OpenAPI generators, test harnesses)
|
|
93
|
+
* can discover the dataset's shape without additional wiring.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* const UserStore = defineStore({
|
|
98
|
+
* name: 'my-app/UserStore',
|
|
99
|
+
* model: User,
|
|
100
|
+
* primaryKey: 'username',
|
|
101
|
+
* factory: () => new InMemoryStore({ model: User, primaryKey: 'username' }),
|
|
102
|
+
* })
|
|
103
|
+
*
|
|
104
|
+
* export const UserDataSet = defineDataSet({
|
|
105
|
+
* name: 'my-app/UserDataSet',
|
|
106
|
+
* store: UserStore,
|
|
107
|
+
* settings: {
|
|
108
|
+
* authorizeAdd: async ({ injector, entity }) => ({ isAllowed: true }),
|
|
109
|
+
* },
|
|
110
|
+
* })
|
|
111
|
+
*
|
|
112
|
+
* const ds = injector.get(UserDataSet)
|
|
113
|
+
* await ds.add(injector, { username: 'alice', roles: [] })
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export const defineDataSet = <T, const TPrimaryKey extends keyof T, TWritableData = WithOptionalId<T, TPrimaryKey>>(
|
|
117
|
+
options: DefineDataSetOptions<T, TPrimaryKey, TWritableData>,
|
|
118
|
+
): DataSetToken<T, TPrimaryKey, TWritableData> => {
|
|
119
|
+
const token = defineService({
|
|
120
|
+
name: options.name,
|
|
121
|
+
lifetime: 'singleton',
|
|
122
|
+
factory: (ctx: ServiceContext<'singleton'>) => {
|
|
123
|
+
const physicalStore = resolvePhysicalStore<T, TPrimaryKey, TWritableData>(ctx, options.store)
|
|
124
|
+
const dataSet = new DataSet<T, TPrimaryKey, TWritableData>({
|
|
125
|
+
...options.settings,
|
|
126
|
+
physicalStore,
|
|
127
|
+
} as DataSetSettings<T, TPrimaryKey, TWritableData>)
|
|
128
|
+
// Disposal is delegated to the injector via `onDispose`; the dataset
|
|
129
|
+
// outlives this factory invocation and is torn down on scope teardown.
|
|
130
|
+
// eslint-disable-next-line furystack/prefer-using-wrapper
|
|
131
|
+
ctx.onDispose(() => dataSet[Symbol.dispose]())
|
|
132
|
+
return dataSet
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
const result: DataSetToken<T, TPrimaryKey, TWritableData> = Object.assign(token, {
|
|
136
|
+
model: options.store.model,
|
|
137
|
+
primaryKey: options.store.primaryKey,
|
|
138
|
+
})
|
|
139
|
+
return result
|
|
140
|
+
}
|