Skip to content

Commit

Permalink
feat: initial version
Browse files Browse the repository at this point in the history
  • Loading branch information
posva committed Dec 20, 2023
1 parent b226d57 commit 7abe80d
Show file tree
Hide file tree
Showing 8 changed files with 464 additions and 7 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"packageManager": "pnpm@8.12.0",
"version": "0.0.0",
"type": "module",
"description": "",
"description": "The smart data fetching layer for Pinia",
"publishConfig": {
"access": "public"
},
Expand Down
149 changes: 149 additions & 0 deletions src/data-fetching-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { defineStore } from 'pinia'
import { shallowReactive } from 'vue'
import type {
UseQueryOptionsWithDefaults,
UseDataFetchingQueryEntry,
UseQueryKey,
} from './use-query'

export const useDataFetchingStore = defineStore('PiniaColada', () => {
/**
* - These are reactive because they are needed for SSR
* - They are split into multiple stores to better handle reactivity
* - With `shallowReactive()` we only observe the first level of the object, which is enough here as the user only
* gets read-only access to the data
*/
const dataRegistry = shallowReactive(new Map<UseQueryKey, unknown>())
const errorRegistry = shallowReactive(new Map<UseQueryKey, any>())
const isFetchingRegistry = shallowReactive(new Map<UseQueryKey, boolean>())

// no reactive on this one as it's only used internally and is not needed for hydration
const queryEntriesRegistry = new Map<
UseQueryKey,
UseDataFetchingQueryEntry<unknown, unknown>
>()

function ensureEntry<TResult = unknown, TError = Error>(
key: UseQueryKey,
{ fetcher, initialValue, cacheTime }: UseQueryOptionsWithDefaults<TResult>
): UseDataFetchingQueryEntry<TResult, TError> {
// ensure the data
console.log('⚙️ Ensuring entry', key)
if (!dataRegistry.has(key)) {
dataRegistry.set(key, initialValue?.() ?? undefined)
errorRegistry.set(key, null)
isFetchingRegistry.set(key, false)
}

// we need to repopulate the entry registry separately from data and errors
if (!queryEntriesRegistry.has(key)) {
const entry: UseDataFetchingQueryEntry<TResult, TError> = {
data: () => dataRegistry.get(key) as TResult,
error: () => errorRegistry.get(key) as TError,
// FIXME: not reactive
isPending: () => !entry.previous,
isFetching: () => isFetchingRegistry.get(key)!,
pending: null,
previous: null,
async fetch(): Promise<TResult> {
if (!entry.previous || isExpired(entry.previous.when, cacheTime)) {
if (entry.previous) {
console.log(
`⬇️ fetching "${String(key)}". expired ${entry.previous
?.when} / ${cacheTime}`
)
}
await (entry.pending?.refreshCall ?? entry.refresh())
}

return entry.data()!
},
async refresh() {
console.log('🔄 refreshing', key)
// when if there an ongoing request
if (entry.pending) {
console.log(' -> skipped!')
return entry.pending.refreshCall
}
isFetchingRegistry.set(key, true)
errorRegistry.set(key, null)
const nextPrevious = {
when: 0,
data: undefined as TResult | undefined,
error: null as TError | null,
} satisfies UseDataFetchingQueryEntry['previous']

entry.pending = {
refreshCall: fetcher()
.then((data) => {
nextPrevious.data = data
dataRegistry.set(key, data)
})
.catch((error) => {
nextPrevious.error = error
errorRegistry.set(key, error)
throw error
})
.finally(() => {
entry.pending = null
nextPrevious.when = Date.now()
entry.previous = nextPrevious
isFetchingRegistry.set(key, false)
}),
when: Date.now(),
}

return entry.pending.refreshCall
},
}
queryEntriesRegistry.set(key, entry)
}

const entry = queryEntriesRegistry.get(key)!
// automatically try to refresh the data if it's expired
entry.fetch()

return entry as UseDataFetchingQueryEntry<TResult, TError>
}

/**
* Invalidates a query entry, forcing a refetch of the data if `refresh` is true
*
* @param key - the key of the query to invalidate
* @param refresh - whether to force a refresh of the data
*/
function invalidateEntry(key: string, refresh = false) {
if (!queryEntriesRegistry.has(key)) {
console.warn(
`⚠️ trying to invalidate "${key}" but it's not in the registry`
)
return
}
const entry = queryEntriesRegistry.get(key)!

if (entry.previous) {
// will force a fetch next time
entry.previous.when = 0
}

if (refresh) {
// reset any pending request
entry.pending = null
// force refresh
entry.refresh()
}
}

return {
dataRegistry,
errorRegistry,
isLoadingRegistry: isFetchingRegistry,

ensureEntry,
invalidateEntry,
}
})

function isExpired(lastRefresh: number, cacheTime: number): boolean {
return lastRefresh + cacheTime < Date.now()
}
18 changes: 17 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,17 @@
export const test = 0
export {
useMutation,
type UseMutationReturn,
type UseMutationsOptions,
} from './use-mutation'

export {
USE_QUERY_DEFAULTS,
useQuery,
type UseDataFetchingQueryEntry,
type UseQueryKey,
type UseQueryOptions,
type UseQueryOptionsWithDefaults,
type UseQueryReturn,
} from './use-query'

export { useDataFetchingStore } from './data-fetching-store'
108 changes: 108 additions & 0 deletions src/use-mutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { computed, ref, type ComputedRef, shallowRef } from 'vue'
import { useDataFetchingStore } from './data-fetching-store'

type _MutatorKeys<TParams extends readonly any[], TResult> = readonly (
| string
| ((context: { variables: TParams; result: TResult }) => string)
)[]

export interface UseMutationsOptions<
TResult = unknown,
TParams extends readonly unknown[] = readonly [],
> {
/**
* The key of the mutation. If the mutation is successful, it will invalidate the query with the same key and refetch it
*/
mutator: (...args: TParams) => Promise<TResult>
keys?: _MutatorKeys<TParams, TResult>
}
// export const USE_MUTATIONS_DEFAULTS = {} satisfies Partial<UseMutationsOptions>

export interface UseMutationReturn<
TResult = unknown,
TParams extends readonly unknown[] = readonly [],
TError = Error,
> {
data: ComputedRef<TResult | undefined>
error: ComputedRef<TError | null>
isPending: ComputedRef<boolean>

mutate: (...params: TParams) => Promise<TResult>
reset: () => void
}

export function useMutation<
TResult,
TParams extends readonly unknown[],
TError = Error,
>(
options: UseMutationsOptions<TResult, TParams>
): UseMutationReturn<TResult, TParams, TError> {
console.log(options)
const store = useDataFetchingStore()

const isPending = ref(false)
const data = shallowRef<TResult>()
const error = shallowRef<TError | null>(null)

// a pending promise allows us to discard previous ongoing requests
let pendingPromise: Promise<TResult> | null = null
function mutate(...args: TParams) {
isPending.value = true
error.value = null

const promise = (pendingPromise = options
.mutator(...args)
.then((_data) => {
if (pendingPromise === promise) {
data.value = _data
if (options.keys) {
for (const key of options.keys) {
store.invalidateEntry(
typeof key === 'string'
? key
: key({ variables: args, result: _data }),
true
)
}
}
}
return _data
})
.catch((_error) => {
if (pendingPromise === promise) {
error.value = _error
}
throw _error
})
.finally(() => {
if (pendingPromise === promise) {
isPending.value = false
}
}))

return promise
}

function reset() {
data.value = undefined
error.value = null
}

const mutationReturn = {
data: computed(() => data.value),
isPending: computed(() => isPending.value),
error: computed(() => error.value),
mutate,
reset,
} satisfies UseMutationReturn<TResult, TParams, TError>

return mutationReturn
}

// useMutation({
// async mutator(one: string, other?: number) {
// return { one, other: other || 0 }
// },
// keys: ['register', ({ variables: [one], result }) => `register:${one}` + result.one],
// })

0 comments on commit 7abe80d

Please sign in to comment.