diff --git a/packages/query/.size-limit.json b/packages/query/.size-limit.json index 01b7811e..732e5dfd 100644 --- a/packages/query/.size-limit.json +++ b/packages/query/.size-limit.json @@ -3,12 +3,12 @@ "name": "All publics", "path": "dist/index.js", "import": "*", - "limit": "4.01 kB" + "limit": "4.25 kB" }, { "name": "Minimal set", "path": "dist/index.js", "import": "{ client, mutations, queryKey }", - "limit": "1.7 kB" + "limit": "1.71 kB" } ] diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts index 6429d24b..09a5756d 100644 --- a/packages/query/src/index.ts +++ b/packages/query/src/index.ts @@ -25,3 +25,4 @@ export { } from './ClientContext.js' export * from './RequestContext.js' export * from './client.js' +export * from './utils.js' diff --git a/packages/query/src/utils.spec.ts b/packages/query/src/utils.spec.ts new file mode 100644 index 00000000..359e64cc --- /dev/null +++ b/packages/query/src/utils.spec.ts @@ -0,0 +1,197 @@ +import { + vi, + describe, + expect, + it +} from 'vitest' +import { + addFn, + append, + prepend, + drop, + sort, + mapPages, + mapPageAt, + mapFirstPage, + mapLastPage, + sortPages +} from './utils.js' +import type { InfinitePages } from './client.js' + +interface Page { + items: number[] +} + +describe('query', () => { + describe('utils', () => { + describe('addFn', () => { + it('should return function when previous function is empty', () => { + const fn = vi.fn() + + expect(addFn(undefined, fn)).toBe(fn) + }) + + it('should call both functions', () => { + const prevFn = vi.fn() + const fn = vi.fn() + const result = addFn(prevFn, fn) + + result('value') + + expect(prevFn).toHaveBeenCalledWith('value') + expect(fn).toHaveBeenCalledWith('value') + }) + }) + + describe('array helpers', () => { + it('should append value', () => { + expect(append([1, 2], 3)).toEqual([1, 2, 3]) + expect(append(null, 1)).toEqual([1]) + }) + + it('should prepend value', () => { + expect(prepend([2, 3], 1)).toEqual([1, 2, 3]) + expect(prepend(null, 1)).toEqual([1]) + }) + + it('should drop matching values', () => { + expect(drop([1, 2, 3], value => value === 2)).toEqual([1, 3]) + expect(drop(null, () => true)).toBe(null) + }) + + it('should sort values', () => { + const input = [3, 1, 2] + + expect(sort(input, (a, b) => a - b)).toEqual([1, 2, 3]) + expect(input).toEqual([3, 1, 2]) + expect(sort(null, (a: number, b: number) => a - b)).toBe(null) + }) + }) + + describe('page helpers', () => { + const pages: InfinitePages = { + pages: [ + { + items: [1, 2] + }, + { + items: [3, 4] + }, + { + items: [5, 6] + } + ], + next: 3, + more: true + } + + it('should map pages', () => { + expect(mapPages(pages, page => ({ + items: page.items.map(value => value * 2) + }))).toEqual({ + ...pages, + pages: [ + { + items: [2, 4] + }, + { + items: [6, 8] + }, + { + items: [10, 12] + } + ] + }) + expect(mapPages(null, page => page)).toBe(null) + }) + + it('should map page by index', () => { + expect(mapPageAt(pages, 1, page => ({ + items: page.items.map(value => value * 10) + }))).toEqual({ + ...pages, + pages: [ + { + items: [1, 2] + }, + { + items: [30, 40] + }, + { + items: [5, 6] + } + ] + }) + }) + + it('should return original pages for missing index', () => { + expect(mapPageAt(pages, 10, page => page)).toBe(pages) + expect(mapPageAt(null, 0, page => page)).toBe(null) + }) + + it('should map first page', () => { + expect(mapFirstPage(pages, page => ({ + items: page.items.map(value => value * 10) + })).pages[0]).toEqual({ + items: [10, 20] + }) + }) + + it('should map last page', () => { + expect(mapLastPage(pages, page => ({ + items: page.items.map(value => value * 10) + })).pages[2]).toEqual({ + items: [50, 60] + }) + }) + + it('should sort items across pages', () => { + const result = sortPages( + { + ...pages, + pages: [ + { + items: [5, 1] + }, + { + items: [4] + }, + { + items: [3, 2, 6] + } + ] + }, + (a: number, b: number) => a - b, + (sort, page) => ({ + ...page, + items: sort(page.items) + }) + ) + + expect(result?.pages).toEqual([ + { + items: [1, 2] + }, + { + items: [3] + }, + { + items: [4, 5, 6] + } + ]) + expect(result?.next).toBe(3) + expect(result?.more).toBe(true) + }) + + it('should keep empty pages value', () => { + expect(sortPages( + null as InfinitePages | null, + (a: number, b: number) => a - b, + (sort, page) => ({ + items: sort(page.items) + }) + )).toBe(null) + }) + }) + }) +}) diff --git a/packages/query/src/utils.ts b/packages/query/src/utils.ts index a64b8ecd..e12cd4cb 100644 --- a/packages/query/src/utils.ts +++ b/packages/query/src/utils.ts @@ -1,3 +1,9 @@ +import type { + AnyFn, + EmptyValue +} from '@nano_kit/store' +import type { InfinitePages } from './client.js' + /* @__NO_SIDE_EFFECTS__ */ export function addFn< // oxlint-disable-next-line typescript/no-explicit-any @@ -22,3 +28,177 @@ export function settle( error => onSettled(undefined, error) ) } + +type ArrayItem = T extends (infer I)[] ? I : never + +/** + * Append a value to an array-like cache value. + * @param array - Previous array or empty value. + * @param value - Value to append. + * @returns New array with the value appended, or a single-value array when input is empty. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function append( + array: A, + value: ArrayItem +) { + return (array ? [...(array as []), value] : [value]) as A +} + +/** + * Prepend a value to an array-like cache value. + * @param array - Previous array or empty value. + * @param value - Value to prepend. + * @returns New array with the value prepended, or a single-value array when input is empty. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function prepend( + array: A, + value: ArrayItem +) { + return (array ? [value, ...(array as [])] : [value]) as A +} + +/** + * Remove values matching the predicate from an array-like cache value. + * @param array - Previous array or empty value. + * @param what - Predicate that returns true for values to remove. + * @returns Filtered array, or the empty value when input is empty. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function drop( + array: A, + what: (value: ArrayItem) => boolean +) { + return (array ? (array as []).filter(value => !what(value)) : array) as A +} + +/** + * Sort an array-like cache value. + * @param array - Previous array or empty value. + * @param compare - Item comparison function. + * @returns Sorted array copy, or the empty value when input is empty. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function sort( + array: A, + compare: (a: ArrayItem, b: ArrayItem) => number +) { + return (array ? array.toSorted(compare as AnyFn) : array) as A +} + +type PagesItem = T extends InfinitePages ? P : never + +/** + * Map every page in infinite pages data. + * @param pages - Infinite pages data or empty value. + * @param callback - Page mapper. + * @returns Infinite pages with mapped pages, or the empty value. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function mapPages

