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.
Files changed (32) hide show
  1. package/README.md +2261 -0
  2. package/package.json +18 -0
  3. package/packages/client/package.json +47 -0
  4. package/packages/client/src/configure.ts +34 -0
  5. package/packages/client/src/index.ts +4 -0
  6. package/packages/client/src/services/base-api.service.ts +26 -0
  7. package/packages/client/src/services/resource-api.service.ts +117 -0
  8. package/packages/client/src/utils/handle-api-error.ts +20 -0
  9. package/packages/client/tsconfig.json +13 -0
  10. package/packages/client/tsup.config.ts +10 -0
  11. package/packages/core/package.json +41 -0
  12. package/packages/core/src/id.ts +19 -0
  13. package/packages/core/src/index.ts +4 -0
  14. package/packages/core/src/string-to-id.ts +22 -0
  15. package/packages/core/src/types/index.ts +86 -0
  16. package/packages/core/src/utils/catch-error.ts +20 -0
  17. package/packages/core/tsconfig.json +13 -0
  18. package/packages/core/tsup.config.ts +9 -0
  19. package/packages/react/package.json +80 -0
  20. package/packages/react/src/hooks/createResourceHooks.ts +872 -0
  21. package/packages/react/src/hooks/useAlertMessage.ts +45 -0
  22. package/packages/react/src/hooks/useAsyncState.ts +110 -0
  23. package/packages/react/src/hooks/useCallTimer.ts +37 -0
  24. package/packages/react/src/hooks/useModal.ts +71 -0
  25. package/packages/react/src/hooks/usePagination.ts +57 -0
  26. package/packages/react/src/index.ts +43 -0
  27. package/packages/react/src/routing/createRouteContract.ts +483 -0
  28. package/packages/react/src/socket/createSocketHooks.ts +351 -0
  29. package/packages/react/tsconfig.json +14 -0
  30. package/packages/react/tsup.config.ts +10 -0
  31. package/pnpm-workspace.yaml +2 -0
  32. 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
+ }