void-snippets-monorepo 0.1.1
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/README.md +2261 -0
- package/package.json +18 -0
- package/packages/client/package.json +47 -0
- package/packages/client/src/configure.ts +34 -0
- package/packages/client/src/index.ts +4 -0
- package/packages/client/src/services/base-api.service.ts +26 -0
- package/packages/client/src/services/resource-api.service.ts +117 -0
- package/packages/client/src/utils/handle-api-error.ts +20 -0
- package/packages/client/tsconfig.json +13 -0
- package/packages/client/tsup.config.ts +10 -0
- package/packages/core/package.json +41 -0
- package/packages/core/src/id.ts +19 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/string-to-id.ts +22 -0
- package/packages/core/src/types/index.ts +86 -0
- package/packages/core/src/utils/catch-error.ts +20 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/core/tsup.config.ts +9 -0
- package/packages/react/package.json +80 -0
- package/packages/react/src/hooks/createResourceHooks.ts +872 -0
- package/packages/react/src/hooks/useAlertMessage.ts +45 -0
- package/packages/react/src/hooks/useAsyncState.ts +110 -0
- package/packages/react/src/hooks/useCallTimer.ts +37 -0
- package/packages/react/src/hooks/useModal.ts +71 -0
- package/packages/react/src/hooks/usePagination.ts +57 -0
- package/packages/react/src/index.ts +43 -0
- package/packages/react/src/routing/createRouteContract.ts +483 -0
- package/packages/react/src/socket/createSocketHooks.ts +351 -0
- package/packages/react/tsconfig.json +14 -0
- package/packages/react/tsup.config.ts +10 -0
- package/pnpm-workspace.yaml +2 -0
- package/tsconfig.base.json +12 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useInfiniteQuery,
|
|
3
|
+
useMutation,
|
|
4
|
+
useQuery,
|
|
5
|
+
useQueryClient,
|
|
6
|
+
type InfiniteData,
|
|
7
|
+
type QueryClient,
|
|
8
|
+
type QueryKey,
|
|
9
|
+
} from "@tanstack/react-query";
|
|
10
|
+
import type { ResourceService } from "@void-snippets/client";
|
|
11
|
+
import type {
|
|
12
|
+
VSAdapters,
|
|
13
|
+
VSListResult,
|
|
14
|
+
VSPagination,
|
|
15
|
+
VSQueryParams,
|
|
16
|
+
VSDefaultPaginatedResponse,
|
|
17
|
+
VSDefaultSingleResponse,
|
|
18
|
+
} from "@void-snippets/core";
|
|
19
|
+
import { createDefaultAdapters } from "@void-snippets/core";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// TYPE HELPERS
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
const DEFAULT_PAGINATION: VSQueryParams = { page: 1, limit: 10 };
|
|
26
|
+
|
|
27
|
+
interface WithResourceTypes {
|
|
28
|
+
readonly __types: {
|
|
29
|
+
id: unknown;
|
|
30
|
+
base: unknown;
|
|
31
|
+
detail: unknown;
|
|
32
|
+
create: unknown;
|
|
33
|
+
update: unknown;
|
|
34
|
+
listRaw: unknown;
|
|
35
|
+
singleRaw: unknown;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Id<S extends WithResourceTypes> = S["__types"]["id"];
|
|
40
|
+
type Base<S extends WithResourceTypes> = S["__types"]["base"];
|
|
41
|
+
type Detail<S extends WithResourceTypes> = S["__types"]["detail"];
|
|
42
|
+
type Create<S extends WithResourceTypes> = S["__types"]["create"];
|
|
43
|
+
type Update<S extends WithResourceTypes> = S["__types"]["update"];
|
|
44
|
+
type ListRaw<S extends WithResourceTypes> = S["__types"]["listRaw"];
|
|
45
|
+
type SingleRaw<S extends WithResourceTypes> = S["__types"]["singleRaw"];
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// RETURN TYPES
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
export interface VSUseListReturn<TBase> {
|
|
52
|
+
list: TBase[];
|
|
53
|
+
pagination: VSPagination;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* True only on the very first fetch when there is no cached data yet.
|
|
57
|
+
* Use this to render a full-page spinner or skeleton.
|
|
58
|
+
* Does NOT become true during background refetches — for that see `isRefetching`.
|
|
59
|
+
*/
|
|
60
|
+
isLoading: boolean;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* True during any fetch in progress — initial load or background refetch.
|
|
64
|
+
* Use for a subtle progress indicator that doesn't blank out the list.
|
|
65
|
+
*/
|
|
66
|
+
isFetching: boolean;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True during a background refetch while cached data is already present.
|
|
70
|
+
* Equivalent to `isFetching && !isLoading`.
|
|
71
|
+
* Use for a lightweight "Refreshing…" badge overlaid on the existing list.
|
|
72
|
+
*/
|
|
73
|
+
isRefetching: boolean;
|
|
74
|
+
|
|
75
|
+
/** True when the last fetch attempt resulted in an error. */
|
|
76
|
+
isError: boolean;
|
|
77
|
+
error: Error | null;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Re-runs this specific query. Wire to a retry button in your error state.
|
|
81
|
+
* Narrower than `invalidate` — only refetches this exact param variant.
|
|
82
|
+
*/
|
|
83
|
+
refetch: () => Promise<unknown>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Marks all active queries under this resource prefix as stale and
|
|
87
|
+
* triggers a background refetch. Broader than `refetch` — affects every
|
|
88
|
+
* mounted param variant of this resource simultaneously.
|
|
89
|
+
*/
|
|
90
|
+
invalidate: () => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface VSUseGetReturn<TDetail> {
|
|
94
|
+
item: TDetail | undefined;
|
|
95
|
+
|
|
96
|
+
/** True only on the first fetch when no cached data exists. */
|
|
97
|
+
isLoading: boolean;
|
|
98
|
+
|
|
99
|
+
/** True during any fetch in progress. */
|
|
100
|
+
isFetching: boolean;
|
|
101
|
+
|
|
102
|
+
/** True during a background refetch while cached data is already present. */
|
|
103
|
+
isRefetching: boolean;
|
|
104
|
+
|
|
105
|
+
/** True when the last fetch attempt resulted in an error. */
|
|
106
|
+
isError: boolean;
|
|
107
|
+
error: Error | null;
|
|
108
|
+
|
|
109
|
+
/** Re-runs this query. */
|
|
110
|
+
refetch: () => Promise<unknown>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ============================================================================
|
|
114
|
+
// OPTIMISTIC TYPES
|
|
115
|
+
// ============================================================================
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The operation context passed to `onError` and `onSuccess` callbacks.
|
|
119
|
+
* Discriminate by `kind` to handle each mutation type differently.
|
|
120
|
+
*/
|
|
121
|
+
export type VSOptimisticOperation<TId, TCreate, TUpdate> =
|
|
122
|
+
| { kind: "create"; payload: TCreate; tempId: string }
|
|
123
|
+
| { kind: "update"; _id: TId; payload: TUpdate }
|
|
124
|
+
| { kind: "remove"; _id: TId };
|
|
125
|
+
|
|
126
|
+
export interface VSOptimisticHandlers<
|
|
127
|
+
TBase,
|
|
128
|
+
TId,
|
|
129
|
+
TUpdate,
|
|
130
|
+
TCreate = Partial<TBase>,
|
|
131
|
+
TDetail = TBase
|
|
132
|
+
> {
|
|
133
|
+
/**
|
|
134
|
+
* Optimistically transforms the list when `update.mutate()` fires.
|
|
135
|
+
* `_id` is the mutation target — it is **separate** from `payload`.
|
|
136
|
+
* Applied to every active `useList` and `useInfinite` cache.
|
|
137
|
+
* The `useGet` cache is shallow-merged automatically; override with `updateSingle`.
|
|
138
|
+
* Return a new array — never mutate `cache` in place.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* update: (cache, { _id, payload }) =>
|
|
142
|
+
* cache.map(item => item._id === _id ? { ...item, ...payload } : item)
|
|
143
|
+
*/
|
|
144
|
+
update?: (cache: TBase[], args: { _id: TId; payload: TUpdate }) => TBase[];
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Overrides the default `{ ...current, ...payload }` shallow-merge for the
|
|
148
|
+
* `useGet` cache. Only needed when `TDetail` has nested objects requiring
|
|
149
|
+
* a deep merge. Ignored if `update` is not also provided.
|
|
150
|
+
*/
|
|
151
|
+
updateSingle?: (current: TDetail, payload: TUpdate) => TDetail;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Optimistically removes an item when `remove.mutate()` fires.
|
|
155
|
+
* `totalDocuments` / `totalPages` are patched automatically from the diff.
|
|
156
|
+
* The matching `useGet` entry is staled (keeps showing data until confirmed).
|
|
157
|
+
* Return a new array — never mutate `cache` in place.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* remove: (cache, id) => cache.filter(item => item._id !== id)
|
|
161
|
+
*/
|
|
162
|
+
remove?: (cache: TBase[], id: TId) => TBase[];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Optimistically inserts an item when `create.mutate()` fires.
|
|
166
|
+
* Applied to every `useList` cache and the **first page** of every
|
|
167
|
+
* `useInfinite` cache. `totalDocuments` / `totalPages` are patched automatically.
|
|
168
|
+
* `tempId` is a library-generated UUID — use it to set `_id` on the item.
|
|
169
|
+
* Return a new array — never mutate `cache` in place.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* create: (cache, { payload, tempId }) => [
|
|
173
|
+
* { ...payload, _id: tempId as Contact.Id },
|
|
174
|
+
* ...cache,
|
|
175
|
+
* ]
|
|
176
|
+
*/
|
|
177
|
+
create?: (cache: TBase[], args: { payload: TCreate; tempId: string }) => TBase[];
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Called after the optimistic rollback completes when a mutation fails.
|
|
181
|
+
* The cache is already restored to the correct state when this fires.
|
|
182
|
+
* Use for resource-level error notifications across all call sites.
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* onError: (error, operation) => {
|
|
186
|
+
* toast.error(`Failed to ${operation.kind} item: ${error.message}`)
|
|
187
|
+
* }
|
|
188
|
+
*/
|
|
189
|
+
onError?: (
|
|
190
|
+
error: Error,
|
|
191
|
+
operation: VSOptimisticOperation<TId, TCreate, TUpdate>,
|
|
192
|
+
) => void;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Called after `effectiveBase` has been advanced for this operation.
|
|
196
|
+
* Fires once per successfully confirmed mutation, before the final
|
|
197
|
+
* invalidation when all pending operations have settled.
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* onSuccess: (operation) => {
|
|
201
|
+
* if (operation.kind === 'create') analytics.track('item_created')
|
|
202
|
+
* }
|
|
203
|
+
*/
|
|
204
|
+
onSuccess?: (operation: VSOptimisticOperation<TId, TCreate, TUpdate>) => void;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ============================================================================
|
|
208
|
+
// OPTIONS
|
|
209
|
+
// ============================================================================
|
|
210
|
+
|
|
211
|
+
export interface VSResourceHooksOptions<
|
|
212
|
+
TListRaw,
|
|
213
|
+
TBase,
|
|
214
|
+
TSingleRaw,
|
|
215
|
+
TDetail,
|
|
216
|
+
TId = unknown,
|
|
217
|
+
TUpdate = unknown,
|
|
218
|
+
TCreate = unknown
|
|
219
|
+
> {
|
|
220
|
+
adapters?: VSAdapters<TListRaw, TBase, TSingleRaw, TDetail>;
|
|
221
|
+
defaultParams?: VSQueryParams;
|
|
222
|
+
optimistic?: VSOptimisticHandlers<TBase, TId, TUpdate, TCreate, TDetail>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ============================================================================
|
|
226
|
+
// INTERNAL: OPTIMISTIC STACK
|
|
227
|
+
// ============================================================================
|
|
228
|
+
|
|
229
|
+
type PendingOp<TId, TUpdate, TCreate> =
|
|
230
|
+
| { id: symbol; kind: "update"; _id: TId; payload: TUpdate }
|
|
231
|
+
| { id: symbol; kind: "remove"; _id: TId }
|
|
232
|
+
| { id: symbol; kind: "create"; payload: TCreate; tempId: string };
|
|
233
|
+
|
|
234
|
+
interface OptimisticStack<TBase, TId, TUpdate, TCreate, TDetail> {
|
|
235
|
+
pendingOps: PendingOp<TId, TUpdate, TCreate>[];
|
|
236
|
+
effectiveBaseListSnapshots: [QueryKey, VSListResult<TBase> | undefined][];
|
|
237
|
+
effectiveBaseInfiniteSnapshots: [QueryKey, InfiniteData<VSListResult<TBase>> | undefined][];
|
|
238
|
+
effectiveBaseGet: Map<string, TDetail | undefined>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
type MutationContext = { operationId: symbol };
|
|
242
|
+
|
|
243
|
+
/** Strips the internal `id: symbol` field to produce the public operation shape. */
|
|
244
|
+
function toOperation<TId, TUpdate, TCreate>(
|
|
245
|
+
op: PendingOp<TId, TUpdate, TCreate>,
|
|
246
|
+
): VSOptimisticOperation<TId, TCreate, TUpdate> {
|
|
247
|
+
if (op.kind === "create") return { kind: "create", payload: op.payload, tempId: op.tempId };
|
|
248
|
+
if (op.kind === "update") return { kind: "update", _id: op._id, payload: op.payload };
|
|
249
|
+
return { kind: "remove", _id: op._id };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ============================================================================
|
|
253
|
+
// INTERNAL: STACK STORAGE
|
|
254
|
+
// WeakMap<QueryClient> → no leaks on client destruction, SSR-safe isolation
|
|
255
|
+
// ============================================================================
|
|
256
|
+
|
|
257
|
+
const _stacks = new WeakMap<
|
|
258
|
+
object,
|
|
259
|
+
Map<string, OptimisticStack<any, any, any, any, any>>
|
|
260
|
+
>();
|
|
261
|
+
|
|
262
|
+
function getStack<TBase, TId, TUpdate, TCreate, TDetail>(
|
|
263
|
+
client: QueryClient,
|
|
264
|
+
prefix: string,
|
|
265
|
+
): OptimisticStack<TBase, TId, TUpdate, TCreate, TDetail> {
|
|
266
|
+
if (!_stacks.has(client)) _stacks.set(client, new Map());
|
|
267
|
+
const map = _stacks.get(client)!;
|
|
268
|
+
if (!map.has(prefix)) {
|
|
269
|
+
map.set(prefix, {
|
|
270
|
+
pendingOps: [],
|
|
271
|
+
effectiveBaseListSnapshots: [],
|
|
272
|
+
effectiveBaseInfiniteSnapshots: [],
|
|
273
|
+
effectiveBaseGet: new Map(),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return map.get(prefix)! as OptimisticStack<TBase, TId, TUpdate, TCreate, TDetail>;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ============================================================================
|
|
280
|
+
// INTERNAL: QUERY KEY DISCRIMINATOR
|
|
281
|
+
// useList → [prefix, VSQueryParams] key[1] is a plain object
|
|
282
|
+
// useGet → [prefix, TId] key[1] is a string
|
|
283
|
+
// useInfinite→ [prefix, "INFINITE", …] length > 2
|
|
284
|
+
// ============================================================================
|
|
285
|
+
|
|
286
|
+
function isListQueryKey(query: { queryKey: readonly unknown[] }): boolean {
|
|
287
|
+
const key = query.queryKey;
|
|
288
|
+
return (
|
|
289
|
+
key.length === 2 &&
|
|
290
|
+
key[1] !== null &&
|
|
291
|
+
typeof key[1] === "object" &&
|
|
292
|
+
!Array.isArray(key[1])
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ============================================================================
|
|
297
|
+
// INTERNAL: TEMP ID GENERATOR
|
|
298
|
+
// ============================================================================
|
|
299
|
+
|
|
300
|
+
function generateTempId(): string {
|
|
301
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
302
|
+
return crypto.randomUUID();
|
|
303
|
+
}
|
|
304
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ============================================================================
|
|
308
|
+
// INTERNAL: PAGINATION PATCH
|
|
309
|
+
// ============================================================================
|
|
310
|
+
|
|
311
|
+
function patchPagination(pagination: VSPagination, delta: number): VSPagination {
|
|
312
|
+
if (delta === 0) return pagination;
|
|
313
|
+
const newTotal = Math.max(0, pagination.totalDocuments + delta);
|
|
314
|
+
return {
|
|
315
|
+
...pagination,
|
|
316
|
+
totalDocuments: newTotal,
|
|
317
|
+
totalPages: Math.ceil(newTotal / pagination.limit),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ============================================================================
|
|
322
|
+
// INTERNAL: CACHE APPLICATION HELPERS
|
|
323
|
+
// ============================================================================
|
|
324
|
+
|
|
325
|
+
function applyUpdateToAllCaches<TBase, TId, TUpdate>(
|
|
326
|
+
client: QueryClient,
|
|
327
|
+
prefix: string,
|
|
328
|
+
_id: TId,
|
|
329
|
+
payload: TUpdate,
|
|
330
|
+
handler: (cache: TBase[], args: { _id: TId; payload: TUpdate }) => TBase[],
|
|
331
|
+
): void {
|
|
332
|
+
client.setQueriesData<VSListResult<TBase>>(
|
|
333
|
+
{ queryKey: [prefix], predicate: isListQueryKey as any },
|
|
334
|
+
(old) => old ? { ...old, items: handler(old.items, { _id, payload }) } : old,
|
|
335
|
+
);
|
|
336
|
+
client.setQueriesData<InfiniteData<VSListResult<TBase>>>(
|
|
337
|
+
{ queryKey: [prefix, "INFINITE"] },
|
|
338
|
+
(old) =>
|
|
339
|
+
old
|
|
340
|
+
? { ...old, pages: old.pages.map((p) => ({ ...p, items: handler(p.items, { _id, payload }) })) }
|
|
341
|
+
: old,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function applyRemoveFromAllCaches<TBase, TId>(
|
|
346
|
+
client: QueryClient,
|
|
347
|
+
prefix: string,
|
|
348
|
+
_id: TId,
|
|
349
|
+
handler: (cache: TBase[], id: TId) => TBase[],
|
|
350
|
+
): void {
|
|
351
|
+
client.setQueriesData<VSListResult<TBase>>(
|
|
352
|
+
{ queryKey: [prefix], predicate: isListQueryKey as any },
|
|
353
|
+
(old) => {
|
|
354
|
+
if (!old) return old;
|
|
355
|
+
const newItems = handler(old.items, _id);
|
|
356
|
+
return { ...old, items: newItems, pagination: patchPagination(old.pagination, newItems.length - old.items.length) };
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
client.setQueriesData<InfiniteData<VSListResult<TBase>>>(
|
|
360
|
+
{ queryKey: [prefix, "INFINITE"] },
|
|
361
|
+
(old) =>
|
|
362
|
+
old
|
|
363
|
+
? {
|
|
364
|
+
...old,
|
|
365
|
+
pages: old.pages.map((p) => {
|
|
366
|
+
const newItems = handler(p.items, _id);
|
|
367
|
+
return { ...p, items: newItems, pagination: patchPagination(p.pagination, newItems.length - p.items.length) };
|
|
368
|
+
}),
|
|
369
|
+
}
|
|
370
|
+
: old,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function applyCreateToAllCaches<TBase, TCreate>(
|
|
375
|
+
client: QueryClient,
|
|
376
|
+
prefix: string,
|
|
377
|
+
payload: TCreate,
|
|
378
|
+
tempId: string,
|
|
379
|
+
handler: (cache: TBase[], args: { payload: TCreate; tempId: string }) => TBase[],
|
|
380
|
+
): void {
|
|
381
|
+
client.setQueriesData<VSListResult<TBase>>(
|
|
382
|
+
{ queryKey: [prefix], predicate: isListQueryKey as any },
|
|
383
|
+
(old) => {
|
|
384
|
+
if (!old) return old;
|
|
385
|
+
const newItems = handler(old.items, { payload, tempId });
|
|
386
|
+
return { ...old, items: newItems, pagination: patchPagination(old.pagination, newItems.length - old.items.length) };
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
// First page only — new items belong at the top of the feed, not duplicated
|
|
390
|
+
client.setQueriesData<InfiniteData<VSListResult<TBase>>>(
|
|
391
|
+
{ queryKey: [prefix, "INFINITE"] },
|
|
392
|
+
(old) => {
|
|
393
|
+
if (!old) return old;
|
|
394
|
+
return {
|
|
395
|
+
...old,
|
|
396
|
+
pages: old.pages.map((p, i) => {
|
|
397
|
+
if (i !== 0) return p;
|
|
398
|
+
const newItems = handler(p.items, { payload, tempId });
|
|
399
|
+
return { ...p, items: newItems, pagination: patchPagination(p.pagination, newItems.length - p.items.length) };
|
|
400
|
+
}),
|
|
401
|
+
};
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ============================================================================
|
|
407
|
+
// INTERNAL: EFFECTIVE BASE OPERATIONS
|
|
408
|
+
// ============================================================================
|
|
409
|
+
|
|
410
|
+
function restoreEffectiveBase<TBase, TId, TUpdate, TCreate, TDetail>(
|
|
411
|
+
client: QueryClient,
|
|
412
|
+
prefix: string,
|
|
413
|
+
stack: OptimisticStack<TBase, TId, TUpdate, TCreate, TDetail>,
|
|
414
|
+
): void {
|
|
415
|
+
stack.effectiveBaseListSnapshots.forEach(([key, data]) => client.setQueryData(key, data));
|
|
416
|
+
stack.effectiveBaseInfiniteSnapshots.forEach(([key, data]) => client.setQueryData(key, data));
|
|
417
|
+
stack.effectiveBaseGet.forEach((data, idStr) => client.setQueryData([prefix, idStr], data));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function advanceEffectiveBase<TBase, TId, TUpdate, TCreate, TDetail>(
|
|
421
|
+
stack: OptimisticStack<TBase, TId, TUpdate, TCreate, TDetail>,
|
|
422
|
+
op: PendingOp<TId, TUpdate, TCreate>,
|
|
423
|
+
optimistic: VSOptimisticHandlers<TBase, TId, TUpdate, TCreate, TDetail>,
|
|
424
|
+
): void {
|
|
425
|
+
if (op.kind === "update" && optimistic.update) {
|
|
426
|
+
stack.effectiveBaseListSnapshots = stack.effectiveBaseListSnapshots.map(([key, data]) =>
|
|
427
|
+
data ? [key, { ...data, items: optimistic.update!(data.items, { _id: op._id, payload: op.payload }) }] : [key, data],
|
|
428
|
+
);
|
|
429
|
+
stack.effectiveBaseInfiniteSnapshots = stack.effectiveBaseInfiniteSnapshots.map(([key, data]) =>
|
|
430
|
+
data
|
|
431
|
+
? [key, { ...data, pages: data.pages.map((p) => ({ ...p, items: optimistic.update!(p.items, { _id: op._id, payload: op.payload }) })) }]
|
|
432
|
+
: [key, data],
|
|
433
|
+
);
|
|
434
|
+
const baseEntry = stack.effectiveBaseGet.get(String(op._id));
|
|
435
|
+
if (baseEntry !== undefined) {
|
|
436
|
+
const advanced = optimistic.updateSingle
|
|
437
|
+
? optimistic.updateSingle(baseEntry, op.payload)
|
|
438
|
+
: ({ ...baseEntry, ...(op.payload as object) } as TDetail);
|
|
439
|
+
stack.effectiveBaseGet.set(String(op._id), advanced);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (op.kind === "remove" && optimistic.remove) {
|
|
445
|
+
stack.effectiveBaseListSnapshots = stack.effectiveBaseListSnapshots.map(([key, data]) => {
|
|
446
|
+
if (!data) return [key, data];
|
|
447
|
+
const newItems = optimistic.remove!(data.items, op._id);
|
|
448
|
+
return [key, { ...data, items: newItems, pagination: patchPagination(data.pagination, newItems.length - data.items.length) }];
|
|
449
|
+
});
|
|
450
|
+
stack.effectiveBaseInfiniteSnapshots = stack.effectiveBaseInfiniteSnapshots.map(([key, data]) =>
|
|
451
|
+
data
|
|
452
|
+
? [
|
|
453
|
+
key,
|
|
454
|
+
{
|
|
455
|
+
...data,
|
|
456
|
+
pages: data.pages.map((p) => {
|
|
457
|
+
const newItems = optimistic.remove!(p.items, op._id);
|
|
458
|
+
return { ...p, items: newItems, pagination: patchPagination(p.pagination, newItems.length - p.items.length) };
|
|
459
|
+
}),
|
|
460
|
+
},
|
|
461
|
+
]
|
|
462
|
+
: [key, data],
|
|
463
|
+
);
|
|
464
|
+
stack.effectiveBaseGet.delete(String(op._id));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// create: effectiveBase intentionally not advanced.
|
|
468
|
+
// The temp item is replaced by server data when all ops settle and invalidation fires.
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function replayPendingOps<TBase, TId, TUpdate, TCreate, TDetail>(
|
|
472
|
+
client: QueryClient,
|
|
473
|
+
prefix: string,
|
|
474
|
+
stack: OptimisticStack<TBase, TId, TUpdate, TCreate, TDetail>,
|
|
475
|
+
optimistic: VSOptimisticHandlers<TBase, TId, TUpdate, TCreate, TDetail>,
|
|
476
|
+
): void {
|
|
477
|
+
for (const op of stack.pendingOps) {
|
|
478
|
+
if (op.kind === "update" && optimistic.update) {
|
|
479
|
+
applyUpdateToAllCaches(client, prefix, op._id, op.payload, optimistic.update);
|
|
480
|
+
const current = client.getQueryData<TDetail>([prefix, String(op._id)]);
|
|
481
|
+
if (current !== undefined) {
|
|
482
|
+
const updated = optimistic.updateSingle
|
|
483
|
+
? optimistic.updateSingle(current, op.payload)
|
|
484
|
+
: ({ ...current, ...(op.payload as object) } as TDetail);
|
|
485
|
+
client.setQueryData([prefix, String(op._id)], updated);
|
|
486
|
+
}
|
|
487
|
+
} else if (op.kind === "remove" && optimistic.remove) {
|
|
488
|
+
applyRemoveFromAllCaches(client, prefix, op._id, optimistic.remove);
|
|
489
|
+
client.invalidateQueries({ queryKey: [prefix, String(op._id)], refetchType: "none" });
|
|
490
|
+
} else if (op.kind === "create" && optimistic.create) {
|
|
491
|
+
applyCreateToAllCaches(client, prefix, op.payload, op.tempId, optimistic.create);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function flushStack<TBase, TId, TUpdate, TCreate, TDetail>(
|
|
497
|
+
client: QueryClient,
|
|
498
|
+
prefix: string,
|
|
499
|
+
stack: OptimisticStack<TBase, TId, TUpdate, TCreate, TDetail>,
|
|
500
|
+
): void {
|
|
501
|
+
stack.pendingOps = [];
|
|
502
|
+
stack.effectiveBaseListSnapshots = [];
|
|
503
|
+
stack.effectiveBaseInfiniteSnapshots = [];
|
|
504
|
+
stack.effectiveBaseGet.clear();
|
|
505
|
+
client.invalidateQueries({ queryKey: [prefix] });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ============================================================================
|
|
509
|
+
// INTERNAL: SHARED MUTATION LIFECYCLE HANDLERS
|
|
510
|
+
// ============================================================================
|
|
511
|
+
|
|
512
|
+
function handleOptimisticError<TBase, TId, TUpdate, TCreate, TDetail>(
|
|
513
|
+
client: QueryClient,
|
|
514
|
+
prefix: string,
|
|
515
|
+
error: Error,
|
|
516
|
+
context: MutationContext | undefined,
|
|
517
|
+
optimistic: VSOptimisticHandlers<TBase, TId, TUpdate, TCreate, TDetail>,
|
|
518
|
+
): void {
|
|
519
|
+
if (!context) return;
|
|
520
|
+
|
|
521
|
+
const stack = getStack<TBase, TId, TUpdate, TCreate, TDetail>(client, prefix);
|
|
522
|
+
|
|
523
|
+
// Find the failed op before removing it — needed for the onError callback
|
|
524
|
+
const failedOp = stack.pendingOps.find((op) => op.id === context.operationId);
|
|
525
|
+
|
|
526
|
+
stack.pendingOps = stack.pendingOps.filter((op) => op.id !== context.operationId);
|
|
527
|
+
restoreEffectiveBase(client, prefix, stack);
|
|
528
|
+
replayPendingOps(client, prefix, stack, optimistic);
|
|
529
|
+
|
|
530
|
+
// Fire AFTER rollback — cache is consistent when developer code runs.
|
|
531
|
+
// Wrapped in try/catch so a throwing user callback cannot corrupt internal state.
|
|
532
|
+
if (failedOp && optimistic.onError) {
|
|
533
|
+
try {
|
|
534
|
+
optimistic.onError(error, toOperation(failedOp));
|
|
535
|
+
} catch {
|
|
536
|
+
// Swallow — developer callbacks must not interrupt library lifecycle
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function handleOptimisticSettled<TBase, TId, TUpdate, TCreate, TDetail>(
|
|
542
|
+
client: QueryClient,
|
|
543
|
+
prefix: string,
|
|
544
|
+
error: Error | null,
|
|
545
|
+
context: MutationContext | undefined,
|
|
546
|
+
optimistic: VSOptimisticHandlers<TBase, TId, TUpdate, TCreate, TDetail>,
|
|
547
|
+
): void {
|
|
548
|
+
// onMutate threw before returning — fall back to plain invalidation
|
|
549
|
+
if (!context) {
|
|
550
|
+
client.invalidateQueries({ queryKey: [prefix] });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const stack = getStack<TBase, TId, TUpdate, TCreate, TDetail>(client, prefix);
|
|
555
|
+
|
|
556
|
+
if (!error) {
|
|
557
|
+
// Success path: advance the confirmed floor before removing the op
|
|
558
|
+
const settledOp = stack.pendingOps.find((op) => op.id === context.operationId);
|
|
559
|
+
if (settledOp) {
|
|
560
|
+
advanceEffectiveBase(stack, settledOp, optimistic);
|
|
561
|
+
|
|
562
|
+
// Fire onSuccess after advance — developer sees the confirmed cache state.
|
|
563
|
+
if (optimistic.onSuccess) {
|
|
564
|
+
try {
|
|
565
|
+
optimistic.onSuccess(toOperation(settledOp));
|
|
566
|
+
} catch {
|
|
567
|
+
// Swallow — developer callbacks must not interrupt library lifecycle
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Remove this op — idempotent on the error path since onError already removed it
|
|
574
|
+
stack.pendingOps = stack.pendingOps.filter((op) => op.id !== context.operationId);
|
|
575
|
+
|
|
576
|
+
if (stack.pendingOps.length === 0) {
|
|
577
|
+
// All mutations settled — sync with server now
|
|
578
|
+
flushStack(client, prefix, stack);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ============================================================================
|
|
583
|
+
// FACTORY
|
|
584
|
+
// ============================================================================
|
|
585
|
+
|
|
586
|
+
export function createResourceHooks<K extends string, S extends WithResourceTypes>(
|
|
587
|
+
queryKeyPrefix: K,
|
|
588
|
+
apiService: S,
|
|
589
|
+
options: VSResourceHooksOptions<
|
|
590
|
+
ListRaw<S>, Base<S>, SingleRaw<S>, Detail<S>, Id<S>, Update<S>, Create<S>
|
|
591
|
+
> = {},
|
|
592
|
+
) {
|
|
593
|
+
const service = apiService as unknown as ResourceService<
|
|
594
|
+
Id<S>, Base<S>, Detail<S>, Create<S>, Update<S>, ListRaw<S>, SingleRaw<S>
|
|
595
|
+
>;
|
|
596
|
+
|
|
597
|
+
const {
|
|
598
|
+
adapters = createDefaultAdapters<Base<S>, Detail<S>>() as VSAdapters<
|
|
599
|
+
VSDefaultPaginatedResponse<Base<S>>, Base<S>, VSDefaultSingleResponse<Detail<S>>, Detail<S>
|
|
600
|
+
> as VSAdapters<ListRaw<S>, Base<S>, SingleRaw<S>, Detail<S>>,
|
|
601
|
+
defaultParams = DEFAULT_PAGINATION,
|
|
602
|
+
optimistic,
|
|
603
|
+
} = options;
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
// -------------------------------------------------------------------------
|
|
607
|
+
// useList
|
|
608
|
+
// -------------------------------------------------------------------------
|
|
609
|
+
useList: (params: VSQueryParams = defaultParams): VSUseListReturn<Base<S>> => {
|
|
610
|
+
const queryClient = useQueryClient();
|
|
611
|
+
|
|
612
|
+
const query = useQuery<VSListResult<Base<S>>, Error>({
|
|
613
|
+
queryKey: [queryKeyPrefix, params],
|
|
614
|
+
queryFn: async () => {
|
|
615
|
+
const raw = await service.list(params);
|
|
616
|
+
return adapters.fromList(raw as ListRaw<S>);
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
list: query.data?.items ?? [],
|
|
622
|
+
pagination: query.data?.pagination ?? {
|
|
623
|
+
page: 1,
|
|
624
|
+
limit: defaultParams.limit ?? 10,
|
|
625
|
+
totalPages: 0,
|
|
626
|
+
totalDocuments: 0,
|
|
627
|
+
},
|
|
628
|
+
isLoading: query.isLoading,
|
|
629
|
+
isFetching: query.isFetching,
|
|
630
|
+
isRefetching: query.isRefetching,
|
|
631
|
+
isError: query.isError,
|
|
632
|
+
error: query.error,
|
|
633
|
+
refetch: query.refetch,
|
|
634
|
+
invalidate: () => queryClient.invalidateQueries({ queryKey: [queryKeyPrefix] }),
|
|
635
|
+
};
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
// -------------------------------------------------------------------------
|
|
639
|
+
// useGet
|
|
640
|
+
// -------------------------------------------------------------------------
|
|
641
|
+
useGet: (id: Id<S>, staleTime = 30_000): VSUseGetReturn<Detail<S>> => {
|
|
642
|
+
const query = useQuery<Detail<S>, Error>({
|
|
643
|
+
queryKey: [queryKeyPrefix, id],
|
|
644
|
+
queryFn: async () => {
|
|
645
|
+
const raw = await service.get(id);
|
|
646
|
+
return adapters.fromSingle(raw as SingleRaw<S>);
|
|
647
|
+
},
|
|
648
|
+
enabled: id !== undefined && id !== null && id !== "",
|
|
649
|
+
staleTime,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
item: query.data,
|
|
654
|
+
isLoading: query.isLoading,
|
|
655
|
+
isFetching: query.isFetching,
|
|
656
|
+
isRefetching: query.isRefetching,
|
|
657
|
+
isError: query.isError,
|
|
658
|
+
error: query.error,
|
|
659
|
+
refetch: query.refetch,
|
|
660
|
+
};
|
|
661
|
+
},
|
|
662
|
+
|
|
663
|
+
// -------------------------------------------------------------------------
|
|
664
|
+
// useMutations
|
|
665
|
+
// -------------------------------------------------------------------------
|
|
666
|
+
useMutations: () => {
|
|
667
|
+
const queryClient = useQueryClient();
|
|
668
|
+
const invalidate = () => queryClient.invalidateQueries({ queryKey: [queryKeyPrefix] });
|
|
669
|
+
|
|
670
|
+
// Synchronously captures base snapshots the moment the first operation
|
|
671
|
+
// fires. Called AFTER cancelQueries so the snapshot is always stable.
|
|
672
|
+
function captureBaseIfFirstOp(
|
|
673
|
+
stack: OptimisticStack<Base<S>, Id<S>, Update<S>, Create<S>, Detail<S>>,
|
|
674
|
+
): void {
|
|
675
|
+
if (stack.pendingOps.length === 0) {
|
|
676
|
+
stack.effectiveBaseListSnapshots = queryClient.getQueriesData<VSListResult<Base<S>>>({
|
|
677
|
+
queryKey: [queryKeyPrefix],
|
|
678
|
+
predicate: isListQueryKey as any,
|
|
679
|
+
});
|
|
680
|
+
stack.effectiveBaseInfiniteSnapshots = queryClient.getQueriesData<InfiniteData<VSListResult<Base<S>>>>({
|
|
681
|
+
queryKey: [queryKeyPrefix, "INFINITE"],
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// -----------------------------------------------------------------------
|
|
687
|
+
// createMutation
|
|
688
|
+
// -----------------------------------------------------------------------
|
|
689
|
+
const createMutation = useMutation<Detail<S>, Error, Create<S>, MutationContext | undefined>({
|
|
690
|
+
mutationFn: async (payload) => {
|
|
691
|
+
const raw = await service.create(payload);
|
|
692
|
+
return adapters.fromSingle(raw as SingleRaw<S>);
|
|
693
|
+
},
|
|
694
|
+
onMutate: async (payload) => {
|
|
695
|
+
if (!optimistic?.create) return undefined;
|
|
696
|
+
|
|
697
|
+
// 1. Cancel first — prevents in-flight refetches from overwriting
|
|
698
|
+
// the optimistic write after it lands.
|
|
699
|
+
await queryClient.cancelQueries({ queryKey: [queryKeyPrefix] });
|
|
700
|
+
|
|
701
|
+
const stack = getStack<Base<S>, Id<S>, Update<S>, Create<S>, Detail<S>>(
|
|
702
|
+
queryClient, queryKeyPrefix,
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
// 2. Snapshot the now-stable cache (no fetches in flight).
|
|
706
|
+
captureBaseIfFirstOp(stack);
|
|
707
|
+
|
|
708
|
+
// 3. Register the operation.
|
|
709
|
+
const tempId = generateTempId();
|
|
710
|
+
const op: PendingOp<Id<S>, Update<S>, Create<S>> = {
|
|
711
|
+
id: Symbol(), kind: "create", payload, tempId,
|
|
712
|
+
};
|
|
713
|
+
stack.pendingOps.push(op);
|
|
714
|
+
|
|
715
|
+
// 4. Apply — fully synchronous from here, no race window.
|
|
716
|
+
try {
|
|
717
|
+
applyCreateToAllCaches(queryClient, queryKeyPrefix, payload, tempId, optimistic.create);
|
|
718
|
+
} catch {
|
|
719
|
+
stack.pendingOps = stack.pendingOps.filter((o) => o.id !== op.id);
|
|
720
|
+
throw new Error("[@void-snippets/react] Optimistic create setup failed.");
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return { operationId: op.id };
|
|
724
|
+
},
|
|
725
|
+
onError: (_err, _vars, context) => {
|
|
726
|
+
if (!optimistic?.create) return;
|
|
727
|
+
handleOptimisticError(queryClient, queryKeyPrefix, _err, context, optimistic);
|
|
728
|
+
},
|
|
729
|
+
onSettled: (_data, error, _vars, context) => {
|
|
730
|
+
if (!optimistic?.create) { invalidate(); return; }
|
|
731
|
+
handleOptimisticSettled(queryClient, queryKeyPrefix, error ?? null, context, optimistic);
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// -----------------------------------------------------------------------
|
|
736
|
+
// updateMutation
|
|
737
|
+
// -----------------------------------------------------------------------
|
|
738
|
+
const updateMutation = useMutation<
|
|
739
|
+
Detail<S>, Error, { _id: Id<S>; payload: Update<S> }, MutationContext | undefined
|
|
740
|
+
>({
|
|
741
|
+
mutationFn: async ({ _id, payload }) => {
|
|
742
|
+
const raw = await service.update(_id, payload);
|
|
743
|
+
return adapters.fromSingle(raw as SingleRaw<S>);
|
|
744
|
+
},
|
|
745
|
+
onMutate: async ({ _id, payload }) => {
|
|
746
|
+
if (!optimistic?.update) return undefined;
|
|
747
|
+
|
|
748
|
+
await queryClient.cancelQueries({ queryKey: [queryKeyPrefix] });
|
|
749
|
+
|
|
750
|
+
const stack = getStack<Base<S>, Id<S>, Update<S>, Create<S>, Detail<S>>(
|
|
751
|
+
queryClient, queryKeyPrefix,
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
captureBaseIfFirstOp(stack);
|
|
755
|
+
|
|
756
|
+
const currentSingle = queryClient.getQueryData<Detail<S>>([queryKeyPrefix, String(_id)]);
|
|
757
|
+
if (currentSingle !== undefined) {
|
|
758
|
+
stack.effectiveBaseGet.set(String(_id), currentSingle);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const op: PendingOp<Id<S>, Update<S>, Create<S>> = {
|
|
762
|
+
id: Symbol(), kind: "update", _id, payload,
|
|
763
|
+
};
|
|
764
|
+
stack.pendingOps.push(op);
|
|
765
|
+
|
|
766
|
+
try {
|
|
767
|
+
applyUpdateToAllCaches(queryClient, queryKeyPrefix, _id, payload, optimistic.update);
|
|
768
|
+
|
|
769
|
+
if (currentSingle !== undefined) {
|
|
770
|
+
const updated = optimistic.updateSingle
|
|
771
|
+
? optimistic.updateSingle(currentSingle, payload)
|
|
772
|
+
: ({ ...currentSingle, ...(payload as object) } as Detail<S>);
|
|
773
|
+
queryClient.setQueryData([queryKeyPrefix, String(_id)], updated);
|
|
774
|
+
}
|
|
775
|
+
} catch {
|
|
776
|
+
stack.pendingOps = stack.pendingOps.filter((o) => o.id !== op.id);
|
|
777
|
+
throw new Error("[@void-snippets/react] Optimistic update setup failed.");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return { operationId: op.id };
|
|
781
|
+
},
|
|
782
|
+
onError: (_err, _vars, context) => {
|
|
783
|
+
if (!optimistic?.update) return;
|
|
784
|
+
handleOptimisticError(queryClient, queryKeyPrefix, _err, context, optimistic);
|
|
785
|
+
},
|
|
786
|
+
onSettled: (_data, error, _vars, context) => {
|
|
787
|
+
if (!optimistic?.update) { invalidate(); return; }
|
|
788
|
+
handleOptimisticSettled(queryClient, queryKeyPrefix, error ?? null, context, optimistic);
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// -----------------------------------------------------------------------
|
|
793
|
+
// removeMutation
|
|
794
|
+
// -----------------------------------------------------------------------
|
|
795
|
+
const removeMutation = useMutation<Detail<S>, Error, Id<S>, MutationContext | undefined>({
|
|
796
|
+
mutationFn: async (_id) => {
|
|
797
|
+
const raw = await service.delete(_id);
|
|
798
|
+
return adapters.fromSingle(raw as SingleRaw<S>);
|
|
799
|
+
},
|
|
800
|
+
onMutate: async (_id) => {
|
|
801
|
+
if (!optimistic?.remove) return undefined;
|
|
802
|
+
|
|
803
|
+
await queryClient.cancelQueries({ queryKey: [queryKeyPrefix] });
|
|
804
|
+
|
|
805
|
+
const stack = getStack<Base<S>, Id<S>, Update<S>, Create<S>, Detail<S>>(
|
|
806
|
+
queryClient, queryKeyPrefix,
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
captureBaseIfFirstOp(stack);
|
|
810
|
+
|
|
811
|
+
const currentSingle = queryClient.getQueryData<Detail<S>>([queryKeyPrefix, String(_id)]);
|
|
812
|
+
if (currentSingle !== undefined) {
|
|
813
|
+
stack.effectiveBaseGet.set(String(_id), currentSingle);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const op: PendingOp<Id<S>, Update<S>, Create<S>> = {
|
|
817
|
+
id: Symbol(), kind: "remove", _id,
|
|
818
|
+
};
|
|
819
|
+
stack.pendingOps.push(op);
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
applyRemoveFromAllCaches(queryClient, queryKeyPrefix, _id, optimistic.remove);
|
|
823
|
+
|
|
824
|
+
if (currentSingle !== undefined) {
|
|
825
|
+
queryClient.invalidateQueries({
|
|
826
|
+
queryKey: [queryKeyPrefix, String(_id)],
|
|
827
|
+
refetchType: "none",
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
stack.pendingOps = stack.pendingOps.filter((o) => o.id !== op.id);
|
|
832
|
+
throw new Error("[@void-snippets/react] Optimistic remove setup failed.");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return { operationId: op.id };
|
|
836
|
+
},
|
|
837
|
+
onError: (_err, _vars, context) => {
|
|
838
|
+
if (!optimistic?.remove) return;
|
|
839
|
+
handleOptimisticError(queryClient, queryKeyPrefix, _err, context, optimistic);
|
|
840
|
+
},
|
|
841
|
+
onSettled: (_data, error, _vars, context) => {
|
|
842
|
+
if (!optimistic?.remove) { invalidate(); return; }
|
|
843
|
+
handleOptimisticSettled(queryClient, queryKeyPrefix, error ?? null, context, optimistic);
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
return { create: createMutation, update: updateMutation, remove: removeMutation };
|
|
848
|
+
},
|
|
849
|
+
|
|
850
|
+
// -------------------------------------------------------------------------
|
|
851
|
+
// useInfinite
|
|
852
|
+
// -------------------------------------------------------------------------
|
|
853
|
+
useInfinite: (params: VSQueryParams = defaultParams) => {
|
|
854
|
+
return useInfiniteQuery<VSListResult<Base<S>>, Error>({
|
|
855
|
+
queryKey: [queryKeyPrefix, "INFINITE", params],
|
|
856
|
+
queryFn: async ({ pageParam }) => {
|
|
857
|
+
const raw = await service.list({
|
|
858
|
+
...params,
|
|
859
|
+
page: pageParam as number,
|
|
860
|
+
limit: params.limit ?? 20,
|
|
861
|
+
});
|
|
862
|
+
return adapters.fromList(raw as ListRaw<S>);
|
|
863
|
+
},
|
|
864
|
+
getNextPageParam: (lastPage) => {
|
|
865
|
+
const { page, totalPages } = lastPage.pagination;
|
|
866
|
+
return page < totalPages ? page + 1 : undefined;
|
|
867
|
+
},
|
|
868
|
+
initialPageParam: 1,
|
|
869
|
+
});
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
}
|