| EmptyValue>( + pages: P, + callback: (page: PagesItem

) => PagesItem

+) { + return pages && { + ...pages, + pages: pages.pages.map(callback as AnyFn) + } as P +} + +/** + * Map a page by index in infinite pages data. + * @param pages - Infinite pages data or empty value. + * @param index - Page index. Negative indexes count from the end. + * @param callback - Page mapper. + * @returns Infinite pages with the selected page mapped, or the original value. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function mapPageAt

| EmptyValue>( + pages: P, + index: number, + callback: (page: PagesItem

) => PagesItem

+) { + const firstPage = pages?.pages.at(index) + + if (!firstPage) { + return pages + } + + return { + ...pages, + pages: (pages as InfinitePages).pages.map( + page => (page === firstPage ? callback(page as PagesItem

) : page) + ) + } as P +} + +/** + * Map the first page in infinite pages data. + * @param pages - Infinite pages data or empty value. + * @param callback - Page mapper. + * @returns Infinite pages with the first page mapped, or the original value. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function mapFirstPage

| EmptyValue>( + pages: P, + callback: (page: PagesItem

) => PagesItem

+) { + return mapPageAt(pages, 0, callback) +} + +/** + * Map the last page in infinite pages data. + * @param pages - Infinite pages data or empty value. + * @param callback - Page mapper. + * @returns Infinite pages with the last page mapped, or the original value. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function mapLastPage

| EmptyValue>( + pages: P, + callback: (page: PagesItem

) => PagesItem

+) { + return mapPageAt(pages, -1, callback) +} + +/** + * Sort items across all infinite pages while preserving page sizes. + * The callback adapts a page shape to the item list that should be sorted. + * It is called first to collect items, then again to write sorted items back. + * @param pages - Infinite pages data or empty value. + * @param compare - Item comparison function. + * @param callback - Page adapter that receives an item-list mapper and returns an updated page. + * @returns Infinite pages with sorted items spread back across original page chunks, or the empty value. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function sortPages

| EmptyValue, I>( + pages: P, + compare: (a: I, b: I) => number, + callback: ( + sort: (items: I[]) => I[], + page: PagesItem

+ ) => PagesItem

+) { + if (!pages) { + return pages + } + + const allItems = pages.pages.flatMap((page) => { + let items: I[] + + callback(i => items = i, page as PagesItem

) + + return items! + }).sort(compare) + let index = 0 + + return { + ...pages, + pages: pages.pages.map( + page => callback( + i => i.map(() => allItems[index++]), + page as PagesItem

+ ) + ) + } as P +} diff --git a/todo.txt b/todo.txt index 39850ceb..6f0bf346 100644 --- a/todo.txt +++ b/todo.txt @@ -14,7 +14,6 @@ ## 🔥🔥🔥 Query 🔥🔥🔥 -- cache mutation utils for entities data / optimistic utils - map data / default data - synced cache between tabs