@furystack/repository 10.1.11 → 11.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +74 -36
- package/esm/data-set-setting.d.ts +30 -44
- package/esm/data-set-setting.d.ts.map +1 -1
- package/esm/data-set.d.ts +17 -68
- package/esm/data-set.d.ts.map +1 -1
- package/esm/data-set.js +18 -69
- package/esm/data-set.js.map +1 -1
- package/esm/data-set.spec.d.ts +2 -0
- package/esm/data-set.spec.d.ts.map +1 -0
- package/esm/data-set.spec.js +348 -0
- package/esm/data-set.spec.js.map +1 -0
- package/esm/define-data-set.d.ts +71 -0
- package/esm/define-data-set.d.ts.map +1 -0
- package/esm/define-data-set.js +65 -0
- package/esm/define-data-set.js.map +1 -0
- package/esm/define-data-set.spec.d.ts +2 -0
- package/esm/define-data-set.spec.d.ts.map +1 -0
- package/esm/define-data-set.spec.js +134 -0
- package/esm/define-data-set.spec.js.map +1 -0
- package/esm/helpers.d.ts +12 -29
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +8 -28
- package/esm/helpers.js.map +1 -1
- package/esm/index.d.ts +2 -2
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +2 -2
- package/esm/index.js.map +1 -1
- package/package.json +5 -5
- package/src/data-set-setting.ts +32 -46
- package/src/data-set.spec.ts +397 -0
- package/src/data-set.ts +28 -72
- package/src/define-data-set.spec.ts +145 -0
- package/src/define-data-set.ts +122 -0
- package/src/helpers.ts +14 -33
- package/src/index.ts +2 -2
- package/esm/dataset.spec.d.ts +0 -2
- package/esm/dataset.spec.d.ts.map +0 -1
- package/esm/dataset.spec.js +0 -552
- package/esm/dataset.spec.js.map +0 -1
- package/esm/repository.d.ts +0 -15
- package/esm/repository.d.ts.map +0 -1
- package/esm/repository.js +0 -50
- package/esm/repository.js.map +0 -1
- package/esm/repository.spec.d.ts +0 -2
- package/esm/repository.spec.d.ts.map +0 -1
- package/esm/repository.spec.js +0 -18
- package/esm/repository.spec.js.map +0 -1
- package/src/dataset.spec.ts +0 -641
- package/src/repository.spec.ts +0 -19
- package/src/repository.ts +0 -50
|
@@ -0,0 +1,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 {
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
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,
|
|
37
|
-
* await dataSet.add(systemInjector,
|
|
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
|
-
|
|
45
|
-
|
|
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
package/esm/dataset.spec.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dataset.spec.d.ts","sourceRoot":"","sources":["../src/dataset.spec.ts"],"names":[],"mappings":""}
|