suparisma 0.0.1 → 0.0.2

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