From 43e3db58733ea4d30061c7f3084162bb0f1946ff Mon Sep 17 00:00:00 2001 From: dangreen Date: Fri, 6 Nov 2026 20:03:14 +0400 Subject: [PATCH] feat(query): add `restore` and `restorePages` to undo `drop` A plain revert brings the whole list back and drops what was written since. They put removed items back into the data as it is by now, for the third argument of `$data`. --- packages/query/.size-limit.json | 4 +- packages/query/src/cache.spec.ts | 71 +++++++ packages/query/src/utils.spec.ts | 180 +++++++++++++++++- packages/query/src/utils.ts | 65 +++++++ skills/nano-kit-query/DOCS.md | 44 ++++- skills/nano-kit-query/SKILL.md | 1 + website/src/content/docs/query/advanced.mdx | 33 +++- .../src/content/docs/query/core-concepts.mdx | 11 ++ 8 files changed, 402 insertions(+), 7 deletions(-) diff --git a/packages/query/.size-limit.json b/packages/query/.size-limit.json index 7e0116a0..6e53c45d 100644 --- a/packages/query/.size-limit.json +++ b/packages/query/.size-limit.json @@ -4,13 +4,13 @@ "gzip": true, "path": "dist/index.production.js", "import": "*", - "limit": "4.6 kB" + "limit": "4.75 kB" }, { "name": "All publics (Brotli)", "path": "dist/index.production.js", "import": "*", - "limit": "4.25 kB" + "limit": "4.35 kB" }, { "name": "Minimal set (Gzip)", diff --git a/packages/query/src/cache.spec.ts b/packages/query/src/cache.spec.ts index d49880c7..9f929689 100644 --- a/packages/query/src/cache.spec.ts +++ b/packages/query/src/cache.spec.ts @@ -15,6 +15,15 @@ import { dataCacheFacade, errorCacheFacade } from './cache.js' +import { + append, + drop, + mapFirstPage, + mapPages, + restore, + restorePages +} from './utils.js' +import type { InfinitePages } from './client.js' describe('query', () => { describe('cache', () => { @@ -402,6 +411,68 @@ describe('query', () => { expect($cache(CountKey('a'))).toBe(1) expect($cache(CountKey('b'))).toBe(2) }) + + it('should take a removal back with `restore`', () => { + const ListKey = queryKey<[], number[]>('list') + const $cache = dataCacheFacade(new CacheStorage()) + const key = ListKey() + const isTwo = (value: number) => value === 2 + + $cache(key, [1, 2, 3]) + + const revert = $cache( + key, + list => drop(list, isTwo), + (current, prev) => restore(current, prev, isTwo) + ) + + $cache(key, list => append(list, 4)) + revert() + + expect($cache(key)).toEqual([1, 2, 3, 4]) + }) + + it('should take a removal from pages back with `restorePages`', () => { + interface Page { + items: number[] + } + + const PagesKey = queryKey<[], InfinitePages>('pages') + const $cache = dataCacheFacade(new CacheStorage()) + const key = PagesKey() + const isTwo = (value: number) => value === 2 + + $cache(key, { + pages: [ + { + items: [1, 2] + } + ], + next: 2, + more: true + }) + + const revert = $cache( + key, + pages => mapPages(pages, page => ({ + items: drop(page.items, isTwo) + })), + (current, prev) => restorePages(current, prev, isTwo, (restoreItems, page) => ({ + items: restoreItems(page.items) + })) + ) + + $cache(key, pages => mapFirstPage(pages, page => ({ + items: append(page.items, 3) + }))) + revert() + + expect($cache(key)?.pages).toEqual([ + { + items: [1, 2, 3] + } + ]) + }) }) }) diff --git a/packages/query/src/utils.spec.ts b/packages/query/src/utils.spec.ts index cf89efe0..ff8b2182 100644 --- a/packages/query/src/utils.spec.ts +++ b/packages/query/src/utils.spec.ts @@ -9,13 +9,15 @@ import { append, prepend, drop, + restore, map, sort, mapPages, mapPageAt, mapFirstPage, mapLastPage, - sortPages + sortPages, + restorePages } from './utils.js' import type { InfinitePages } from './client.js' @@ -75,6 +77,64 @@ describe('query', () => { expect(input).toEqual([3, 1, 2]) expect(sort(null, (a: number, b: number) => a - b)).toBe(null) }) + + describe('restore', () => { + const isTwo = (value: number) => value === 2 + + it('should put a value back at its previous index', () => { + expect(restore([1, 3], [1, 2, 3], isTwo)).toEqual([1, 2, 3]) + }) + + it('should keep values written since', () => { + expect(restore([1, 3, 4], [1, 2, 3], isTwo)).toEqual([1, 2, 3, 4]) + }) + + it('should put back every removed value', () => { + const isEven = (value: number) => value % 2 === 0 + + expect(restore([1, 3, 5], [1, 2, 3, 4, 5, 6], isEven)).toEqual([1, 2, 3, 4, 5, 6]) + }) + + it('should put a value back into an empty array', () => { + expect(restore([], [2], isTwo)).toEqual([2]) + }) + + it('should put a value at the end when the array became shorter', () => { + expect(restore([1], [1, 3, 4, 2], isTwo)).toEqual([1, 2]) + }) + + it('should not mutate arrays', () => { + const array = [1, 3] + const prev = [1, 2, 3] + + restore(array, prev, isTwo) + + expect(array).toEqual([1, 3]) + expect(prev).toEqual([1, 2, 3]) + }) + + it('should keep the array when it holds the value again', () => { + const array = [2, 1, 3] + + expect(restore(array, [1, 2, 3], isTwo)).toBe(array) + }) + + it('should keep the array when the previous one did not hold the value', () => { + const array = [1, 3] + + expect(restore(array, [1, 3], isTwo)).toBe(array) + }) + + it('should keep the array when there is no previous one', () => { + const array = [1, 3] + + expect(restore(array, null, isTwo)).toBe(array) + }) + + it('should keep the empty value', () => { + expect(restore(null, [1, 2, 3], isTwo)).toBe(null) + }) + }) }) describe('page helpers', () => { @@ -201,6 +261,124 @@ describe('query', () => { }) )).toBe(null) }) + + describe('restorePages', () => { + const isFour = (value: number) => value === 4 + const removed = mapPages(pages, page => ({ + items: drop(page.items, isFour) + })) + const adapter = (restoreItems: (items: number[]) => number[], page: Page) => ({ + items: restoreItems(page.items) + }) + + it('should put an item back into the page it stood in', () => { + expect(restorePages(removed, pages, isFour, adapter)).toEqual(pages) + }) + + it('should keep items written since', () => { + const current = mapFirstPage(removed, page => ({ + items: prepend(page.items, 0) + })) + + expect(restorePages(current, pages, isFour, adapter).pages).toEqual([ + { + items: [0, 1, 2] + }, + { + items: [3, 4] + }, + { + items: [5, 6] + } + ]) + }) + + it('should keep the pagination of the current pages', () => { + const result = restorePages({ + ...removed, + next: 4, + more: false + }, pages, isFour, adapter) + + expect(result.next).toBe(4) + expect(result.more).toBe(false) + }) + + it('should put back items of several pages', () => { + const isEven = (value: number) => value % 2 === 0 + const current = mapPages(pages, page => ({ + items: drop(page.items, isEven) + })) + + expect(restorePages(current, pages, isEven, adapter)).toEqual(pages) + }) + + it('should adapt a page shape', () => { + interface PostsPage { + posts: number[] + cursor: number + } + + const page = (posts: number[]): PostsPage => ({ + posts, + cursor: 1 + }) + const pagesOf = (posts: number[]): InfinitePages => ({ + pages: [page(posts)], + next: 2, + more: true + }) + + expect(restorePages(pagesOf([3]), pagesOf([3, 4]), isFour, (restoreItems, page) => ({ + ...page, + posts: restoreItems(page.posts) + }))).toEqual(pagesOf([3, 4])) + }) + + it('should keep pages when an item is back on another page', () => { + const current = { + ...removed, + pages: [ + { + items: [4] + }, + ...removed.pages + ] + } + + expect(restorePages(current, pages, isFour, adapter)).toBe(current) + }) + + it('should keep a page loaded since', () => { + const page = { + items: [7, 8] + } + const result = restorePages({ + ...removed, + pages: [...removed.pages, page] + }, pages, isFour, adapter) + + expect(result.pages.at(-1)).toBe(page) + expect(result.pages[1].items).toEqual([3, 4]) + }) + + it('should not put an item back when its page is no longer loaded', () => { + const current = { + ...removed, + pages: removed.pages.slice(0, 1) + } + + expect(restorePages(current, pages, isFour, adapter)).toEqual(current) + }) + + it('should keep pages when there are no previous ones', () => { + expect(restorePages | null, number>(removed, null, isFour, adapter)).toBe(removed) + }) + + it('should keep empty pages value', () => { + expect(restorePages | null, number>(null, pages, isFour, adapter)).toBe(null) + }) + }) }) }) }) diff --git a/packages/query/src/utils.ts b/packages/query/src/utils.ts index e5bdaf51..f3e926ca 100644 --- a/packages/query/src/utils.ts +++ b/packages/query/src/utils.ts @@ -73,6 +73,29 @@ export function drop( return (array ? (array as []).filter((value, index) => !what(value, index)) : array) as A } +/** + * Put values removed with `drop` back at their previous indexes in the current array. + * It is the `undo` of a removal for `$data`: unlike a plain revert, it keeps what was written since. + * An array that holds a matching value again stays as it is, so nothing comes back twice. + * @param array - Current array or empty value. + * @param prev - Array before the removal or empty value. + * @param what - Predicate that returns true for removed values. + * @returns Array with the values back, or the input array when there is nothing to put back. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function restore( + array: A, + prev: A, + what: (value: ArrayItem) => boolean +) { + return (array && prev && !(array as []).some(what) + ? (prev as []).reduce( + (next: unknown[], value, index) => (what(value) ? next.toSpliced(index, 0, value) : next), + array + ) + : array) as A +} + /** * Map values in an array-like cache value. * @param array - Previous array or empty value. @@ -216,3 +239,45 @@ export function sortPages

| EmptyValue ) } as P } + +/** + * Put items removed with `drop` back into the pages they stood in, in the current pages. + * It is the `undo` of a removal for `$data`: unlike a plain revert, it keeps what was written since. + * Pages stay as they are when a matching item is back on any of them, as a refetch can do, + * and a page that is no longer loaded gets nothing back. + * The callback adapts a page shape to the item list, as in `sortPages`. + * It is called first to read items, then again to write items back. + * @param pages - Current infinite pages data or empty value. + * @param prev - Infinite pages data before the removal or empty value. + * @param what - Predicate that returns true for removed items. + * @param callback - Page adapter that receives an item-list mapper and returns an updated page. + * @returns Infinite pages with the items back, or the input value when there is nothing to put back. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function restorePages

| EmptyValue, I>( + pages: P, + prev: P, + what: (item: I) => boolean, + callback: ( + restore: (items: I[]) => I[], + page: PagesItem

+ ) => PagesItem

+) { + const read = (page: PagesItem

) => { + let items: I[] + + callback(i => items = i, page) + + return items! + } + + return !prev || pages?.pages.some(page => read(page as PagesItem

).some(what)) + ? pages + : mapPages(pages, (page, index) => { + const prevPage = prev.pages[index] as PagesItem

| undefined + + return prevPage + ? callback(items => restore(items, read(prevPage), what), page) + : page + }) +} diff --git a/skills/nano-kit-query/DOCS.md b/skills/nano-kit-query/DOCS.md index 88eb7a4b..9002f765 100644 --- a/skills/nano-kit-query/DOCS.md +++ b/skills/nano-kit-query/DOCS.md @@ -388,6 +388,17 @@ revert() Previous data is `null` when the entry had none at the time of the write, so whatever it holds now came later. The spread above keeps it, while `current && prev && { ... }` would clear it. +A removal from a list has a ready-made undo function: `restore` puts what `drop` took out back where it stood, among everything written since, and `restorePages` does the same for infinite pages: + +```ts +const isRemoved = (post: Post) => post.id === postId +const revert = $data( + PostsKey(), + posts => drop(posts, isRemoved), + (current, prev) => restore(current, prev, isRemoved) +) +``` + #### Revalidating Cache Use `revalidate()` to mark cache entries as stale, triggering active queries to refetch: @@ -785,7 +796,7 @@ For comfortable cache mutations, use helpers that fit naturally into `$data` red ### Array Helpers -Use `append`, `prepend`, `drop`, `map`, and `sort` for array cache values: +Use `append`, `prepend`, `drop`, `restore`, `map`, and `sort` for array cache values: ```ts import { @@ -795,6 +806,7 @@ import { map, prepend, queryKey, + restore, sort } from '@nano_kit/query' @@ -810,6 +822,14 @@ $data(PostsKey(), posts => prepend(posts, pinnedPost)) /* Remove matching items. */ $data(PostsKey(), posts => drop(posts, post => post.id === removedPostId)) +/* Remove optimistically: if the request fails, revert() puts the items back. */ +const isRemoved = (post: Post) => post.id === removedPostId +const revert = $data( + PostsKey(), + posts => drop(posts, isRemoved), + (current, prev) => restore(current, prev, isRemoved) +) + /* Transform items. */ $data(PostsKey(), posts => map(posts, post => ({ ...post, @@ -822,18 +842,22 @@ $data(PostsKey(), posts => sort(posts, (a, b) => b.createdAt - a.createdAt)) The helpers return the original empty value for operations where that makes sense, or create a new array when adding a value. +`restore` undoes a `drop` as the third argument of `$data`. It puts removed values back at their previous indexes in the array as it is by now, so what was written since stays. An array that holds a matching value again, as after a refetch, stays as it is. A previous index can be off by what was added since, so when the client knows the order of the list, such as by date, wrap the result in `sort`. + ### Infinite Page Helpers -Use `mapPages`, `mapPageAt`, `mapFirstPage`, `mapLastPage`, and `sortPages` for [`InfinitePages`](#infinite) data: +Use `mapPages`, `mapPageAt`, `mapFirstPage`, `mapLastPage`, `sortPages`, and `restorePages` for [`InfinitePages`](#infinite) data: ```ts import { type InfinitePages, client, + drop, mapFirstPage, mapPages, prepend, queryKey, + restorePages, sortPages } from '@nano_kit/query' @@ -868,10 +892,26 @@ $data(FeedKey(), data => sortPages( posts: sort(page.posts) }) )) + +/* Remove optimistically: if the request fails, revert() puts the items back. */ +const isRemoved = (post: Post) => post.id === removedPostId +const revert = $data( + FeedKey(), + data => mapPages(data, page => ({ + ...page, + posts: drop(page.posts, isRemoved) + })), + (current, prev) => restorePages(current, prev, isRemoved, (restore, page) => ({ + ...page, + posts: restore(page.posts) + })) +) ``` `sortPages` collects items from every page, sorts the combined list, and writes items back into the original page chunks. Pagination metadata such as `next` and `more` stays intact. +`restorePages` puts removed items back into the pages they stood in, and its callback adapts the page shape the same way. Pages stay as they are when a matching item is back on any page, as a refetch can do, and a page that is no longer loaded gets nothing back. As with `restore`, when the client knows the order of the list, wrap the result in `sortPages`. + ## Request Context Query and mutation fetcher functions receive request context as last parameter. It provides methods to manage request lifecycle: diff --git a/skills/nano-kit-query/SKILL.md b/skills/nano-kit-query/SKILL.md index 271f48a8..563aee7d 100644 --- a/skills/nano-kit-query/SKILL.md +++ b/skills/nano-kit-query/SKILL.md @@ -58,6 +58,7 @@ metadata: - After a mutation, `revalidate` the affected keys: cached data stays visible while the refetch runs. `invalidate` blanks the entries and causes loading flashes; use it for session changes and hard resets. - Optimistic update: write the cache first, register the revert with the error callback of the request context, then send the request. - A plain revert puts back the whole value and drops what was written since. When other writes can reach the entry while the request is in flight, such as a second press or a change to another field, give `$data` the function that undoes only this change. +- The function that undoes a `drop` is ready-made: `restore` for arrays, `restorePages` for infinite pages. They put items back at their previous index in the data as it is by now, and leave it alone when a refetch has already brought them back. - `keys(...)` sweeps every registered builder at once; on login, logout or locale change invalidate everything except keys that must survive, such as translations. ## Settings that matter diff --git a/website/src/content/docs/query/advanced.mdx b/website/src/content/docs/query/advanced.mdx index 746bfd11..351b3bb7 100644 --- a/website/src/content/docs/query/advanced.mdx +++ b/website/src/content/docs/query/advanced.mdx @@ -197,7 +197,7 @@ For comfortable cache mutations, use helpers that fit naturally into [`$data`](/ ### Array Helpers -Use `append`, `prepend`, `drop`, `map`, and `sort` for array cache values: +Use `append`, `prepend`, `drop`, `restore`, `map`, and `sort` for array cache values: ```ts import { @@ -207,6 +207,7 @@ import { map, prepend, queryKey, + restore, sort } from '@nano_kit/query' @@ -222,6 +223,14 @@ $data(PostsKey(), posts => prepend(posts, pinnedPost)) /* Remove matching items. */ $data(PostsKey(), posts => drop(posts, post => post.id === removedPostId)) +/* Remove optimistically: if the request fails, revert() puts the items back. */ +const isRemoved = (post: Post) => post.id === removedPostId +const revert = $data( + PostsKey(), + posts => drop(posts, isRemoved), + (current, prev) => restore(current, prev, isRemoved) +) + /* Transform items. */ $data(PostsKey(), posts => map(posts, post => ({ ...post, @@ -234,18 +243,22 @@ $data(PostsKey(), posts => sort(posts, (a, b) => b.createdAt - a.createdAt)) The helpers return the original empty value for operations where that makes sense, or create a new array when adding a value. +`restore` undoes a `drop` as the [third argument of `$data`](/query/core-concepts#reading-and-writing-data). It puts removed values back at their previous indexes in the array as it is by now, so what was written since stays. An array that holds a matching value again, as after a refetch, stays as it is. A previous index can be off by what was added since, so when the client knows the order of the list, such as by date, wrap the result in `sort`. + ### Infinite Page Helpers -Use `mapPages`, `mapPageAt`, `mapFirstPage`, `mapLastPage`, and `sortPages` for [`InfinitePages`](#infinite) data: +Use `mapPages`, `mapPageAt`, `mapFirstPage`, `mapLastPage`, `sortPages`, and `restorePages` for [`InfinitePages`](#infinite) data: ```ts import { type InfinitePages, client, + drop, mapFirstPage, mapPages, prepend, queryKey, + restorePages, sortPages } from '@nano_kit/query' @@ -280,10 +293,26 @@ $data(FeedKey(), data => sortPages( posts: sort(page.posts) }) )) + +/* Remove optimistically: if the request fails, revert() puts the items back. */ +const isRemoved = (post: Post) => post.id === removedPostId +const revert = $data( + FeedKey(), + data => mapPages(data, page => ({ + ...page, + posts: drop(page.posts, isRemoved) + })), + (current, prev) => restorePages(current, prev, isRemoved, (restore, page) => ({ + ...page, + posts: restore(page.posts) + })) +) ``` `sortPages` collects items from every page, sorts the combined list, and writes items back into the original page chunks. Pagination metadata such as `next` and `more` stays intact. +`restorePages` puts removed items back into the pages they stood in, and its callback adapts the page shape the same way. Pages stay as they are when a matching item is back on any page, as a refetch can do, and a page that is no longer loaded gets nothing back. As with `restore`, when the client knows the order of the list, wrap the result in `sortPages`. + ## Request Context Query and mutation fetcher functions receive request context as last parameter. It provides methods to manage request lifecycle: diff --git a/website/src/content/docs/query/core-concepts.mdx b/website/src/content/docs/query/core-concepts.mdx index 3f3963a6..0d03e0a3 100644 --- a/website/src/content/docs/query/core-concepts.mdx +++ b/website/src/content/docs/query/core-concepts.mdx @@ -327,6 +327,17 @@ revert() Previous data is `null` when the entry had none at the time of the write, so whatever it holds now came later. The spread above keeps it, while `current && prev && { ... }` would clear it. +A removal from a list has a ready-made undo function: [`restore`](/query/advanced#array-helpers) puts what `drop` took out back where it stood, among everything written since, and [`restorePages`](/query/advanced#infinite-page-helpers) does the same for infinite pages: + +```ts +const isRemoved = (post: Post) => post.id === postId +const revert = $data( + PostsKey(), + posts => drop(posts, isRemoved), + (current, prev) => restore(current, prev, isRemoved) +) +``` + #### Revalidating Cache Use `revalidate()` to mark cache entries as stale, triggering active queries to refetch: