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> extends EventHub<{ onEntityAdded: { injector: Injector; entity: T } onEntityUpdated: { injector: Injector; id: T[TPrimaryKey]; change: Partial } onEntityRemoved: { injector: Injector; key: T[TPrimaryKey] } onListenerError: ListenerErrorPayload }> implements Disposable { public primaryKey: TPrimaryKey public async add(injector: Injector, ...entities: TWritableData[]): Promise> { 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): Promise { 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): Promise { 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>( injector: Injector, filter: FindOptions, ): Promise>> { 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>( injector: Injector, key: T[TPrimaryKey], select?: TSelect, ): Promise | 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): Promise { 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, }) 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) { super() this.primaryKey = this.settings.physicalStore.primaryKey } }