@furystack/repository
Version:
Repository implementation for FuryStack
188 lines (177 loc) • 7.03 kB
text/typescript
import type {
CreateResult,
FilterType,
FindOptions,
PartialResult,
PhysicalStore,
WithOptionalId,
} from '@furystack/core'
import { AuthorizationError, selectFields } from '@furystack/core'
import type { Injector } from '@furystack/inject'
import { EventHub, type ListenerErrorPayload } from '@furystack/utils'
import type { DataSetSettings } from './data-set-setting.js'
/**
* Authorization-enforcing wrapper around a {@link PhysicalStore}. The
* recommended write gateway for application code — `furystack/no-direct-store-token`
* enforces this. Each mutation runs the relevant `authorize*` and
* `modify*` callbacks from {@link DataSetSettings}, persists, then emits
* `onEntityAdded` / `onEntityUpdated` / `onEntityRemoved` (consumed by
* entity sync, audit logs).
*
* Mutating methods take an `injector` parameter to surface caller identity
* to the authorizers. For server-side / background work without an HTTP
* request, wrap the injector with `useSystemIdentityContext` from
* `@furystack/core`.
*
* @example
* ```ts
* await usingAsync(
* useSystemIdentityContext({ injector, username: 'background-job' }),
* async (systemInjector) => {
* const dataSet = getDataSetFor(systemInjector, UserDataSet)
* await dataSet.add(systemInjector, { username: 'alice', roles: [] })
* },
* )
* ```
*/
export class DataSet<T, TPrimaryKey extends keyof T, TWritableData = WithOptionalId<T, TPrimaryKey>>
extends EventHub<{
onEntityAdded: { injector: Injector; entity: T }
onEntityUpdated: { injector: Injector; id: T[TPrimaryKey]; change: Partial<T> }
onEntityRemoved: { injector: Injector; key: T[TPrimaryKey] }
onListenerError: ListenerErrorPayload
}>
implements Disposable
{
public primaryKey: TPrimaryKey
public async add(injector: Injector, ...entities: TWritableData[]): Promise<CreateResult<T>> {
await Promise.all(
entities.map(async (entity) => {
if (this.settings.authorizeAdd) {
const result = await this.settings.authorizeAdd({ injector, entity })
if (!result.isAllowed) {
throw new AuthorizationError(result.message)
}
}
}),
)
const parsed = await Promise.all(
entities.map(async (entity) => {
return this.settings.modifyOnAdd ? await this.settings.modifyOnAdd({ injector, entity }) : entity
}),
)
const createResult = await this.settings.physicalStore.add(...parsed)
createResult.created.map((entity) => {
this.emit('onEntityAdded', { injector, entity })
})
return createResult
}
public async update(injector: Injector, id: T[TPrimaryKey], change: Partial<T>): Promise<void> {
if (this.settings.authorizeUpdate) {
const result = await this.settings.authorizeUpdate({ injector, change })
if (!result.isAllowed) {
throw new AuthorizationError(result.message)
}
}
if (this.settings.authorizeUpdateEntity) {
const entity = await this.settings.physicalStore.get(id)
if (entity) {
const result = await this.settings.authorizeUpdateEntity({ injector, change, entity })
if (!result.isAllowed) {
throw new AuthorizationError(result.message)
}
}
}
const parsed = this.settings.modifyOnUpdate
? await this.settings.modifyOnUpdate({ injector, id, entity: change })
: change
await this.settings.physicalStore.update(id, parsed)
this.emit('onEntityUpdated', { injector, change: parsed, id })
}
public async count(injector: Injector, filter?: FilterType<T>): Promise<number> {
if (this.settings.authorizeGet) {
const result = await this.settings.authorizeGet({ injector })
if (!result.isAllowed) {
throw new AuthorizationError(result.message)
}
}
return await this.settings.physicalStore.count(filter)
}
public async find<TFields extends Array<keyof T>>(
injector: Injector,
filter: FindOptions<T, TFields>,
): Promise<Array<PartialResult<T, TFields>>> {
if (this.settings.authorizeGet) {
const result = await this.settings.authorizeGet({ injector })
if (!result.isAllowed) {
throw new AuthorizationError(result.message)
}
}
const parsedFilter = this.settings.addFilter ? await this.settings.addFilter({ injector, filter }) : filter
return this.settings.physicalStore.find(parsedFilter)
}
public async get<TSelect extends Array<keyof T>>(
injector: Injector,
key: T[TPrimaryKey],
select?: TSelect,
): Promise<PartialResult<T, TSelect> | undefined> {
if (this.settings.authorizeGet) {
const result = await this.settings.authorizeGet({ injector })
if (!result.isAllowed) {
throw new AuthorizationError(result.message)
}
}
if (this.settings.authorizeGetEntity) {
const fullEntity = await this.settings.physicalStore.get(key)
if (!fullEntity) {
return undefined
}
const result = await this.settings.authorizeGetEntity({ injector, entity: fullEntity })
if (!result.isAllowed) {
throw new AuthorizationError(result.message)
}
if (select) {
return selectFields(fullEntity as T & object, ...select)
}
return fullEntity
}
return await this.settings.physicalStore.get(key, select)
}
/**
* Removes by primary key. Pre-load `authorizeRemove` and per-entity
* `authorizeRemoveEntity` are all-or-nothing — any rejection aborts the
* whole batch before any persist call. When `authorizeRemoveEntity` is
* configured, missing keys are silently forwarded to the physical store
* (no entity to authorize).
*/
public async remove(injector: Injector, ...keys: Array<T[TPrimaryKey]>): Promise<void> {
if (keys.length === 0) {
return
}
if (this.settings.authorizeRemove) {
const result = await this.settings.authorizeRemove({ injector })
if (!result.isAllowed) {
throw new AuthorizationError(result.message)
}
}
if (this.settings.authorizeRemoveEntity) {
const entities = await this.settings.physicalStore.find({
filter: { [this.primaryKey]: { $in: keys } } as unknown as FilterType<T>,
})
await Promise.all(
entities.map(async (entity) => {
const removeResult = await this.settings.authorizeRemoveEntity!({ injector, entity })
if (!removeResult.isAllowed) {
throw new AuthorizationError(removeResult.message)
}
}),
)
}
await this.settings.physicalStore.remove(...keys)
keys.forEach((key) => this.emit('onEntityRemoved', { injector, key }))
}
constructor(public readonly settings: DataSetSettings<T, TPrimaryKey, TWritableData>) {
super()
this.primaryKey = this.settings.physicalStore.primaryKey
}
}