suparisma 0.0.3 → 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.
@@ -0,0 +1,1490 @@
1
+ // THIS FILE IS AUTO-GENERATED - DO NOT EDIT DIRECTLY
2
+ // Edit the generator script instead: scripts/generate-realtime-hooks.ts
3
+
4
+ import { useEffect, useState, useCallback, useRef } from 'react';
5
+ // This import should be relative to its new location in utils/
6
+ import { supabase } from './supabase-client';
7
+
8
+ /**
9
+ * Represents a single search query against a field
10
+ * @example
11
+ * // Search for users with names containing "john"
12
+ * const query = { field: "name", value: "john" };
13
+ */
14
+ export type SearchQuery = {
15
+ /** The field name to search in */
16
+ field: string;
17
+ /** The search term/value to look for */
18
+ value: string;
19
+ };
20
+
21
+ // Define type for Supabase query builder
22
+ export type SupabaseQueryBuilder = ReturnType<ReturnType<typeof supabase.from>['select']>;
23
+
24
+ /**
25
+ * Advanced filter operators for complex queries
26
+ * @example
27
+ * // Users older than 21
28
+ * { age: { gt: 21 } }
29
+ *
30
+ * @example
31
+ * // Posts with titles containing "news"
32
+ * { title: { contains: "news" } }
33
+ */
34
+ export type FilterOperators<T> = {
35
+ /** Equal to value */
36
+ equals?: T;
37
+ /** Not equal to value */
38
+ not?: T;
39
+ /** Value is in the array */
40
+ in?: T[];
41
+ /** Value is not in the array */
42
+ notIn?: T[];
43
+ /** Less than value */
44
+ lt?: T;
45
+ /** Less than or equal to value */
46
+ lte?: T;
47
+ /** Greater than value */
48
+ gt?: T;
49
+ /** Greater than or equal to value */
50
+ gte?: T;
51
+ /** String contains value (case insensitive) */
52
+ contains?: string;
53
+ /** String starts with value (case insensitive) */
54
+ startsWith?: string;
55
+ /** String ends with value (case insensitive) */
56
+ endsWith?: string;
57
+ };
58
+
59
+ // Type for a single field in an advanced where filter
60
+ export type AdvancedWhereInput<T> = {
61
+ [K in keyof T]?: T[K] | FilterOperators<T[K]>;
62
+ };
63
+
64
+ /**
65
+ * Configuration options for the Suparisma hooks
66
+ * @example
67
+ * // Basic usage
68
+ * const { data } = useSuparismaUser();
69
+ *
70
+ * @example
71
+ * // With filtering
72
+ * const { data } = useSuparismaUser({
73
+ * where: { age: { gt: 21 } }
74
+ * });
75
+ *
76
+ * @example
77
+ * // With ordering and limits
78
+ * const { data } = useSuparismaUser({
79
+ * orderBy: { created_at: 'desc' },
80
+ * limit: 10
81
+ * });
82
+ */
83
+ export type SuparismaOptions<TWhereInput, TOrderByInput> = {
84
+ /** Whether to enable realtime updates (default: true) */
85
+ realtime?: boolean;
86
+ /** Custom channel name for realtime subscription */
87
+ channelName?: string;
88
+ /** Type-safe filter for queries and realtime events */
89
+ where?: TWhereInput;
90
+ /** Legacy string filter (use 'where' instead for type safety) */
91
+ realtimeFilter?: string;
92
+ /** Type-safe ordering for queries */
93
+ orderBy?: TOrderByInput;
94
+ /** Limit the number of records returned */
95
+ limit?: number;
96
+ /** Offset for pagination (skip records) */
97
+ offset?: number;
98
+ };
99
+
100
+ /**
101
+ * Return type for database operations
102
+ * @example
103
+ * const result = await users.create({ name: "John" });
104
+ * if (result.error) {
105
+ * console.error(result.error);
106
+ * } else {
107
+ * console.log(result.data);
108
+ * }
109
+ */
110
+ export type ModelResult<T> = Promise<{
111
+ data: T;
112
+ error: null;
113
+ } | {
114
+ data: null;
115
+ error: Error;
116
+ }>;
117
+
118
+ /**
119
+ * Complete search state and methods for searchable models
120
+ * @example
121
+ * // Search for users with name containing "john"
122
+ * users.search.addQuery({ field: "name", value: "john" });
123
+ *
124
+ * @example
125
+ * // Check if search is loading
126
+ * if (users.search.loading) {
127
+ * return <div>Searching...</div>;
128
+ * }
129
+ */
130
+ export type SearchState = {
131
+ /** Current active search queries */
132
+ queries: SearchQuery[];
133
+ /** Whether a search is currently in progress */
134
+ loading: boolean;
135
+ /** Replace all search queries with a new set */
136
+ setQueries: (queries: SearchQuery[]) => void;
137
+ /** Add a new search query (replaces existing query for same field) */
138
+ addQuery: (query: SearchQuery) => void;
139
+ /** Remove a search query by field name */
140
+ removeQuery: (field: string) => void;
141
+ /** Clear all search queries and return to normal data fetching */
142
+ clearQueries: () => void;
143
+ };
144
+
145
+ /**
146
+ * Convert a type-safe where filter to Supabase filter string
147
+ */
148
+ export function buildFilterString<T>(where?: T): string | undefined {
149
+ if (!where) return undefined;
150
+
151
+ const filters: string[] = [];
152
+
153
+ for (const [key, value] of Object.entries(where)) {
154
+ if (value !== undefined) {
155
+ if (typeof value === 'object' && value !== null) {
156
+ // Handle advanced operators
157
+ const advancedOps = value as unknown as FilterOperators<any>;
158
+
159
+ if ('equals' in advancedOps && advancedOps.equals !== undefined) {
160
+ filters.push(`${key}=eq.${advancedOps.equals}`);
161
+ }
162
+
163
+ if ('not' in advancedOps && advancedOps.not !== undefined) {
164
+ filters.push(`${key}=neq.${advancedOps.not}`);
165
+ }
166
+
167
+ if ('gt' in advancedOps && advancedOps.gt !== undefined) {
168
+ filters.push(`${key}=gt.${advancedOps.gt}`);
169
+ }
170
+
171
+ if ('gte' in advancedOps && advancedOps.gte !== undefined) {
172
+ filters.push(`${key}=gte.${advancedOps.gte}`);
173
+ }
174
+
175
+ if ('lt' in advancedOps && advancedOps.lt !== undefined) {
176
+ filters.push(`${key}=lt.${advancedOps.lt}`);
177
+ }
178
+
179
+ if ('lte' in advancedOps && advancedOps.lte !== undefined) {
180
+ filters.push(`${key}=lte.${advancedOps.lte}`);
181
+ }
182
+
183
+ if ('in' in advancedOps && advancedOps.in?.length) {
184
+ filters.push(`${key}=in.(${advancedOps.in.join(',')})`);
185
+ }
186
+
187
+ if ('contains' in advancedOps && advancedOps.contains !== undefined) {
188
+ filters.push(`${key}=ilike.*${advancedOps.contains}*`);
189
+ }
190
+
191
+ if ('startsWith' in advancedOps && advancedOps.startsWith !== undefined) {
192
+ filters.push(`${key}=ilike.${advancedOps.startsWith}%`);
193
+ }
194
+
195
+ if ('endsWith' in advancedOps && advancedOps.endsWith !== undefined) {
196
+ filters.push(`${key}=ilike.%${advancedOps.endsWith}`);
197
+ }
198
+ } else {
199
+ // Simple equality
200
+ filters.push(`${key}=eq.${value}`);
201
+ }
202
+ }
203
+ }
204
+
205
+ return filters.length > 0 ? filters.join(',') : undefined;
206
+ }
207
+
208
+ /**
209
+ * Apply filter to the query builder
210
+ */
211
+ export function applyFilter<T>(
212
+ query: SupabaseQueryBuilder,
213
+ where: T
214
+ ): SupabaseQueryBuilder {
215
+ if (!where) return query;
216
+
217
+ let filteredQuery = query;
218
+
219
+ // Apply each filter condition
220
+ for (const [key, value] of Object.entries(where)) {
221
+ if (value !== undefined) {
222
+ if (typeof value === 'object' && value !== null) {
223
+ // Handle advanced operators
224
+ const advancedOps = value as unknown as FilterOperators<any>;
225
+
226
+ if ('equals' in advancedOps && advancedOps.equals !== undefined) {
227
+ // @ts-ignore: Supabase typing issue
228
+ filteredQuery = filteredQuery.eq(key, advancedOps.equals);
229
+ }
230
+
231
+ if ('not' in advancedOps && advancedOps.not !== undefined) {
232
+ // @ts-ignore: Supabase typing issue
233
+ filteredQuery = filteredQuery.neq(key, advancedOps.not);
234
+ }
235
+
236
+ if ('gt' in advancedOps && advancedOps.gt !== undefined) {
237
+ // @ts-ignore: Supabase typing issue
238
+ filteredQuery = filteredQuery.gt(key, advancedOps.gt);
239
+ }
240
+
241
+ if ('gte' in advancedOps && advancedOps.gte !== undefined) {
242
+ // @ts-ignore: Supabase typing issue
243
+ filteredQuery = filteredQuery.gte(key, advancedOps.gte);
244
+ }
245
+
246
+ if ('lt' in advancedOps && advancedOps.lt !== undefined) {
247
+ // @ts-ignore: Supabase typing issue
248
+ filteredQuery = filteredQuery.lt(key, advancedOps.lt);
249
+ }
250
+
251
+ if ('lte' in advancedOps && advancedOps.lte !== undefined) {
252
+ // @ts-ignore: Supabase typing issue
253
+ filteredQuery = filteredQuery.lte(key, advancedOps.lte);
254
+ }
255
+
256
+ if ('in' in advancedOps && advancedOps.in?.length) {
257
+ // @ts-ignore: Supabase typing issue
258
+ filteredQuery = filteredQuery.in(key, advancedOps.in);
259
+ }
260
+
261
+ if ('contains' in advancedOps && advancedOps.contains !== undefined) {
262
+ // @ts-ignore: Supabase typing issue
263
+ filteredQuery = filteredQuery.ilike(key, `*${advancedOps.contains}*`);
264
+ }
265
+
266
+ if ('startsWith' in advancedOps && advancedOps.startsWith !== undefined) {
267
+ // @ts-ignore: Supabase typing issue
268
+ filteredQuery = filteredQuery.ilike(key, `${advancedOps.startsWith}%`);
269
+ }
270
+
271
+ if ('endsWith' in advancedOps && advancedOps.endsWith !== undefined) {
272
+ // @ts-ignore: Supabase typing issue
273
+ filteredQuery = filteredQuery.ilike(key, `%${advancedOps.endsWith}`);
274
+ }
275
+ } else {
276
+ // Simple equality
277
+ // @ts-ignore: Supabase typing issue
278
+ filteredQuery = filteredQuery.eq(key, value);
279
+ }
280
+ }
281
+ }
282
+
283
+ return filteredQuery;
284
+ }
285
+
286
+ /**
287
+ * Apply order by to the query builder
288
+ */
289
+ export function applyOrderBy<T>(
290
+ query: SupabaseQueryBuilder,
291
+ orderBy?: T,
292
+ hasCreatedAt?: boolean,
293
+ createdAtField: string = 'createdAt'
294
+ ): SupabaseQueryBuilder {
295
+ if (!orderBy) {
296
+ // By default, sort by createdAt if available, using the actual field name from Prisma
297
+ if (hasCreatedAt) {
298
+ // @ts-ignore: Supabase typing issue
299
+ return query.order(createdAtField, { ascending: false });
300
+ }
301
+ return query;
302
+ }
303
+
304
+ // Apply each order by clause
305
+ let orderedQuery = query;
306
+ for (const [key, direction] of Object.entries(orderBy)) {
307
+ // @ts-ignore: Supabase typing issue
308
+ orderedQuery = orderedQuery.order(key, {
309
+ ascending: direction === 'asc'
310
+ });
311
+ }
312
+
313
+ return orderedQuery;
314
+ }
315
+
316
+ /**
317
+ * Core hook factory function that creates a type-safe realtime hook for a specific model.
318
+ * This is the foundation for all Suparisma hooks.
319
+ */
320
+ export function createSuparismaHook<
321
+ TModel,
322
+ TWithRelations,
323
+ TCreateInput,
324
+ TUpdateInput,
325
+ TWhereInput,
326
+ TWhereUniqueInput,
327
+ TOrderByInput
328
+ >(config: {
329
+ tableName: string;
330
+ hasCreatedAt: boolean;
331
+ hasUpdatedAt: boolean;
332
+ searchFields?: string[];
333
+ defaultValues?: Record<string, string>;
334
+ createdAtField?: string;
335
+ updatedAtField?: string;
336
+ }) {
337
+ const {
338
+ tableName,
339
+ hasCreatedAt,
340
+ hasUpdatedAt,
341
+ searchFields = [],
342
+ defaultValues = {},
343
+ createdAtField = 'createdAt',
344
+ updatedAtField = 'updatedAt'
345
+ } = config;
346
+
347
+ /**
348
+ * The main hook function that provides all data access methods for a model.
349
+ *
350
+ * @param options - Optional configuration for data fetching, filtering, and realtime
351
+ *
352
+ * @returns An API object with data state and CRUD methods
353
+ *
354
+ * @example
355
+ * // Basic usage
356
+ * const users = useSuparismaUser();
357
+ * const { data, loading, error } = users;
358
+ *
359
+ * @example
360
+ * // With filtering
361
+ * const users = useSuparismaUser({
362
+ * where: { role: 'admin' },
363
+ * orderBy: { created_at: 'desc' }
364
+ * });
365
+ */
366
+ return function useSuparismaHook(options: SuparismaOptions<TWhereInput, TOrderByInput> = {}) {
367
+ const {
368
+ realtime = true,
369
+ channelName,
370
+ where,
371
+ realtimeFilter,
372
+ orderBy,
373
+ limit,
374
+ offset,
375
+ } = options;
376
+
377
+ // Compute the actual filter string from the type-safe where object or use legacy filter
378
+ const computedFilter = where ? buildFilterString(where) : realtimeFilter;
379
+
380
+ // Single data collection for holding results
381
+ const [data, setData] = useState<TWithRelations[]>([]);
382
+ const [error, setError] = useState<Error | null>(null);
383
+ const [loading, setLoading] = useState<boolean>(false);
384
+
385
+ // This is the total count, unaffected by pagination limits
386
+ const [count, setCount] = useState<number>(0);
387
+
388
+ // Search state
389
+ const [searchQueries, setSearchQueries] = useState<SearchQuery[]>([]);
390
+ const [searchLoading, setSearchLoading] = useState<boolean>(false);
391
+
392
+ const initialLoadRef = useRef(false);
393
+ const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
394
+ const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
395
+ const isSearchingRef = useRef<boolean>(false);
396
+
397
+ // Function to fetch the total count from Supabase with current filters
398
+ const fetchTotalCount = useCallback(async () => {
399
+ try {
400
+ // Skip count updates during search
401
+ if (isSearchingRef.current) return;
402
+
403
+ let countQuery = supabase.from(tableName).select('*', { count: 'exact', head: true });
404
+
405
+ // Apply where conditions if provided
406
+ if (where) {
407
+ countQuery = applyFilter(countQuery, where);
408
+ }
409
+
410
+ const { count: totalCount, error: countError } = await countQuery;
411
+
412
+ if (!countError) {
413
+ setCount(totalCount || 0);
414
+ }
415
+ } catch (err) {
416
+ console.error(`Error fetching count for ${tableName}:`, err);
417
+ }
418
+ }, [where, tableName]);
419
+
420
+ // Update total count whenever where filter changes
421
+ useEffect(() => {
422
+ fetchTotalCount();
423
+ }, [fetchTotalCount]);
424
+
425
+ // Create the search state object with all required methods
426
+ const search: SearchState = {
427
+ queries: searchQueries,
428
+ loading: searchLoading,
429
+
430
+ // Set all search queries at once
431
+ setQueries: useCallback((queries: SearchQuery[]) => {
432
+ // Validate that all fields are searchable
433
+ const validQueries = queries.filter(query =>
434
+ searchFields.includes(query.field) && query.value.trim() !== ''
435
+ );
436
+
437
+ setSearchQueries(validQueries);
438
+
439
+ // Execute search if there are valid queries
440
+ if (validQueries.length > 0) {
441
+ executeSearch(validQueries);
442
+ } else {
443
+ // If no valid queries, reset to normal data fetching
444
+ isSearchingRef.current = false;
445
+ findMany({ where, orderBy, take: limit, skip: offset });
446
+ }
447
+ }, [where, orderBy, limit, offset]),
448
+
449
+ // Add a single search query
450
+ addQuery: useCallback((query: SearchQuery) => {
451
+ // Validate that the field is searchable
452
+ if (!searchFields.includes(query.field) || query.value.trim() === '') {
453
+ return;
454
+ }
455
+
456
+ setSearchQueries(prev => {
457
+ // Replace if query for this field already exists, otherwise add
458
+ const exists = prev.some(q => q.field === query.field);
459
+ const newQueries = exists
460
+ ? prev.map(q => q.field === query.field ? query : q)
461
+ : [...prev, query];
462
+
463
+ // Execute search with updated queries
464
+ executeSearch(newQueries);
465
+
466
+ return newQueries;
467
+ });
468
+ }, []),
469
+
470
+ // Remove a search query by field
471
+ removeQuery: useCallback((field: string) => {
472
+ setSearchQueries(prev => {
473
+ const newQueries = prev.filter(q => q.field !== field);
474
+
475
+ // If we still have queries, execute search with remaining queries
476
+ if (newQueries.length > 0) {
477
+ executeSearch(newQueries);
478
+ } else {
479
+ // If no queries left, reset to normal data fetching
480
+ isSearchingRef.current = false;
481
+ findMany({ where, orderBy, take: limit, skip: offset });
482
+ }
483
+
484
+ return newQueries;
485
+ });
486
+ }, [where, orderBy, limit, offset]),
487
+
488
+ // Clear all search queries
489
+ clearQueries: useCallback(() => {
490
+ setSearchQueries([]);
491
+ isSearchingRef.current = false;
492
+ findMany({ where, orderBy, take: limit, skip: offset });
493
+ }, [where, orderBy, limit, offset])
494
+ };
495
+
496
+ // Execute search based on queries
497
+ const executeSearch = useCallback(async (queries: SearchQuery[]) => {
498
+ // Clear the previous timeout
499
+ if (searchTimeoutRef.current) {
500
+ clearTimeout(searchTimeoutRef.current);
501
+ }
502
+
503
+ // Skip if no searchable fields or no valid queries
504
+ if (searchFields.length === 0 || queries.length === 0) {
505
+ return;
506
+ }
507
+
508
+ setSearchLoading(true);
509
+ isSearchingRef.current = true;
510
+
511
+ // Use debounce to prevent rapid searches
512
+ searchTimeoutRef.current = setTimeout(async () => {
513
+ try {
514
+ let results: TWithRelations[] = [];
515
+
516
+ // Execute RPC function for each query using Promise.all
517
+ const searchPromises = queries.map(query => {
518
+ // Build function name: search_tablename_by_fieldname_prefix
519
+ const functionName = `search_${tableName}_by_${query.field}_prefix`;
520
+
521
+ // Call RPC function
522
+ return supabase.rpc(functionName, { prefix: query.value.trim() });
523
+ });
524
+
525
+ // Execute all search queries in parallel
526
+ const searchResults = await Promise.all(searchPromises);
527
+
528
+ // Combine and deduplicate results
529
+ const allResults: Record<string, TWithRelations> = {};
530
+
531
+ // Process each search result
532
+ searchResults.forEach((result, index) => {
533
+ if (result.error) {
534
+ console.error(`Search error for ${queries[index]?.field}:`, result.error);
535
+ return;
536
+ }
537
+
538
+ if (result.data) {
539
+ // Add each result, using id as key to deduplicate
540
+ for (const item of result.data as TWithRelations[]) {
541
+ // @ts-ignore: Assume item has an id property
542
+ if (item.id) {
543
+ // @ts-ignore: Add to results using id as key
544
+ allResults[item.id] = item;
545
+ }
546
+ }
547
+ }
548
+ });
549
+
550
+ // Convert back to array
551
+ results = Object.values(allResults);
552
+
553
+ // Apply any where conditions client-side
554
+ if (where) {
555
+ results = results.filter((item) => {
556
+ for (const [key, value] of Object.entries(where)) {
557
+ if (typeof value === 'object' && value !== null) {
558
+ // Skip complex filters for now
559
+ continue;
560
+ }
561
+
562
+ if (item[key as keyof typeof item] !== value) {
563
+ return false;
564
+ }
565
+ }
566
+ return true;
567
+ });
568
+ }
569
+
570
+ // Set count directly for search results
571
+ setCount(results.length);
572
+
573
+ // Apply ordering if needed
574
+ if (orderBy) {
575
+ const orderEntries = Object.entries(orderBy);
576
+ if (orderEntries.length > 0) {
577
+ const [orderField, direction] = orderEntries[0] || [];
578
+ results = [...results].sort((a, b) => {
579
+ const aValue = a[orderField as keyof typeof a] ?? '';
580
+ const bValue = b[orderField as keyof typeof b] ?? '';
581
+
582
+ if (direction === 'asc') {
583
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
584
+ } else {
585
+ return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
586
+ }
587
+ });
588
+ }
589
+ }
590
+
591
+ // Apply pagination if needed
592
+ let paginatedResults = results;
593
+ if (limit && limit > 0) {
594
+ paginatedResults = results.slice(0, limit);
595
+ }
596
+
597
+ if (offset && offset > 0) {
598
+ paginatedResults = paginatedResults.slice(offset);
599
+ }
600
+
601
+ // Update data with search results
602
+ setData(paginatedResults);
603
+ } catch (err) {
604
+ console.error('Search error:', err);
605
+ setError(err as Error);
606
+ } finally {
607
+ setSearchLoading(false);
608
+ }
609
+ }, 300); // 300ms debounce
610
+ }, [tableName, searchFields, where, orderBy, limit, offset]);
611
+
612
+ /**
613
+ * Fetch multiple records with support for filtering, sorting, and pagination.
614
+ *
615
+ * @param params - Query parameters for filtering and ordering records
616
+ * @returns A promise with the fetched data or error
617
+ *
618
+ * @example
619
+ * // Get all active users
620
+ * const result = await users.findMany({
621
+ * where: { active: true }
622
+ * });
623
+ *
624
+ * @example
625
+ * // Get 10 most recent posts with pagination
626
+ * const page1 = await posts.findMany({
627
+ * orderBy: { created_at: 'desc' },
628
+ * take: 10,
629
+ * skip: 0
630
+ * });
631
+ *
632
+ * const page2 = await posts.findMany({
633
+ * orderBy: { created_at: 'desc' },
634
+ * take: 10,
635
+ * skip: 10
636
+ * });
637
+ */
638
+ const findMany = useCallback(async (params?: {
639
+ where?: TWhereInput;
640
+ orderBy?: TOrderByInput;
641
+ take?: number;
642
+ skip?: number;
643
+ }): ModelResult<TWithRelations[]> => {
644
+ try {
645
+ setLoading(true);
646
+ setError(null);
647
+
648
+ let query = supabase.from(tableName).select('*');
649
+
650
+ // Apply where conditions if provided
651
+ if (params?.where) {
652
+ query = applyFilter(query, params.where);
653
+ }
654
+
655
+ // Apply order by if provided
656
+ if (params?.orderBy) {
657
+ query = applyOrderBy(query, params.orderBy, hasCreatedAt, createdAtField);
658
+ } else if (hasCreatedAt) {
659
+ // Use the actual createdAt field name from Prisma
660
+ // @ts-ignore: Supabase typing issue
661
+ query = query.order(createdAtField, { ascending: false });
662
+ }
663
+
664
+ // Apply limit if provided
665
+ if (params?.take) {
666
+ query = query.limit(params.take);
667
+ }
668
+
669
+ // Apply offset if provided (for pagination)
670
+ if (params?.skip !== undefined && params.skip >= 0) {
671
+ query = query.range(params.skip, params.skip + (params.take || 10) - 1);
672
+ }
673
+
674
+ const { data, error } = await query;
675
+
676
+ if (error) throw error;
677
+
678
+ const typedData = (data || []) as TWithRelations[];
679
+
680
+ // Only update data if not currently searching
681
+ if (!isSearchingRef.current) {
682
+ setData(typedData);
683
+
684
+ // If the where filter changed, update the total count
685
+ if (JSON.stringify(params?.where) !== JSON.stringify(where)) {
686
+ // Use our standard count fetching function instead of duplicating logic
687
+ setTimeout(() => fetchTotalCount(), 0);
688
+ }
689
+ }
690
+
691
+ return { data: typedData, error: null };
692
+ } catch (err: any) {
693
+ console.error('Error finding records:', err);
694
+ setError(err);
695
+ return { data: null, error: err };
696
+ } finally {
697
+ setLoading(false);
698
+ }
699
+ }, [fetchTotalCount, where, tableName, hasCreatedAt, createdAtField]);
700
+
701
+ /**
702
+ * Find a single record by its unique identifier (usually ID).
703
+ *
704
+ * @param where - The unique identifier to find the record by
705
+ * @returns A promise with the found record or error
706
+ *
707
+ * @example
708
+ * // Find user by ID
709
+ * const result = await users.findUnique({ id: "123" });
710
+ * if (result.data) {
711
+ * console.log("Found user:", result.data.name);
712
+ * }
713
+ */
714
+ const findUnique = useCallback(async (
715
+ where: TWhereUniqueInput
716
+ ): ModelResult<TWithRelations> => {
717
+ try {
718
+ setLoading(true);
719
+ setError(null);
720
+
721
+ // Find the primary field (usually 'id')
722
+ // @ts-ignore: Supabase typing issue
723
+ const primaryKey = Object.keys(where)[0];
724
+ if (!primaryKey) {
725
+ throw new Error('A unique identifier is required');
726
+ }
727
+
728
+ const value = where[primaryKey as keyof typeof where];
729
+ if (value === undefined) {
730
+ throw new Error('A unique identifier is required');
731
+ }
732
+
733
+ const { data, error } = await supabase
734
+ .from(tableName)
735
+ .select('*')
736
+ .eq(primaryKey, value)
737
+ .maybeSingle();
738
+
739
+ if (error) throw error;
740
+
741
+ return { data: data as TWithRelations, error: null };
742
+ } catch (err: any) {
743
+ console.error('Error finding unique record:', err);
744
+ setError(err);
745
+ return { data: null, error: err };
746
+ } finally {
747
+ setLoading(false);
748
+ }
749
+ }, []);
750
+
751
+ // Set up realtime subscription for the list
752
+ useEffect(() => {
753
+ if (!realtime) return;
754
+
755
+ // Clean up previous subscription if it exists
756
+ if (channelRef.current) {
757
+ channelRef.current.unsubscribe();
758
+ channelRef.current = null;
759
+ }
760
+
761
+ const channelId = channelName || `changes_to_${tableName}_${Math.random().toString(36).substring(2, 15)}`;
762
+
763
+ // Store the current filter and options for closure
764
+ const currentFilter = computedFilter;
765
+ const currentWhere = where;
766
+ const currentOrderBy = orderBy;
767
+ const currentLimit = limit;
768
+ const currentOffset = offset;
769
+
770
+ console.log(`Setting up subscription for ${tableName} with filter: ${currentFilter}`);
771
+
772
+ const channel = supabase
773
+ .channel(channelId)
774
+ .on(
775
+ 'postgres_changes',
776
+ {
777
+ event: '*',
778
+ schema: 'public',
779
+ table: tableName,
780
+ filter: currentFilter,
781
+ },
782
+ (payload) => {
783
+ console.log(`Received ${payload.eventType} event for ${tableName}`, payload);
784
+
785
+ // Skip realtime updates when search is active
786
+ if (isSearchingRef.current) return;
787
+
788
+ if (payload.eventType === 'INSERT') {
789
+ // Process insert event
790
+ setData((prev) => {
791
+ try {
792
+ const newRecord = payload.new as TWithRelations;
793
+ console.log(`Processing INSERT for ${tableName}`, { newRecord });
794
+
795
+ // Check if this record matches our filter if we have one
796
+ if (currentWhere) {
797
+ let matchesFilter = true;
798
+
799
+ // Check each filter condition
800
+ for (const [key, value] of Object.entries(currentWhere)) {
801
+ if (typeof value === 'object' && value !== null) {
802
+ // Complex filter - this is handled by Supabase, assume it matches
803
+ } else if (newRecord[key as keyof typeof newRecord] !== value) {
804
+ matchesFilter = false;
805
+ console.log(`Filter mismatch on ${key}`, { expected: value, actual: newRecord[key as keyof typeof newRecord] });
806
+ break;
807
+ }
808
+ }
809
+
810
+ if (!matchesFilter) {
811
+ console.log('New record does not match filter criteria, skipping');
812
+ return prev;
813
+ }
814
+ }
815
+
816
+ // Check if record already exists (avoid duplicates)
817
+ const exists = prev.some(item =>
818
+ // @ts-ignore: Supabase typing issue
819
+ 'id' in item && 'id' in newRecord && item.id === newRecord.id
820
+ );
821
+
822
+ if (exists) {
823
+ console.log('Record already exists, skipping insertion');
824
+ return prev;
825
+ }
826
+
827
+ // Add the new record to the data
828
+ let newData = [newRecord, ...prev];
829
+
830
+ // Apply ordering if specified
831
+ if (currentOrderBy) {
832
+ const [orderField, direction] = Object.entries(currentOrderBy)[0] || [];
833
+ if (orderField) {
834
+ newData = [...newData].sort((a, b) => {
835
+ const aValue = a[orderField as keyof typeof a] ?? '';
836
+ const bValue = b[orderField as keyof typeof b] ?? '';
837
+
838
+ if (direction === 'asc') {
839
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
840
+ } else {
841
+ return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
842
+ }
843
+ });
844
+ }
845
+ }
846
+
847
+ // Apply limit if specified
848
+ if (currentLimit && currentLimit > 0) {
849
+ newData = newData.slice(0, currentLimit);
850
+ }
851
+
852
+ // Fetch the updated count after the data changes
853
+ setTimeout(() => fetchTotalCount(), 0);
854
+
855
+ return newData;
856
+ } catch (error) {
857
+ console.error('Error processing INSERT event:', error);
858
+ return prev;
859
+ }
860
+ });
861
+ } else if (payload.eventType === 'UPDATE') {
862
+ // Process update event
863
+ setData((prev) => {
864
+ // Skip if search is active
865
+ if (isSearchingRef.current) {
866
+ return prev;
867
+ }
868
+
869
+ const newData = prev.map((item) =>
870
+ // @ts-ignore: Supabase typing issue
871
+ 'id' in item && 'id' in payload.new && item.id === payload.new.id
872
+ ? (payload.new as TWithRelations)
873
+ : item
874
+ );
875
+
876
+ // Fetch the updated count after the data changes
877
+ // For updates, the count might not change but we fetch anyway to be consistent
878
+ setTimeout(() => fetchTotalCount(), 0);
879
+
880
+ return newData;
881
+ });
882
+ } else if (payload.eventType === 'DELETE') {
883
+ // Process delete event
884
+ setData((prev) => {
885
+ // Skip if search is active
886
+ if (isSearchingRef.current) {
887
+ return prev;
888
+ }
889
+
890
+ // Save the current size before filtering
891
+ const currentSize = prev.length;
892
+
893
+ // Filter out the deleted item
894
+ const filteredData = prev.filter((item) => {
895
+ // @ts-ignore: Supabase typing issue
896
+ return !('id' in item && 'id' in payload.old && item.id === payload.old.id);
897
+ });
898
+
899
+ // Fetch the updated count after the data changes
900
+ setTimeout(() => fetchTotalCount(), 0);
901
+
902
+ // If we need to maintain the size with a limit
903
+ if (currentLimit && currentLimit > 0 && filteredData.length < currentSize && currentSize === currentLimit) {
904
+ console.log(`Record deleted with limit ${currentLimit}, will fetch additional record to maintain size`);
905
+
906
+ // Use setTimeout to ensure this state update completes first
907
+ setTimeout(() => {
908
+ findMany({
909
+ where: currentWhere,
910
+ orderBy: currentOrderBy,
911
+ take: currentLimit,
912
+ skip: currentOffset
913
+ });
914
+ }, 0);
915
+ }
916
+
917
+ return filteredData;
918
+ });
919
+ }
920
+ }
921
+ )
922
+ .subscribe((status) => {
923
+ console.log(`Subscription status for ${tableName}`, status);
924
+ });
925
+
926
+ // Store the channel ref
927
+ channelRef.current = channel;
928
+
929
+ return () => {
930
+ console.log(`Unsubscribing from ${channelId}`);
931
+ if (channelRef.current) {
932
+ channelRef.current.unsubscribe();
933
+ channelRef.current = null;
934
+ }
935
+
936
+ if (searchTimeoutRef.current) {
937
+ clearTimeout(searchTimeoutRef.current);
938
+ searchTimeoutRef.current = null;
939
+ }
940
+ };
941
+ }, [realtime, channelName, computedFilter]);
942
+
943
+ // Create a memoized options object to prevent unnecessary re-renders
944
+ const optionsRef = useRef({ where, orderBy, limit, offset });
945
+
946
+ // Compare current options with previous options
947
+ const optionsChanged = useCallback(() => {
948
+ // Create stable string representations for deep comparison
949
+ const whereStr = where ? JSON.stringify(where) : '';
950
+ const orderByStr = orderBy ? JSON.stringify(orderBy) : '';
951
+ const prevWhereStr = optionsRef.current.where ? JSON.stringify(optionsRef.current.where) : '';
952
+ const prevOrderByStr = optionsRef.current.orderBy ? JSON.stringify(optionsRef.current.orderBy) : '';
953
+
954
+ // Compare the stable representations
955
+ const hasChanged =
956
+ whereStr !== prevWhereStr ||
957
+ orderByStr !== prevOrderByStr ||
958
+ limit !== optionsRef.current.limit ||
959
+ offset !== optionsRef.current.offset;
960
+
961
+ if (hasChanged) {
962
+ // Update the ref with the new values
963
+ optionsRef.current = { where, orderBy, limit, offset };
964
+ return true;
965
+ }
966
+
967
+ return false;
968
+ }, [where, orderBy, limit, offset]);
969
+
970
+ // Load initial data based on options
971
+ useEffect(() => {
972
+ // Skip if search is active
973
+ if (isSearchingRef.current) return;
974
+
975
+ // Skip if we've already loaded or if no filter criteria are provided
976
+ if (initialLoadRef.current) {
977
+ // Only reload if options have changed significantly
978
+ if (optionsChanged()) {
979
+ console.log(`Options changed for ${tableName}, reloading data`);
980
+ findMany({
981
+ where,
982
+ orderBy,
983
+ take: limit,
984
+ skip: offset
985
+ });
986
+
987
+ // Also update the total count
988
+ fetchTotalCount();
989
+ }
990
+ return;
991
+ }
992
+
993
+ // Initial load
994
+ initialLoadRef.current = true;
995
+ findMany({
996
+ where,
997
+ orderBy,
998
+ take: limit,
999
+ skip: offset
1000
+ });
1001
+
1002
+ // Initial count fetch
1003
+ fetchTotalCount();
1004
+ }, [findMany, where, orderBy, limit, offset, optionsChanged, fetchTotalCount]);
1005
+
1006
+ /**
1007
+ * Create a new record with the provided data.
1008
+ * Default values from the schema will be applied if not provided.
1009
+ *
1010
+ * @param data - The data to create the record with
1011
+ * @returns A promise with the created record or error
1012
+ *
1013
+ * @example
1014
+ * // Create a new user
1015
+ * const result = await users.create({
1016
+ * name: "John Doe",
1017
+ * email: "john@example.com"
1018
+ * });
1019
+ *
1020
+ * @example
1021
+ * // Create with custom ID (overriding default)
1022
+ * const result = await users.create({
1023
+ * id: "custom-id-123",
1024
+ * name: "John Doe"
1025
+ * });
1026
+ */
1027
+ const create = useCallback(async (
1028
+ data: TCreateInput
1029
+ ): ModelResult<TWithRelations> => {
1030
+ try {
1031
+ setLoading(true);
1032
+ setError(null);
1033
+
1034
+ const now = new Date().toISOString();
1035
+
1036
+ // Apply default values from schema
1037
+ const appliedDefaults: Record<string, any> = {};
1038
+
1039
+ // Apply all default values that aren't already in the data
1040
+ for (const [field, defaultValue] of Object.entries(defaultValues)) {
1041
+ // @ts-ignore: Supabase typing issue
1042
+ if (!(field in data)) {
1043
+ // Parse the default value based on its type
1044
+ if (defaultValue.includes('now()') || defaultValue.includes('now')) {
1045
+ appliedDefaults[field] = now;
1046
+ } else if (defaultValue.includes('uuid()') || defaultValue.includes('uuid')) {
1047
+ appliedDefaults[field] = crypto.randomUUID();
1048
+ } else if (defaultValue.includes('cuid()') || defaultValue.includes('cuid')) {
1049
+ // Simple cuid-like implementation for client-side
1050
+ appliedDefaults[field] = 'c' + Math.random().toString(36).substring(2, 15);
1051
+ } else if (defaultValue.includes('true')) {
1052
+ appliedDefaults[field] = true;
1053
+ } else if (defaultValue.includes('false')) {
1054
+ appliedDefaults[field] = false;
1055
+ } else if (!isNaN(Number(defaultValue))) {
1056
+ // If it's a number
1057
+ appliedDefaults[field] = Number(defaultValue);
1058
+ } else {
1059
+ // String or other value, remove quotes if present
1060
+ const strValue = defaultValue.replace(/^["'](.*)["']$/, '$1');
1061
+ appliedDefaults[field] = strValue;
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ const itemWithDefaults = {
1067
+ ...appliedDefaults, // Apply schema defaults first
1068
+ ...data, // Then user data (overrides defaults)
1069
+ // Use the actual field names from Prisma
1070
+ ...(hasCreatedAt ? { [createdAtField]: now } : {}),
1071
+ ...(hasUpdatedAt ? { [updatedAtField]: now } : {})
1072
+ };
1073
+
1074
+ const { data: result, error } = await supabase
1075
+ .from(tableName)
1076
+ .insert([itemWithDefaults])
1077
+ .select();
1078
+
1079
+ if (error) throw error;
1080
+
1081
+ // Update the total count after a successful creation
1082
+ setTimeout(() => fetchTotalCount(), 0);
1083
+
1084
+ // Return created record
1085
+ return { data: result?.[0] as TWithRelations, error: null };
1086
+ } catch (err: any) {
1087
+ console.error('Error creating record:', err);
1088
+ setError(err);
1089
+ return { data: null, error: err };
1090
+ } finally {
1091
+ setLoading(false);
1092
+ }
1093
+ }, [fetchTotalCount]);
1094
+
1095
+ /**
1096
+ * Update an existing record identified by a unique identifier.
1097
+ *
1098
+ * @param params - Object containing the identifier and update data
1099
+ * @returns A promise with the updated record or error
1100
+ *
1101
+ * @example
1102
+ * // Update a user's name
1103
+ * const result = await users.update({
1104
+ * where: { id: "123" },
1105
+ * data: { name: "New Name" }
1106
+ * });
1107
+ *
1108
+ * @example
1109
+ * // Update multiple fields
1110
+ * const result = await users.update({
1111
+ * where: { id: "123" },
1112
+ * data: {
1113
+ * name: "New Name",
1114
+ * active: false
1115
+ * }
1116
+ * });
1117
+ */
1118
+ const update = useCallback(async (params: {
1119
+ where: TWhereUniqueInput;
1120
+ data: TUpdateInput;
1121
+ }): ModelResult<TWithRelations> => {
1122
+ try {
1123
+ setLoading(true);
1124
+ setError(null);
1125
+
1126
+ // Find the primary field (usually 'id')
1127
+ // @ts-ignore: Supabase typing issue
1128
+ const primaryKey = Object.keys(params.where)[0];
1129
+ if (!primaryKey) {
1130
+ throw new Error('A unique identifier is required');
1131
+ }
1132
+
1133
+ const value = params.where[primaryKey as keyof typeof params.where];
1134
+ if (value === undefined) {
1135
+ throw new Error('A unique identifier is required');
1136
+ }
1137
+
1138
+ const now = new Date().toISOString();
1139
+
1140
+ // We do not apply default values for updates
1141
+ // Default values are only for creation, not for updates,
1142
+ // as existing records already have these values set
1143
+
1144
+ const itemWithDefaults = {
1145
+ ...params.data,
1146
+ // Use the actual updatedAt field name from Prisma
1147
+ ...(hasUpdatedAt ? { [updatedAtField]: now } : {})
1148
+ };
1149
+
1150
+ const { data, error } = await supabase
1151
+ .from(tableName)
1152
+ .update(itemWithDefaults)
1153
+ .eq(primaryKey, value)
1154
+ .select();
1155
+
1156
+ if (error) throw error;
1157
+
1158
+ // Update the total count after a successful update
1159
+ // This is for consistency with other operations, and because
1160
+ // updates can sometimes affect filtering based on updated values
1161
+ setTimeout(() => fetchTotalCount(), 0);
1162
+
1163
+ // Return updated record
1164
+ return { data: data?.[0] as TWithRelations, error: null };
1165
+ } catch (err: any) {
1166
+ console.error('Error updating record:', err);
1167
+ setError(err);
1168
+ return { data: null, error: err };
1169
+ } finally {
1170
+ setLoading(false);
1171
+ }
1172
+ }, [fetchTotalCount]);
1173
+
1174
+ /**
1175
+ * Delete a record by its unique identifier.
1176
+ *
1177
+ * @param where - The unique identifier to delete the record by
1178
+ * @returns A promise with the deleted record or error
1179
+ *
1180
+ * @example
1181
+ * // Delete a user by ID
1182
+ * const result = await users.delete({ id: "123" });
1183
+ * if (result.data) {
1184
+ * console.log("Deleted user:", result.data.name);
1185
+ * }
1186
+ */
1187
+ const deleteRecord = useCallback(async (
1188
+ where: TWhereUniqueInput
1189
+ ): ModelResult<TWithRelations> => {
1190
+ try {
1191
+ setLoading(true);
1192
+ setError(null);
1193
+
1194
+ // Find the primary field (usually 'id')
1195
+ // @ts-ignore: Supabase typing issue
1196
+ const primaryKey = Object.keys(where)[0];
1197
+ if (!primaryKey) {
1198
+ throw new Error('A unique identifier is required');
1199
+ }
1200
+
1201
+ const value = where[primaryKey as keyof typeof where];
1202
+ if (value === undefined) {
1203
+ throw new Error('A unique identifier is required');
1204
+ }
1205
+
1206
+ // First fetch the record to return it
1207
+ const { data: recordToDelete } = await supabase
1208
+ .from(tableName)
1209
+ .select('*')
1210
+ .eq(primaryKey, value)
1211
+ .maybeSingle();
1212
+
1213
+ if (!recordToDelete) {
1214
+ throw new Error('Record not found');
1215
+ }
1216
+
1217
+ // Then delete it
1218
+ const { error } = await supabase
1219
+ .from(tableName)
1220
+ .delete()
1221
+ .eq(primaryKey, value);
1222
+
1223
+ if (error) throw error;
1224
+
1225
+ // Update the total count after a successful deletion
1226
+ setTimeout(() => fetchTotalCount(), 0);
1227
+
1228
+ // Return the deleted record
1229
+ return { data: recordToDelete as TWithRelations, error: null };
1230
+ } catch (err: any) {
1231
+ console.error('Error deleting record:', err);
1232
+ setError(err);
1233
+ return { data: null, error: err };
1234
+ } finally {
1235
+ setLoading(false);
1236
+ }
1237
+ }, [fetchTotalCount]);
1238
+
1239
+ /**
1240
+ * Delete multiple records matching the filter criteria.
1241
+ *
1242
+ * @param params - Query parameters for filtering records to delete
1243
+ * @returns A promise with the count of deleted records or error
1244
+ *
1245
+ * @example
1246
+ * // Delete all inactive users
1247
+ * const result = await users.deleteMany({
1248
+ * where: { active: false }
1249
+ * });
1250
+ * console.log(`Deleted ${result.count} inactive users`);
1251
+ *
1252
+ * @example
1253
+ * // Delete all records (use with caution!)
1254
+ * const result = await users.deleteMany();
1255
+ */
1256
+ const deleteMany = useCallback(async (params?: {
1257
+ where?: TWhereInput;
1258
+ }): Promise<{ count: number; error: Error | null }> => {
1259
+ try {
1260
+ setLoading(true);
1261
+ setError(null);
1262
+
1263
+ // First, get the records that will be deleted to count them
1264
+ let query = supabase.from(tableName).select('*');
1265
+
1266
+ // Apply where conditions if provided
1267
+ if (params?.where) {
1268
+ query = applyFilter(query, params.where);
1269
+ }
1270
+
1271
+ // Get records that will be deleted
1272
+ const { data: recordsToDelete, error: fetchError } = await query;
1273
+
1274
+ if (fetchError) throw fetchError;
1275
+
1276
+ if (!recordsToDelete?.length) {
1277
+ return { count: 0, error: null };
1278
+ }
1279
+
1280
+ // Build the delete query
1281
+ let deleteQuery = supabase.from(tableName).delete();
1282
+
1283
+ // Apply the same filter to the delete operation
1284
+ if (params?.where) {
1285
+ // @ts-ignore: Supabase typing issue
1286
+ deleteQuery = applyFilter(deleteQuery, params.where);
1287
+ }
1288
+
1289
+ // Perform the delete
1290
+ const { error: deleteError } = await deleteQuery;
1291
+
1292
+ if (deleteError) throw deleteError;
1293
+
1294
+ // Update the total count after a successful bulk deletion
1295
+ setTimeout(() => fetchTotalCount(), 0);
1296
+
1297
+ // Return the count of deleted records
1298
+ return { count: recordsToDelete.length, error: null };
1299
+ } catch (err: any) {
1300
+ console.error('Error deleting multiple records:', err);
1301
+ setError(err);
1302
+ return { count: 0, error: err };
1303
+ } finally {
1304
+ setLoading(false);
1305
+ }
1306
+ }, [fetchTotalCount]);
1307
+
1308
+ /**
1309
+ * Find the first record matching the filter criteria.
1310
+ *
1311
+ * @param params - Query parameters for filtering and ordering
1312
+ * @returns A promise with the first matching record or error
1313
+ *
1314
+ * @example
1315
+ * // Find the first admin user
1316
+ * const result = await users.findFirst({
1317
+ * where: { role: 'admin' }
1318
+ * });
1319
+ *
1320
+ * @example
1321
+ * // Find the oldest post
1322
+ * const result = await posts.findFirst({
1323
+ * orderBy: { created_at: 'asc' }
1324
+ * });
1325
+ */
1326
+ const findFirst = useCallback(async (params?: {
1327
+ where?: TWhereInput;
1328
+ orderBy?: TOrderByInput;
1329
+ }): ModelResult<TWithRelations> => {
1330
+ try {
1331
+ const result = await findMany({
1332
+ ...params,
1333
+ take: 1
1334
+ });
1335
+
1336
+ if (result.error) return { data: null, error: result.error };
1337
+ if (!result.data.length) return { data: null, error: new Error('No records found') };
1338
+
1339
+ // @ts-ignore: Supabase typing issue
1340
+ return { data: result.data[0], error: null };
1341
+ } catch (err: any) {
1342
+ console.error('Error finding first record:', err);
1343
+ return { data: null, error: err };
1344
+ }
1345
+ }, [findMany]);
1346
+
1347
+ /**
1348
+ * Create a record if it doesn't exist, or update it if it does.
1349
+ *
1350
+ * @param params - Object containing the identifier, update data, and create data
1351
+ * @returns A promise with the created or updated record or error
1352
+ *
1353
+ * @example
1354
+ * // Upsert a user by ID
1355
+ * const result = await users.upsert({
1356
+ * where: { id: "123" },
1357
+ * update: { lastLogin: new Date().toISOString() },
1358
+ * create: {
1359
+ * id: "123",
1360
+ * name: "John Doe",
1361
+ * email: "john@example.com",
1362
+ * lastLogin: new Date().toISOString()
1363
+ * }
1364
+ * });
1365
+ */
1366
+ const upsert = useCallback(async (params: {
1367
+ where: TWhereUniqueInput;
1368
+ update: TUpdateInput;
1369
+ create: TCreateInput;
1370
+ }): ModelResult<TWithRelations> => {
1371
+ try {
1372
+ // Check if record exists
1373
+ const { data: existing } = await findUnique(params.where);
1374
+
1375
+ // Update if exists, otherwise create
1376
+ if (existing) {
1377
+ return update({ where: params.where, data: params.update });
1378
+ } else {
1379
+ return create(params.create);
1380
+ }
1381
+ } catch (err: any) {
1382
+ console.error('Error upserting record:', err);
1383
+ return { data: null, error: err };
1384
+ }
1385
+ }, [findUnique, update, create]);
1386
+
1387
+ /**
1388
+ * Count the number of records matching the filter criteria.
1389
+ * This is a manual method to get the count with a different filter
1390
+ * than the main hook's filter.
1391
+ *
1392
+ * @param params - Query parameters for filtering
1393
+ * @returns A promise with the count of matching records
1394
+ */
1395
+ const countFn = useCallback(async (params?: {
1396
+ where?: TWhereInput;
1397
+ }): Promise<number> => {
1398
+ try {
1399
+ let query = supabase.from(tableName).select('*', { count: 'exact', head: true });
1400
+
1401
+ // Use provided where filter, or fall back to the hook's original where filter
1402
+ const effectiveWhere = params?.where ?? where;
1403
+
1404
+ if (effectiveWhere) {
1405
+ query = applyFilter(query, effectiveWhere);
1406
+ }
1407
+
1408
+ const { count, error } = await query;
1409
+
1410
+ if (error) throw error;
1411
+
1412
+ return count || 0;
1413
+ } catch (err) {
1414
+ console.error('Error counting records:', err);
1415
+ return 0;
1416
+ }
1417
+ }, [where]);
1418
+
1419
+ /**
1420
+ * Manually refresh the data with current filter settings.
1421
+ * Useful after external operations or when realtime is disabled.
1422
+ *
1423
+ * @param params - Optional override parameters for this specific refresh
1424
+ * @returns A promise with the refreshed data or error
1425
+ *
1426
+ * @example
1427
+ * // Refresh with current filter settings
1428
+ * await users.refresh();
1429
+ *
1430
+ * @example
1431
+ * // Refresh with different filters for this call only
1432
+ * await users.refresh({
1433
+ * where: { active: true },
1434
+ * orderBy: { name: 'asc' }
1435
+ * });
1436
+ */
1437
+ const refresh = useCallback((params?: {
1438
+ where?: TWhereInput;
1439
+ orderBy?: TOrderByInput;
1440
+ take?: number;
1441
+ skip?: number;
1442
+ }) => {
1443
+ // If search is active, refresh search results
1444
+ if (isSearchingRef.current && searchQueries.length > 0) {
1445
+ executeSearch(searchQueries);
1446
+ return Promise.resolve({ data: data, error: null });
1447
+ }
1448
+
1449
+ // Otherwise, refresh normal data using original params if not explicitly overridden
1450
+ return findMany({
1451
+ where: params?.where ?? where,
1452
+ orderBy: params?.orderBy ?? orderBy,
1453
+ take: params?.take ?? limit,
1454
+ skip: params?.skip ?? offset
1455
+ });
1456
+ }, [findMany, data, searchQueries, where, orderBy, limit, offset]);
1457
+
1458
+ // Construct final hook API with or without search
1459
+ const api = {
1460
+ // State
1461
+ data,
1462
+ error,
1463
+ loading,
1464
+ count, // Now including count as a reactive state value
1465
+
1466
+ // Finder methods
1467
+ findUnique,
1468
+ findMany,
1469
+ findFirst,
1470
+
1471
+ // Mutation methods
1472
+ create,
1473
+ update,
1474
+ delete: deleteRecord,
1475
+ deleteMany,
1476
+ upsert,
1477
+
1478
+ // Manual refresh
1479
+ refresh
1480
+ };
1481
+
1482
+ // Add search object if searchable fields are present
1483
+ return searchFields.length > 0
1484
+ ? {
1485
+ ...api,
1486
+ search
1487
+ }
1488
+ : api;
1489
+ };
1490
+ }