@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +74 -36
  3. package/esm/data-set.spec.d.ts +2 -0
  4. package/esm/data-set.spec.d.ts.map +1 -0
  5. package/esm/data-set.spec.js +348 -0
  6. package/esm/data-set.spec.js.map +1 -0
  7. package/esm/define-data-set.d.ts +89 -0
  8. package/esm/define-data-set.d.ts.map +1 -0
  9. package/esm/define-data-set.js +75 -0
  10. package/esm/define-data-set.js.map +1 -0
  11. package/esm/define-data-set.spec.d.ts +2 -0
  12. package/esm/define-data-set.spec.d.ts.map +1 -0
  13. package/esm/define-data-set.spec.js +134 -0
  14. package/esm/define-data-set.spec.js.map +1 -0
  15. package/esm/helpers.d.ts +21 -22
  16. package/esm/helpers.d.ts.map +1 -1
  17. package/esm/helpers.js +17 -21
  18. package/esm/helpers.js.map +1 -1
  19. package/esm/index.d.ts +2 -2
  20. package/esm/index.d.ts.map +1 -1
  21. package/esm/index.js +2 -2
  22. package/esm/index.js.map +1 -1
  23. package/package.json +6 -6
  24. package/src/data-set.spec.ts +396 -0
  25. package/src/define-data-set.spec.ts +145 -0
  26. package/src/define-data-set.ts +140 -0
  27. package/src/helpers.ts +23 -26
  28. package/src/index.ts +2 -2
  29. package/esm/dataset.spec.d.ts +0 -2
  30. package/esm/dataset.spec.d.ts.map +0 -1
  31. package/esm/dataset.spec.js +0 -552
  32. package/esm/dataset.spec.js.map +0 -1
  33. package/esm/repository.d.ts +0 -15
  34. package/esm/repository.d.ts.map +0 -1
  35. package/esm/repository.js +0 -50
  36. package/esm/repository.js.map +0 -1
  37. package/esm/repository.spec.d.ts +0 -2
  38. package/esm/repository.spec.d.ts.map +0 -1
  39. package/esm/repository.spec.js +0 -18
  40. package/esm/repository.spec.js.map +0 -1
  41. package/src/dataset.spec.ts +0 -641
  42. package/src/repository.spec.ts +0 -19
  43. 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
+ }