UNPKG

@furystack/repository

Version:

Repository implementation for FuryStack

188 lines (177 loc) 7.03 kB
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 } }