@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,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,122 @@
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
+ * Debug-only identifier. Token identity is established by the returned
62
+ * {@link DataSetToken} object reference, not this string.
63
+ */
64
+ name: string
65
+ /** Backing store; its `model` and `primaryKey` propagate to the returned token. */
66
+ store: StoreToken<T, TPrimaryKey>
67
+ settings?: NoInfer<DefineDataSetSettings<T, TPrimaryKey, TWritableData>>
68
+ }
69
+
70
+ /**
71
+ * Defines a singleton {@link DataSet} token backed by a {@link StoreToken}.
72
+ * The token mirrors the store's `model` and `primaryKey` so reflection
73
+ * tools (entity sync, OpenAPI generators, test harnesses) can discover the
74
+ * dataset's shape from the token alone. Disposal of the owning injector
75
+ * tears down the dataset's event subscriptions.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * const UserStore = defineStore({
80
+ * name: 'my-app/UserStore',
81
+ * model: User,
82
+ * primaryKey: 'username',
83
+ * factory: () => new InMemoryStore({ model: User, primaryKey: 'username' }),
84
+ * })
85
+ *
86
+ * export const UserDataSet = defineDataSet({
87
+ * name: 'my-app/UserDataSet',
88
+ * store: UserStore,
89
+ * settings: {
90
+ * authorizeAdd: async ({ injector, entity }) => ({ isAllowed: true }),
91
+ * },
92
+ * })
93
+ *
94
+ * const ds = injector.get(UserDataSet)
95
+ * await ds.add(injector, { username: 'alice', roles: [] })
96
+ * ```
97
+ */
98
+ export const defineDataSet = <T, const TPrimaryKey extends keyof T, TWritableData = WithOptionalId<T, TPrimaryKey>>(
99
+ options: DefineDataSetOptions<T, TPrimaryKey, TWritableData>,
100
+ ): DataSetToken<T, TPrimaryKey, TWritableData> => {
101
+ const token = defineService({
102
+ name: options.name,
103
+ lifetime: 'singleton',
104
+ factory: (ctx: ServiceContext<'singleton'>) => {
105
+ const physicalStore = resolvePhysicalStore<T, TPrimaryKey, TWritableData>(ctx, options.store)
106
+ const dataSet = new DataSet<T, TPrimaryKey, TWritableData>({
107
+ ...options.settings,
108
+ physicalStore,
109
+ })
110
+ // Disposal is delegated to the injector via `onDispose`; the dataset
111
+ // outlives this factory invocation and is torn down on scope teardown.
112
+ // eslint-disable-next-line furystack/prefer-using-wrapper
113
+ ctx.onDispose(() => dataSet[Symbol.dispose]())
114
+ return dataSet
115
+ },
116
+ })
117
+ const result: DataSetToken<T, TPrimaryKey, TWritableData> = Object.assign(token, {
118
+ model: options.store.model,
119
+ primaryKey: options.store.primaryKey,
120
+ })
121
+ return result
122
+ }
package/src/helpers.ts CHANGED
@@ -1,46 +1,27 @@
1
- import type { Constructable, Injector } from '@furystack/inject'
2
- import { Repository } from './repository.js'
1
+ import type { WithOptionalId } from '@furystack/core'
2
+ import type { Injector } from '@furystack/inject'
3
+ import type { DataSet } from './data-set.js'
4
+ import type { DataSetToken } from './define-data-set.js'
3
5
 
4
6
  /**
5
- * Returns a Repository on an injector
6
- * @param injector The Injector instance
7
- * @returns The Repository instance
8
- */
9
- export const getRepository = (injector: Injector) => injector.getInstance(Repository)
10
-
11
- /**
12
- * Gets a DataSet for a specific model from the repository.
13
- *
14
- * The DataSet is the recommended way to perform all entity mutations (add, update, remove).
15
- * Writing through the DataSet ensures that authorization, modification hooks, and change events
16
- * are properly triggered -- which is required for features like entity sync.
17
- *
18
- * For server-side or background operations that don't originate from an HTTP request,
19
- * use {@link useSystemIdentityContext} to create a scoped child injector with elevated privileges.
20
- *
21
- * @param injector The Injector instance
22
- * @param model The Model
23
- * @param primaryKey The Primary Key field
24
- * @returns A Repository DataSet for a specific model
7
+ * Resolves the {@link DataSet} for `token` on `injector`. The preferred
8
+ * write gateway for application code — `furystack/no-direct-store-token`
9
+ * forbids resolving the underlying `StoreToken` directly. For server-side
10
+ * or background work, wrap `injector` with `useSystemIdentityContext` from
11
+ * `@furystack/core` to obtain an elevated identity before calling this.
25
12
  *
26
13
  * @example
27
14
  * ```ts
28
- * import { useSystemIdentityContext } from '@furystack/core'
29
- * import { getDataSetFor } from '@furystack/repository'
30
- * import { usingAsync } from '@furystack/utils'
31
- *
32
- * // In a background job or service
33
15
  * await usingAsync(
34
16
  * useSystemIdentityContext({ injector, username: 'background-job' }),
35
17
  * async (systemInjector) => {
36
- * const dataSet = getDataSetFor(systemInjector, MyModel, 'id')
37
- * await dataSet.add(systemInjector, newEntity)
18
+ * const dataSet = getDataSetFor(systemInjector, UserDataSet)
19
+ * await dataSet.add(systemInjector, { username: 'alice', roles: [] })
38
20
  * },
39
21
  * )
40
22
  * ```
41
23
  */
42
- export const getDataSetFor = <T, TPrimaryKey extends keyof T>(
24
+ export const getDataSetFor = <T, TPrimaryKey extends keyof T, TWritableData = WithOptionalId<T, TPrimaryKey>>(
43
25
  injector: Injector,
44
- model: Constructable<T>,
45
- primaryKey: TPrimaryKey,
46
- ) => injector.getInstance(Repository).getDataSetFor(model, primaryKey)
26
+ token: DataSetToken<T, TPrimaryKey, TWritableData>,
27
+ ): DataSet<T, TPrimaryKey, TWritableData> => injector.get(token)
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export * from './repository.js'
2
- export * from './data-set.js'
3
1
  export * from './data-set-setting.js'
2
+ export * from './data-set.js'
3
+ export * from './define-data-set.js'
4
4
  export * from './helpers.js'
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=dataset.spec.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"dataset.spec.d.ts","sourceRoot":"","sources":["../src/dataset.spec.ts"],"names":[],"mappings":""}