diff --git a/.changeset/nervous-tigers-vanish.md b/.changeset/nervous-tigers-vanish.md new file mode 100644 index 0000000..f4ba246 --- /dev/null +++ b/.changeset/nervous-tigers-vanish.md @@ -0,0 +1,5 @@ +--- +'svelte-query-pocketbase': patch +--- + +refactor: all queries are simplified, now update cache immutably, and infinite queries should work correctly now diff --git a/package.json b/package.json index 8aa56b5..dd360a4 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@commitlint/prompt-cli": "17.4.2", "@square/svelte-store": "1.0.13", "@sveltejs/adapter-node": "1.1.4", - "@sveltejs/kit": "1.2.9", + "@sveltejs/kit": "1.3.2", "@types/lodash": "4.14.191", "@typescript-eslint/eslint-plugin": "5.49.0", "@typescript-eslint/parser": "5.49.0", @@ -38,17 +38,18 @@ "eslint-config-prettier": "8.6.0", "eslint-plugin-svelte3": "4.0.0", "husky": "8.0.3", + "immer": "9.0.19", "lodash": "4.17.21", "postcss": "8.4.21", "prettier": "2.8.3", "prettier-plugin-svelte": "2.9.0", "svelte-check": "3.0.3", "tailwindcss": "3.2.4", - "tslib": "2.4.1", + "tslib": "2.5.0", "tsup": "6.5.0", "typescript": "4.9.4", "vite": "4.0.4", - "vitest": "0.28.1" + "vitest": "0.28.3" }, "tsup": { "entry": [ @@ -58,7 +59,6 @@ "esm" ], "sourcemap": true, - "minify": true, "clean": true, "dts": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30118c7..6d9e794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@commitlint/prompt-cli': 17.4.2 '@square/svelte-store': 1.0.13 '@sveltejs/adapter-node': 1.1.4 - '@sveltejs/kit': 1.2.9 + '@sveltejs/kit': 1.3.2 '@tanstack/svelte-query': ^4.22.2 '@types/lodash': 4.14.191 '@typescript-eslint/eslint-plugin': 5.49.0 @@ -18,6 +18,7 @@ specifiers: eslint-config-prettier: 8.6.0 eslint-plugin-svelte3: 4.0.0 husky: 8.0.3 + immer: 9.0.19 lodash: 4.17.21 pocketbase: ^0.10.0 postcss: 8.4.21 @@ -26,15 +27,15 @@ specifiers: svelte: ^3.54.0 svelte-check: 3.0.3 tailwindcss: 3.2.4 - tslib: 2.4.1 + tslib: 2.5.0 tsup: 6.5.0 typescript: 4.9.4 vite: 4.0.4 - vitest: 0.28.1 + vitest: 0.28.3 dependencies: - '@tanstack/svelte-query': 4.22.2_svelte@3.55.1 - pocketbase: 0.10.0 + '@tanstack/svelte-query': 4.22.4_svelte@3.55.1 + pocketbase: 0.10.1 svelte: 3.55.1 devDependencies: @@ -44,8 +45,8 @@ devDependencies: '@commitlint/config-conventional': 17.4.2 '@commitlint/prompt-cli': 17.4.2 '@square/svelte-store': 1.0.13 - '@sveltejs/adapter-node': 1.1.4_@sveltejs+kit@1.2.9 - '@sveltejs/kit': 1.2.9_svelte@3.55.1+vite@4.0.4 + '@sveltejs/adapter-node': 1.1.4_@sveltejs+kit@1.3.2 + '@sveltejs/kit': 1.3.2_svelte@3.55.1+vite@4.0.4 '@types/lodash': 4.14.191 '@typescript-eslint/eslint-plugin': 5.49.0_iu322prlnwsygkcra5kbpy22si '@typescript-eslint/parser': 5.49.0_7uibuqfxkfaozanbtbziikiqje @@ -54,17 +55,18 @@ devDependencies: eslint-config-prettier: 8.6.0_eslint@8.32.0 eslint-plugin-svelte3: 4.0.0_tmo5zkisvhu6htudosk5k7m6pu husky: 8.0.3 + immer: 9.0.19 lodash: 4.17.21 postcss: 8.4.21 prettier: 2.8.3 prettier-plugin-svelte: 2.9.0_kdmmghgdi3ngrsq6otxkjilbry svelte-check: 3.0.3_pehl75e5jsy22vp33udjja4soi tailwindcss: 3.2.4_postcss@8.4.21 - tslib: 2.4.1 + tslib: 2.5.0 tsup: 6.5.0_k22f457wtryv7dv7ltdlwpdpdm typescript: 4.9.4 vite: 4.0.4 - vitest: 0.28.1 + vitest: 0.28.3 packages: @@ -893,7 +895,7 @@ packages: svelte: 3.55.1 dev: true - /@sveltejs/adapter-node/1.1.4_@sveltejs+kit@1.2.9: + /@sveltejs/adapter-node/1.1.4_@sveltejs+kit@1.3.2: resolution: {integrity: sha512-3iEBqi1fXLXP9YIbVuz2LXajoebRJCmAFEQbN40DlxAnA7G+InxUgnqFun3q9gBMz2Qvd99K51g/HxWetXRe8Q==} peerDependencies: '@sveltejs/kit': ^1.0.0 @@ -901,12 +903,12 @@ packages: '@rollup/plugin-commonjs': 24.0.0_rollup@3.10.0 '@rollup/plugin-json': 6.0.0_rollup@3.10.0 '@rollup/plugin-node-resolve': 15.0.1_rollup@3.10.0 - '@sveltejs/kit': 1.2.9_svelte@3.55.1+vite@4.0.4 + '@sveltejs/kit': 1.3.2_svelte@3.55.1+vite@4.0.4 rollup: 3.10.0 dev: true - /@sveltejs/kit/1.2.9_svelte@3.55.1+vite@4.0.4: - resolution: {integrity: sha512-zHuYwMgCJpmgYoQ1EOIgNFNkBTKqErHH+fqZVfNmEw2hICoKZHIjqm8/fetRUtF71dqXq0TmPS/HuCCBHXQiZw==} + /@sveltejs/kit/1.3.2_svelte@3.55.1+vite@4.0.4: + resolution: {integrity: sha512-sCJORYwK/DY4SEmlmaGSzO/7K/dpb2QXJIHy6di5FV/9p8OSeMjlbOFxZMKzW7PHH6jCaKkoApgjkbI+A/y/qw==} engines: {node: ^16.14 || >=18} hasBin: true requiresBuild: true @@ -952,16 +954,16 @@ packages: - supports-color dev: true - /@tanstack/query-core/4.22.0: - resolution: {integrity: sha512-OeLyBKBQoT265f5G9biReijeP8mBxNFwY7ZUu1dKL+YzqpG5q5z7J/N1eT8aWyKuhyDTiUHuKm5l+oIVzbtrjw==} + /@tanstack/query-core/4.22.4: + resolution: {integrity: sha512-t79CMwlbBnj+yL82tEcmRN93bL4U3pae2ota4t5NN2z3cIeWw74pzdWrKRwOfTvLcd+b30tC+ciDlfYOKFPGUw==} dev: false - /@tanstack/svelte-query/4.22.2_svelte@3.55.1: - resolution: {integrity: sha512-e7PIMuRS3viilkMXrDOxYYnU7LbeqpyK6J+cQTtDjA9tFvAFzYt/9DSy5uhjKkNPh0jGFSmY2GBRa2UtSlGQJg==} + /@tanstack/svelte-query/4.22.4_svelte@3.55.1: + resolution: {integrity: sha512-yVfwgN5tMf/Wap3cVAadRHQoqhX2rJJvC7aSIfE9PDbOZD0it7gvGuAql1Ut0DTPBb+b6Q1FMrXwnWP+SnFADQ==} peerDependencies: svelte: ^3.54.0 dependencies: - '@tanstack/query-core': 4.22.0 + '@tanstack/query-core': 4.22.4 svelte: 3.55.1 dev: false @@ -1180,30 +1182,30 @@ packages: eslint-visitor-keys: 3.3.0 dev: true - /@vitest/expect/0.28.1: - resolution: {integrity: sha512-BOvWjBoocKrrTTTC0opIvzOEa7WR/Ovx4++QYlbjYKjnQJfWRSEQkTpAIEfOURtZ/ICcaLk5jvsRshXvjarZew==} + /@vitest/expect/0.28.3: + resolution: {integrity: sha512-dnxllhfln88DOvpAK1fuI7/xHwRgTgR4wdxHldPaoTaBu6Rh9zK5b//v/cjTkhOfNP/AJ8evbNO8H7c3biwd1g==} dependencies: - '@vitest/spy': 0.28.1 - '@vitest/utils': 0.28.1 + '@vitest/spy': 0.28.3 + '@vitest/utils': 0.28.3 chai: 4.3.7 dev: true - /@vitest/runner/0.28.1: - resolution: {integrity: sha512-kOdmgiNe+mAxZhvj2eUTqKnjfvzzknmrcS+SZXV7j6VgJuWPFAMCv3TWOe03nF9dkqDfVLCDRw/hwFuCzmzlQg==} + /@vitest/runner/0.28.3: + resolution: {integrity: sha512-P0qYbATaemy1midOLkw7qf8jraJszCoEvjQOSlseiXZyEDaZTZ50J+lolz2hWiWv6RwDu1iNseL9XLsG0Jm2KQ==} dependencies: - '@vitest/utils': 0.28.1 + '@vitest/utils': 0.28.3 p-limit: 4.0.0 pathe: 1.1.0 dev: true - /@vitest/spy/0.28.1: - resolution: {integrity: sha512-XGlD78cG3IxXNnGwEF121l0MfTNlHSdI25gS2ik0z6f/D9wWUOru849QkJbuNl4CMlZCtNkx3b5IS6MRwKGKuA==} + /@vitest/spy/0.28.3: + resolution: {integrity: sha512-jULA6suS6CCr9VZfr7/9x97pZ0hC55prnUNHNrg5/q16ARBY38RsjsfhuUXt6QOwvIN3BhSS0QqPzyh5Di8g6w==} dependencies: tinyspy: 1.0.2 dev: true - /@vitest/utils/0.28.1: - resolution: {integrity: sha512-a7cV1fs5MeU+W+8sn8gM9gV+q7V/wYz3/4y016w/icyJEKm9AMdSHnrzxTWaElJ07X40pwU6m5353Jlw6Rbd8w==} + /@vitest/utils/0.28.3: + resolution: {integrity: sha512-YHiQEHQqXyIbhDqETOJUKx9/psybF7SFFVCNfOvap0FvyUqbzTSDCa3S5lL4C0CLXkwVZttz9xknDoyHMguFRQ==} dependencies: cli-truncate: 3.1.0 diff: 5.1.0 @@ -2833,6 +2835,10 @@ packages: engines: {node: '>= 4'} dev: true + /immer/9.0.19: + resolution: {integrity: sha512-eY+Y0qcsB4TZKwgQzLaE/lqYMlKhv5J9dyd2RhhtGhNo2njPXDqU9XPfcNfa3MIDsdtZt5KlkIsirlo4dHsWdQ==} + dev: true + /import-fresh/3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -3739,8 +3745,8 @@ packages: pathe: 1.1.0 dev: true - /pocketbase/0.10.0: - resolution: {integrity: sha512-OWcPRcOPNiTqek5wkf56mCmkox4w5/h1x+ySBNqzQ0Ux7ztEv3aq3Gr91bwn3VcXN7o48XVbKny7A922hNIbQw==} + /pocketbase/0.10.1: + resolution: {integrity: sha512-FMBhF+9o2AmdKJYbfz2qunnk4Q5/efYMRcukdQF49UfhsIuaSYl39fSPN1l880bYI4XAvDDeySAjK1MlxrK37A==} dev: false /postcss-import/14.1.0_postcss@8.4.21: @@ -4587,8 +4593,8 @@ packages: resolution: {integrity: sha512-hGYWYBMPr7p4g5IarQE7XhlyWveh1EKhy4wUBS1LrHXCKYgvz+4/jCqgmJqZxxldesn05vccrtME2RLLZNW7iA==} dev: true - /tinypool/0.3.0: - resolution: {integrity: sha512-NX5KeqHOBZU6Bc0xj9Vr5Szbb1j8tUHIeD18s41aDJaPeC5QTdEhK0SpdpUrZlj2nv5cctNcSjaKNanXlfcVEQ==} + /tinypool/0.3.1: + resolution: {integrity: sha512-zLA1ZXlstbU2rlpA4CIeVaqvWq41MTWqLY3FfsAXgC8+f7Pk7zroaJQxDgxn1xNudKW6Kmj4808rPFShUlIRmQ==} engines: {node: '>=14.0.0'} dev: true @@ -4675,8 +4681,8 @@ packages: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} dev: true - /tslib/2.4.1: - resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} + /tslib/2.5.0: + resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==} dev: true /tsup/6.5.0_k22f457wtryv7dv7ltdlwpdpdm: @@ -4853,8 +4859,8 @@ packages: spdx-expression-parse: 3.0.1 dev: true - /vite-node/0.28.1_@types+node@18.11.18: - resolution: {integrity: sha512-Mmab+cIeElkVn4noScCRjy8nnQdh5LDIR4QCH/pVWtY15zv5Z1J7u6/471B9JZ2r8CEIs42vTbngaamOVkhPLA==} + /vite-node/0.28.3_@types+node@18.11.18: + resolution: {integrity: sha512-uJJAOkgVwdfCX8PUQhqLyDOpkBS5+j+FdbsXoPVPDlvVjRkb/W/mLYQPSL6J+t8R0UV8tJSe8c9VyxVQNsDSyg==} engines: {node: '>=v14.16.0'} hasBin: true dependencies: @@ -4954,8 +4960,8 @@ packages: vite: 4.0.4 dev: true - /vitest/0.28.1: - resolution: {integrity: sha512-F6wAO3K5+UqJCCGt0YAl3Ila2f+fpBrJhl9n7qWEhREwfzQeXlMkkCqGqGtzBxCSa8kv5QHrkshX8AaPTXYACQ==} + /vitest/0.28.3: + resolution: {integrity: sha512-N41VPNf3VGJlWQizGvl1P5MGyv3ZZA2Zvh+2V8L6tYBAAuqqDK4zExunT1Cdb6dGfZ4gr+IMrnG8d4Z6j9ctPw==} engines: {node: '>=v14.16.0'} hasBin: true peerDependencies: @@ -4979,10 +4985,10 @@ packages: '@types/chai': 4.3.4 '@types/chai-subset': 1.3.3 '@types/node': 18.11.18 - '@vitest/expect': 0.28.1 - '@vitest/runner': 0.28.1 - '@vitest/spy': 0.28.1 - '@vitest/utils': 0.28.1 + '@vitest/expect': 0.28.3 + '@vitest/runner': 0.28.3 + '@vitest/spy': 0.28.3 + '@vitest/utils': 0.28.3 acorn: 8.8.1 acorn-walk: 8.2.0 cac: 6.7.14 @@ -4995,10 +5001,10 @@ packages: std-env: 3.3.1 strip-literal: 1.0.0 tinybench: 2.3.1 - tinypool: 0.3.0 + tinypool: 0.3.1 tinyspy: 1.0.2 vite: 4.0.4_@types+node@18.11.18 - vite-node: 0.28.1_@types+node@18.11.18 + vite-node: 0.28.3_@types+node@18.11.18 why-is-node-running: 2.2.2 transitivePeerDependencies: - less diff --git a/src/lib/queries/collection.ts b/src/lib/queries/collection.ts index aa316fd..ef9fd8f 100644 --- a/src/lib/queries/collection.ts +++ b/src/lib/queries/collection.ts @@ -1,11 +1,14 @@ import { createQuery, + type QueryClient, useQueryClient, type CreateQueryResult, type FetchQueryOptions, type QueryKey } from '@tanstack/svelte-query'; +import { setAutoFreeze, produce, type Draft } from 'immer'; + import type Client from 'pocketbase'; import type { Record, @@ -18,41 +21,90 @@ import { collectionKeys } from '../query-key-factory'; import { realtimeStoreExpand } from '../internal'; import type { CollectionStoreOptions, QueryPrefetchOptions } from '../types'; -const collectionStoreCallback = async = Pick>( - list: T[], +setAutoFreeze(false); + +const collectionStoreCallback = async < + T extends Pick = Pick, + TQueryKey extends QueryKey = QueryKey +>( + queryClient: QueryClient, + queryKey: TQueryKey, subscription: RecordSubscription, collection: ReturnType, - queryParams: RecordListQueryParams | undefined = undefined + queryParams: RecordListQueryParams | undefined = undefined, + sortFunction?: (a: T, b: T) => number, + filterFunction?: (value: T, index: number, array: T[]) => boolean, + filterFunctionThisArg?: any ) => { + let data = queryClient.getQueryData(queryKey) ?? []; + + let expandedRecord = subscription.record; + if ( + (subscription.action === 'update' || subscription.action === 'create') && + queryParams?.expand + ) { + expandedRecord = await realtimeStoreExpand(collection, subscription.record, queryParams.expand); + // get data again because the cache could've changed while we were awaiting the expand + data = queryClient.getQueryData(queryKey) ?? []; + } + + let actionIgnored = false; switch (subscription.action) { case 'update': - subscription.record = await realtimeStoreExpand( - collection, - subscription.record, - queryParams?.expand - ); - return list.map((item) => (item.id === subscription.record.id ? subscription.record : item)); case 'create': - subscription.record = await realtimeStoreExpand( - collection, - subscription.record, - queryParams?.expand - ); - return [...list, subscription.record]; + data = produce(data, (draft) => { + let updateIndex = draft.findIndex((item) => item.id === expandedRecord.id); + if (updateIndex !== -1) { + if (new Date(expandedRecord.updated) > new Date(draft[updateIndex].updated)) { + draft[updateIndex] = expandedRecord as Draft; + } else { + actionIgnored = true; + } + } else { + draft.push(expandedRecord as Draft); + } + }); + break; case 'delete': - return list.filter((item) => item.id !== subscription.record.id); - default: - return list; + data = produce(data, (draft) => { + let deleteIndex = draft.findIndex((item) => item.id === expandedRecord.id); + if (deleteIndex !== -1) { + if (new Date(expandedRecord.updated) > new Date(draft[deleteIndex].updated)) { + draft.splice(deleteIndex, 1); + } else { + actionIgnored = true; + } + } + }); + break; + } + + if (!actionIgnored) { + if (subscription.action !== 'delete') { + if (filterFunction) { + data = produce( + data, + (draft) => (draft as T[]).filter(filterFunction, filterFunctionThisArg) as Draft[] + ); + } + if (sortFunction) { + data.sort(sortFunction); + } + } + + queryClient.setQueryData(queryKey, data); } }; -export const createCollectionQueryInitialData = = Pick>( +export const createCollectionQueryInitialData = async < + T extends Pick = Pick +>( collection: ReturnType, { queryParams = undefined }: { queryParams?: RecordListQueryParams } -): Promise> => collection.getFullList(undefined, queryParams); +): Promise> => [...(await collection.getFullList(undefined, queryParams))]; export const createCollectionQueryPrefetch = < - T extends Pick = Pick, + T extends Pick = Pick, TQueryKey extends QueryKey = QueryKey >( collection: ReturnType, @@ -72,25 +124,8 @@ export const createCollectionQueryPrefetch = < queryFn: async () => await createCollectionQueryInitialData(collection, { queryParams }) }); -/** - * Readable async Svelte store wrapper around an entire Pocketbase collection that updates in realtime. - * - * Notes: - * - When running server-side, this store returns the "empty version" version of this store, i.e. an empty array. - * - Create action received by the realtime subscription are added to the end of the returned store. - * - When an action is received via the realtime subscription, `sortFunction` runs first, then `filterFunction` runs. - * - This version of the collection store does not have pagination. Use `paginatedCollectionStore` if you want pagination. - * - * @param collection Collection whose updates to fetch in realtime. - * @param [options.queryParams] Pocketbase query parameters to apply on inital data fetch. **Only `expand` field is used when an action is received via the realtime subscription. Use `sortFunction` and `filterFunction` to apply sorting and filtering on actions received via the realtime subscription.** - * @param [options.sortFunction] `compareFn` from `Array.prototype.sort` that runs when an action is received via the realtime subscription. This is used since realtime subscriptions does not support `sort` in `queryParams`. - * @param [options.filterFunction] `predicate` from `Array.prototype.filter` that runs when an action is received via the realtime subscription. This is used since realtime subscriptions does not support `filter` in `queryParams`. - * @param [options.filterFunctionThisArg] `thisArg` from `Array.prototype.filter` that runs when an action is received via the realtime subscription. This is used since realtime subscriptions does not support `filter` in `queryParams`. - * @param [options.initial] If provided, skips initial data fetching and uses the provided value instead. Useful if you want to perform initial fetch during SSR and initialize a realtime subscription client-side. - * @param [options.disableRealtime] Only performs the initial fetch and does not subscribe to anything. This has an effect only when provided client-side. - */ export const createCollectionQuery = < - T extends Pick = Pick, + T extends Pick = Pick, TQueryKey extends QueryKey = QueryKey >( collection: ReturnType, @@ -125,24 +160,21 @@ export const createCollectionQuery = < : collection .subscribe('*', (data) => { collectionStoreCallback( - queryClient.getQueryData(queryKey) ?? [], + queryClient, + queryKey, data, collection, - queryParams + queryParams, + options.sortFunction, + options.filterFunction, + options.filterFunctionThisArg ) - .then((r) => { + .then(() => { console.log( `(C) ${JSON.stringify(queryKey)}: updating with realtime action:`, data.action, data.record.id ); - queryClient.setQueryData(queryKey, () => - options.filterFunction - ? r - .sort(options.sortFunction) - .filter(options.filterFunction, options.filterFunctionThisArg) - : r.sort(options.sortFunction) - ); }) .catch((e) => { console.log( diff --git a/src/lib/queries/infinite-collection.ts b/src/lib/queries/infinite-collection.ts index bbe57f2..da6e050 100644 --- a/src/lib/queries/infinite-collection.ts +++ b/src/lib/queries/infinite-collection.ts @@ -12,117 +12,220 @@ import { type FetchQueryOptions, type QueryKey, type InfiniteData, - type CreateInfiniteQueryResult + type CreateInfiniteQueryResult, + type QueryClient } from '@tanstack/svelte-query'; -import { chunk, uniqBy } from 'lodash'; +import { setAutoFreeze, produce, type Draft } from 'immer'; import { realtimeStoreExpand } from '../internal'; import { collectionKeys } from '../query-key-factory'; import type { InfiniteCollectionStoreOptions, InfiniteQueryPrefetchOptions } from '../types'; -const infiniteCollectionStoreCallback = async >( - data: InfiniteData>, +setAutoFreeze(false); + +const infiniteCollectionStoreCallback = async < + T extends Pick = Pick, + TQueryKey extends QueryKey = QueryKey +>( + queryClient: QueryClient, + queryKey: TQueryKey, subscription: RecordSubscription, collection: ReturnType, perPage: number, queryParams: RecordListQueryParams | undefined = undefined, sortFunction?: (a: T, b: T) => number, filterFunction?: (value: T, index: number, array: T[]) => boolean, - filterFunctionThisArg?: any, - ignoreUnknownRecords = true -): Promise<{ data: InfiniteData>; invalidatePages: Set }> => { - let allPages = uniqBy( - data.pages.flatMap((page) => page.items), - 'id' - ); - - const lastDataPage = data.pages.slice(-1).at(0); + filterFunctionThisArg?: any +) => { + let data = queryClient.getQueryData>>(queryKey); + if (data) { + let expandedRecord = subscription.record; + if ( + (subscription.action === 'update' || subscription.action === 'create') && + queryParams?.expand + ) { + expandedRecord = await realtimeStoreExpand( + collection, + subscription.record, + queryParams.expand + ); + // get data again because the cache could've changed while we were awaiting the expand + data = queryClient.getQueryData>>(queryKey); + } - let totalItems = lastDataPage?.totalItems ?? 0; + if (data) { + let allItems = data.pages.flatMap((page) => page.items); - switch (subscription.action) { - case 'update': - let updateIndex = allPages.findIndex((item) => item.id === subscription.record.id); - subscription.record = - updateIndex === -1 && !ignoreUnknownRecords - ? await realtimeStoreExpand(collection, subscription.record, queryParams?.expand) - : subscription.record; - if (updateIndex === -1 && !ignoreUnknownRecords) { - allPages.push(subscription.record); - } else { - allPages[updateIndex] = subscription.record; - } - break; - case 'create': - let createIndex = allPages.findIndex((item) => item.id === subscription.record.id); - subscription.record = - createIndex === -1 && !ignoreUnknownRecords - ? await realtimeStoreExpand(collection, subscription.record, queryParams?.expand) - : subscription.record; - if (createIndex === -1 && !ignoreUnknownRecords) { - allPages.push(subscription.record); - totalItems += 1; - } else { - allPages[createIndex] = subscription.record; + let actionIgnored = false; + let updateIndex = -1; + let deleteIndex = -1; + switch (subscription.action) { + case 'update': + case 'create': + allItems = produce(allItems, (draft) => { + updateIndex = draft.findIndex((item) => item.id === expandedRecord.id); + if (updateIndex !== -1) { + if (new Date(expandedRecord.updated) > new Date(draft[updateIndex].updated)) { + draft[updateIndex] = expandedRecord as Draft; + } else { + actionIgnored = true; + } + } else { + draft.push(expandedRecord as Draft); + } + }); + break; + case 'delete': + allItems = produce(allItems, (draft) => { + deleteIndex = draft.findIndex((item) => item.id === expandedRecord.id); + if (new Date(expandedRecord.updated) > new Date(draft[deleteIndex].updated)) { + draft.splice(deleteIndex, 1); + } else { + actionIgnored = true; + } + }); + break; } - break; - case 'delete': - allPages = allPages.filter((item) => item.id !== subscription.record.id); - totalItems -= 1; - break; - } - allPages = allPages.sort(sortFunction); - if (filterFunction) { - let allPagesCountBeforeFilter = allPages.length; - allPages = allPages.filter(filterFunction, filterFunctionThisArg); - let allPagesCountAfterFilter = allPages.length; - if (allPagesCountAfterFilter < allPagesCountBeforeFilter) { - totalItems -= 1; - } - } + if (!actionIgnored) { + let itemUnawareDelete = false; + let reduceTotalItemsBy = 0; + if (updateIndex === -1 && subscription.action === 'create') { + reduceTotalItemsBy = -1; + } else if ( + deleteIndex !== -1 || + (subscription.action === 'delete' && filterFunction + ? [expandedRecord].filter(filterFunction, filterFunctionThisArg).length === 0 + : true) + ) { + reduceTotalItemsBy = 1; + if (deleteIndex === -1) { + itemUnawareDelete = true; + } + } - const chunks = chunk(allPages, perPage); - let totalPages = Math.ceil(totalItems / perPage); + if (filterFunction) { + const allItemsCountBeforeFilter = allItems.length; + allItems = produce( + allItems, + (draft) => (draft as T[]).filter(filterFunction, filterFunctionThisArg) as Draft[] + ); + const allItemsCountAfterFilter = allItems.length; + if (allItemsCountAfterFilter < allItemsCountBeforeFilter) { + reduceTotalItemsBy -= allItemsCountBeforeFilter - allItemsCountAfterFilter; + } + } - let invalidatePages = new Set(); - let lastDataPageNum = lastDataPage?.page ?? 0; - while (chunks.length > data.pages.length) { - lastDataPageNum++; - data.pages.push(new ListResult(lastDataPageNum, perPage, totalItems, totalPages, [])); - data.pageParams.push(lastDataPageNum); - } + let positionBeforeSort = -1; + let positionAfterSort = -1; + if (sortFunction && subscription.action !== 'delete') { + positionBeforeSort = allItems.findIndex((item) => item.id === expandedRecord.id); + allItems.sort(sortFunction); + positionAfterSort = allItems.findIndex((item) => item.id === expandedRecord.id); + } - let dataPageIndex = 0; - for (const chunk of chunks) { - data.pages[dataPageIndex].totalItems = totalItems; - data.pages[dataPageIndex].totalPages = totalPages; - data.pages[dataPageIndex].items = chunk; - if (chunk.length < perPage && totalItems > (dataPageIndex + 1) * perPage + chunk.length) { - invalidatePages.add(data.pages[dataPageIndex].page); - } - dataPageIndex++; - } + let totalItemsWeHave = 0; + let totalItemsInDatabase = 0; + data = produce(data, (draft) => { + for (let index = 0; index < draft.pages.length; index++) { + draft.pages[index].totalItems -= reduceTotalItemsBy; + draft.pages[index].totalPages = Math.ceil( + draft.pages[index].totalItems / draft.pages[index].perPage + ); + draft.pages[index].items = allItems.splice(0, draft.pages[index].perPage) as Draft; + if (draft.pages[index].totalItems === 0) { + draft.pages[index].totalPages = 0; + } else if (draft.pages[index].totalItems === 1) { + draft.pages[index].totalPages = 1; + } + totalItemsWeHave += draft.pages[index].items.length; + totalItemsInDatabase = Math.max(totalItemsInDatabase, draft.pages[index].totalItems); + } + }); + + const pagesLeftInAllItems = Math.ceil(allItems.length / perPage); + if (allItems.length > 0 && totalItemsWeHave === totalItemsInDatabase) { + // add pages only if we have all items in the database + data = produce(data, (draft) => { + for (let index = 0; index < pagesLeftInAllItems; index++) { + draft.pages.push( + new ListResult( + draft.pages[draft.pages.length - 1].page + 1, + perPage, + draft.pages[draft.pages.length - 1].totalItems, + draft.pages[draft.pages.length - 1].totalPages, + allItems.splice(0, perPage) + ) as Draft> + ); + draft.pageParams.push(draft.pages[draft.pages.length - 1].page + 1); + } + }); + } + + if ( + totalItemsWeHave === totalItemsInDatabase && + data.pages[data.pages.length - 1].items.length === 0 + ) { + // delete empty pages only if we have all items in the database + data = produce(data, (draft) => { + draft.pages.pop(); + draft.pageParams.pop(); + }); + } - for (let index = dataPageIndex; index < data.pages.length; index++) { - if (totalItems > (dataPageIndex + 1) * perPage) { - data.pages[dataPageIndex].totalItems = totalItems; - data.pages[dataPageIndex].totalPages = totalPages; - data.pages[dataPageIndex].items = []; - invalidatePages.add(data.pages[dataPageIndex].page); - } else { - data.pages.length = dataPageIndex + 1; - data.pageParams.length = dataPageIndex + 1; - break; + queryClient.setQueryData>>(queryKey, () => data); + + if (deleteIndex !== -1 && totalItemsWeHave !== totalItemsInDatabase) { + // something cache was aware of was deleted and we don't have all items in the database, so invalidate last page + const pageNumToInvalidate = data.pages[data.pages.length - 1].page; + queryClient.invalidateQueries>({ + queryKey, + refetchPage: (lastPage, index, allPages) => + allPages[index].page === pageNumToInvalidate, + exact: true + }); + } else if ( + itemUnawareDelete && + totalItemsWeHave !== totalItemsInDatabase && + (data.pages[0].page !== 1 || + data.pages[data.pages.length - 1].page !== data.pages[data.pages.length - 1].totalPages) + ) { + // an item we were unaware of but affects this query was deleted, we don't have all items in database, and we don't have the first page or last page, so invalidate all pages since item we were unaware of that was deleted could change what items are in our pages + queryClient.invalidateQueries>({ + queryKey, + exact: true + }); + } else if ( + positionBeforeSort !== -1 && + positionAfterSort !== -1 && + totalItemsWeHave !== totalItemsInDatabase + ) { + // something was updated/created and we don't have all items in the database + if ( + positionAfterSort === allItems.length && + data.pages[data.pages.length - 1].page !== data.pages[data.pages.length - 1].totalPages + ) { + // after sorting, item ended up as the last item and we don't have the last page, so invalidate all pages + queryClient.invalidateQueries>({ + queryKey, + exact: true + }); + } else if (positionAfterSort === 0 && data.pages[0].page !== 1) { + // after sorting, item ended up as the first item and we don't have the first page, so invalidate all pages + queryClient.invalidateQueries>({ + queryKey, + exact: true + }); + } + } + } } } - - return { data, invalidatePages }; }; -export const infiniteCollectionQueryInitialData = < - T extends Pick = Pick +export const infiniteCollectionQueryInitialData = async < + T extends Pick = Pick >( collection: ReturnType, { @@ -130,10 +233,10 @@ export const infiniteCollectionQueryInitialData = < perPage = 20, queryParams = undefined }: { page?: number; perPage?: number; queryParams?: RecordListQueryParams } = {} -): Promise> => collection.getList(page, perPage, queryParams); +): Promise> => ({ ...(await collection.getList(page, perPage, queryParams)) }); export const infiniteCollectionQueryPrefetch = < - T extends Pick = Pick, + T extends Pick = Pick, TQueryKey extends QueryKey = QueryKey >( collection: ReturnType, @@ -159,7 +262,7 @@ export const infiniteCollectionQueryPrefetch = < }); export const createInfiniteCollectionQuery = < - T extends Pick = Pick, + T extends Pick = Pick, TQueryKey extends QueryKey = QueryKey >( collection: ReturnType, @@ -179,7 +282,6 @@ export const createInfiniteCollectionQuery = < disableRealtime = false, invalidateQueryOnRealtimeError = true, keepCurrentPageOnly = false, - ignoreUnknownRecords = true, ...options }: InfiniteCollectionStoreOptions< ListResult, @@ -209,8 +311,10 @@ export const createInfiniteCollectionQuery = < if (keepCurrentPageOnly) { queryClient.setQueryData>>(queryKey, (old) => { if (old) { - old.pages = []; - old.pageParams = []; + return produce(old, (draft) => { + draft.pages = []; + draft.pageParams = []; + }); } return old; }); @@ -219,10 +323,11 @@ export const createInfiniteCollectionQuery = < latestTotalPages = data.totalPages; queryClient.setQueryData>>(queryKey, (old) => { if (old) { - old.pages = old.pages.map((page) => { - page.totalItems = latestTotalItems; - page.totalPages = latestTotalPages; - return page; + return produce(old, (draft) => { + for (let index = 0; index < draft.pages.length; index++) { + draft.pages[index].totalItems = latestTotalItems; + draft.pages[index].totalPages = latestTotalPages; + } }); } return old; @@ -242,32 +347,22 @@ export const createInfiniteCollectionQuery = < : collection .subscribe('*', (data) => { infiniteCollectionStoreCallback( - queryClient.getQueryData>>(queryKey) ?? { - pages: [], - pageParams: [] - }, + queryClient, + queryKey, data, collection, perPage, queryParams, options.sortFunction, options.filterFunction, - options.filterFunctionThisArg, - ignoreUnknownRecords + options.filterFunctionThisArg ) - .then((r) => { + .then(() => { console.log( `(IC) ${JSON.stringify(queryKey)}: updating with realtime action:`, data.action, data.record.id ); - queryClient.setQueryData>>(queryKey, () => r.data); - queryClient.invalidateQueries>({ - queryKey, - refetchPage: (lastPage, index, allPages) => - r.invalidatePages.has(allPages[index].page), - exact: true - }); }) .catch((e) => { console.log( diff --git a/src/lib/queries/record.ts b/src/lib/queries/record.ts index 1cc83a5..663a064 100644 --- a/src/lib/queries/record.ts +++ b/src/lib/queries/record.ts @@ -1,5 +1,6 @@ import { createQuery, + type QueryClient, useQueryClient, type CreateQueryResult, type FetchQueryOptions, @@ -18,32 +19,55 @@ import { collectionKeys } from '../query-key-factory'; import { realtimeStoreExpand } from '../internal'; import type { QueryPrefetchOptions, RecordStoreOptions } from '../types'; -const createRecordQueryCallback = async = Pick>( - item: T | null, +const createRecordQueryCallback = async < + T extends Pick = Pick, + TQueryKey extends QueryKey = QueryKey +>( + queryClient: QueryClient, + queryKey: TQueryKey, subscription: RecordSubscription, collection: ReturnType, queryParams: RecordQueryParams | undefined = undefined ) => { - switch (subscription.action) { - case 'update': - return await realtimeStoreExpand(collection, subscription.record, queryParams?.expand); - case 'create': - return await realtimeStoreExpand(collection, subscription.record, queryParams?.expand); - case 'delete': - return null; - default: - return item; + let data = queryClient.getQueryData(queryKey); + + let expandedRecord = subscription.record; + if (data ? new Date(expandedRecord.updated) > new Date(data.updated) : true) { + if ( + (subscription.action === 'update' || subscription.action === 'create') && + queryParams?.expand + ) { + expandedRecord = await realtimeStoreExpand( + collection, + subscription.record, + queryParams.expand + ); + // get data again because the cache could've changed while we were awaiting the expand + data = queryClient.getQueryData(queryKey); + } + + switch (subscription.action) { + case 'update': + case 'create': + queryClient.setQueryData(queryKey, () => expandedRecord); + break; + case 'delete': + queryClient.setQueryData(queryKey, () => null); + break; + } } }; -export const createRecordQueryInitialData = = Pick>( +export const createRecordQueryInitialData = < + T extends Pick = Pick +>( collection: ReturnType, id: string, { queryParams = undefined }: { queryParams?: RecordQueryParams } ): Promise => collection.getOne(id, queryParams); export const createRecordQueryPrefetch = < - T extends Pick = Pick, + T extends Pick = Pick, TQueryKey extends QueryKey = QueryKey >( collection: ReturnType, @@ -65,21 +89,8 @@ export const createRecordQueryPrefetch = < queryFn: async () => await createRecordQueryInitialData(collection, id, { queryParams }) }); -/** - * Readable async Svelte store wrapper around a Pocketbase record that updates in realtime. - * - * Notes: - * - When running server-side, this store returns the "empty version" version of this store, i.e. `undefined`. - * - If a delete action is received via the realtime subscription, the store's value changes to `undefined`. - * - * @param collection Collection the record is a part of whose updates to fetch in realtime. - * @param id ID of the Pocketbase record which will be updated in realtime. - * @param [options.queryParams] Pocketbase query paramteres to apply on initial data fetch and everytime an update is received via the realtime subscription. **Only `expand` field is used when an action is received via the realtime subscription.** - * @param [options.initial] If provided, skips initial data fetching and uses the provided value instead. Useful if you want to perform initial fetch during SSR and initialize a realtime subscription client-side. - * @param [options.disableRealtime] Only performs the initial fetch and does not subscribe to anything. This has an effect only when provided client-side. - */ export const createRecordQuery = < - T extends Pick = Pick, + T extends Pick = Pick, TQueryKey extends QueryKey = QueryKey >( collection: ReturnType, @@ -121,19 +132,13 @@ export const createRecordQuery = < ? async () => {} : collection .subscribe(id, (data) => { - createRecordQueryCallback( - queryClient.getQueryData(queryKey) ?? null, - data, - collection, - queryParams - ) - .then((r) => { + createRecordQueryCallback(queryClient, queryKey, data, collection, queryParams) + .then(() => { console.log( `(R) ${JSON.stringify(queryKey)}: updating with realtime action:`, data.action, data.record.id ); - queryClient.setQueryData(queryKey, () => r); }) .catch((e) => { console.log( diff --git a/src/lib/types.ts b/src/lib/types.ts index db59ae8..844f11d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -96,7 +96,6 @@ export interface InfiniteCollectionStoreOptions< disableRealtime?: boolean; invalidateQueryOnRealtimeError?: boolean; keepCurrentPageOnly?: boolean; - ignoreUnknownRecords?: boolean; /** * This callback will fire any time the realtime subscription receives an update. */ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 66fd2a8..1a6951c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -6,8 +6,12 @@ const pocketbase = new Pocketbase('https://voel.local'); - const query = createCollectionQuery(pocketbase.collection('test'), { - queryParams: { filter: '' } + const query = createInfiniteCollectionQuery(pocketbase.collection('test'), { + page: 2, + perPage: 1, + queryParams: { sort: '+name' }, + sortFunction: (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) + // filterFunction: (item) => item.name.includes('bunbee') }); // onMount(() => { @@ -30,7 +34,7 @@ {#if $query.isSuccess}
-			{JSON.stringify($query, null, 2)}
+			{JSON.stringify($query.data.pages, null, 2)}