import { inject, injectable, Store } from '@servicetitan/react-ioc'; import { QueryApi } from '../query.api'; import { MutationApi } from '../mutation.api'; import { QueryClientStore } from '../query-client.store'; import { RuntimeQueries } from './runtime-queries'; import { RuntimeMutations } from './runtime-mutations'; import { RefreshOnMountDef } from './refresh-on-mount'; /** @internal Per-instance state managed by the decorator. */ export interface QueryStoreState { managedApis: (QueryApi | MutationApi)[]; runtimeQueries: Map>; runtimeMutations: Map>; } /** @internal Interface for accessing decorator-managed properties from standalone helpers. */ export interface ComposableStore { queryClientStore?: QueryClientStore; queryStoreState?: QueryStoreState; } /** * Class decorator that provides automatic QueryApi/MutationApi lifecycle management. * This is the recommended approach for new stores. * * **What it handles automatically:** * - Injects QueryClientStore (no `@inject(QueryClientStore)` needed) * - Applies `@injectable()` automatically — no need to add it separately. * If migrating and you want to keep `@injectable()`, place it **below** `@queryStore` in source: * ``` * @queryStore * @injectable() * class MyStore extends Store { ... } * ``` * Placing `@injectable()` above `@queryStore` will throw. * - Auto-discovers `QueryApi`, `MutationApi`, `RuntimeQueries`, `RuntimeMutations`, * and `RefreshOnMountDef` class properties and wires their lifecycle * - Chains consumer `initialize()` and `dispose()` methods * - Safe with deep inheritance — prevents double setup/dispose * * **Declarative helpers** (class fields, auto-discovered): * - `query()` / `mutation()` — static queries and mutations * - `setupRuntimeQueries()` / `setupRuntimeMutations()` — containers for queries/mutations * created after initialization (e.g., in response to user actions) * - `refreshOnMount()` — invalidate queries on store initialization * * @example Basic store with queries, mutations, and refresh * ```typescript * @queryStore * class JobsStore extends Store { * @inject(JobsApi) private api?: JobsApi; * * jobs = query(() => ({ * queryKey: ['scheduling', 'jobs'], * queryFn: async () => (await this.api?.getJobs())?.data ?? [], * })); * * deleteJob = mutation(() => ({ * mutationFn: async (arg) => await this.api?.deleteJob(arg.id), * invalidatedQueries: [['scheduling', 'jobs']], * })); * * // Ensure fresh data from external store caches when this store mounts * refresh = refreshOnMount(['business-units', 'list']); * } * ``` * * @example Runtime queries and mutations (created after initialization) * ```typescript * @queryStore * class DetailsStore extends Store { * @inject(DetailsApi) private api?: DetailsApi; * private queries = setupRuntimeQueries(); * private mutations = setupRuntimeMutations(); * * loadDetails(id: number) { * return this.queries.add
(() => ({ * queryKey: ['details', id], * queryFn: () => this.api?.getDetails(id), * }), ['details', id]); * } * * setupDelete(id: number) { * return this.mutations.add(() => ({ * mutationFn: (args) => this.api?.delete(id, args), * }), ['delete', id]); * } * } * ``` */ export function queryStore Store>(Base: T) { // @ts-expect-error TS2797: mixin extending abstract type variable must be abstract, but we need concrete for DI class QueryStoreWrapper extends Base implements ComposableStore { @inject(QueryClientStore) queryClientStore?: QueryClientStore; queryStoreState?: QueryStoreState; initialize() { const clientStore = this.queryClientStore; // Initialize per-instance state if not already set by a parent wrapper this.queryStoreState ??= { managedApis: [], runtimeQueries: new Map(), runtimeMutations: new Map(), }; const { managedApis } = this.queryStoreState; // Auto-discover declared QueryApi/MutationApi properties, skip already-wired ones for (const api of collectQueryApis(this)) { if (managedApis.includes(api)) { continue; } managedApis.push(api); if (api instanceof QueryApi) { api.setup(clientStore); } else if (api instanceof MutationApi) { api.setup(clientStore); } } // Bind RuntimeQueries / RuntimeMutations containers and RefreshOnMountDef markers for (const key of Object.keys(this)) { const val = (this as any)[key]; if (val instanceof RuntimeQueries || val instanceof RuntimeMutations) { val.composableStore = this; } else if (val instanceof RefreshOnMountDef) { val.composableStore = this; val.execute(); } } return super.initialize?.(); } dispose() { if (this.queryStoreState) { for (const api of this.queryStoreState.managedApis) { api.dispose(); } this.queryStoreState = undefined; } return super.dispose?.(); } } // Apply @injectable() only if not already applied (safe to combine with @injectable()) if (!Reflect.hasOwnMetadata('inversify:paramtypes', QueryStoreWrapper)) { injectable()(QueryStoreWrapper); } // Preserve the original class name for debugging and rootStore.name patterns Object.defineProperty(QueryStoreWrapper, 'name', { value: Base.name }); return QueryStoreWrapper as unknown as T; } /** * Scans instance properties for QueryApi and MutationApi instances. */ function collectQueryApis(instance: any): (QueryApi | MutationApi)[] { const apis: (QueryApi | MutationApi)[] = []; for (const key of Object.keys(instance)) { const val = instance[key]; if (val instanceof QueryApi || val instanceof MutationApi) { apis.push(val); } } return apis; }