@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +74 -36
  3. package/esm/data-set-setting.d.ts +30 -44
  4. package/esm/data-set-setting.d.ts.map +1 -1
  5. package/esm/data-set.d.ts +17 -68
  6. package/esm/data-set.d.ts.map +1 -1
  7. package/esm/data-set.js +18 -69
  8. package/esm/data-set.js.map +1 -1
  9. package/esm/data-set.spec.d.ts +2 -0
  10. package/esm/data-set.spec.d.ts.map +1 -0
  11. package/esm/data-set.spec.js +348 -0
  12. package/esm/data-set.spec.js.map +1 -0
  13. package/esm/define-data-set.d.ts +71 -0
  14. package/esm/define-data-set.d.ts.map +1 -0
  15. package/esm/define-data-set.js +65 -0
  16. package/esm/define-data-set.js.map +1 -0
  17. package/esm/define-data-set.spec.d.ts +2 -0
  18. package/esm/define-data-set.spec.d.ts.map +1 -0
  19. package/esm/define-data-set.spec.js +134 -0
  20. package/esm/define-data-set.spec.js.map +1 -0
  21. package/esm/helpers.d.ts +12 -29
  22. package/esm/helpers.d.ts.map +1 -1
  23. package/esm/helpers.js +8 -28
  24. package/esm/helpers.js.map +1 -1
  25. package/esm/index.d.ts +2 -2
  26. package/esm/index.d.ts.map +1 -1
  27. package/esm/index.js +2 -2
  28. package/esm/index.js.map +1 -1
  29. package/package.json +5 -5
  30. package/src/data-set-setting.ts +32 -46
  31. package/src/data-set.spec.ts +397 -0
  32. package/src/data-set.ts +28 -72
  33. package/src/define-data-set.spec.ts +145 -0
  34. package/src/define-data-set.ts +122 -0
  35. package/src/helpers.ts +14 -33
  36. package/src/index.ts +2 -2
  37. package/esm/dataset.spec.d.ts +0 -2
  38. package/esm/dataset.spec.d.ts.map +0 -1
  39. package/esm/dataset.spec.js +0 -552
  40. package/esm/dataset.spec.js.map +0 -1
  41. package/esm/repository.d.ts +0 -15
  42. package/esm/repository.d.ts.map +0 -1
  43. package/esm/repository.js +0 -50
  44. package/esm/repository.js.map +0 -1
  45. package/esm/repository.spec.d.ts +0 -2
  46. package/esm/repository.spec.d.ts.map +0 -1
  47. package/esm/repository.spec.js +0 -18
  48. package/esm/repository.spec.js.map +0 -1
  49. package/src/dataset.spec.ts +0 -641
  50. package/src/repository.spec.ts +0 -19
  51. 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 { CreateResult, FilterType, FindOptions, PartialResult, WithOptionalId } from '@furystack/core'
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
- * An authorized Repository Store instance that wraps a {@link PhysicalStore} with authorization,
9
- * modification hooks, and event dispatching.
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
- * The DataSet is the **recommended write gateway** for all entity mutations. Writing through the DataSet
12
- * ensures that authorization rules are enforced, modification hooks are applied, and change events
13
- * (`onEntityAdded`, `onEntityUpdated`, `onEntityRemoved`) are emitted -- which are required for features
14
- * like entity sync to function correctly.
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, MyModel, 'id')
31
- * await dataSet.add(systemInjector, newEntity)
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 as T })
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 as PartialResult<T, TSelect>
144
+ return fullEntity
183
145
  }
184
146
  return await this.settings.physicalStore.get(key, select)
185
147
  }
186
148
 
187
149
  /**
188
- * Removes one or more entities based on their primary keys.
189
- * Runs authorization checks (all-or-nothing), persists to the physical store,
190
- * and emits an `onEntityRemoved` event for each removed entity.
191
- *
192
- * When `authorizeRemoveEntity` is configured, only entities that exist in the store
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: entity as T })
172
+ const removeResult = await this.settings.authorizeRemoveEntity!({ injector, entity })
217
173
  if (!removeResult.isAllowed) {
218
174
  throw new AuthorizationError(removeResult.message)
219
175
  }