tanstack-cacher 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +518 -0
- package/dist/index.d.mts +43 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +292 -0
- package/dist/index.mjs +284 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# React Query Cache Manager
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/tanstack-cacher)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
|
|
7
|
+
## Introduction
|
|
8
|
+
|
|
9
|
+
A robust, TypeScript-first cache manager for React Query that works with **ANY response structure**. Using simple path-based configuration, it handles simple arrays, deeply nested data, paginated responses, and any custom format you throw at it.
|
|
10
|
+
|
|
11
|
+
**Key Features:**
|
|
12
|
+
- Path-based configuration (e.g., `itemsPath: "data.content"`)
|
|
13
|
+
- Automatic handling of missing data and nested structures
|
|
14
|
+
- Built-in pagination metadata management
|
|
15
|
+
- Works with any response shape
|
|
16
|
+
- Full TypeScript support with type inference
|
|
17
|
+
- Zero dependencies (except React Query)
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @your-scope/tanstack-cacher
|
|
23
|
+
# or
|
|
24
|
+
yarn add @your-scope/tanstack-cacher
|
|
25
|
+
# or
|
|
26
|
+
pnpm add @your-scope/tanstack-cacher
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
### 1. Simple Array Response
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { QueryCacheManager } from '@your-scope/tanstack-cacher';
|
|
35
|
+
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
|
36
|
+
|
|
37
|
+
interface Todo {
|
|
38
|
+
id: string;
|
|
39
|
+
title: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// API returns: Todo[]
|
|
43
|
+
function TodoList() {
|
|
44
|
+
const queryClient = useQueryClient();
|
|
45
|
+
|
|
46
|
+
const cache = new QueryCacheManager<Todo[], Todo>({
|
|
47
|
+
queryClient,
|
|
48
|
+
queryKey: ['todos'],
|
|
49
|
+
itemsPath: '', // Empty string = data IS the array
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const createMutation = useMutation({
|
|
53
|
+
mutationFn: createTodo,
|
|
54
|
+
onMutate: (newTodo) => cache.add(newTodo),
|
|
55
|
+
onError: () => cache.invalidate(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const updateMutation = useMutation({
|
|
59
|
+
mutationFn: updateTodo,
|
|
60
|
+
onMutate: (todo) => cache.update(todo),
|
|
61
|
+
onError: () => cache.invalidate(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const deleteMutation = useMutation({
|
|
65
|
+
mutationFn: deleteTodo,
|
|
66
|
+
onMutate: (id) => cache.delete(id),
|
|
67
|
+
onError: () => cache.invalidate(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 2. Nested Response
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
interface ApiResponse {
|
|
76
|
+
success: true;
|
|
77
|
+
data: {
|
|
78
|
+
items: User[];
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cache = new QueryCacheManager<ApiResponse, User>({
|
|
83
|
+
queryClient,
|
|
84
|
+
queryKey: ['users'],
|
|
85
|
+
itemsPath: 'data.items', // Path to the array
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 3. Paginated Response (Automatic Metadata Updates!)
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
interface PaginatedResponse {
|
|
93
|
+
content: User[];
|
|
94
|
+
page: {
|
|
95
|
+
number: number;
|
|
96
|
+
size: number;
|
|
97
|
+
totalElements: number;
|
|
98
|
+
totalPages: number;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cache = new QueryCacheManager<PaginatedResponse, User>({
|
|
103
|
+
queryClient,
|
|
104
|
+
queryKey: ['users', { page: 0 }],
|
|
105
|
+
itemsPath: 'content', // Path to items
|
|
106
|
+
pagination: {
|
|
107
|
+
totalElementsPath: 'page.totalElements', // Auto-updates on add/delete
|
|
108
|
+
totalPagesPath: 'page.totalPages', // Auto-recalculated on add/delete
|
|
109
|
+
currentPagePath: 'page.number',
|
|
110
|
+
pageSizePath: 'page.size', // Required for totalPages calculation
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Now when you add/delete, totalElements AND totalPages update automatically!
|
|
115
|
+
cache.add(newUser); // totalElements++, totalPages recalculated
|
|
116
|
+
cache.delete(userId); // totalElements--, totalPages recalculated
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 4. Different Pagination Format
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
interface Response {
|
|
123
|
+
items: Product[];
|
|
124
|
+
meta: {
|
|
125
|
+
total: number;
|
|
126
|
+
currentPage: number;
|
|
127
|
+
totalPages: number;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cache = new QueryCacheManager<Response, Product>({
|
|
132
|
+
queryClient,
|
|
133
|
+
queryKey: ['products'],
|
|
134
|
+
itemsPath: 'items',
|
|
135
|
+
pagination: {
|
|
136
|
+
totalElementsPath: 'meta.total',
|
|
137
|
+
totalPagesPath: 'meta.totalPages',
|
|
138
|
+
currentPagePath: 'meta.currentPage',
|
|
139
|
+
pageSizePath: 'meta.pageSize', // For auto totalPages calculation
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 5. Deeply Nested Response
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
interface ComplexResponse {
|
|
148
|
+
status: 'ok';
|
|
149
|
+
result: {
|
|
150
|
+
data: {
|
|
151
|
+
users: User[];
|
|
152
|
+
};
|
|
153
|
+
metadata: {
|
|
154
|
+
count: number;
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const cache = new QueryCacheManager<ComplexResponse, User>({
|
|
160
|
+
queryClient,
|
|
161
|
+
queryKey: ['users'],
|
|
162
|
+
itemsPath: 'result.data.users', // Navigate with dots
|
|
163
|
+
pagination: {
|
|
164
|
+
totalElementsPath: 'result.metadata.count',
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## API Reference
|
|
170
|
+
|
|
171
|
+
### Constructor
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
new QueryCacheManager<TData, TItem>(config)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Config Options:**
|
|
178
|
+
|
|
179
|
+
| Option | Type | Required | Description |
|
|
180
|
+
|--------|------|----------|-------------|
|
|
181
|
+
| `queryClient` | `QueryClient` | Yes | React Query client instance |
|
|
182
|
+
| `queryKey` | `QueryKey` | Yes | Query key for the cache |
|
|
183
|
+
| `itemsPath` | `string` | Yes | Dot-separated path to items array (empty string if data IS array) |
|
|
184
|
+
| `pagination` | `PaginationConfig` | No | Pagination metadata paths |
|
|
185
|
+
| `keyExtractor` | `(item: TItem) => string \| number` | No | Extract unique ID (default: `item.id`) |
|
|
186
|
+
| `initialData` | `TData` | No | Initial structure when cache is empty |
|
|
187
|
+
|
|
188
|
+
**PaginationConfig:**
|
|
189
|
+
|
|
190
|
+
| Option | Type | Description |
|
|
191
|
+
|--------|------|-------------|
|
|
192
|
+
| `totalElementsPath` | `string` | Path to total count (auto-updated on add/delete) |
|
|
193
|
+
| `totalPagesPath` | `string` | Path to total pages (auto-recalculated when pageSizePath provided) |
|
|
194
|
+
| `currentPagePath` | `string` | Path to current page number |
|
|
195
|
+
| `pageSizePath` | `string` | Path to page size (required for automatic totalPages recalculation) |
|
|
196
|
+
|
|
197
|
+
### Methods
|
|
198
|
+
|
|
199
|
+
#### `add(newItem, position?)`
|
|
200
|
+
Add new item to cache
|
|
201
|
+
```typescript
|
|
202
|
+
cache.add({ id: '1', name: 'John' }); // Adds at start (default)
|
|
203
|
+
cache.add({ id: '2', name: 'Jane' }, 'end'); // Adds at end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
#### `update(updatedItem, matcher?)`
|
|
207
|
+
Update existing item
|
|
208
|
+
```typescript
|
|
209
|
+
// Update by ID (default)
|
|
210
|
+
cache.update({ id: '1', name: 'Johnny' });
|
|
211
|
+
|
|
212
|
+
// Custom matcher
|
|
213
|
+
cache.update(
|
|
214
|
+
{ status: 'active' },
|
|
215
|
+
(item) => item.email === 'user@example.com'
|
|
216
|
+
);
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### `delete(itemOrId, matcher?)`
|
|
220
|
+
Remove item from cache
|
|
221
|
+
```typescript
|
|
222
|
+
// Delete by ID
|
|
223
|
+
cache.delete('1');
|
|
224
|
+
|
|
225
|
+
// Delete by object
|
|
226
|
+
cache.delete({ id: '1', name: 'John' });
|
|
227
|
+
|
|
228
|
+
// Custom matcher
|
|
229
|
+
cache.delete(null, (item) => item.isDeleted);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
#### `replace(newData)`
|
|
233
|
+
Replace entire cache data
|
|
234
|
+
```typescript
|
|
235
|
+
cache.replace(newFullData);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
#### `clear()`
|
|
239
|
+
Clear all items (resets array to empty, updates pagination to 0)
|
|
240
|
+
```typescript
|
|
241
|
+
cache.clear();
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
#### `getItemsFromCache()`
|
|
245
|
+
Get current items array
|
|
246
|
+
```typescript
|
|
247
|
+
const items = cache.getItemsFromCache(); // returns TItem[]
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
#### `getDataFromCache()`
|
|
251
|
+
Get full cache data
|
|
252
|
+
```typescript
|
|
253
|
+
const fullData = cache.getDataFromCache(); // returns TData | undefined
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
#### `invalidate()`
|
|
257
|
+
Trigger refetch from server
|
|
258
|
+
```typescript
|
|
259
|
+
cache.invalidate();
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
#### `createHandlers()`
|
|
263
|
+
Get handlers for mutation callbacks
|
|
264
|
+
```typescript
|
|
265
|
+
const handlers = cache.createHandlers();
|
|
266
|
+
|
|
267
|
+
useMutation({
|
|
268
|
+
mutationFn: createUser,
|
|
269
|
+
onMutate: handlers.onAdd,
|
|
270
|
+
onError: () => cache.invalidate(),
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Real-World Example
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
278
|
+
import { QueryCacheManager } from '@your-scope/tanstack-cacher';
|
|
279
|
+
|
|
280
|
+
interface Todo {
|
|
281
|
+
id: string;
|
|
282
|
+
title: string;
|
|
283
|
+
completed: boolean;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function TodoApp() {
|
|
287
|
+
const queryClient = useQueryClient();
|
|
288
|
+
|
|
289
|
+
// Setup cache manager
|
|
290
|
+
const todoCache = new QueryCacheManager<Todo[], Todo>({
|
|
291
|
+
queryClient,
|
|
292
|
+
queryKey: ['todos'],
|
|
293
|
+
itemsPath: '', // Data IS the array
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Fetch todos
|
|
297
|
+
const { data: todos } = useQuery({
|
|
298
|
+
queryKey: ['todos'],
|
|
299
|
+
queryFn: () => fetch('/api/todos').then(r => r.json()),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Mutations with optimistic updates
|
|
303
|
+
const createMutation = useMutation({
|
|
304
|
+
mutationFn: async (title: string) => {
|
|
305
|
+
const res = await fetch('/api/todos', {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
body: JSON.stringify({ title }),
|
|
308
|
+
});
|
|
309
|
+
return res.json();
|
|
310
|
+
},
|
|
311
|
+
onMutate: (title) => {
|
|
312
|
+
// Optimistically add with temp ID
|
|
313
|
+
todoCache.add({
|
|
314
|
+
id: `temp-${Date.now()}`,
|
|
315
|
+
title,
|
|
316
|
+
completed: false,
|
|
317
|
+
});
|
|
318
|
+
},
|
|
319
|
+
onError: () => todoCache.invalidate(),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const toggleMutation = useMutation({
|
|
323
|
+
mutationFn: async (todo: Todo) => {
|
|
324
|
+
const res = await fetch(`/api/todos/${todo.id}`, {
|
|
325
|
+
method: 'PUT',
|
|
326
|
+
body: JSON.stringify({ ...todo, completed: !todo.completed }),
|
|
327
|
+
});
|
|
328
|
+
return res.json();
|
|
329
|
+
},
|
|
330
|
+
onMutate: (todo) => {
|
|
331
|
+
// Optimistically update
|
|
332
|
+
todoCache.update({ id: todo.id, completed: !todo.completed });
|
|
333
|
+
},
|
|
334
|
+
onError: () => todoCache.invalidate(),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const deleteMutation = useMutation({
|
|
338
|
+
mutationFn: (id: string) => fetch(`/api/todos/${id}`, { method: 'DELETE' }),
|
|
339
|
+
onMutate: (id) => {
|
|
340
|
+
// Optimistically remove
|
|
341
|
+
todoCache.delete(id);
|
|
342
|
+
},
|
|
343
|
+
onError: () => todoCache.invalidate(),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<div>
|
|
348
|
+
<input
|
|
349
|
+
onKeyDown={(e) => {
|
|
350
|
+
if (e.key === 'Enter') {
|
|
351
|
+
createMutation.mutate(e.currentTarget.value);
|
|
352
|
+
e.currentTarget.value = '';
|
|
353
|
+
}
|
|
354
|
+
}}
|
|
355
|
+
placeholder="Add todo..."
|
|
356
|
+
/>
|
|
357
|
+
<ul>
|
|
358
|
+
{todos?.map((todo) => (
|
|
359
|
+
<li key={todo.id}>
|
|
360
|
+
<input
|
|
361
|
+
type="checkbox"
|
|
362
|
+
checked={todo.completed}
|
|
363
|
+
onChange={() => toggleMutation.mutate(todo)}
|
|
364
|
+
/>
|
|
365
|
+
<span>{todo.title}</span>
|
|
366
|
+
<button onClick={() => deleteMutation.mutate(todo.id)}>
|
|
367
|
+
Delete
|
|
368
|
+
</button>
|
|
369
|
+
</li>
|
|
370
|
+
))}
|
|
371
|
+
</ul>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## Next.js Usage
|
|
378
|
+
|
|
379
|
+
Works seamlessly with Next.js App Router:
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
// app/users/page.tsx
|
|
383
|
+
'use client';
|
|
384
|
+
|
|
385
|
+
import { QueryCacheManager } from '@your-scope/tanstack-cacher';
|
|
386
|
+
|
|
387
|
+
export default function UsersPage() {
|
|
388
|
+
const queryClient = useQueryClient();
|
|
389
|
+
|
|
390
|
+
const cache = new QueryCacheManager({
|
|
391
|
+
queryClient,
|
|
392
|
+
queryKey: ['users'],
|
|
393
|
+
itemsPath: 'data.users',
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ... rest of component
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## Advanced Examples
|
|
401
|
+
|
|
402
|
+
### Handling Missing Data
|
|
403
|
+
|
|
404
|
+
The manager automatically creates the structure if data is missing:
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
const cache = new QueryCacheManager<ApiResponse, User>({
|
|
408
|
+
queryClient,
|
|
409
|
+
queryKey: ['users'],
|
|
410
|
+
itemsPath: 'data.content',
|
|
411
|
+
// If cache is empty, this structure is created:
|
|
412
|
+
initialData: {
|
|
413
|
+
data: {
|
|
414
|
+
content: [],
|
|
415
|
+
},
|
|
416
|
+
page: {
|
|
417
|
+
totalElements: 0,
|
|
418
|
+
totalPages: 0,
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Even if cache is empty, this works fine!
|
|
424
|
+
cache.add(newUser); // Creates structure + adds item
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Custom Key Extractor
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
interface User {
|
|
431
|
+
userId: string; // Not "id"
|
|
432
|
+
name: string;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const cache = new QueryCacheManager<User[], User>({
|
|
436
|
+
queryClient,
|
|
437
|
+
queryKey: ['users'],
|
|
438
|
+
itemsPath: '',
|
|
439
|
+
keyExtractor: (user) => user.userId, // Custom ID field
|
|
440
|
+
});
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Multiple Cache Managers
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
function App() {
|
|
447
|
+
const queryClient = useQueryClient();
|
|
448
|
+
|
|
449
|
+
// One manager per query
|
|
450
|
+
const usersCache = new QueryCacheManager({
|
|
451
|
+
queryClient,
|
|
452
|
+
queryKey: ['users'],
|
|
453
|
+
itemsPath: 'data.users',
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const postsCache = new QueryCacheManager({
|
|
457
|
+
queryClient,
|
|
458
|
+
queryKey: ['posts'],
|
|
459
|
+
itemsPath: 'posts',
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Use independently
|
|
463
|
+
usersCache.add(newUser);
|
|
464
|
+
postsCache.add(newPost);
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
## Best Practices
|
|
469
|
+
|
|
470
|
+
1. **Always handle errors** - Call `invalidate()` on mutation errors to refetch from server
|
|
471
|
+
2. **Use temporary IDs** - For optimistic creates, use `temp-${Date.now()}` or similar
|
|
472
|
+
3. **One manager per query** - Create separate instances for different queries
|
|
473
|
+
4. **Specify paths clearly** - Use dot notation for nested paths: `"data.result.items"`
|
|
474
|
+
5. **Type everything** - Provide TypeScript types for full type safety
|
|
475
|
+
|
|
476
|
+
## Why This Package?
|
|
477
|
+
|
|
478
|
+
Traditional cache managers assume your API structure. This package:
|
|
479
|
+
|
|
480
|
+
- **Works with ANY structure** - Just tell it the path to your data
|
|
481
|
+
- **Handles edge cases** - Missing data, nested objects, pagination metadata
|
|
482
|
+
- **Zero configuration overhead** - No complex setup or boilerplate
|
|
483
|
+
- **Type-safe** - Full TypeScript support with inference
|
|
484
|
+
- **Framework agnostic** - Works anywhere React Query works
|
|
485
|
+
|
|
486
|
+
## Migration from Old API
|
|
487
|
+
|
|
488
|
+
If you were using the old `getItems`/`setItems` approach:
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
// Old way ❌
|
|
492
|
+
const cache = new QueryCacheManager({
|
|
493
|
+
getItems: (data) => data.content,
|
|
494
|
+
setItems: (data, items) => ({ ...data, content: items }),
|
|
495
|
+
onItemsAdd: (data, count) => ({
|
|
496
|
+
...data,
|
|
497
|
+
page: { ...data.page, totalElements: data.page.totalElements + count }
|
|
498
|
+
}),
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// New way ✅
|
|
502
|
+
const cache = new QueryCacheManager({
|
|
503
|
+
itemsPath: 'content',
|
|
504
|
+
pagination: {
|
|
505
|
+
totalElementsPath: 'page.totalElements',
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Much simpler and safer!
|
|
511
|
+
|
|
512
|
+
## License
|
|
513
|
+
|
|
514
|
+
MIT
|
|
515
|
+
|
|
516
|
+
## Contributing
|
|
517
|
+
|
|
518
|
+
Issues and PRs welcome!
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { QueryClient, QueryKey } from '@tanstack/react-query';
|
|
2
|
+
export * from '@tanstack/react-query';
|
|
3
|
+
|
|
4
|
+
interface PaginationConfig {
|
|
5
|
+
totalElementsPath?: string;
|
|
6
|
+
totalPagesPath?: string;
|
|
7
|
+
currentPagePath?: string;
|
|
8
|
+
pageSizePath?: string;
|
|
9
|
+
}
|
|
10
|
+
interface CacheConfig<TData, TItem> {
|
|
11
|
+
queryClient: QueryClient;
|
|
12
|
+
queryKey: QueryKey;
|
|
13
|
+
itemsPath: string;
|
|
14
|
+
pagination?: PaginationConfig;
|
|
15
|
+
keyExtractor?: (item: TItem) => string | number;
|
|
16
|
+
initialData?: TData;
|
|
17
|
+
}
|
|
18
|
+
interface CacheHandlers<TItem> {
|
|
19
|
+
onAdd: (newItem: TItem, position?: 'start' | 'end') => void;
|
|
20
|
+
onUpdate: (updatedItem: Partial<TItem>, matcher?: (item: TItem) => boolean) => void;
|
|
21
|
+
onDelete: (itemOrId: TItem | string | number, matcher?: (item: TItem) => boolean) => void;
|
|
22
|
+
}
|
|
23
|
+
type InsertPosition = 'start' | 'end';
|
|
24
|
+
|
|
25
|
+
declare class QueryCacheManager<TData, TItem> {
|
|
26
|
+
private config;
|
|
27
|
+
constructor(config: CacheConfig<TData, TItem>);
|
|
28
|
+
private getItems;
|
|
29
|
+
private setItems;
|
|
30
|
+
private updatePaginationOnAdd;
|
|
31
|
+
private updatePaginationOnRemove;
|
|
32
|
+
add(newItem: TItem, position?: InsertPosition): void;
|
|
33
|
+
update(updatedItem: Partial<TItem>, matcher?: (item: TItem) => boolean): void;
|
|
34
|
+
delete(itemOrId: TItem | string | number, matcher?: (item: TItem) => boolean): void;
|
|
35
|
+
replace(newData: TData): void;
|
|
36
|
+
clear(): void;
|
|
37
|
+
getItemsFromCache(): TItem[];
|
|
38
|
+
getDataFromCache(): TData | undefined;
|
|
39
|
+
invalidate(): void;
|
|
40
|
+
createHandlers(): CacheHandlers<TItem>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { type CacheConfig, type CacheHandlers, type InsertPosition, type PaginationConfig, QueryCacheManager };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { QueryClient, QueryKey } from '@tanstack/react-query';
|
|
2
|
+
export * from '@tanstack/react-query';
|
|
3
|
+
|
|
4
|
+
interface PaginationConfig {
|
|
5
|
+
totalElementsPath?: string;
|
|
6
|
+
totalPagesPath?: string;
|
|
7
|
+
currentPagePath?: string;
|
|
8
|
+
pageSizePath?: string;
|
|
9
|
+
}
|
|
10
|
+
interface CacheConfig<TData, TItem> {
|
|
11
|
+
queryClient: QueryClient;
|
|
12
|
+
queryKey: QueryKey;
|
|
13
|
+
itemsPath: string;
|
|
14
|
+
pagination?: PaginationConfig;
|
|
15
|
+
keyExtractor?: (item: TItem) => string | number;
|
|
16
|
+
initialData?: TData;
|
|
17
|
+
}
|
|
18
|
+
interface CacheHandlers<TItem> {
|
|
19
|
+
onAdd: (newItem: TItem, position?: 'start' | 'end') => void;
|
|
20
|
+
onUpdate: (updatedItem: Partial<TItem>, matcher?: (item: TItem) => boolean) => void;
|
|
21
|
+
onDelete: (itemOrId: TItem | string | number, matcher?: (item: TItem) => boolean) => void;
|
|
22
|
+
}
|
|
23
|
+
type InsertPosition = 'start' | 'end';
|
|
24
|
+
|
|
25
|
+
declare class QueryCacheManager<TData, TItem> {
|
|
26
|
+
private config;
|
|
27
|
+
constructor(config: CacheConfig<TData, TItem>);
|
|
28
|
+
private getItems;
|
|
29
|
+
private setItems;
|
|
30
|
+
private updatePaginationOnAdd;
|
|
31
|
+
private updatePaginationOnRemove;
|
|
32
|
+
add(newItem: TItem, position?: InsertPosition): void;
|
|
33
|
+
update(updatedItem: Partial<TItem>, matcher?: (item: TItem) => boolean): void;
|
|
34
|
+
delete(itemOrId: TItem | string | number, matcher?: (item: TItem) => boolean): void;
|
|
35
|
+
replace(newData: TData): void;
|
|
36
|
+
clear(): void;
|
|
37
|
+
getItemsFromCache(): TItem[];
|
|
38
|
+
getDataFromCache(): TData | undefined;
|
|
39
|
+
invalidate(): void;
|
|
40
|
+
createHandlers(): CacheHandlers<TItem>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { type CacheConfig, type CacheHandlers, type InsertPosition, type PaginationConfig, QueryCacheManager };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var reactQuery = require('@tanstack/react-query');
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
|
|
7
|
+
// src/managers/QueryCacheManager/QueryCache.utils.ts
|
|
8
|
+
function getAtPath(obj, path, defaultValue) {
|
|
9
|
+
if (!obj || !path) return defaultValue;
|
|
10
|
+
const keys = path.split(".");
|
|
11
|
+
let result = obj;
|
|
12
|
+
for (const key of keys) {
|
|
13
|
+
if (result === null || result === void 0) {
|
|
14
|
+
return defaultValue;
|
|
15
|
+
}
|
|
16
|
+
result = result[key];
|
|
17
|
+
}
|
|
18
|
+
return result !== void 0 ? result : defaultValue;
|
|
19
|
+
}
|
|
20
|
+
function setAtPath(obj, path, value) {
|
|
21
|
+
if (!path) return obj;
|
|
22
|
+
const keys = path.split(".");
|
|
23
|
+
const root = obj ? { ...obj } : {};
|
|
24
|
+
let current = root;
|
|
25
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
26
|
+
const key = keys[i];
|
|
27
|
+
if (!current[key] || typeof current[key] !== "object") {
|
|
28
|
+
current[key] = {};
|
|
29
|
+
} else {
|
|
30
|
+
current[key] = { ...current[key] };
|
|
31
|
+
}
|
|
32
|
+
current = current[key];
|
|
33
|
+
}
|
|
34
|
+
current[keys[keys.length - 1]] = value;
|
|
35
|
+
return root;
|
|
36
|
+
}
|
|
37
|
+
function incrementAtPath(obj, path, increment) {
|
|
38
|
+
const currentValue = getAtPath(obj, path, 0);
|
|
39
|
+
const newValue = currentValue + increment;
|
|
40
|
+
return setAtPath(obj, path, newValue);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/managers/QueryCacheManager/QueryCache.manager.ts
|
|
44
|
+
var QueryCacheManager = class {
|
|
45
|
+
constructor(config) {
|
|
46
|
+
this.config = {
|
|
47
|
+
...config,
|
|
48
|
+
keyExtractor: config.keyExtractor || ((item) => item.id)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Get items array from data
|
|
53
|
+
* Returns empty array if path doesn't exist or data is null
|
|
54
|
+
*/
|
|
55
|
+
getItems(data) {
|
|
56
|
+
if (!data) return [];
|
|
57
|
+
if (!this.config.itemsPath) {
|
|
58
|
+
return Array.isArray(data) ? data : [];
|
|
59
|
+
}
|
|
60
|
+
const items = getAtPath(data, this.config.itemsPath, []);
|
|
61
|
+
return Array.isArray(items) ? items : [];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Set items array in data
|
|
65
|
+
* Creates nested structure if it doesn't exist
|
|
66
|
+
*/
|
|
67
|
+
setItems(data, items) {
|
|
68
|
+
if (!data) {
|
|
69
|
+
if (this.config.initialData) {
|
|
70
|
+
data = this.config.initialData;
|
|
71
|
+
} else {
|
|
72
|
+
if (!this.config.itemsPath) {
|
|
73
|
+
return items;
|
|
74
|
+
}
|
|
75
|
+
data = {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!this.config.itemsPath) {
|
|
79
|
+
return items;
|
|
80
|
+
}
|
|
81
|
+
return setAtPath(data, this.config.itemsPath, items);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Update pagination metadata after adding items
|
|
85
|
+
*/
|
|
86
|
+
updatePaginationOnAdd(data, addedCount) {
|
|
87
|
+
if (!this.config.pagination) return data;
|
|
88
|
+
let result = data;
|
|
89
|
+
if (this.config.pagination.totalElementsPath) {
|
|
90
|
+
result = incrementAtPath(
|
|
91
|
+
result,
|
|
92
|
+
this.config.pagination.totalElementsPath,
|
|
93
|
+
addedCount
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (this.config.pagination.totalPagesPath && this.config.pagination.pageSizePath && this.config.pagination.totalElementsPath) {
|
|
97
|
+
const pageSize = getAtPath(result, this.config.pagination.pageSizePath, 0);
|
|
98
|
+
const totalElements = getAtPath(
|
|
99
|
+
result,
|
|
100
|
+
this.config.pagination.totalElementsPath,
|
|
101
|
+
0
|
|
102
|
+
);
|
|
103
|
+
if (pageSize > 0) {
|
|
104
|
+
const totalPages = Math.ceil(totalElements / pageSize);
|
|
105
|
+
result = setAtPath(
|
|
106
|
+
result,
|
|
107
|
+
this.config.pagination.totalPagesPath,
|
|
108
|
+
totalPages
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Update pagination metadata after removing items
|
|
116
|
+
*/
|
|
117
|
+
updatePaginationOnRemove(data, removedCount) {
|
|
118
|
+
if (!this.config.pagination) return data;
|
|
119
|
+
let result = data;
|
|
120
|
+
if (this.config.pagination.totalElementsPath) {
|
|
121
|
+
result = incrementAtPath(
|
|
122
|
+
result,
|
|
123
|
+
this.config.pagination.totalElementsPath,
|
|
124
|
+
-removedCount
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (this.config.pagination.totalPagesPath && this.config.pagination.pageSizePath && this.config.pagination.totalElementsPath) {
|
|
128
|
+
const pageSize = getAtPath(result, this.config.pagination.pageSizePath, 0);
|
|
129
|
+
const totalElements = getAtPath(
|
|
130
|
+
result,
|
|
131
|
+
this.config.pagination.totalElementsPath,
|
|
132
|
+
0
|
|
133
|
+
);
|
|
134
|
+
if (pageSize > 0) {
|
|
135
|
+
const totalPages = Math.ceil(Math.max(0, totalElements) / pageSize);
|
|
136
|
+
result = setAtPath(
|
|
137
|
+
result,
|
|
138
|
+
this.config.pagination.totalPagesPath,
|
|
139
|
+
totalPages
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Add item to cache
|
|
147
|
+
*
|
|
148
|
+
* @param newItem - The item to add
|
|
149
|
+
* @param position - Where to add: 'start' or 'end'
|
|
150
|
+
*/
|
|
151
|
+
add(newItem, position = "start") {
|
|
152
|
+
try {
|
|
153
|
+
this.config.queryClient.setQueryData(this.config.queryKey, (oldData) => {
|
|
154
|
+
const items = this.getItems(oldData);
|
|
155
|
+
const updatedItems = position === "start" ? [newItem, ...items] : [...items, newItem];
|
|
156
|
+
let result = this.setItems(oldData, updatedItems);
|
|
157
|
+
result = this.updatePaginationOnAdd(result, 1);
|
|
158
|
+
return result;
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error("[QueryCacheManager] Add failed:", error);
|
|
162
|
+
this.invalidate();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Update existing item
|
|
167
|
+
*
|
|
168
|
+
* @param updatedItem - Partial item data to update
|
|
169
|
+
* @param matcher - Optional custom matcher function. Defaults to matching by key
|
|
170
|
+
*/
|
|
171
|
+
update(updatedItem, matcher) {
|
|
172
|
+
try {
|
|
173
|
+
this.config.queryClient.setQueryData(this.config.queryKey, (oldData) => {
|
|
174
|
+
const items = this.getItems(oldData);
|
|
175
|
+
const matchFn = matcher || ((item) => this.config.keyExtractor(item) === this.config.keyExtractor(updatedItem));
|
|
176
|
+
const updatedItems = items.map(
|
|
177
|
+
(item) => matchFn(item) ? { ...item, ...updatedItem } : item
|
|
178
|
+
);
|
|
179
|
+
return this.setItems(oldData, updatedItems);
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.error("[QueryCacheManager] Update failed:", error);
|
|
183
|
+
this.invalidate();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Remove item from cache
|
|
188
|
+
*
|
|
189
|
+
* @param itemOrId - Item object or ID to remove
|
|
190
|
+
* @param matcher - Optional custom matcher function. Defaults to matching by key
|
|
191
|
+
*/
|
|
192
|
+
delete(itemOrId, matcher) {
|
|
193
|
+
try {
|
|
194
|
+
this.config.queryClient.setQueryData(this.config.queryKey, (oldData) => {
|
|
195
|
+
const items = this.getItems(oldData);
|
|
196
|
+
const matchFn = matcher || ((item) => {
|
|
197
|
+
if (typeof itemOrId === "object") {
|
|
198
|
+
return this.config.keyExtractor(item) === this.config.keyExtractor(itemOrId);
|
|
199
|
+
}
|
|
200
|
+
return this.config.keyExtractor(item) === itemOrId;
|
|
201
|
+
});
|
|
202
|
+
const originalLength = items.length;
|
|
203
|
+
const updatedItems = items.filter((item) => !matchFn(item));
|
|
204
|
+
const removedCount = originalLength - updatedItems.length;
|
|
205
|
+
let result = this.setItems(oldData, updatedItems);
|
|
206
|
+
if (removedCount > 0) {
|
|
207
|
+
result = this.updatePaginationOnRemove(result, removedCount);
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.error("[QueryCacheManager] Delete failed:", error);
|
|
213
|
+
this.invalidate();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Replace full data
|
|
218
|
+
*
|
|
219
|
+
* @param newData - Complete new data to replace cache
|
|
220
|
+
*/
|
|
221
|
+
replace(newData) {
|
|
222
|
+
try {
|
|
223
|
+
this.config.queryClient.setQueryData(this.config.queryKey, newData);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
console.error("[QueryCacheManager] Replace failed:", error);
|
|
226
|
+
this.invalidate();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Clear all items (keeps structure, empties array)
|
|
231
|
+
*/
|
|
232
|
+
clear() {
|
|
233
|
+
try {
|
|
234
|
+
this.config.queryClient.setQueryData(this.config.queryKey, (oldData) => {
|
|
235
|
+
let result = this.setItems(oldData, []);
|
|
236
|
+
if (this.config.pagination?.totalElementsPath) {
|
|
237
|
+
result = setAtPath(result, this.config.pagination.totalElementsPath, 0);
|
|
238
|
+
}
|
|
239
|
+
if (this.config.pagination?.totalPagesPath) {
|
|
240
|
+
result = setAtPath(result, this.config.pagination.totalPagesPath, 0);
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
});
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.error("[QueryCacheManager] Clear failed:", error);
|
|
246
|
+
this.invalidate();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Get current items from cache
|
|
251
|
+
*
|
|
252
|
+
* @returns Current items array or empty array if no data
|
|
253
|
+
*/
|
|
254
|
+
getItemsFromCache() {
|
|
255
|
+
const data = this.config.queryClient.getQueryData(this.config.queryKey);
|
|
256
|
+
return this.getItems(data);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Get full data from cache
|
|
260
|
+
*
|
|
261
|
+
* @returns Current full data or undefined if no data
|
|
262
|
+
*/
|
|
263
|
+
getDataFromCache() {
|
|
264
|
+
return this.config.queryClient.getQueryData(this.config.queryKey);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Invalidate query to trigger refetch
|
|
268
|
+
*/
|
|
269
|
+
invalidate() {
|
|
270
|
+
this.config.queryClient.invalidateQueries({ queryKey: this.config.queryKey });
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Get handlers for use with mutations
|
|
274
|
+
*
|
|
275
|
+
* @returns Object with onAdd, onUpdate, onDelete handlers
|
|
276
|
+
*/
|
|
277
|
+
createHandlers() {
|
|
278
|
+
return {
|
|
279
|
+
onAdd: (newItem, position) => this.add(newItem, position),
|
|
280
|
+
onUpdate: (updatedItem, matcher) => this.update(updatedItem, matcher),
|
|
281
|
+
onDelete: (itemOrId, matcher) => this.delete(itemOrId, matcher)
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
exports.QueryCacheManager = QueryCacheManager;
|
|
287
|
+
Object.keys(reactQuery).forEach(function (k) {
|
|
288
|
+
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
|
|
289
|
+
enumerable: true,
|
|
290
|
+
get: function () { return reactQuery[k]; }
|
|
291
|
+
});
|
|
292
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
export * from '@tanstack/react-query';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
|
|
5
|
+
// src/managers/QueryCacheManager/QueryCache.utils.ts
|
|
6
|
+
function getAtPath(obj, path, defaultValue) {
|
|
7
|
+
if (!obj || !path) return defaultValue;
|
|
8
|
+
const keys = path.split(".");
|
|
9
|
+
let result = obj;
|
|
10
|
+
for (const key of keys) {
|
|
11
|
+
if (result === null || result === void 0) {
|
|
12
|
+
return defaultValue;
|
|
13
|
+
}
|
|
14
|
+
result = result[key];
|
|
15
|
+
}
|
|
16
|
+
return result !== void 0 ? result : defaultValue;
|
|
17
|
+
}
|
|
18
|
+
function setAtPath(obj, path, value) {
|
|
19
|
+
if (!path) return obj;
|
|
20
|
+
const keys = path.split(".");
|
|
21
|
+
const root = obj ? { ...obj } : {};
|
|
22
|
+
let current = root;
|
|
23
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
24
|
+
const key = keys[i];
|
|
25
|
+
if (!current[key] || typeof current[key] !== "object") {
|
|
26
|
+
current[key] = {};
|
|
27
|
+
} else {
|
|
28
|
+
current[key] = { ...current[key] };
|
|
29
|
+
}
|
|
30
|
+
current = current[key];
|
|
31
|
+
}
|
|
32
|
+
current[keys[keys.length - 1]] = value;
|
|
33
|
+
return root;
|
|
34
|
+
}
|
|
35
|
+
function incrementAtPath(obj, path, increment) {
|
|
36
|
+
const currentValue = getAtPath(obj, path, 0);
|
|
37
|
+
const newValue = currentValue + increment;
|
|
38
|
+
return setAtPath(obj, path, newValue);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/managers/QueryCacheManager/QueryCache.manager.ts
|
|
42
|
+
var QueryCacheManager = class {
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this.config = {
|
|
45
|
+
...config,
|
|
46
|
+
keyExtractor: config.keyExtractor || ((item) => item.id)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get items array from data
|
|
51
|
+
* Returns empty array if path doesn't exist or data is null
|
|
52
|
+
*/
|
|
53
|
+
getItems(data) {
|
|
54
|
+
if (!data) return [];
|
|
55
|
+
if (!this.config.itemsPath) {
|
|
56
|
+
return Array.isArray(data) ? data : [];
|
|
57
|
+
}
|
|
58
|
+
const items = getAtPath(data, this.config.itemsPath, []);
|
|
59
|
+
return Array.isArray(items) ? items : [];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Set items array in data
|
|
63
|
+
* Creates nested structure if it doesn't exist
|
|
64
|
+
*/
|
|
65
|
+
setItems(data, items) {
|
|
66
|
+
if (!data) {
|
|
67
|
+
if (this.config.initialData) {
|
|
68
|
+
data = this.config.initialData;
|
|
69
|
+
} else {
|
|
70
|
+
if (!this.config.itemsPath) {
|
|
71
|
+
return items;
|
|
72
|
+
}
|
|
73
|
+
data = {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!this.config.itemsPath) {
|
|
77
|
+
return items;
|
|
78
|
+
}
|
|
79
|
+
return setAtPath(data, this.config.itemsPath, items);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Update pagination metadata after adding items
|
|
83
|
+
*/
|
|
84
|
+
updatePaginationOnAdd(data, addedCount) {
|
|
85
|
+
if (!this.config.pagination) return data;
|
|
86
|
+
let result = data;
|
|
87
|
+
if (this.config.pagination.totalElementsPath) {
|
|
88
|
+
result = incrementAtPath(
|
|
89
|
+
result,
|
|
90
|
+
this.config.pagination.totalElementsPath,
|
|
91
|
+
addedCount
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (this.config.pagination.totalPagesPath && this.config.pagination.pageSizePath && this.config.pagination.totalElementsPath) {
|
|
95
|
+
const pageSize = getAtPath(result, this.config.pagination.pageSizePath, 0);
|
|
96
|
+
const totalElements = getAtPath(
|
|
97
|
+
result,
|
|
98
|
+
this.config.pagination.totalElementsPath,
|
|
99
|
+
0
|
|
100
|
+
);
|
|
101
|
+
if (pageSize > 0) {
|
|
102
|
+
const totalPages = Math.ceil(totalElements / pageSize);
|
|
103
|
+
result = setAtPath(
|
|
104
|
+
result,
|
|
105
|
+
this.config.pagination.totalPagesPath,
|
|
106
|
+
totalPages
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Update pagination metadata after removing items
|
|
114
|
+
*/
|
|
115
|
+
updatePaginationOnRemove(data, removedCount) {
|
|
116
|
+
if (!this.config.pagination) return data;
|
|
117
|
+
let result = data;
|
|
118
|
+
if (this.config.pagination.totalElementsPath) {
|
|
119
|
+
result = incrementAtPath(
|
|
120
|
+
result,
|
|
121
|
+
this.config.pagination.totalElementsPath,
|
|
122
|
+
-removedCount
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (this.config.pagination.totalPagesPath && this.config.pagination.pageSizePath && this.config.pagination.totalElementsPath) {
|
|
126
|
+
const pageSize = getAtPath(result, this.config.pagination.pageSizePath, 0);
|
|
127
|
+
const totalElements = getAtPath(
|
|
128
|
+
result,
|
|
129
|
+
this.config.pagination.totalElementsPath,
|
|
130
|
+
0
|
|
131
|
+
);
|
|
132
|
+
if (pageSize > 0) {
|
|
133
|
+
const totalPages = Math.ceil(Math.max(0, totalElements) / pageSize);
|
|
134
|
+
result = setAtPath(
|
|
135
|
+
result,
|
|
136
|
+
this.config.pagination.totalPagesPath,
|
|
137
|
+
totalPages
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Add item to cache
|
|
145
|
+
*
|
|
146
|
+
* @param newItem - The item to add
|
|
147
|
+
* @param position - Where to add: 'start' or 'end'
|
|
148
|
+
*/
|
|
149
|
+
add(newItem, position = "start") {
|
|
150
|
+
try {
|
|
151
|
+
this.config.queryClient.setQueryData(this.config.queryKey, (oldData) => {
|
|
152
|
+
const items = this.getItems(oldData);
|
|
153
|
+
const updatedItems = position === "start" ? [newItem, ...items] : [...items, newItem];
|
|
154
|
+
let result = this.setItems(oldData, updatedItems);
|
|
155
|
+
result = this.updatePaginationOnAdd(result, 1);
|
|
156
|
+
return result;
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error("[QueryCacheManager] Add failed:", error);
|
|
160
|
+
this.invalidate();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Update existing item
|
|
165
|
+
*
|
|
166
|
+
* @param updatedItem - Partial item data to update
|
|
167
|
+
* @param matcher - Optional custom matcher function. Defaults to matching by key
|
|
168
|
+
*/
|
|
169
|
+
update(updatedItem, matcher) {
|
|
170
|
+
try {
|
|
171
|
+
this.config.queryClient.setQueryData(this.config.queryKey, (oldData) => {
|
|
172
|
+
const items = this.getItems(oldData);
|
|
173
|
+
const matchFn = matcher || ((item) => this.config.keyExtractor(item) === this.config.keyExtractor(updatedItem));
|
|
174
|
+
const updatedItems = items.map(
|
|
175
|
+
(item) => matchFn(item) ? { ...item, ...updatedItem } : item
|
|
176
|
+
);
|
|
177
|
+
return this.setItems(oldData, updatedItems);
|
|
178
|
+
});
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error("[QueryCacheManager] Update failed:", error);
|
|
181
|
+
this.invalidate();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Remove item from cache
|
|
186
|
+
*
|
|
187
|
+
* @param itemOrId - Item object or ID to remove
|
|
188
|
+
* @param matcher - Optional custom matcher function. Defaults to matching by key
|
|
189
|
+
*/
|
|
190
|
+
delete(itemOrId, matcher) {
|
|
191
|
+
try {
|
|
192
|
+
this.config.queryClient.setQueryData(this.config.queryKey, (oldData) => {
|
|
193
|
+
const items = this.getItems(oldData);
|
|
194
|
+
const matchFn = matcher || ((item) => {
|
|
195
|
+
if (typeof itemOrId === "object") {
|
|
196
|
+
return this.config.keyExtractor(item) === this.config.keyExtractor(itemOrId);
|
|
197
|
+
}
|
|
198
|
+
return this.config.keyExtractor(item) === itemOrId;
|
|
199
|
+
});
|
|
200
|
+
const originalLength = items.length;
|
|
201
|
+
const updatedItems = items.filter((item) => !matchFn(item));
|
|
202
|
+
const removedCount = originalLength - updatedItems.length;
|
|
203
|
+
let result = this.setItems(oldData, updatedItems);
|
|
204
|
+
if (removedCount > 0) {
|
|
205
|
+
result = this.updatePaginationOnRemove(result, removedCount);
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error("[QueryCacheManager] Delete failed:", error);
|
|
211
|
+
this.invalidate();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Replace full data
|
|
216
|
+
*
|
|
217
|
+
* @param newData - Complete new data to replace cache
|
|
218
|
+
*/
|
|
219
|
+
replace(newData) {
|
|
220
|
+
try {
|
|
221
|
+
this.config.queryClient.setQueryData(this.config.queryKey, newData);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error("[QueryCacheManager] Replace failed:", error);
|
|
224
|
+
this.invalidate();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Clear all items (keeps structure, empties array)
|
|
229
|
+
*/
|
|
230
|
+
clear() {
|
|
231
|
+
try {
|
|
232
|
+
this.config.queryClient.setQueryData(this.config.queryKey, (oldData) => {
|
|
233
|
+
let result = this.setItems(oldData, []);
|
|
234
|
+
if (this.config.pagination?.totalElementsPath) {
|
|
235
|
+
result = setAtPath(result, this.config.pagination.totalElementsPath, 0);
|
|
236
|
+
}
|
|
237
|
+
if (this.config.pagination?.totalPagesPath) {
|
|
238
|
+
result = setAtPath(result, this.config.pagination.totalPagesPath, 0);
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error("[QueryCacheManager] Clear failed:", error);
|
|
244
|
+
this.invalidate();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Get current items from cache
|
|
249
|
+
*
|
|
250
|
+
* @returns Current items array or empty array if no data
|
|
251
|
+
*/
|
|
252
|
+
getItemsFromCache() {
|
|
253
|
+
const data = this.config.queryClient.getQueryData(this.config.queryKey);
|
|
254
|
+
return this.getItems(data);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Get full data from cache
|
|
258
|
+
*
|
|
259
|
+
* @returns Current full data or undefined if no data
|
|
260
|
+
*/
|
|
261
|
+
getDataFromCache() {
|
|
262
|
+
return this.config.queryClient.getQueryData(this.config.queryKey);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Invalidate query to trigger refetch
|
|
266
|
+
*/
|
|
267
|
+
invalidate() {
|
|
268
|
+
this.config.queryClient.invalidateQueries({ queryKey: this.config.queryKey });
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Get handlers for use with mutations
|
|
272
|
+
*
|
|
273
|
+
* @returns Object with onAdd, onUpdate, onDelete handlers
|
|
274
|
+
*/
|
|
275
|
+
createHandlers() {
|
|
276
|
+
return {
|
|
277
|
+
onAdd: (newItem, position) => this.add(newItem, position),
|
|
278
|
+
onUpdate: (updatedItem, matcher) => this.update(updatedItem, matcher),
|
|
279
|
+
onDelete: (itemOrId, matcher) => this.delete(itemOrId, matcher)
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
export { QueryCacheManager };
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tanstack-cacher",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A lightweight cache management utility for TanStack Query that simplifies adding, updating, deleting, and synchronizing cached data",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/hacagahasanli/tanstack-cacher"
|
|
9
|
+
},
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "Hasanli Hajagha",
|
|
12
|
+
"email": "hacagahasanli@gmail.com",
|
|
13
|
+
"url": "https://github.com/hacagahasanli"
|
|
14
|
+
},
|
|
15
|
+
"maintainers": [
|
|
16
|
+
{
|
|
17
|
+
"name": "Hasanli Hajagha",
|
|
18
|
+
"email": "hacagahasanli@gmail.com",
|
|
19
|
+
"web": "http://github.com/hacagahasanli"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"homepage": "https://github.com/hacagahasanli/tanstack-cacher",
|
|
23
|
+
"keywords": [
|
|
24
|
+
"react",
|
|
25
|
+
"react-query",
|
|
26
|
+
"tanstack-query",
|
|
27
|
+
"cache",
|
|
28
|
+
"cache-manager",
|
|
29
|
+
"optimistic-updates"
|
|
30
|
+
],
|
|
31
|
+
"main": "./dist/index.js",
|
|
32
|
+
"module": "./dist/index.mjs",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"import": "./dist/index.mjs",
|
|
38
|
+
"require": "./dist/index.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=16"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"dev": "tsup --watch",
|
|
52
|
+
"lint": "eslint src --ext .ts,.tsx",
|
|
53
|
+
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
|
54
|
+
"type-check": "tsc --noEmit",
|
|
55
|
+
"prepublishOnly": "npm run build",
|
|
56
|
+
"test": "echo \"No tests yet\" && exit 0"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@tanstack/react-query": "^5.62.11"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/react": "^18.3.12",
|
|
66
|
+
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
67
|
+
"@typescript-eslint/parser": "^7.18.0",
|
|
68
|
+
"eslint": "^8.57.1",
|
|
69
|
+
"eslint-config-prettier": "^9.1.0",
|
|
70
|
+
"eslint-plugin-prettier": "^5.2.1",
|
|
71
|
+
"prettier": "^3.4.2",
|
|
72
|
+
"react": "^18.3.1",
|
|
73
|
+
"tsup": "^8.3.5",
|
|
74
|
+
"typescript": "^5.7.2"
|
|
75
|
+
}
|
|
76
|
+
}
|