diff --git a/apps/server/src/lib/stores/queries.ts b/apps/server/src/lib/stores/queries.ts deleted file mode 100644 index 77d3308b3..000000000 --- a/apps/server/src/lib/stores/queries.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { browser } from '$app/environment' -import { - store as queryStore, - createQueryKey as createKeyForQueryStore, - type QueryResultState, -} from '@latitude-data/client' -import { writable, get, derived, Readable } from 'svelte/store' -import { ViewParams, getAllViewParams, useViewParams } from './viewParams' -import { type QueryResultArray } from '@latitude-data/query_result' -import { debounce } from 'lodash-es' - -let loaded = false - -// TODO: Refactor this madness - -/** - * The middlewareQueryStore is a store that keeps track of the queries being - * used in the current view, and points to the key in the core query store with - * the results for each query, which will change when the viewParams change. - */ -type MiddlewareStoreState = Record< - string, - { - queryPath: string - inlineParams: InlineParams - coreQueryKey: string // key in the core query store - } -> -const middlewareQueryStore = writable({}) - -export type InlineParams = Record -type InlineParam = { - key: string - callback: (viewParams: ViewParams) => unknown -} - -export const input = (key: string, defaultValue?: unknown): InlineParam => ({ - key: `input(${key})`, - callback: (viewParams: ViewParams) => - key in viewParams ? viewParams[key] : defaultValue, -}) - -function computeQueryParams( - inlineParams: InlineParams -): Record { - const viewParams = getAllViewParams() - const sanitizedViewParams = sanitizeParams(viewParams) - const composedParams = composeParams(inlineParams, viewParams) - - return { ...sanitizedViewParams, ...composedParams } -} - -function sanitizeParams( - params: Record -): Record { - return Object.fromEntries( - Object.entries(params).filter(([_, value]) => value !== undefined) - ) -} - -function composeParams( - inlineParams: InlineParams, - viewParams: Record -): Record { - return Object.entries(inlineParams).reduce>( - (accumulatedParams, [key, inlineParam]) => { - const paramValue = resolveParamValue(inlineParam, viewParams) - if (paramValue !== undefined) { - accumulatedParams[key] = paramValue - } - return accumulatedParams - }, - {} - ) -} - -function resolveParamValue( - inlineParam: unknown, - viewParams: Record -): unknown { - if ( - typeof inlineParam === 'object' && - inlineParam !== null && - 'callback' in inlineParam - ) { - return ( - inlineParam as { - callback: (viewParams: Record) => string - } - ).callback(viewParams) - } - return inlineParam -} - -function createMiddlewareKey( - queryPath: string, - inlineParams: InlineParams = {} -): string { - const hashedParams = Object.keys(inlineParams) - .sort() - .map( - (paramName) => - `${paramName}=${ - inlineParams[paramName].key ?? String(inlineParams[paramName]) - }` - ) - .join('&') - return `query:${queryPath}?${hashedParams}` -} - -/** - * Updates the middlewareQueryStore for a given queryPath and inlineParams. - * If the query is already in the store and resolves to the same parameters as before, it does nothing. - * Otherwise, it fetches it from the core query store (which fetches it from the server if not already in that store). - * If force is true, it forces a refetch of the query from the server. - */ -async function fetchQueryFromCore({ - query, - inlineParams, - force = false, - skipIfParamsUnchanged = true, -}: { - query: string - inlineParams: InlineParams - force?: boolean // Adds the 'force' flag to the request, to invalidate the backend cache - skipIfParamsUnchanged?: boolean // If true, it won't refetch if the params haven't changed -}): Promise { - const queryKey = createMiddlewareKey(query, inlineParams) - const computedParams = computeQueryParams(inlineParams) - const coreQueryKey = createKeyForQueryStore(query, computedParams) - - const oldQueryKey = get(middlewareQueryStore)[queryKey]?.coreQueryKey - - middlewareQueryStore.update((state: MiddlewareStoreState) => ({ - ...state, - [queryKey]: { queryPath: query, inlineParams, coreQueryKey }, - })) - - if (!browser || !loaded) return // Don't fetch queries until the page is fully loaded - if (skipIfParamsUnchanged && oldQueryKey === coreQueryKey) return // Don't refetch if there have been no changes to the params - - if (force) { - queryStore - .getState() - .forceRefetch({ queryPath: query, params: computedParams }) - } else { - queryStore.getState().fetch({ queryPath: query, params: computedParams }) - } -} - -export type QuerySubscriptionOptions = { - reactToParams?: boolean | number - reactiveToParams?: boolean | number // Deprecated -} - -export type QueryProps = { - query: string - inlineParams?: InlineParams - opts?: QuerySubscriptionOptions -} - -/** - * useQuery returns a store with the state of the query. The state contains the following properties: - * - isLoading: boolean - * - error: Error | null - * - data: QueryResult | null - */ -export function useQuery({ - query, - inlineParams = {}, - opts = {}, -}: QueryProps): Readable { - const queryResultStore = writable({ isLoading: true }) - if (!browser) return queryResultStore - - const middlewareKey = createMiddlewareKey(query, inlineParams) - if (!get(middlewareQueryStore)[middlewareKey]) { - fetchQueryFromCore({ query, inlineParams }) - } - - const coreQueryKeyStore = writable( - get(middlewareQueryStore)[middlewareKey]!.coreQueryKey - ) - // Update coreQueryKey when middlewareQueryStore changes - middlewareQueryStore.subscribe((state) => { - if (state[middlewareKey].coreQueryKey === get(coreQueryKeyStore)) return - coreQueryKeyStore.set(state[middlewareKey].coreQueryKey) - }) - - const updateState = () => { - const coreQueryKey = get(coreQueryKeyStore) - if (!(coreQueryKey in queryStore.getState().queries)) return - const queryResultState = queryStore.getState().queries[coreQueryKey] - if (queryResultState === get(queryResultStore)) return - queryResultStore.set({ - ...queryResultState, - data: queryResultState.data ?? get(queryResultStore).data, - }) - } - // Update state when coreQueryKey changes - coreQueryKeyStore.subscribe(updateState) - - // Check for state updates when queryStore changes - queryStore.subscribe(updateState) - - if (opts.reactiveToParams) { - console.warn( - 'The "reactiveToParams" option is deprecated. Please use "reactToParams" instead.' - ) - } - - const reactive = opts.reactiveToParams ?? opts.reactToParams - - // Refetch when viewParams change - if (reactive || reactive === 0) { - const debounceTime = reactive === true ? 0 : reactive - const debouncedRefetch = debounce(() => { - fetchQueryFromCore({ query, inlineParams }) - }, debounceTime) - - useViewParams().subscribe(() => { - const newComputedParams = computeQueryParams(inlineParams) - const newCoreQueryKey = createKeyForQueryStore(query, newComputedParams) - if ( - debounceTime === 0 || - newCoreQueryKey in queryStore.getState().queries - ) { - fetchQueryFromCore({ query, inlineParams }) - return - } - debouncedRefetch() - }) - } - - updateState() - - return queryResultStore -} - -/** - * runQuery returns a store with a promise that resolves to the query result, which is returned as an array of row hashes. - * This method is targeted for easier use in Svelte pages made by users. - */ -export function runQuery( - query: string, - inlineParams: InlineParams = {}, - opts: QuerySubscriptionOptions = {} -): Readable> { - const pendingPromise = () => new Promise(() => {}) - const resolvedPromise = (value: QueryResultArray) => - new Promise((resolve) => resolve(value)) - const rejectedPromise = (reason?: Error) => - new Promise((_, reject) => reject(reason)) - - const queryStateToPromise = (queryState: QueryResultState) => { - if (queryState.isLoading) return pendingPromise() - if (queryState.error) return rejectedPromise(queryState.error) - return resolvedPromise(queryState.data!.toArray()) - } - - return derived( - useQuery({ query, inlineParams, opts }), - ($queryResultState, set) => { - set(queryStateToPromise($queryResultState)) - } - ) -} - -export async function computeQueries({ - queryPaths = [], - force = true, - skipIfParamsUnchanged = false, -}: { - queryPaths: string[] - force?: boolean - skipIfParamsUnchanged?: boolean -}): Promise { - if (!browser) return [] - - const queriesInView = get(middlewareQueryStore) - return Promise.all( - Object.values(queriesInView) - .filter( - (queryInView) => - queryPaths.length === 0 || queryPaths.includes(queryInView.queryPath) - ) - .map((queryInView) => - fetchQueryFromCore({ - query: queryInView.queryPath, - inlineParams: queryInView.inlineParams, - force, - skipIfParamsUnchanged, - }) - ) - ) -} - -/** - * To avoid requesting multiple fetch requests to the server while the page is - * loading and the params are still being added while the document loads, we - * need to wait for the page to be fully loaded before fetching the queries. - * - * TODO: Find a better way to detect when the page is fully loaded without having - * to manually call init() in the page. - */ -export function init() { - loaded = true - return computeQueries({ - queryPaths: [], - force: false, - skipIfParamsUnchanged: false, - }) -} diff --git a/apps/server/src/lib/stores/queries/functions.ts b/apps/server/src/lib/stores/queries/functions.ts new file mode 100644 index 000000000..95e15e3c4 --- /dev/null +++ b/apps/server/src/lib/stores/queries/functions.ts @@ -0,0 +1,96 @@ +import middlewareQueryStore from './shared/middlewareQueryStore' +import { + InlineParam, + InlineParams, + QuerySubscriptionOptions, + useQuery, +} from './hooks' +import { QueryResultArray } from '@latitude-data/query_result' +import { QueryResultState } from '@latitude-data/client' +import { Readable, derived, get } from 'svelte/store' +import { browser } from '$app/environment' +import { fetchQueryFromCore } from './shared/api' +import { loaded } from './shared/utils' +import { ViewParams } from '../viewParams' + +/** + * runQuery returns a store with a promise that resolves to the query result, which is returned as an array of row hashes. + * This method is targeted for easier use in Svelte pages made by users. + */ +export function runQuery( + query: string, + inlineParams: InlineParams = {}, + opts: QuerySubscriptionOptions = {} +): Readable> { + const pendingPromise = () => new Promise(() => {}) + const resolvedPromise = (value: QueryResultArray) => + new Promise((resolve) => resolve(value)) + const rejectedPromise = (reason?: Error) => + new Promise((_, reject) => reject(reason)) + + const queryStateToPromise = (queryState: QueryResultState) => { + if (queryState.isLoading) return pendingPromise() + if (queryState.error) return rejectedPromise(queryState.error) + return resolvedPromise(queryState.data!.toArray()) + } + + return derived( + useQuery({ query, inlineParams, opts }), + ($queryResultState, set) => { + set(queryStateToPromise($queryResultState)) + } + ) +} + +export async function computeQueries({ + queryPaths = [], + force = true, + skipIfParamsUnchanged = false, +}: { + queryPaths: string[] + force?: boolean + skipIfParamsUnchanged?: boolean +}): Promise { + if (!browser) return [] + + const queriesInView = get(middlewareQueryStore) + return Promise.all( + Object.values(queriesInView) + .filter( + (queryInView) => + queryPaths.length === 0 || queryPaths.includes(queryInView.queryPath) + ) + .map((queryInView) => + fetchQueryFromCore({ + query: queryInView.queryPath, + inlineParams: queryInView.inlineParams, + force, + skipIfParamsUnchanged, + }) + ) + ) +} + +export const input = (key: string, defaultValue?: unknown): InlineParam => ({ + key: `input(${key})`, + callback: (viewParams: ViewParams) => + key in viewParams ? viewParams[key] : defaultValue, +}) + +/** + * To avoid requesting multiple fetch requests to the server while the page is + * loading and the params are still being added while the document loads, we + * need to wait for the page to be fully loaded before fetching the queries. + * + * TODO: Find a better way to detect when the page is fully loaded without having + * to manually call init() in the page. + */ +export function init() { + loaded.set(true) + + return computeQueries({ + queryPaths: [], + force: false, + skipIfParamsUnchanged: false, + }) +} diff --git a/apps/server/src/lib/stores/queries.test.ts b/apps/server/src/lib/stores/queries/hooks.test.ts similarity index 86% rename from apps/server/src/lib/stores/queries.test.ts rename to apps/server/src/lib/stores/queries/hooks.test.ts index 3727c37f0..0167832ec 100644 --- a/apps/server/src/lib/stores/queries.test.ts +++ b/apps/server/src/lib/stores/queries/hooks.test.ts @@ -1,10 +1,14 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { useQuery, init as initQueries } from './queries' -import { type ViewParams } from './viewParams' +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest' import { get, Writable, writable } from 'svelte/store' -import { v4 as uuidv4 } from 'uuid' +import { init as initQueries } from './functions' import { store } from '@latitude-data/client' -import { setViewParam } from './viewParams' +import { useQuery } from './hooks' +import { v4 as uuidv4 } from 'uuid' + +// @ts-expect-error - mocking resetviewparams +import { resetViewParams, getAllViewParams, setViewParam } from '../viewParams' + +import type { ViewParams } from '../viewParams' type MockQueryState = { queries: Record @@ -18,7 +22,7 @@ vi.mock('$app/environment', () => { } }) -const mockFetchFn = vi.hoisted(() => vi.fn(async () => {})) +const mockFetchFn = vi.hoisted(vi.fn) const initialQueryState = { queries: {}, forceRefetch: mockFetchFn, @@ -26,6 +30,7 @@ const initialQueryState = { } const resetQueryState = () => { mockFetchFn.mockClear() + // @ts-expect-error - mock types conflict store.setState(initialQueryState) } vi.mock('@latitude-data/client', () => { @@ -54,11 +59,15 @@ vi.mock('@latitude-data/client', () => { } }) -let mockViewParams: Writable +vi.mock('../viewParams', () => { + let mockViewParams: Writable = writable({}) + const resetViewParams = () => { + mockViewParams = writable({}) // Reset to initial state + } -vi.mock('./viewParams', () => { return { - useViewParams: () => mockViewParams, + resetViewParams, + useViewParams: vi.fn(() => mockViewParams), getAllViewParams: vi.fn(() => get(mockViewParams)), setViewParam: vi.fn((key: string, value: unknown) => { mockViewParams.update((params) => { @@ -72,11 +81,13 @@ vi.mock('./viewParams', () => { initQueries() // Initialize the queries store. Otherwise the fetch function would never be called describe('useQuery with reactiveParams', () => { - beforeEach(() => { + beforeAll(() => { vi.useFakeTimers() + }) + + beforeEach(() => { resetQueryState() - vi.clearAllMocks() - mockViewParams = writable({}) + resetViewParams() // Reset the viewParams state explicitly }) it('should not run the refetch function when reactiveParams is not set', () => { @@ -145,9 +156,7 @@ describe('useQuery with reactiveParams', () => { useQuery({ query, opts }) vi.runAllTimers() - // @ts-expect-error - Typescript is wrong here const lastCallArgs = mockFetchFn.mock.calls[0][0] - // @ts-expect-error - Typescript is wrong here expect(lastCallArgs!.params).toStrictEqual({}) }) @@ -156,6 +165,7 @@ describe('useQuery with reactiveParams', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const query = uuidv4() const opts = { reactiveToParams: true } + useQuery({ query, opts }) // The 'fetch' method should be called once when the query is first subscribed @@ -163,6 +173,7 @@ describe('useQuery with reactiveParams', () => { // Update the viewParams setViewParam('param', 'value') + vi.runAllTimers() // The 'fetch' method should have been called the 1 initial time + 1 time after the viewParams were updated diff --git a/apps/server/src/lib/stores/queries/hooks.ts b/apps/server/src/lib/stores/queries/hooks.ts new file mode 100644 index 000000000..65bcf16bb --- /dev/null +++ b/apps/server/src/lib/stores/queries/hooks.ts @@ -0,0 +1,155 @@ +import { browser } from '$app/environment' +import { store as queryStore, createQueryKey } from '@latitude-data/client' +import { writable, get, Readable, Writable } from 'svelte/store' +import { ViewParams, useViewParams } from '../viewParams' +import { debounce } from 'lodash-es' +import { computeQueryParams, createMiddlewareKey } from './shared/utils' +import middlewareQueryStore from './shared/middlewareQueryStore' +import { fetchQueryFromCore } from './shared/api' + +import type { QueryResultState } from '@latitude-data/client' + +export type InlineParams = Record +export type InlineParam = { + key: string + callback: (viewParams: ViewParams) => unknown +} +export type QuerySubscriptionOptions = { + reactToParams?: boolean | number + reactiveToParams?: boolean | number // Deprecated +} + +export type QueryProps = { + query: string + inlineParams?: InlineParams + opts?: QuerySubscriptionOptions +} + +function createQueryResultStore(): Writable { + return writable({ isLoading: true }) +} + +function setupMiddlewareQuerySubscription( + query: string, + inlineParams: InlineParams, + middlewareKey: string +): string { + if (!get(middlewareQueryStore)[middlewareKey]) { + fetchQueryFromCore({ query, inlineParams }) + } + + return get(middlewareQueryStore)[middlewareKey]!.coreQueryKey +} + +function subscribeToMiddlewareChanges( + middlewareKey: string, + coreQueryKeyStore: Writable +): void { + middlewareQueryStore.subscribe((state) => { + const currentKey = get(coreQueryKeyStore) + const newKey = state[middlewareKey].coreQueryKey + if (newKey !== currentKey) { + coreQueryKeyStore.set(newKey) + } + }) +} + +function updateState( + coreQueryKeyStore: Readable, + queryResultStore: Writable +) { + return () => { + const coreQueryKey = get(coreQueryKeyStore) + const currentQueryResultState = get(queryResultStore) + const queryStoreResult = queryStore.getState().queries[coreQueryKey] + + if (!queryStoreResult || queryStoreResult === currentQueryResultState) + return + + queryResultStore.set({ + ...queryStoreResult, + data: queryStoreResult.data ?? currentQueryResultState.data, + }) + } +} + +function subscribeToUpdateState( + coreQueryKeyStore: Readable, + queryResultStore: Writable +): void { + coreQueryKeyStore.subscribe(updateState(coreQueryKeyStore, queryResultStore)) + queryStore.subscribe(updateState(coreQueryKeyStore, queryResultStore)) +} + +function handleParamReactivity( + query: string, + inlineParams: InlineParams, + reactToParamsOption: boolean | number +): void { + const debounceTime = + reactToParamsOption === true ? 0 : Number(reactToParamsOption) + const debouncedRefetch = debounce(() => { + fetchQueryFromCore({ query, inlineParams }) + }, debounceTime) + + useViewParams().subscribe(() => { + const newComputedParams = computeQueryParams(inlineParams) + const newCoreQueryKey = createQueryKey(query, newComputedParams) + + if (debounceTime === 0 || queryStore.getState().queries[newCoreQueryKey]) { + fetchQueryFromCore({ query, inlineParams }) + return + } + + debouncedRefetch() + }) +} + +function createCoreQueryKeyStore({ + query, + inlineParams = {}, + middlewareKey, +}: { + query: string + inlineParams: InlineParams + middlewareKey: string +}) { + return writable( + setupMiddlewareQuerySubscription(query, inlineParams, middlewareKey) + ) +} + +export function useQuery({ + query, + inlineParams = {}, + opts = {}, +}: QueryProps): Writable { + const queryResultStore = createQueryResultStore() + if (!browser) return queryResultStore + + const middlewareKey = createMiddlewareKey(query, inlineParams) + const coreQueryKeyStore = createCoreQueryKeyStore({ + query, + inlineParams, + middlewareKey, + }) + + subscribeToMiddlewareChanges(middlewareKey, coreQueryKeyStore) + subscribeToUpdateState(coreQueryKeyStore, queryResultStore) + + if (opts.reactiveToParams !== undefined || opts.reactToParams !== undefined) { + console.warn( + 'The "reactiveToParams" option is deprecated. Please use "reactToParams" instead.' + ) + const reactToParams = opts.reactToParams ?? opts.reactiveToParams + if (reactToParams || reactToParams === 0) { + // handle "0" as explicit debounce time + handleParamReactivity(query, inlineParams, reactToParams) + } + } + + // Initial update to set the state correctly before returning it. + updateState(coreQueryKeyStore, queryResultStore)() + + return queryResultStore +} diff --git a/apps/server/src/lib/stores/setUrlParam.test.ts b/apps/server/src/lib/stores/queries/setUrlParam.test.ts similarity index 94% rename from apps/server/src/lib/stores/setUrlParam.test.ts rename to apps/server/src/lib/stores/queries/setUrlParam.test.ts index 2372f63d5..a483e9d19 100644 --- a/apps/server/src/lib/stores/setUrlParam.test.ts +++ b/apps/server/src/lib/stores/queries/setUrlParam.test.ts @@ -1,5 +1,5 @@ import { replaceState } from '$app/navigation' -import { setUrlParam, ViewParams } from './viewParams' +import { setUrlParam, ViewParams } from '../viewParams' import { describe, it, expect, vi } from 'vitest' vi.mock('$app/navigation', () => ({ diff --git a/apps/server/src/lib/stores/queries/shared/api.ts b/apps/server/src/lib/stores/queries/shared/api.ts new file mode 100644 index 000000000..606473980 --- /dev/null +++ b/apps/server/src/lib/stores/queries/shared/api.ts @@ -0,0 +1,46 @@ +import { createQueryKey, store } from '@latitude-data/client' +import { computeQueryParams, createMiddlewareKey, loaded } from './utils' +import { get } from 'svelte/store' +import middlewareQueryStore, { + MiddlewareStoreState, +} from './middlewareQueryStore' +import { browser } from '$app/environment' +import { InlineParams } from '../hooks' + +/** + * Updates the middlewareQueryStore for a given queryPath and inlineParams. + * If the query is already in the store and resolves to the same parameters as before, it does nothing. + * Otherwise, it fetches it from the core query store (which fetches it from the server if not already in that store). + * If force is true, it forces a refetch of the query from the server. + */ +export async function fetchQueryFromCore({ + query, + inlineParams, + force = false, + skipIfParamsUnchanged = true, +}: { + query: string + inlineParams: InlineParams + force?: boolean // Adds the 'force' flag to the request, to invalidate the backend cache + skipIfParamsUnchanged?: boolean // If true, it won't refetch if the params haven't changed +}): Promise { + const queryKey = createMiddlewareKey(query, inlineParams) + const computedParams = computeQueryParams(inlineParams) + const coreQueryKey = createQueryKey(query, computedParams) + + const oldQueryKey = get(middlewareQueryStore)[queryKey]?.coreQueryKey + + middlewareQueryStore.update((state: MiddlewareStoreState) => ({ + ...state, + [queryKey]: { queryPath: query, inlineParams, coreQueryKey }, + })) + + if (!browser || !loaded.get()) return // Don't fetch queries until the page is fully loaded + if (skipIfParamsUnchanged && oldQueryKey === coreQueryKey) return // Don't refetch if there have been no changes to the params + + if (force) { + store.getState().forceRefetch({ queryPath: query, params: computedParams }) + } else { + store.getState().fetch({ queryPath: query, params: computedParams }) + } +} diff --git a/apps/server/src/lib/stores/queries/shared/middlewareQueryStore.ts b/apps/server/src/lib/stores/queries/shared/middlewareQueryStore.ts new file mode 100644 index 000000000..dbd46ae65 --- /dev/null +++ b/apps/server/src/lib/stores/queries/shared/middlewareQueryStore.ts @@ -0,0 +1,19 @@ +import { writable } from 'svelte/store' +import { InlineParams } from '../hooks' + +/** + * The middlewareQueryStore is a store that keeps track of the queries being + * used in the current view, and points to the key in the core query store with + * the results for each query, which will change when the viewParams change. + */ +export type MiddlewareStoreState = Record< + string, + { + queryPath: string + inlineParams: InlineParams + coreQueryKey: string // key in the core query store + } +> +export const middlewareQueryStore = writable({}) + +export default middlewareQueryStore diff --git a/apps/server/src/lib/stores/queries/shared/utils.ts b/apps/server/src/lib/stores/queries/shared/utils.ts new file mode 100644 index 000000000..7211607f5 --- /dev/null +++ b/apps/server/src/lib/stores/queries/shared/utils.ts @@ -0,0 +1,83 @@ +import { InlineParams } from '../hooks' +import { getAllViewParams } from '../../viewParams' + +export function computeQueryParams( + inlineParams: InlineParams +): Record { + const viewParams = getAllViewParams() + const sanitizedViewParams = sanitizeParams(viewParams) + const composedParams = composeParams(inlineParams, viewParams) + + return { ...sanitizedViewParams, ...composedParams } +} + +export function sanitizeParams( + params: Record +): Record { + return Object.fromEntries( + Object.entries(params).filter(([_, value]) => value !== undefined) + ) +} + +export function composeParams( + inlineParams: InlineParams, + viewParams: Record +): Record { + return Object.entries(inlineParams).reduce>( + (accumulatedParams, [key, inlineParam]) => { + const paramValue = resolveParamValue(inlineParam, viewParams) + if (paramValue !== undefined) { + accumulatedParams[key] = paramValue + } + return accumulatedParams + }, + {} + ) +} + +export function resolveParamValue( + inlineParam: unknown, + viewParams: Record +): unknown { + if ( + typeof inlineParam === 'object' && + inlineParam !== null && + 'callback' in inlineParam + ) { + return ( + inlineParam as { + callback: (viewParams: Record) => string + } + ).callback(viewParams) + } + return inlineParam +} + +export function createMiddlewareKey( + queryPath: string, + inlineParams: InlineParams = {} +): string { + const hashedParams = Object.keys(inlineParams) + .sort() + .map( + (paramName) => + `${paramName}=${ + inlineParams[paramName].key ?? String(inlineParams[paramName]) + }` + ) + .join('&') + return `query:${queryPath}?${hashedParams}` +} + +function loadedKlass() { + let _loaded = false + + return { + get: () => _loaded, + set: (value: boolean) => { + _loaded = value + }, + } +} + +export const loaded = loadedKlass()