suparisma 1.2.2 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +51 -2
  2. package/dist/generators/coreGenerator.js +200 -15
  3. package/dist/generators/hookGenerator.js +20 -2
  4. package/dist/generators/typeGenerator.js +55 -5
  5. package/dist/index.js +6 -1
  6. package/package.json +1 -1
  7. package/tmp/generated-test/hooks/useSuparismaAsset.ts +94 -0
  8. package/tmp/generated-test/hooks/useSuparismaChapter.ts +96 -0
  9. package/tmp/generated-test/hooks/useSuparismaCourse.ts +96 -0
  10. package/tmp/generated-test/hooks/useSuparismaDeviceSession.ts +94 -0
  11. package/tmp/generated-test/hooks/useSuparismaEnrollment.ts +92 -0
  12. package/tmp/generated-test/hooks/useSuparismaLesson.ts +96 -0
  13. package/tmp/generated-test/hooks/useSuparismaLessonPurchase.ts +92 -0
  14. package/tmp/generated-test/hooks/useSuparismaLessonQuestion.ts +96 -0
  15. package/tmp/generated-test/hooks/useSuparismaPayoutMethod.ts +96 -0
  16. package/tmp/generated-test/hooks/useSuparismaPayoutRequest.ts +96 -0
  17. package/tmp/generated-test/hooks/useSuparismaQuestionOption.ts +92 -0
  18. package/tmp/generated-test/hooks/useSuparismaSavedPaymentMethod.ts +96 -0
  19. package/tmp/generated-test/hooks/useSuparismaTeacherPayoutInfo.ts +96 -0
  20. package/tmp/generated-test/hooks/useSuparismaThing.ts +96 -0
  21. package/tmp/generated-test/hooks/useSuparismaUser.ts +96 -0
  22. package/tmp/generated-test/hooks/useSuparismaVideoNote.ts +96 -0
  23. package/tmp/generated-test/hooks/useSuparismaWallet.ts +96 -0
  24. package/tmp/generated-test/hooks/useSuparismaWalletTransaction.ts +96 -0
  25. package/tmp/generated-test/hooks/useSuparismaWatchProgress.ts +96 -0
  26. package/tmp/generated-test/index.ts +140 -0
  27. package/tmp/generated-test/types/AssetTypes.ts +485 -0
  28. package/tmp/generated-test/types/ChapterTypes.ts +488 -0
  29. package/tmp/generated-test/types/CourseTypes.ts +519 -0
  30. package/tmp/generated-test/types/DeviceSessionTypes.ts +489 -0
  31. package/tmp/generated-test/types/EnrollmentTypes.ts +495 -0
  32. package/tmp/generated-test/types/LessonPurchaseTypes.ts +490 -0
  33. package/tmp/generated-test/types/LessonQuestionTypes.ts +496 -0
  34. package/tmp/generated-test/types/LessonTypes.ts +517 -0
  35. package/tmp/generated-test/types/PayoutMethodTypes.ts +517 -0
  36. package/tmp/generated-test/types/PayoutRequestTypes.ts +528 -0
  37. package/tmp/generated-test/types/QuestionOptionTypes.ts +479 -0
  38. package/tmp/generated-test/types/SavedPaymentMethodTypes.ts +497 -0
  39. package/tmp/generated-test/types/TeacherPayoutInfoTypes.ts +480 -0
  40. package/tmp/generated-test/types/ThingTypes.ts +482 -0
  41. package/tmp/generated-test/types/UserTypes.ts +487 -0
  42. package/tmp/generated-test/types/VideoNoteTypes.ts +489 -0
  43. package/tmp/generated-test/types/WalletTransactionTypes.ts +505 -0
  44. package/tmp/generated-test/types/WalletTypes.ts +480 -0
  45. package/tmp/generated-test/types/WatchProgressTypes.ts +493 -0
  46. package/tmp/generated-test/utils/core.ts +2306 -0
  47. package/tmp/generated-test/utils/supabase-client.ts +17 -0
  48. package/tmp/generated-test2/hooks/useSuparismaAsset.ts +94 -0
  49. package/tmp/generated-test2/hooks/useSuparismaChapter.ts +96 -0
  50. package/tmp/generated-test2/hooks/useSuparismaCourse.ts +96 -0
  51. package/tmp/generated-test2/hooks/useSuparismaDeviceSession.ts +94 -0
  52. package/tmp/generated-test2/hooks/useSuparismaEnrollment.ts +92 -0
  53. package/tmp/generated-test2/hooks/useSuparismaLesson.ts +96 -0
  54. package/tmp/generated-test2/hooks/useSuparismaLessonPurchase.ts +92 -0
  55. package/tmp/generated-test2/hooks/useSuparismaLessonQuestion.ts +96 -0
  56. package/tmp/generated-test2/hooks/useSuparismaPayoutMethod.ts +96 -0
  57. package/tmp/generated-test2/hooks/useSuparismaPayoutRequest.ts +96 -0
  58. package/tmp/generated-test2/hooks/useSuparismaQuestionOption.ts +92 -0
  59. package/tmp/generated-test2/hooks/useSuparismaSavedPaymentMethod.ts +96 -0
  60. package/tmp/generated-test2/hooks/useSuparismaTeacherPayoutInfo.ts +96 -0
  61. package/tmp/generated-test2/hooks/useSuparismaThing.ts +96 -0
  62. package/tmp/generated-test2/hooks/useSuparismaUser.ts +96 -0
  63. package/tmp/generated-test2/hooks/useSuparismaVideoNote.ts +96 -0
  64. package/tmp/generated-test2/hooks/useSuparismaWallet.ts +96 -0
  65. package/tmp/generated-test2/hooks/useSuparismaWalletTransaction.ts +96 -0
  66. package/tmp/generated-test2/hooks/useSuparismaWatchProgress.ts +96 -0
  67. package/tmp/generated-test2/index.ts +140 -0
  68. package/tmp/generated-test2/types/AssetTypes.ts +485 -0
  69. package/tmp/generated-test2/types/ChapterTypes.ts +488 -0
  70. package/tmp/generated-test2/types/CourseTypes.ts +522 -0
  71. package/tmp/generated-test2/types/DeviceSessionTypes.ts +489 -0
  72. package/tmp/generated-test2/types/EnrollmentTypes.ts +495 -0
  73. package/tmp/generated-test2/types/LessonPurchaseTypes.ts +490 -0
  74. package/tmp/generated-test2/types/LessonQuestionTypes.ts +496 -0
  75. package/tmp/generated-test2/types/LessonTypes.ts +517 -0
  76. package/tmp/generated-test2/types/PayoutMethodTypes.ts +517 -0
  77. package/tmp/generated-test2/types/PayoutRequestTypes.ts +528 -0
  78. package/tmp/generated-test2/types/QuestionOptionTypes.ts +479 -0
  79. package/tmp/generated-test2/types/SavedPaymentMethodTypes.ts +497 -0
  80. package/tmp/generated-test2/types/TeacherPayoutInfoTypes.ts +480 -0
  81. package/tmp/generated-test2/types/ThingTypes.ts +482 -0
  82. package/tmp/generated-test2/types/UserTypes.ts +490 -0
  83. package/tmp/generated-test2/types/VideoNoteTypes.ts +489 -0
  84. package/tmp/generated-test2/types/WalletTransactionTypes.ts +505 -0
  85. package/tmp/generated-test2/types/WalletTypes.ts +480 -0
  86. package/tmp/generated-test2/types/WatchProgressTypes.ts +493 -0
  87. package/tmp/generated-test2/utils/core.ts +2306 -0
  88. package/tmp/generated-test2/utils/supabase-client.ts +17 -0
  89. package/tmp/generated-test3/hooks/useSuparismaAsset.ts +94 -0
  90. package/tmp/generated-test3/hooks/useSuparismaChapter.ts +98 -0
  91. package/tmp/generated-test3/hooks/useSuparismaCourse.ts +98 -0
  92. package/tmp/generated-test3/hooks/useSuparismaDeviceSession.ts +94 -0
  93. package/tmp/generated-test3/hooks/useSuparismaEnrollment.ts +94 -0
  94. package/tmp/generated-test3/hooks/useSuparismaLesson.ts +98 -0
  95. package/tmp/generated-test3/hooks/useSuparismaLessonPurchase.ts +94 -0
  96. package/tmp/generated-test3/hooks/useSuparismaLessonQuestion.ts +98 -0
  97. package/tmp/generated-test3/hooks/useSuparismaPayoutMethod.ts +96 -0
  98. package/tmp/generated-test3/hooks/useSuparismaPayoutRequest.ts +96 -0
  99. package/tmp/generated-test3/hooks/useSuparismaQuestionOption.ts +94 -0
  100. package/tmp/generated-test3/hooks/useSuparismaSavedPaymentMethod.ts +96 -0
  101. package/tmp/generated-test3/hooks/useSuparismaTeacherPayoutInfo.ts +98 -0
  102. package/tmp/generated-test3/hooks/useSuparismaThing.ts +96 -0
  103. package/tmp/generated-test3/hooks/useSuparismaUser.ts +98 -0
  104. package/tmp/generated-test3/hooks/useSuparismaVideoNote.ts +98 -0
  105. package/tmp/generated-test3/hooks/useSuparismaWallet.ts +98 -0
  106. package/tmp/generated-test3/hooks/useSuparismaWalletTransaction.ts +98 -0
  107. package/tmp/generated-test3/hooks/useSuparismaWatchProgress.ts +98 -0
  108. package/tmp/generated-test3/index.ts +140 -0
  109. package/tmp/generated-test3/types/AssetTypes.ts +485 -0
  110. package/tmp/generated-test3/types/ChapterTypes.ts +488 -0
  111. package/tmp/generated-test3/types/CourseTypes.ts +522 -0
  112. package/tmp/generated-test3/types/DeviceSessionTypes.ts +489 -0
  113. package/tmp/generated-test3/types/EnrollmentTypes.ts +495 -0
  114. package/tmp/generated-test3/types/LessonPurchaseTypes.ts +490 -0
  115. package/tmp/generated-test3/types/LessonQuestionTypes.ts +496 -0
  116. package/tmp/generated-test3/types/LessonTypes.ts +517 -0
  117. package/tmp/generated-test3/types/PayoutMethodTypes.ts +517 -0
  118. package/tmp/generated-test3/types/PayoutRequestTypes.ts +528 -0
  119. package/tmp/generated-test3/types/QuestionOptionTypes.ts +479 -0
  120. package/tmp/generated-test3/types/SavedPaymentMethodTypes.ts +497 -0
  121. package/tmp/generated-test3/types/TeacherPayoutInfoTypes.ts +480 -0
  122. package/tmp/generated-test3/types/ThingTypes.ts +482 -0
  123. package/tmp/generated-test3/types/UserTypes.ts +490 -0
  124. package/tmp/generated-test3/types/VideoNoteTypes.ts +489 -0
  125. package/tmp/generated-test3/types/WalletTransactionTypes.ts +505 -0
  126. package/tmp/generated-test3/types/WalletTypes.ts +480 -0
  127. package/tmp/generated-test3/types/WatchProgressTypes.ts +493 -0
  128. package/tmp/generated-test3/utils/core.ts +2316 -0
  129. package/tmp/generated-test3/utils/supabase-client.ts +17 -0
  130. package/tmp/prisma-test-schema-2.prisma +339 -0
  131. package/tmp/prisma-test-schema.prisma +317 -0
@@ -0,0 +1,2306 @@
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
+ * @example
15
+ * // Search across multiple fields
16
+ * const query = { field: "multi", value: "john" };
17
+ */
18
+ export type SearchQuery = {
19
+ /** The field name to search in, or "multi" for multi-field search */
20
+ field: string;
21
+ /** The search term/value to look for */
22
+ value: string;
23
+ };
24
+
25
+ // Define type for Supabase query builder
26
+ export type SupabaseQueryBuilder = ReturnType<ReturnType<typeof supabase.from>['select']>;
27
+
28
+ /**
29
+ * Utility function to escape regex special characters for safe RegExp usage
30
+ * Prevents "Invalid regular expression" errors when search terms contain special characters
31
+ */
32
+ export function escapeRegexCharacters(str: string): string {
33
+ // Escape all special regex characters: ( ) [ ] { } + * ? ^ $ | . \
34
+ return str.replace(/[()\[\]{}+*?^$|.\\]/g, '\\\\$&');
35
+ }
36
+
37
+ /**
38
+ * Generate a UUID v4, with fallback for environments without crypto.randomUUID()
39
+ * Works in: browsers, Node.js, and React Native (with react-native-get-random-values polyfill)
40
+ *
41
+ * For React Native, ensure you have installed and imported the polyfill:
42
+ * - pnpm install react-native-get-random-values
43
+ * - Import at app entry point: import 'react-native-get-random-values';
44
+ */
45
+ export function generateUUID(): string {
46
+ // Try native crypto.randomUUID() first (modern browsers & Node.js 16.7+)
47
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
48
+ return crypto.randomUUID();
49
+ }
50
+
51
+ // Fallback using crypto.getRandomValues() (works with react-native-get-random-values polyfill)
52
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
53
+ const bytes = new Uint8Array(16);
54
+ crypto.getRandomValues(bytes);
55
+
56
+ // Set version (4) and variant (RFC 4122)
57
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
58
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant RFC 4122
59
+
60
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
61
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
62
+ }
63
+
64
+ // Last resort fallback using Math.random() (not cryptographically secure)
65
+ console.warn('[Suparisma] crypto API not available, using Math.random() fallback for UUID generation');
66
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
67
+ const r = (Math.random() * 16) | 0;
68
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
69
+ return v.toString(16);
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Advanced filter operators for complex queries
75
+ * @example
76
+ * // Users older than 21
77
+ * { age: { gt: 21 } }
78
+ *
79
+ * @example
80
+ * // Posts with titles containing "news"
81
+ * { title: { contains: "news" } }
82
+ *
83
+ * @example
84
+ * // Array contains ANY of these items (overlaps)
85
+ * { tags: { has: ["typescript", "react"] } }
86
+ *
87
+ * @example
88
+ * // Array contains ALL of these items (contains)
89
+ * { categories: { hasEvery: ["tech", "programming"] } }
90
+ *
91
+ * @example
92
+ * // Array contains ANY of these items (same as 'has')
93
+ * { tags: { hasSome: ["javascript", "python"] } }
94
+ */
95
+ export type FilterOperators<T> = {
96
+ /** Equal to value */
97
+ equals?: T;
98
+ /** Not equal to value */
99
+ not?: T;
100
+ /** Value is in the array */
101
+ in?: T[];
102
+ /** Value is not in the array */
103
+ notIn?: T[];
104
+ /** Less than value */
105
+ lt?: T;
106
+ /** Less than or equal to value */
107
+ lte?: T;
108
+ /** Greater than value */
109
+ gt?: T;
110
+ /** Greater than or equal to value */
111
+ gte?: T;
112
+ /** String contains value (case insensitive) */
113
+ contains?: string;
114
+ /** String starts with value (case insensitive) */
115
+ startsWith?: string;
116
+ /** String ends with value (case insensitive) */
117
+ endsWith?: string;
118
+
119
+ // Array-specific operators
120
+ /** Array contains ANY of the specified items (for array fields) */
121
+ has?: T extends Array<infer U> ? U[] : never;
122
+ /** Array contains ANY of the specified items (alias for 'has') */
123
+ hasSome?: T extends Array<infer U> ? U[] : never;
124
+ /** Array contains ALL of the specified items (for array fields) */
125
+ hasEvery?: T extends Array<infer U> ? U[] : never;
126
+ /** Array is empty (for array fields) */
127
+ isEmpty?: T extends Array<any> ? boolean : never;
128
+ };
129
+
130
+ // Type for a single field in an advanced where filter with OR/AND support
131
+ export type AdvancedWhereInput<T> = {
132
+ [K in keyof T]?: T[K] | FilterOperators<T[K]>;
133
+ } & {
134
+ /** Match ANY of the provided conditions */
135
+ OR?: AdvancedWhereInput<T>[];
136
+ /** Match ALL of the provided conditions */
137
+ AND?: AdvancedWhereInput<T>[];
138
+ };
139
+
140
+ /**
141
+ * Configuration options for the Suparisma hooks
142
+ * @example
143
+ * // Basic usage
144
+ * const { data } = useSuparismaUser();
145
+ *
146
+ * @example
147
+ * // With filtering
148
+ * const { data } = useSuparismaUser({
149
+ * where: { age: { gt: 21 } }
150
+ * });
151
+ *
152
+ * @example
153
+ * // With ordering and limits
154
+ * const { data } = useSuparismaUser({
155
+ * orderBy: { created_at: 'desc' },
156
+ * limit: 10
157
+ * });
158
+ */
159
+ /**
160
+ * Select input type - specify which fields to return
161
+ * Use true to include a field, or use an object for relations
162
+ * @example
163
+ * // Select specific fields
164
+ * { id: true, name: true, email: true }
165
+ *
166
+ * @example
167
+ * // Select fields with relations
168
+ * { id: true, name: true, posts: true }
169
+ */
170
+ export type SelectInput<T> = {
171
+ [K in keyof T]?: boolean;
172
+ };
173
+
174
+ /**
175
+ * Include input type - specify which relations to include
176
+ * @example
177
+ * // Include a relation with all fields
178
+ * { posts: true }
179
+ *
180
+ * @example
181
+ * // Include a relation with specific fields
182
+ * { posts: { select: { id: true, title: true } } }
183
+ */
184
+ export type IncludeValue = boolean | { select?: Record<string, boolean> };
185
+
186
+ export type SuparismaOptions<
187
+ TWhereInput,
188
+ TOrderByInput,
189
+ TSelectInput = Record<string, boolean>,
190
+ TIncludeInput = Record<never, never>
191
+ > = {
192
+ /** Whether to enable realtime updates (default: true) */
193
+ realtime?: boolean;
194
+ /** Custom channel name for realtime subscription */
195
+ channelName?: string;
196
+ /** Type-safe filter for queries and realtime events */
197
+ where?: TWhereInput;
198
+ /** Legacy string filter (use 'where' instead for type safety) */
199
+ realtimeFilter?: string;
200
+ /** Type-safe ordering for queries */
201
+ orderBy?: TOrderByInput;
202
+ /** Limit the number of records returned */
203
+ limit?: number;
204
+ /** Offset for pagination (skip records) */
205
+ offset?: number;
206
+ /**
207
+ * Select specific fields to return. Reduces payload size.
208
+ * @example { id: true, name: true, email: true }
209
+ */
210
+ select?: TSelectInput;
211
+ /**
212
+ * Include related records (foreign key relations).
213
+ * @example { posts: true } or { posts: { select: { id: true, title: true } } }
214
+ */
215
+ include?: TIncludeInput;
216
+ };
217
+
218
+ /**
219
+ * Return type for database operations
220
+ * @example
221
+ * const result = await users.create({ name: "John" });
222
+ * if (result.error) {
223
+ * console.error(result.error);
224
+ * } else {
225
+ * console.log(result.data);
226
+ * }
227
+ */
228
+ export type ModelResult<T> = Promise<{
229
+ data: T;
230
+ error: null;
231
+ } | {
232
+ data: null;
233
+ error: Error;
234
+ }>;
235
+
236
+ /**
237
+ * Complete search state and methods for searchable models
238
+ * @example
239
+ * // Search for users with name containing "john"
240
+ * users.search.addQuery({ field: "name", value: "john" });
241
+ *
242
+ * @example
243
+ * // Search across multiple fields
244
+ * users.search.searchMultiField("john doe");
245
+ *
246
+ * @example
247
+ * // Check if search is loading
248
+ * if (users.search.loading) {
249
+ * return <div>Searching...</div>;
250
+ * }
251
+ *
252
+ * @example
253
+ * // Get current search terms for highlighting
254
+ * const searchTerms = users.search.getCurrentSearchTerms();
255
+ *
256
+ * @example
257
+ * // Safely escape regex characters
258
+ * const escaped = users.search.escapeRegex("user@example.com");
259
+ */
260
+ export type SearchState = {
261
+ /** Current active search queries */
262
+ queries: SearchQuery[];
263
+ /** Whether a search is currently in progress */
264
+ loading: boolean;
265
+ /** Replace all search queries with a new set */
266
+ setQueries: (queries: SearchQuery[]) => void;
267
+ /** Add a new search query (replaces existing query for same field) */
268
+ addQuery: (query: SearchQuery) => void;
269
+ /** Remove a search query by field name */
270
+ removeQuery: (field: string) => void;
271
+ /** Clear all search queries and return to normal data fetching */
272
+ clearQueries: () => void;
273
+ /** Search across multiple fields (convenience method) */
274
+ searchMultiField: (value: string) => void;
275
+ /** Search in a specific field (convenience method) */
276
+ searchField: (field: string, value: string) => void;
277
+ /** Get current search terms for custom highlighting */
278
+ getCurrentSearchTerms: () => string[];
279
+ /** Safely escape regex special characters */
280
+ escapeRegex: (text: string) => string;
281
+ };
282
+
283
+ /**
284
+ * Compare two values for sorting with proper type handling
285
+ */
286
+ function compareValues(a: any, b: any, direction: 'asc' | 'desc'): number {
287
+ // Handle undefined/null values
288
+ if (a === undefined || a === null) return direction === 'asc' ? -1 : 1;
289
+ if (b === undefined || b === null) return direction === 'asc' ? 1 : -1;
290
+
291
+ // Handle numbers properly to ensure numeric comparison
292
+ if (typeof a === 'number' && typeof b === 'number') {
293
+ return direction === 'asc'
294
+ ? a - b
295
+ : b - a;
296
+ }
297
+
298
+ // Handle dates (convert to timestamps for comparison)
299
+ if (a instanceof Date && b instanceof Date) {
300
+ return direction === 'asc'
301
+ ? a.getTime() - b.getTime()
302
+ : b.getTime() - a.getTime();
303
+ }
304
+
305
+ // Handle strings or mixed types with string conversion
306
+ const aStr = String(a);
307
+ const bStr = String(b);
308
+
309
+ return direction === 'asc'
310
+ ? aStr.localeCompare(bStr)
311
+ : bStr.localeCompare(aStr);
312
+ }
313
+
314
+ /**
315
+ * Convert a type-safe where filter to Supabase filter string
316
+ * Note: Complex OR/AND operations may not be fully supported in realtime filters
317
+ * and will fall back to client-side filtering
318
+ */
319
+ export function buildFilterString<T>(where?: T): string | undefined {
320
+ if (!where) return undefined;
321
+
322
+ const whereObj = where as any;
323
+
324
+ // Check for OR/AND operations - these are complex for realtime filters
325
+ if (whereObj.OR || whereObj.AND) {
326
+ console.log('⚠️ Complex OR/AND filters detected - realtime will use client-side filtering');
327
+ // For complex logical operations, we'll rely on client-side filtering
328
+ // Return undefined to indicate no database-level filter should be applied
329
+ return undefined;
330
+ }
331
+
332
+ const filters: string[] = [];
333
+
334
+ for (const [key, value] of Object.entries(whereObj)) {
335
+ if (value !== undefined && key !== 'OR' && key !== 'AND') {
336
+ if (typeof value === 'object' && value !== null) {
337
+ // Handle advanced operators
338
+ const advancedOps = value as unknown as FilterOperators<any>;
339
+
340
+ if ('equals' in advancedOps && advancedOps.equals !== undefined) {
341
+ filters.push(`${key}=eq.${advancedOps.equals}`);
342
+ }
343
+
344
+ if ('not' in advancedOps && advancedOps.not !== undefined) {
345
+ filters.push(`${key}=neq.${advancedOps.not}`);
346
+ }
347
+
348
+ if ('gt' in advancedOps && advancedOps.gt !== undefined) {
349
+ const value = advancedOps.gt instanceof Date ? advancedOps.gt.toISOString() : advancedOps.gt;
350
+ filters.push(`${key}=gt.${value}`);
351
+ }
352
+
353
+ if ('gte' in advancedOps && advancedOps.gte !== undefined) {
354
+ const value = advancedOps.gte instanceof Date ? advancedOps.gte.toISOString() : advancedOps.gte;
355
+ filters.push(`${key}=gte.${value}`);
356
+ }
357
+
358
+ if ('lt' in advancedOps && advancedOps.lt !== undefined) {
359
+ const value = advancedOps.lt instanceof Date ? advancedOps.lt.toISOString() : advancedOps.lt;
360
+ filters.push(`${key}=lt.${value}`);
361
+ }
362
+
363
+ if ('lte' in advancedOps && advancedOps.lte !== undefined) {
364
+ const value = advancedOps.lte instanceof Date ? advancedOps.lte.toISOString() : advancedOps.lte;
365
+ filters.push(`${key}=lte.${value}`);
366
+ }
367
+
368
+ if ('in' in advancedOps && advancedOps.in?.length) {
369
+ filters.push(`${key}=in.(${advancedOps.in.join(',')})`);
370
+ }
371
+
372
+ if ('contains' in advancedOps && advancedOps.contains !== undefined) {
373
+ filters.push(`${key}=ilike.*${advancedOps.contains}*`);
374
+ }
375
+
376
+ if ('startsWith' in advancedOps && advancedOps.startsWith !== undefined) {
377
+ filters.push(`${key}=ilike.${advancedOps.startsWith}%`);
378
+ }
379
+
380
+ if ('endsWith' in advancedOps && advancedOps.endsWith !== undefined) {
381
+ filters.push(`${key}=ilike.%${advancedOps.endsWith}`);
382
+ }
383
+
384
+ // Array-specific operators
385
+ if ('has' in advancedOps && advancedOps.has !== undefined) {
386
+ // Array contains ANY of the specified items (overlaps)
387
+ const arrayValue = JSON.stringify(advancedOps.has);
388
+ filters.push(`${key}=ov.${arrayValue}`);
389
+ }
390
+
391
+ if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
392
+ // Array contains ALL of the specified items (contains)
393
+ const arrayValue = JSON.stringify(advancedOps.hasEvery);
394
+ filters.push(`${key}=cs.${arrayValue}`);
395
+ }
396
+
397
+ if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
398
+ // Array contains ANY of the specified items (overlaps)
399
+ const arrayValue = JSON.stringify(advancedOps.hasSome);
400
+ filters.push(`${key}=ov.${arrayValue}`);
401
+ }
402
+
403
+ if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
404
+ if (advancedOps.isEmpty) {
405
+ // Check if array is empty
406
+ filters.push(`${key}=eq.{}`);
407
+ } else {
408
+ // Check if array is not empty
409
+ filters.push(`${key}=neq.{}`);
410
+ }
411
+ }
412
+ } else {
413
+ // Simple equality
414
+ filters.push(`${key}=eq.${value}`);
415
+ }
416
+ }
417
+ }
418
+
419
+ return filters.length > 0 ? filters.join(',') : undefined;
420
+ }
421
+
422
+ /**
423
+ * Apply a single condition group to the query builder
424
+ */
425
+ function applyConditionGroup<T>(
426
+ query: SupabaseQueryBuilder,
427
+ conditions: T
428
+ ): SupabaseQueryBuilder {
429
+ if (!conditions) return query;
430
+
431
+ let filteredQuery = query;
432
+
433
+ for (const [key, value] of Object.entries(conditions)) {
434
+ if (value !== undefined && key !== 'OR' && key !== 'AND') {
435
+ if (typeof value === 'object' && value !== null) {
436
+ // Handle advanced operators
437
+ const advancedOps = value as unknown as FilterOperators<any>;
438
+
439
+ if ('equals' in advancedOps && advancedOps.equals !== undefined) {
440
+ // @ts-ignore: Supabase typing issue
441
+ filteredQuery = filteredQuery.eq(key, advancedOps.equals);
442
+ }
443
+
444
+ if ('not' in advancedOps && advancedOps.not !== undefined) {
445
+ // @ts-ignore: Supabase typing issue
446
+ filteredQuery = filteredQuery.neq(key, advancedOps.not);
447
+ }
448
+
449
+ if ('gt' in advancedOps && advancedOps.gt !== undefined) {
450
+ // Convert Date objects to ISO strings for Supabase
451
+ const value = advancedOps.gt instanceof Date ? advancedOps.gt.toISOString() : advancedOps.gt;
452
+ // @ts-ignore: Supabase typing issue
453
+ filteredQuery = filteredQuery.gt(key, value);
454
+ }
455
+
456
+ if ('gte' in advancedOps && advancedOps.gte !== undefined) {
457
+ // Convert Date objects to ISO strings for Supabase
458
+ const value = advancedOps.gte instanceof Date ? advancedOps.gte.toISOString() : advancedOps.gte;
459
+ // @ts-ignore: Supabase typing issue
460
+ filteredQuery = filteredQuery.gte(key, value);
461
+ }
462
+
463
+ if ('lt' in advancedOps && advancedOps.lt !== undefined) {
464
+ // Convert Date objects to ISO strings for Supabase
465
+ const value = advancedOps.lt instanceof Date ? advancedOps.lt.toISOString() : advancedOps.lt;
466
+ // @ts-ignore: Supabase typing issue
467
+ filteredQuery = filteredQuery.lt(key, value);
468
+ }
469
+
470
+ if ('lte' in advancedOps && advancedOps.lte !== undefined) {
471
+ // Convert Date objects to ISO strings for Supabase
472
+ const value = advancedOps.lte instanceof Date ? advancedOps.lte.toISOString() : advancedOps.lte;
473
+ // @ts-ignore: Supabase typing issue
474
+ filteredQuery = filteredQuery.lte(key, value);
475
+ }
476
+
477
+ if ('in' in advancedOps && advancedOps.in?.length) {
478
+ // @ts-ignore: Supabase typing issue
479
+ filteredQuery = filteredQuery.in(key, advancedOps.in);
480
+ }
481
+
482
+ if ('contains' in advancedOps && advancedOps.contains !== undefined) {
483
+ // @ts-ignore: Supabase typing issue
484
+ filteredQuery = filteredQuery.ilike(key, `*${advancedOps.contains}*`);
485
+ }
486
+
487
+ if ('startsWith' in advancedOps && advancedOps.startsWith !== undefined) {
488
+ // @ts-ignore: Supabase typing issue
489
+ filteredQuery = filteredQuery.ilike(key, `${advancedOps.startsWith}%`);
490
+ }
491
+
492
+ if ('endsWith' in advancedOps && advancedOps.endsWith !== undefined) {
493
+ // @ts-ignore: Supabase typing issue
494
+ filteredQuery = filteredQuery.ilike(key, `%${advancedOps.endsWith}`);
495
+ }
496
+
497
+ // Array-specific operators
498
+ if ('has' in advancedOps && advancedOps.has !== undefined) {
499
+ // Array contains ANY of the specified items (overlaps)
500
+ // @ts-ignore: Supabase typing issue
501
+ filteredQuery = filteredQuery.overlaps(key, advancedOps.has);
502
+ }
503
+
504
+ if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
505
+ // Array contains ALL of the specified items (contains)
506
+ // @ts-ignore: Supabase typing issue
507
+ filteredQuery = filteredQuery.contains(key, advancedOps.hasEvery);
508
+ }
509
+
510
+ if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
511
+ // Array contains ANY of the specified items (overlaps)
512
+ // @ts-ignore: Supabase typing issue
513
+ filteredQuery = filteredQuery.overlaps(key, advancedOps.hasSome);
514
+ }
515
+
516
+ if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
517
+ if (advancedOps.isEmpty) {
518
+ // Check if array is empty
519
+ // @ts-ignore: Supabase typing issue
520
+ filteredQuery = filteredQuery.eq(key, []);
521
+ } else {
522
+ // Check if array is not empty
523
+ // @ts-ignore: Supabase typing issue
524
+ filteredQuery = filteredQuery.neq(key, []);
525
+ }
526
+ }
527
+ } else {
528
+ // Simple equality
529
+ // @ts-ignore: Supabase typing issue
530
+ filteredQuery = filteredQuery.eq(key, value);
531
+ }
532
+ }
533
+ }
534
+
535
+ return filteredQuery;
536
+ }
537
+
538
+ /**
539
+ * Apply filter to the query builder with OR/AND support
540
+ */
541
+ export function applyFilter<T>(
542
+ query: SupabaseQueryBuilder,
543
+ where: T
544
+ ): SupabaseQueryBuilder {
545
+ if (!where) return query;
546
+
547
+ const whereObj = where as any;
548
+ let filteredQuery = query;
549
+
550
+ // Handle regular conditions first (these are implicitly AND-ed)
551
+ filteredQuery = applyConditionGroup(filteredQuery, whereObj);
552
+
553
+ // Handle OR conditions
554
+ if (whereObj.OR && Array.isArray(whereObj.OR) && whereObj.OR.length > 0) {
555
+ // @ts-ignore: Supabase typing issue
556
+ filteredQuery = filteredQuery.or(
557
+ whereObj.OR.map((orCondition: any, index: number) => {
558
+ // Convert each OR condition to a filter string
559
+ const orFilters: string[] = [];
560
+
561
+ for (const [key, value] of Object.entries(orCondition)) {
562
+ if (value !== undefined && key !== 'OR' && key !== 'AND') {
563
+ if (typeof value === 'object' && value !== null) {
564
+ const advancedOps = value as unknown as FilterOperators<any>;
565
+
566
+ if ('equals' in advancedOps && advancedOps.equals !== undefined) {
567
+ orFilters.push(`${key}.eq.${advancedOps.equals}`);
568
+ } else if ('not' in advancedOps && advancedOps.not !== undefined) {
569
+ orFilters.push(`${key}.neq.${advancedOps.not}`);
570
+ } else if ('gt' in advancedOps && advancedOps.gt !== undefined) {
571
+ const value = advancedOps.gt instanceof Date ? advancedOps.gt.toISOString() : advancedOps.gt;
572
+ orFilters.push(`${key}.gt.${value}`);
573
+ } else if ('gte' in advancedOps && advancedOps.gte !== undefined) {
574
+ const value = advancedOps.gte instanceof Date ? advancedOps.gte.toISOString() : advancedOps.gte;
575
+ orFilters.push(`${key}.gte.${value}`);
576
+ } else if ('lt' in advancedOps && advancedOps.lt !== undefined) {
577
+ const value = advancedOps.lt instanceof Date ? advancedOps.lt.toISOString() : advancedOps.lt;
578
+ orFilters.push(`${key}.lt.${value}`);
579
+ } else if ('lte' in advancedOps && advancedOps.lte !== undefined) {
580
+ const value = advancedOps.lte instanceof Date ? advancedOps.lte.toISOString() : advancedOps.lte;
581
+ orFilters.push(`${key}.lte.${value}`);
582
+ } else if ('in' in advancedOps && advancedOps.in?.length) {
583
+ orFilters.push(`${key}.in.(${advancedOps.in.join(',')})`);
584
+ } else if ('contains' in advancedOps && advancedOps.contains !== undefined) {
585
+ orFilters.push(`${key}.ilike.*${advancedOps.contains}*`);
586
+ } else if ('startsWith' in advancedOps && advancedOps.startsWith !== undefined) {
587
+ orFilters.push(`${key}.ilike.${advancedOps.startsWith}%`);
588
+ } else if ('endsWith' in advancedOps && advancedOps.endsWith !== undefined) {
589
+ orFilters.push(`${key}.ilike.%${advancedOps.endsWith}`);
590
+ } else if ('has' in advancedOps && advancedOps.has !== undefined) {
591
+ orFilters.push(`${key}.ov.${JSON.stringify(advancedOps.has)}`);
592
+ } else if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
593
+ orFilters.push(`${key}.cs.${JSON.stringify(advancedOps.hasEvery)}`);
594
+ } else if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
595
+ orFilters.push(`${key}.ov.${JSON.stringify(advancedOps.hasSome)}`);
596
+ } else if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
597
+ if (advancedOps.isEmpty) {
598
+ orFilters.push(`${key}.eq.{}`);
599
+ } else {
600
+ orFilters.push(`${key}.neq.{}`);
601
+ }
602
+ }
603
+ } else {
604
+ // Simple equality
605
+ orFilters.push(`${key}.eq.${value}`);
606
+ }
607
+ }
608
+ }
609
+
610
+ return orFilters.join(',');
611
+ }).join(',')
612
+ );
613
+ }
614
+
615
+ // Handle AND conditions (these are applied in addition to regular conditions)
616
+ if (whereObj.AND && Array.isArray(whereObj.AND) && whereObj.AND.length > 0) {
617
+ for (const andCondition of whereObj.AND) {
618
+ filteredQuery = applyConditionGroup(filteredQuery, andCondition);
619
+ }
620
+ }
621
+
622
+ return filteredQuery;
623
+ }
624
+
625
+ /**
626
+ * Evaluate if a record matches filter criteria (including OR/AND logic)
627
+ */
628
+ function matchesFilter<T>(record: any, filter: T): boolean {
629
+ if (!filter) return true;
630
+
631
+ const filterObj = filter as any;
632
+
633
+ // Separate regular conditions from OR/AND
634
+ const hasOr = filterObj.OR && Array.isArray(filterObj.OR) && filterObj.OR.length > 0;
635
+ const hasAnd = filterObj.AND && Array.isArray(filterObj.AND) && filterObj.AND.length > 0;
636
+
637
+ // Check regular field conditions (these are implicitly AND-ed)
638
+ const regularConditions: any = {};
639
+ for (const [key, value] of Object.entries(filterObj)) {
640
+ if (value !== undefined && key !== 'OR' && key !== 'AND') {
641
+ regularConditions[key] = value;
642
+ }
643
+ }
644
+
645
+ // Helper function to convert values to comparable format for date/time comparisons
646
+ const getComparableValue = (value: any): any => {
647
+ if (value instanceof Date) {
648
+ return value.getTime();
649
+ }
650
+ if (typeof value === 'string' && value.match(/^d{4}-d{2}-d{2}Td{2}:d{2}:d{2}/)) {
651
+ // ISO date string
652
+ return new Date(value).getTime();
653
+ }
654
+ return value;
655
+ };
656
+
657
+ // Helper function to check individual field conditions
658
+ const checkFieldConditions = (conditions: any): boolean => {
659
+ for (const [key, value] of Object.entries(conditions)) {
660
+ if (value !== undefined) {
661
+ const recordValue = record[key];
662
+
663
+ if (typeof value === 'object' && value !== null) {
664
+ // Handle advanced operators
665
+ const advancedOps = value as unknown as FilterOperators<any>;
666
+
667
+ if ('equals' in advancedOps && advancedOps.equals !== undefined) {
668
+ if (recordValue !== advancedOps.equals) return false;
669
+ }
670
+
671
+ if ('not' in advancedOps && advancedOps.not !== undefined) {
672
+ if (recordValue === advancedOps.not) return false;
673
+ }
674
+
675
+ if ('gt' in advancedOps && advancedOps.gt !== undefined) {
676
+ const recordComparable = getComparableValue(recordValue);
677
+ const filterComparable = getComparableValue(advancedOps.gt);
678
+ if (!(recordComparable > filterComparable)) return false;
679
+ }
680
+
681
+ if ('gte' in advancedOps && advancedOps.gte !== undefined) {
682
+ const recordComparable = getComparableValue(recordValue);
683
+ const filterComparable = getComparableValue(advancedOps.gte);
684
+ if (!(recordComparable >= filterComparable)) return false;
685
+ }
686
+
687
+ if ('lt' in advancedOps && advancedOps.lt !== undefined) {
688
+ const recordComparable = getComparableValue(recordValue);
689
+ const filterComparable = getComparableValue(advancedOps.lt);
690
+ if (!(recordComparable < filterComparable)) return false;
691
+ }
692
+
693
+ if ('lte' in advancedOps && advancedOps.lte !== undefined) {
694
+ const recordComparable = getComparableValue(recordValue);
695
+ const filterComparable = getComparableValue(advancedOps.lte);
696
+ if (!(recordComparable <= filterComparable)) return false;
697
+ }
698
+
699
+ if ('in' in advancedOps && advancedOps.in?.length) {
700
+ if (!advancedOps.in.includes(recordValue)) return false;
701
+ }
702
+
703
+ if ('contains' in advancedOps && advancedOps.contains !== undefined) {
704
+ if (!recordValue || !String(recordValue).toLowerCase().includes(String(advancedOps.contains).toLowerCase())) return false;
705
+ }
706
+
707
+ if ('startsWith' in advancedOps && advancedOps.startsWith !== undefined) {
708
+ if (!recordValue || !String(recordValue).toLowerCase().startsWith(String(advancedOps.startsWith).toLowerCase())) return false;
709
+ }
710
+
711
+ if ('endsWith' in advancedOps && advancedOps.endsWith !== undefined) {
712
+ if (!recordValue || !String(recordValue).toLowerCase().endsWith(String(advancedOps.endsWith).toLowerCase())) return false;
713
+ }
714
+
715
+ // Array-specific operators
716
+ if ('has' in advancedOps && advancedOps.has !== undefined) {
717
+ if (!Array.isArray(recordValue) || !advancedOps.has.some((item: any) => recordValue.includes(item))) return false;
718
+ }
719
+
720
+ if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
721
+ if (!Array.isArray(recordValue) || !advancedOps.hasEvery.every((item: any) => recordValue.includes(item))) return false;
722
+ }
723
+
724
+ if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
725
+ if (!Array.isArray(recordValue) || !advancedOps.hasSome.some((item: any) => recordValue.includes(item))) return false;
726
+ }
727
+
728
+ if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
729
+ const isEmpty = !Array.isArray(recordValue) || recordValue.length === 0;
730
+ if (isEmpty !== advancedOps.isEmpty) return false;
731
+ }
732
+ } else {
733
+ // Simple equality
734
+ if (recordValue !== value) return false;
735
+ }
736
+ }
737
+ }
738
+ return true;
739
+ };
740
+
741
+ // All conditions that must be true
742
+ const conditions: boolean[] = [];
743
+
744
+ // Regular field conditions (implicitly AND-ed)
745
+ if (Object.keys(regularConditions).length > 0) {
746
+ conditions.push(checkFieldConditions(regularConditions));
747
+ }
748
+
749
+ // AND conditions (all must be true)
750
+ if (hasAnd) {
751
+ const andResult = filterObj.AND.every((andCondition: any) => matchesFilter(record, andCondition));
752
+ conditions.push(andResult);
753
+ }
754
+
755
+ // OR conditions (at least one must be true)
756
+ if (hasOr) {
757
+ const orResult = filterObj.OR.some((orCondition: any) => matchesFilter(record, orCondition));
758
+ conditions.push(orResult);
759
+ }
760
+
761
+ // All conditions must be true
762
+ return conditions.every(condition => condition);
763
+ }
764
+
765
+ /**
766
+ * Build a Supabase select string from select and include options.
767
+ *
768
+ * @param select - Object specifying which fields to select { field: true }
769
+ * @param include - Object specifying which relations to include { relation: true }
770
+ * @returns A Supabase-compatible select string
771
+ *
772
+ * @example
773
+ * // Select specific fields
774
+ * buildSelectString({ id: true, name: true }) // Returns "id,name"
775
+ *
776
+ * @example
777
+ * // Include relations
778
+ * buildSelectString(undefined, { posts: true }) // Returns "*,posts(*)"
779
+ *
780
+ * @example
781
+ * // Select fields and include relations with specific fields
782
+ * buildSelectString({ id: true, name: true }, { posts: { select: { id: true, title: true } } })
783
+ * // Returns "id,name,posts(id,title)"
784
+ */
785
+ export function buildSelectString<TSelect, TInclude>(
786
+ select?: TSelect,
787
+ include?: TInclude
788
+ ): string {
789
+ const parts: string[] = [];
790
+
791
+ // Handle select - if provided, only return specified fields
792
+ if (select && typeof select === 'object') {
793
+ const selectedFields = Object.entries(select)
794
+ .filter(([_, value]) => value === true)
795
+ .map(([key]) => key);
796
+
797
+ if (selectedFields.length > 0) {
798
+ parts.push(...selectedFields);
799
+ }
800
+ }
801
+
802
+ // Handle include - add related records
803
+ if (include && typeof include === 'object') {
804
+ for (const [relationName, relationValue] of Object.entries(include)) {
805
+ if (relationValue === true) {
806
+ // Include all fields from the relation
807
+ parts.push(`${relationName}(*)`);
808
+ } else if (typeof relationValue === 'object' && relationValue !== null) {
809
+ // Include specific fields from the relation
810
+ const relationOptions = relationValue as { select?: Record<string, boolean> };
811
+ if (relationOptions.select) {
812
+ const relationFields = Object.entries(relationOptions.select)
813
+ .filter(([_, value]) => value === true)
814
+ .map(([key]) => key);
815
+
816
+ if (relationFields.length > 0) {
817
+ parts.push(`${relationName}(${relationFields.join(',')})`);
818
+ } else {
819
+ parts.push(`${relationName}(*)`);
820
+ }
821
+ } else {
822
+ parts.push(`${relationName}(*)`);
823
+ }
824
+ }
825
+ }
826
+ }
827
+
828
+ // If no select specified but include is, we need to include base table fields too
829
+ if (parts.length === 0) {
830
+ return '*';
831
+ }
832
+
833
+ // If only include was specified (no select), we need all base fields plus relations
834
+ if (!select && include) {
835
+ return '*,' + parts.join(',');
836
+ }
837
+
838
+ return parts.join(',');
839
+ }
840
+
841
+ /**
842
+ * Apply order by to the query builder
843
+ */
844
+ export function applyOrderBy<T>(
845
+ query: SupabaseQueryBuilder,
846
+ orderBy?: T,
847
+ hasCreatedAt?: boolean,
848
+ createdAtField: string = 'createdAt'
849
+ ): SupabaseQueryBuilder {
850
+ if (!orderBy) {
851
+ // By default, sort by createdAt if available, using the actual field name from Prisma
852
+ if (hasCreatedAt) {
853
+ // @ts-ignore: Supabase typing issue
854
+ return query.order(createdAtField, { ascending: false });
855
+ }
856
+ return query;
857
+ }
858
+
859
+ // Apply each order by clause
860
+ let orderedQuery = query;
861
+
862
+ // Handle orderBy as array or single object
863
+ const orderByArray = Array.isArray(orderBy) ? orderBy : [orderBy];
864
+
865
+ for (const orderByClause of orderByArray) {
866
+ for (const [key, direction] of Object.entries(orderByClause)) {
867
+ // @ts-ignore: Supabase typing issue
868
+ orderedQuery = orderedQuery.order(key, {
869
+ ascending: direction === 'asc'
870
+ });
871
+ }
872
+ }
873
+
874
+ return orderedQuery;
875
+ }
876
+
877
+ /**
878
+ * Core hook factory function that creates a type-safe realtime hook for a specific model.
879
+ * This is the foundation for all Suparisma hooks.
880
+ */
881
+ export function createSuparismaHook<
882
+ TModel,
883
+ TWithRelations,
884
+ TCreateInput,
885
+ TUpdateInput,
886
+ TWhereInput,
887
+ TWhereUniqueInput,
888
+ TOrderByInput
889
+ >(config: {
890
+ tableName: string;
891
+ hasCreatedAt: boolean;
892
+ hasUpdatedAt: boolean;
893
+ searchFields?: string[];
894
+ defaultValues?: Record<string, string>;
895
+ createdAtField?: string;
896
+ updatedAtField?: string;
897
+ }) {
898
+ const {
899
+ tableName,
900
+ hasCreatedAt,
901
+ hasUpdatedAt,
902
+ searchFields = [],
903
+ defaultValues = {},
904
+ createdAtField = 'createdAt',
905
+ updatedAtField = 'updatedAt'
906
+ } = config;
907
+
908
+ /**
909
+ * The main hook function that provides all data access methods for a model.
910
+ *
911
+ * @param options - Optional configuration for data fetching, filtering, and realtime
912
+ *
913
+ * @returns An API object with data state and CRUD methods
914
+ *
915
+ * @example
916
+ * // Basic usage
917
+ * const users = useSuparismaUser();
918
+ * const { data, loading, error } = users;
919
+ *
920
+ * @example
921
+ * // With filtering
922
+ * const users = useSuparismaUser({
923
+ * where: { role: 'admin' },
924
+ * orderBy: { created_at: 'desc' }
925
+ * });
926
+ */
927
+ return function useSuparismaHook(options: SuparismaOptions<TWhereInput, TOrderByInput> = {}) {
928
+ const {
929
+ realtime = true,
930
+ channelName,
931
+ where,
932
+ realtimeFilter,
933
+ orderBy,
934
+ limit,
935
+ offset,
936
+ select,
937
+ include,
938
+ } = options;
939
+
940
+ // Build the select string once for reuse
941
+ const selectString = buildSelectString(select, include);
942
+
943
+ // Refs to store the latest options for realtime handlers
944
+ const whereRef = useRef(where);
945
+ const orderByRef = useRef(orderBy);
946
+ const limitRef = useRef(limit);
947
+ const offsetRef = useRef(offset);
948
+ const selectStringRef = useRef(selectString);
949
+
950
+ // Update refs whenever options change
951
+ useEffect(() => {
952
+ whereRef.current = where;
953
+ }, [where]);
954
+
955
+ useEffect(() => {
956
+ orderByRef.current = orderBy;
957
+ }, [orderBy]);
958
+
959
+ useEffect(() => {
960
+ limitRef.current = limit;
961
+ }, [limit]);
962
+
963
+ useEffect(() => {
964
+ offsetRef.current = offset;
965
+ }, [offset]);
966
+
967
+ useEffect(() => {
968
+ selectStringRef.current = selectString;
969
+ }, [selectString]);
970
+
971
+ // Single data collection for holding results
972
+ const [data, setData] = useState<TWithRelations[]>([]);
973
+ const [error, setError] = useState<Error | null>(null);
974
+ const [loading, setLoading] = useState<boolean>(true);
975
+
976
+ // This is the total count, unaffected by pagination limits
977
+ const [count, setCount] = useState<number>(0);
978
+
979
+ // Search state
980
+ const [searchQueries, setSearchQueries] = useState<SearchQuery[]>([]);
981
+ const [searchLoading, setSearchLoading] = useState<boolean>(false);
982
+
983
+ const initialLoadRef = useRef(false);
984
+ const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
985
+ const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
986
+ const isSearchingRef = useRef<boolean>(false);
987
+
988
+ // Function to fetch the total count from Supabase with current filters
989
+ const fetchTotalCount = useCallback(async () => {
990
+ try {
991
+ // Skip count updates during search
992
+ if (isSearchingRef.current) return;
993
+
994
+ let countQuery = supabase.from(tableName).select('*', { count: 'exact', head: true });
995
+
996
+ // Apply where conditions if provided
997
+ if (where) {
998
+ countQuery = applyFilter(countQuery, where);
999
+ }
1000
+
1001
+ const { count: totalCount, error: countError } = await countQuery;
1002
+
1003
+ if (!countError) {
1004
+ setCount(totalCount || 0);
1005
+ }
1006
+ } catch (err) {
1007
+ console.error(`Error fetching count for ${tableName}:`, err);
1008
+ }
1009
+ }, [where, tableName]);
1010
+
1011
+ // Update total count whenever where filter changes
1012
+ useEffect(() => {
1013
+ fetchTotalCount();
1014
+ }, [fetchTotalCount]);
1015
+
1016
+ // Create the search state object with all required methods
1017
+ const search: SearchState = {
1018
+ queries: searchQueries,
1019
+ loading: searchLoading,
1020
+
1021
+ // Set all search queries at once
1022
+ setQueries: useCallback((queries: SearchQuery[]) => {
1023
+ // Validate that all fields are searchable
1024
+ const validQueries = queries.filter(query =>
1025
+ searchFields.includes(query.field) && query.value.trim() !== ''
1026
+ );
1027
+
1028
+ setSearchQueries(validQueries);
1029
+
1030
+ // Execute search if there are valid queries
1031
+ if (validQueries.length > 0) {
1032
+ executeSearch(validQueries);
1033
+ } else {
1034
+ // If no valid queries, reset to normal data fetching
1035
+ isSearchingRef.current = false;
1036
+ findMany({ where, orderBy, take: limit, skip: offset });
1037
+ }
1038
+ }, [where, orderBy, limit, offset]),
1039
+
1040
+ // Add a single search query
1041
+ addQuery: useCallback((query: SearchQuery) => {
1042
+ // Validate that the field is searchable
1043
+ if (!searchFields.includes(query.field) || query.value.trim() === '') {
1044
+ return;
1045
+ }
1046
+
1047
+ setSearchQueries(prev => {
1048
+ // Replace if query for this field already exists, otherwise add
1049
+ const exists = prev.some(q => q.field === query.field);
1050
+ const newQueries = exists
1051
+ ? prev.map(q => q.field === query.field ? query : q)
1052
+ : [...prev, query];
1053
+
1054
+ // Execute search with updated queries
1055
+ executeSearch(newQueries);
1056
+
1057
+ return newQueries;
1058
+ });
1059
+ }, []),
1060
+
1061
+ // Remove a search query by field
1062
+ removeQuery: useCallback((field: string) => {
1063
+ setSearchQueries(prev => {
1064
+ const newQueries = prev.filter(q => q.field !== field);
1065
+
1066
+ // If we still have queries, execute search with remaining queries
1067
+ if (newQueries.length > 0) {
1068
+ executeSearch(newQueries);
1069
+ } else {
1070
+ // If no queries left, reset to normal data fetching
1071
+ isSearchingRef.current = false;
1072
+ findMany({ where, orderBy, take: limit, skip: offset });
1073
+ }
1074
+
1075
+ return newQueries;
1076
+ });
1077
+ }, [where, orderBy, limit, offset]),
1078
+
1079
+ // Clear all search queries
1080
+ clearQueries: useCallback(() => {
1081
+ setSearchQueries([]);
1082
+ isSearchingRef.current = false;
1083
+ findMany({ where, orderBy, take: limit, skip: offset });
1084
+ }, [where, orderBy, limit, offset]),
1085
+
1086
+ // Search across multiple fields (convenience method)
1087
+ searchMultiField: useCallback((value: string) => {
1088
+ if (searchFields.length <= 1) {
1089
+ console.warn('Multi-field search requires at least 2 searchable fields');
1090
+ return;
1091
+ }
1092
+
1093
+ setSearchQueries([{ field: 'multi', value }]);
1094
+ executeSearch([{ field: 'multi', value }]);
1095
+ }, [searchFields.length]),
1096
+
1097
+ // Search in a specific field (convenience method)
1098
+ searchField: useCallback((field: string, value: string) => {
1099
+ if (!searchFields.includes(field)) {
1100
+ console.warn(`Field "${field}" is not searchable. Available fields: ${searchFields.join(', ')}`);
1101
+ return;
1102
+ }
1103
+
1104
+ setSearchQueries([{ field, value }]);
1105
+ executeSearch([{ field, value }]);
1106
+ }, [searchFields]),
1107
+
1108
+ // Get current search terms for custom highlighting
1109
+ getCurrentSearchTerms: useCallback(() => {
1110
+ return searchQueries.map(q => q.value.trim());
1111
+ }, [searchQueries]),
1112
+
1113
+ // Safely escape regex special characters
1114
+ escapeRegex: useCallback((text: string) => {
1115
+ return escapeRegexCharacters(text);
1116
+ }, [])
1117
+ };
1118
+
1119
+ // Execute search based on queries
1120
+ const executeSearch = useCallback(async (queries: SearchQuery[]) => {
1121
+ // Clear the previous timeout
1122
+ if (searchTimeoutRef.current) {
1123
+ clearTimeout(searchTimeoutRef.current);
1124
+ }
1125
+
1126
+ // Skip if no searchable fields or no valid queries
1127
+ if (searchFields.length === 0 || queries.length === 0) {
1128
+ return;
1129
+ }
1130
+
1131
+ setSearchLoading(true);
1132
+ isSearchingRef.current = true;
1133
+
1134
+ // Use debounce to prevent rapid searches
1135
+ searchTimeoutRef.current = setTimeout(async () => {
1136
+ try {
1137
+ let results: TWithRelations[] = [];
1138
+
1139
+ // Validate search queries
1140
+ const validQueries = queries.filter(query => {
1141
+ if (!query.field || !query.value) {
1142
+ console.warn('Invalid search query - missing field or value:', query);
1143
+ return false;
1144
+ }
1145
+ // Allow "multi" as a special field for multi-field search
1146
+ if (query.field === 'multi' && searchFields.length > 1) {
1147
+ return true;
1148
+ }
1149
+ if (!searchFields.includes(query.field)) {
1150
+ console.warn(`Field "${query.field}" is not searchable. Available fields: ${searchFields.join(', ')}, or "multi" for multi-field search`);
1151
+ return false;
1152
+ }
1153
+ return true;
1154
+ });
1155
+
1156
+ if (validQueries.length === 0) {
1157
+ console.log('No valid search queries found');
1158
+ setData([]);
1159
+ setCount(0);
1160
+ return;
1161
+ }
1162
+
1163
+ // Execute RPC function for each query using Promise.all
1164
+ const searchPromises = validQueries.map(query => {
1165
+ // Build function name based on field type
1166
+ const functionName = query.field === 'multi'
1167
+ ? `search_${tableName.toLowerCase()}_multi_field`
1168
+ : `search_${tableName.toLowerCase()}_by_${query.field.toLowerCase()}_prefix`;
1169
+
1170
+ console.log(`🔍 Executing search: ${functionName}(search_prefix: "${query.value.trim()}")`);
1171
+
1172
+ // Call RPC function with proper error handling
1173
+ return Promise.resolve(supabase.rpc(functionName, { search_prefix: query.value.trim() }))
1174
+ .then((result: any) => ({
1175
+ ...result,
1176
+ queryField: query.field,
1177
+ queryValue: query.value
1178
+ }))
1179
+ .catch((error: any) => ({
1180
+ data: null,
1181
+ error: error,
1182
+ queryField: query.field,
1183
+ queryValue: query.value
1184
+ }));
1185
+ });
1186
+
1187
+ // Execute all search queries in parallel
1188
+ const searchResults = await Promise.all(searchPromises);
1189
+
1190
+ // Combine and deduplicate results
1191
+ const allResults: Record<string, TWithRelations> = {};
1192
+ let hasErrors = false;
1193
+
1194
+ // Process each search result
1195
+ searchResults.forEach((result: any, index: number) => {
1196
+ if (result.error) {
1197
+ console.error(`🔍 Search error for field "${result.queryField}" with value "${result.queryValue}":`, result.error);
1198
+ hasErrors = true;
1199
+ return;
1200
+ }
1201
+
1202
+ if (result.data && Array.isArray(result.data)) {
1203
+ console.log(`🔍 Search results for "${result.queryField}": ${result.data.length} items`);
1204
+
1205
+ // Add each result, using id as key to deduplicate
1206
+ for (const item of result.data as TWithRelations[]) {
1207
+ // @ts-ignore: Assume item has an id property
1208
+ if (item && typeof item === 'object' && 'id' in item && item.id) {
1209
+ // @ts-ignore: Add to results using id as key
1210
+ allResults[item.id] = item;
1211
+ }
1212
+ }
1213
+ } else if (result.data) {
1214
+ console.warn(`🔍 Unexpected search result format for "${result.queryField}":`, typeof result.data);
1215
+ }
1216
+ });
1217
+
1218
+ // Convert back to array
1219
+ results = Object.values(allResults);
1220
+ console.log(`🔍 Combined search results: ${results.length} unique items`);
1221
+
1222
+ // Apply any where conditions client-side (now using the proper filter function)
1223
+ if (where) {
1224
+ const originalCount = results.length;
1225
+ results = results.filter((item) => matchesFilter(item, where));
1226
+ console.log(`🔍 After applying where filter: ${results.length}/${originalCount} items`);
1227
+ }
1228
+
1229
+ // Set count directly for search results
1230
+ setCount(results.length);
1231
+
1232
+ // Apply ordering if needed (using the proper compare function)
1233
+ if (orderBy) {
1234
+ const orderByArray = Array.isArray(orderBy) ? orderBy : [orderBy];
1235
+ results = [...results].sort((a, b) => {
1236
+ for (const orderByClause of orderByArray) {
1237
+ for (const [field, direction] of Object.entries(orderByClause)) {
1238
+ const aValue = a[field as keyof typeof a];
1239
+ const bValue = b[field as keyof typeof b];
1240
+
1241
+ if (aValue === bValue) continue;
1242
+
1243
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
1244
+ }
1245
+ }
1246
+ return 0;
1247
+ });
1248
+ }
1249
+
1250
+ // Apply pagination if needed
1251
+ let paginatedResults = results;
1252
+ if (offset && offset > 0) {
1253
+ paginatedResults = paginatedResults.slice(offset);
1254
+ }
1255
+
1256
+ if (limit && limit > 0) {
1257
+ paginatedResults = paginatedResults.slice(0, limit);
1258
+ }
1259
+
1260
+ console.log(`🔍 Final search results: ${paginatedResults.length} items (total: ${results.length})`);
1261
+
1262
+ // Update data with search results
1263
+ setData(paginatedResults);
1264
+
1265
+ // Show error if there were issues but still show partial results
1266
+ if (hasErrors && results.length === 0) {
1267
+ setError(new Error('Search failed - please check if search functions are properly configured'));
1268
+ }
1269
+ } catch (err) {
1270
+ console.error('🔍 Search error:', err);
1271
+ setError(err as Error);
1272
+ setData([]);
1273
+ setCount(0);
1274
+ } finally {
1275
+ setSearchLoading(false);
1276
+ }
1277
+ }, 300); // 300ms debounce
1278
+ }, [tableName, searchFields, where, orderBy, limit, offset]);
1279
+
1280
+ /**
1281
+ * Fetch multiple records with support for filtering, sorting, and pagination.
1282
+ *
1283
+ * @param params - Query parameters for filtering and ordering records
1284
+ * @returns A promise with the fetched data or error
1285
+ *
1286
+ * @example
1287
+ * // Get all active users
1288
+ * const result = await users.findMany({
1289
+ * where: { active: true }
1290
+ * });
1291
+ *
1292
+ * @example
1293
+ * // Get 10 most recent posts with pagination
1294
+ * const page1 = await posts.findMany({
1295
+ * orderBy: { created_at: 'desc' },
1296
+ * take: 10,
1297
+ * skip: 0
1298
+ * });
1299
+ *
1300
+ * const page2 = await posts.findMany({
1301
+ * orderBy: { created_at: 'desc' },
1302
+ * take: 10,
1303
+ * skip: 10
1304
+ * });
1305
+ */
1306
+ const findMany = useCallback(async (params?: {
1307
+ where?: TWhereInput;
1308
+ orderBy?: TOrderByInput;
1309
+ take?: number;
1310
+ skip?: number;
1311
+ }): ModelResult<TWithRelations[]> => {
1312
+ try {
1313
+ setLoading(true);
1314
+ setError(null);
1315
+
1316
+ // Use selectString for field selection (includes relations if specified)
1317
+ let query = supabase.from(tableName).select(selectString);
1318
+
1319
+ // Apply where conditions if provided
1320
+ if (params?.where) {
1321
+ query = applyFilter(query, params.where);
1322
+ }
1323
+
1324
+ // Apply order by if provided
1325
+ if (params?.orderBy) {
1326
+ query = applyOrderBy(query, params.orderBy, hasCreatedAt, createdAtField);
1327
+ } else if (hasCreatedAt) {
1328
+ // Use the actual createdAt field name from Prisma
1329
+ // @ts-ignore: Supabase typing issue
1330
+ query = query.order(createdAtField, { ascending: false });
1331
+ }
1332
+
1333
+ // Apply limit if provided
1334
+ if (params?.take) {
1335
+ query = query.limit(params.take);
1336
+ }
1337
+
1338
+ // Apply offset if provided (for pagination)
1339
+ if (params?.skip !== undefined && params.skip >= 0) {
1340
+ query = query.range(params.skip, params.skip + (params.take || 10) - 1);
1341
+ }
1342
+
1343
+ const { data, error } = await query;
1344
+
1345
+ if (error) throw error;
1346
+
1347
+ const typedData = (data || []) as TWithRelations[];
1348
+
1349
+ // Only update data if not currently searching
1350
+ if (!isSearchingRef.current) {
1351
+ setData(typedData);
1352
+
1353
+ // If the where filter changed, update the total count
1354
+ if (JSON.stringify(params?.where) !== JSON.stringify(where)) {
1355
+ // Use our standard count fetching function instead of duplicating logic
1356
+ setTimeout(() => fetchTotalCount(), 0);
1357
+ }
1358
+ }
1359
+
1360
+ return { data: typedData, error: null };
1361
+ } catch (err: any) {
1362
+ console.error('Error finding records:', err);
1363
+ setError(err);
1364
+ return { data: null, error: err };
1365
+ } finally {
1366
+ setLoading(false);
1367
+ }
1368
+ }, [fetchTotalCount, where, tableName, hasCreatedAt, createdAtField]);
1369
+
1370
+ /**
1371
+ * Find a single record by its unique identifier (usually ID).
1372
+ *
1373
+ * @param where - The unique identifier to find the record by
1374
+ * @returns A promise with the found record or error
1375
+ *
1376
+ * @example
1377
+ * // Find user by ID
1378
+ * const result = await users.findUnique({ id: "123" });
1379
+ * if (result.data) {
1380
+ * console.log("Found user:", result.data.name);
1381
+ * }
1382
+ */
1383
+ const findUnique = useCallback(async (
1384
+ where: TWhereUniqueInput
1385
+ ): ModelResult<TWithRelations> => {
1386
+ try {
1387
+ setLoading(true);
1388
+ setError(null);
1389
+
1390
+ // Find the primary field (usually 'id')
1391
+ // @ts-ignore: Supabase typing issue
1392
+ const primaryKey = Object.keys(where)[0];
1393
+ if (!primaryKey) {
1394
+ throw new Error('A unique identifier is required');
1395
+ }
1396
+
1397
+ const value = where[primaryKey as keyof typeof where];
1398
+ if (value === undefined) {
1399
+ throw new Error('A unique identifier is required');
1400
+ }
1401
+
1402
+ const { data, error } = await supabase
1403
+ .from(tableName)
1404
+ .select(selectString)
1405
+ .eq(primaryKey, value)
1406
+ .maybeSingle();
1407
+
1408
+ if (error) throw error;
1409
+
1410
+ return { data: data as TWithRelations, error: null };
1411
+ } catch (err: any) {
1412
+ console.error('Error finding unique record:', err);
1413
+ setError(err);
1414
+ return { data: null, error: err };
1415
+ } finally {
1416
+ setLoading(false);
1417
+ }
1418
+ }, []);
1419
+
1420
+ // Set up realtime subscription for the list - ONCE and listen to ALL events
1421
+ useEffect(() => {
1422
+ if (!realtime) return;
1423
+
1424
+ // Clean up previous subscription if it exists
1425
+ if (channelRef.current) {
1426
+ channelRef.current.unsubscribe();
1427
+ channelRef.current = null;
1428
+ }
1429
+
1430
+ const channelId = channelName || `changes_to_${tableName}_${Math.random().toString(36).substring(2, 15)}`;
1431
+
1432
+ // ALWAYS listen to ALL events and filter client-side for maximum reliability
1433
+ let subscriptionConfig: any = {
1434
+ event: '*',
1435
+ schema: 'public',
1436
+ table: tableName,
1437
+ };
1438
+
1439
+ console.log(`Setting up subscription for ${tableName} - listening to ALL events (client-side filtering)`);
1440
+
1441
+ const channel = supabase
1442
+ .channel(channelId)
1443
+ .on(
1444
+ 'postgres_changes',
1445
+ subscriptionConfig,
1446
+ (payload) => {
1447
+ console.log(`🔥 REALTIME EVENT RECEIVED for ${tableName}:`, payload.eventType, payload);
1448
+
1449
+ // Access current options via refs inside the event handler
1450
+ const currentWhere = whereRef.current;
1451
+ const currentOrderBy = orderByRef.current;
1452
+ const currentLimit = limitRef.current;
1453
+ const currentOffset = offsetRef.current; // Not directly used in handlers but good for consistency
1454
+
1455
+ // Skip realtime updates when search is active
1456
+ if (isSearchingRef.current) {
1457
+ console.log('⏭️ Skipping realtime update - search is active');
1458
+ return;
1459
+ }
1460
+
1461
+ if (payload.eventType === 'INSERT') {
1462
+ // Process insert event
1463
+ setData((prev) => {
1464
+ try {
1465
+ const newRecord = payload.new as TWithRelations;
1466
+ console.log(`Processing INSERT for ${tableName}`, { newRecord });
1467
+
1468
+ // ALWAYS check if this record matches our filter client-side
1469
+ // This is especially important for complex OR/AND/array filters
1470
+ if (currentWhere && !matchesFilter(newRecord, currentWhere)) {
1471
+ console.log('New record does not match filter criteria, skipping');
1472
+ return prev;
1473
+ }
1474
+
1475
+ // Check if record already exists (avoid duplicates)
1476
+ const exists = prev.some(item =>
1477
+ // @ts-ignore: Supabase typing issue
1478
+ 'id' in item && 'id' in newRecord && item.id === newRecord.id
1479
+ );
1480
+
1481
+ if (exists) {
1482
+ console.log('Record already exists, skipping insertion');
1483
+ return prev;
1484
+ }
1485
+
1486
+ // Add the new record to the data
1487
+ let newData = [...prev, newRecord]; // Changed: Use spread on prev for immutability
1488
+
1489
+ // Apply ordering if specified
1490
+ if (currentOrderBy) { // Use ref value
1491
+ // Convert orderBy to array format for consistency if it's an object
1492
+ const orderByArray = Array.isArray(currentOrderBy)
1493
+ ? currentOrderBy
1494
+ : [currentOrderBy];
1495
+
1496
+ // Apply each sort in sequence
1497
+ newData = [...newData].sort((a, b) => {
1498
+ // Check each orderBy clause in sequence
1499
+ for (const orderByClause of orderByArray) {
1500
+ for (const [field, direction] of Object.entries(orderByClause)) {
1501
+ const aValue = a[field as keyof typeof a];
1502
+ const bValue = b[field as keyof typeof b];
1503
+
1504
+ // Skip if values are equal and move to next criterion
1505
+ if (aValue === bValue) continue;
1506
+
1507
+ // Use the compareValues function for proper type handling
1508
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
1509
+ }
1510
+ }
1511
+ return 0; // Equal if all criteria match
1512
+ });
1513
+ } else if (hasCreatedAt) {
1514
+ // Default sort by createdAt desc if no explicit sort but has timestamp
1515
+ newData = [...newData].sort((a, b) => {
1516
+ const aValue = a[createdAtField as keyof typeof a];
1517
+ const bValue = b[createdAtField as keyof typeof b];
1518
+ return compareValues(aValue, bValue, 'desc');
1519
+ });
1520
+ }
1521
+
1522
+ // Apply limit if specified
1523
+ if (currentLimit && currentLimit > 0) { // Use ref value
1524
+ newData = newData.slice(0, currentLimit);
1525
+ }
1526
+
1527
+ // Fetch the updated count after the data changes
1528
+ setTimeout(() => fetchTotalCount(), 0);
1529
+
1530
+ return newData;
1531
+ } catch (error) {
1532
+ console.error('Error processing INSERT event:', error);
1533
+ return prev;
1534
+ }
1535
+ });
1536
+ } else if (payload.eventType === 'UPDATE') {
1537
+ // Process update event
1538
+ setData((prev) => {
1539
+ // Access current options via refs
1540
+ const currentOrderBy = orderByRef.current;
1541
+ const currentLimit = limitRef.current; // If needed for re-fetch logic on update
1542
+ const currentWhere = whereRef.current;
1543
+
1544
+ // Skip if search is active
1545
+ if (isSearchingRef.current) {
1546
+ return prev;
1547
+ }
1548
+
1549
+ const updatedRecord = payload.new as TWithRelations;
1550
+
1551
+ // Check if the updated record still matches our current filter
1552
+ if (currentWhere && !matchesFilter(updatedRecord, currentWhere)) {
1553
+ console.log('Updated record no longer matches filter, removing from list');
1554
+ return prev.filter((item) =>
1555
+ // @ts-ignore: Supabase typing issue
1556
+ !('id' in item && 'id' in updatedRecord && item.id === updatedRecord.id)
1557
+ );
1558
+ }
1559
+
1560
+ const newData = prev.map((item) =>
1561
+ // @ts-ignore: Supabase typing issue
1562
+ 'id' in item && 'id' in payload.new && item.id === payload.new.id
1563
+ ? (payload.new as TWithRelations)
1564
+ : item
1565
+ );
1566
+
1567
+ // Apply ordering again after update to ensure consistency
1568
+ let sortedData = [...newData];
1569
+
1570
+ // Apply ordering if specified
1571
+ if (currentOrderBy) { // Use ref value
1572
+ // Convert orderBy to array format for consistency if it's an object
1573
+ const orderByArray = Array.isArray(currentOrderBy)
1574
+ ? currentOrderBy
1575
+ : [currentOrderBy];
1576
+
1577
+ // Apply each sort in sequence
1578
+ sortedData = sortedData.sort((a, b) => {
1579
+ // Check each orderBy clause in sequence
1580
+ for (const orderByClause of orderByArray) {
1581
+ for (const [field, direction] of Object.entries(orderByClause)) {
1582
+ const aValue = a[field as keyof typeof a];
1583
+ const bValue = b[field as keyof typeof b];
1584
+
1585
+ // Skip if values are equal and move to next criterion
1586
+ if (aValue === bValue) continue;
1587
+
1588
+ // Use the compareValues function for proper type handling
1589
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
1590
+ }
1591
+ }
1592
+ return 0; // Equal if all criteria match
1593
+ });
1594
+ } else if (hasCreatedAt) {
1595
+ // Default sort by createdAt desc if no explicit sort but has timestamp
1596
+ sortedData = sortedData.sort((a, b) => {
1597
+ const aValue = a[createdAtField as keyof typeof a];
1598
+ const bValue = b[createdAtField as keyof typeof b];
1599
+ return compareValues(aValue, bValue, 'desc');
1600
+ });
1601
+ }
1602
+
1603
+ // Fetch the updated count after the data changes
1604
+ // For updates, the count might not change but we fetch anyway to be consistent
1605
+ setTimeout(() => fetchTotalCount(), 0);
1606
+
1607
+ return sortedData;
1608
+ });
1609
+ } else if (payload.eventType === 'DELETE') {
1610
+ // Process delete event
1611
+ console.log('🗑️ Processing DELETE event for', tableName);
1612
+ setData((prev) => {
1613
+ console.log('🗑️ DELETE: Current data before deletion:', prev.length, 'items');
1614
+
1615
+ // Access current options via refs
1616
+ const currentWhere = whereRef.current;
1617
+ const currentOrderBy = orderByRef.current;
1618
+ const currentLimit = limitRef.current;
1619
+ const currentOffset = offsetRef.current;
1620
+
1621
+ // Skip if search is active
1622
+ if (isSearchingRef.current) {
1623
+ console.log('⏭️ DELETE: Skipping - search is active');
1624
+ return prev;
1625
+ }
1626
+
1627
+ // Save the current size before filtering
1628
+ const currentSize = prev.length;
1629
+
1630
+ // Filter out the deleted item
1631
+ const filteredData = prev.filter((item) => {
1632
+ // @ts-ignore: Supabase typing issue
1633
+ const shouldKeep = !('id' in item && 'id' in payload.old && item.id === payload.old.id);
1634
+ if (!shouldKeep) {
1635
+ console.log('🗑️ DELETE: Removing item with ID:', item.id);
1636
+ }
1637
+ return shouldKeep;
1638
+ });
1639
+
1640
+ console.log('🗑️ DELETE: Data after deletion:', filteredData.length, 'items (was', currentSize, ')');
1641
+
1642
+ // Fetch the updated count after the data changes
1643
+ setTimeout(() => fetchTotalCount(), 0);
1644
+
1645
+ // If we need to maintain the size with a limit
1646
+ if (currentLimit && currentLimit > 0 && filteredData.length < currentSize && currentSize === currentLimit) { // Use ref value
1647
+ console.log(`🗑️ DELETE: Record deleted with limit ${currentLimit}, will fetch additional record to maintain size`);
1648
+
1649
+ // Use setTimeout to ensure this state update completes first
1650
+ setTimeout(() => {
1651
+ findMany({
1652
+ where: currentWhere, // Use ref value
1653
+ orderBy: currentOrderBy, // Use ref value
1654
+ take: currentLimit, // Use ref value
1655
+ skip: currentOffset // Use ref value (passed as skip to findMany)
1656
+ });
1657
+ }, 0);
1658
+
1659
+ // Return the filtered data without resizing for now
1660
+ // The findMany call above will update the data later
1661
+ return filteredData;
1662
+ }
1663
+
1664
+ // Re-apply ordering to maintain consistency
1665
+ let sortedData = [...filteredData];
1666
+
1667
+ // Apply ordering if specified
1668
+ if (currentOrderBy) { // Use ref value
1669
+ // Convert orderBy to array format for consistency if it's an object
1670
+ const orderByArray = Array.isArray(currentOrderBy)
1671
+ ? currentOrderBy
1672
+ : [currentOrderBy];
1673
+
1674
+ // Apply each sort in sequence
1675
+ sortedData = sortedData.sort((a, b) => {
1676
+ // Check each orderBy clause in sequence
1677
+ for (const orderByClause of orderByArray) {
1678
+ for (const [field, direction] of Object.entries(orderByClause)) {
1679
+ const aValue = a[field as keyof typeof a];
1680
+ const bValue = b[field as keyof typeof b];
1681
+
1682
+ // Skip if values are equal and move to next criterion
1683
+ if (aValue === bValue) continue;
1684
+
1685
+ // Use the compareValues function for proper type handling
1686
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
1687
+ }
1688
+ }
1689
+ return 0; // Equal if all criteria match
1690
+ });
1691
+ } else if (hasCreatedAt) {
1692
+ // Default sort by createdAt desc if no explicit sort but has timestamp
1693
+ sortedData = sortedData.sort((a, b) => {
1694
+ const aValue = a[createdAtField as keyof typeof a];
1695
+ const bValue = b[createdAtField as keyof typeof b];
1696
+ return compareValues(aValue, bValue, 'desc');
1697
+ });
1698
+ }
1699
+
1700
+ return sortedData;
1701
+ });
1702
+ }
1703
+ }
1704
+ )
1705
+ .subscribe((status) => {
1706
+ console.log(`Subscription status for ${tableName}`, status);
1707
+ });
1708
+
1709
+ // Store the channel ref
1710
+ channelRef.current = channel;
1711
+
1712
+ return () => {
1713
+ console.log(`Unsubscribing from ${channelId}`);
1714
+ if (channelRef.current) {
1715
+ supabase.removeChannel(channelRef.current); // Correct way to remove channel
1716
+ channelRef.current = null;
1717
+ }
1718
+
1719
+ if (searchTimeoutRef.current) {
1720
+ clearTimeout(searchTimeoutRef.current);
1721
+ searchTimeoutRef.current = null;
1722
+ }
1723
+ };
1724
+ }, [realtime, channelName, tableName]); // NEVER include 'where' - subscription should persist
1725
+
1726
+ // Create a memoized options object to prevent unnecessary re-renders
1727
+ const optionsRef = useRef({ where, orderBy, limit, offset, selectString });
1728
+
1729
+ // Compare current options with previous options
1730
+ const optionsChanged = useCallback(() => {
1731
+ // Create stable string representations for deep comparison
1732
+ const whereStr = where ? JSON.stringify(where) : '';
1733
+ const orderByStr = orderBy ? JSON.stringify(orderBy) : '';
1734
+ const prevWhereStr = optionsRef.current.where ? JSON.stringify(optionsRef.current.where) : '';
1735
+ const prevOrderByStr = optionsRef.current.orderBy ? JSON.stringify(optionsRef.current.orderBy) : '';
1736
+
1737
+ // Compare the stable representations
1738
+ const hasChanged =
1739
+ whereStr !== prevWhereStr ||
1740
+ orderByStr !== prevOrderByStr ||
1741
+ limit !== optionsRef.current.limit ||
1742
+ offset !== optionsRef.current.offset ||
1743
+ selectString !== optionsRef.current.selectString;
1744
+
1745
+ if (hasChanged) {
1746
+ // Update the ref with the new values
1747
+ optionsRef.current = { where, orderBy, limit, offset, selectString };
1748
+ return true;
1749
+ }
1750
+
1751
+ return false;
1752
+ }, [where, orderBy, limit, offset, selectString]);
1753
+
1754
+ // Load initial data and refetch when options change (BUT NEVER TOUCH SUBSCRIPTION)
1755
+ useEffect(() => {
1756
+ // Skip if search is active
1757
+ if (isSearchingRef.current) return;
1758
+
1759
+ // Skip if we've already loaded or if no filter criteria are provided
1760
+ if (initialLoadRef.current) {
1761
+ // Only reload if options have changed significantly
1762
+ if (optionsChanged()) {
1763
+ console.log(`Options changed for ${tableName}, refetching data (subscription stays alive)`);
1764
+ findMany({
1765
+ where,
1766
+ orderBy,
1767
+ take: limit,
1768
+ skip: offset
1769
+ });
1770
+
1771
+ // Also update the total count
1772
+ fetchTotalCount();
1773
+ }
1774
+ return;
1775
+ }
1776
+
1777
+ // Initial load
1778
+ initialLoadRef.current = true;
1779
+ findMany({
1780
+ where,
1781
+ orderBy,
1782
+ take: limit,
1783
+ skip: offset
1784
+ });
1785
+
1786
+ // Initial count fetch
1787
+ fetchTotalCount();
1788
+ }, [findMany, where, orderBy, limit, offset, optionsChanged, fetchTotalCount]);
1789
+
1790
+ /**
1791
+ * Create a new record with the provided data.
1792
+ * Default values from the schema will be applied if not provided.
1793
+ * NOTE: This operation does NOT immediately update the local state.
1794
+ * The state will be updated when the realtime INSERT event is received.
1795
+ *
1796
+ * @param data - The data to create the record with
1797
+ * @returns A promise with the created record or error
1798
+ *
1799
+ * @example
1800
+ * // Create a new user
1801
+ * const result = await users.create({
1802
+ * name: "John Doe",
1803
+ * email: "john@example.com"
1804
+ * });
1805
+ *
1806
+ * @example
1807
+ * // Create with custom ID (overriding default)
1808
+ * const result = await users.create({
1809
+ * id: "custom-id-123",
1810
+ * name: "John Doe"
1811
+ * });
1812
+ */
1813
+ const create = useCallback(async (
1814
+ data: TCreateInput
1815
+ ): ModelResult<TWithRelations> => {
1816
+ try {
1817
+ setLoading(true);
1818
+ setError(null);
1819
+
1820
+ const now = new Date();
1821
+
1822
+ // Helper function to convert Date objects to ISO strings for database
1823
+ const convertDatesForDatabase = (obj: any): any => {
1824
+ const result: any = {};
1825
+ for (const [key, value] of Object.entries(obj)) {
1826
+ if (value instanceof Date) {
1827
+ result[key] = value.toISOString();
1828
+ } else {
1829
+ result[key] = value;
1830
+ }
1831
+ }
1832
+ return result;
1833
+ };
1834
+
1835
+ // Apply default values from schema
1836
+ const appliedDefaults: Record<string, any> = {};
1837
+
1838
+ // Apply all default values that aren't already in the data
1839
+ for (const [field, defaultValue] of Object.entries(defaultValues)) {
1840
+ // @ts-ignore: Supabase typing issue
1841
+ if (!(field in data)) {
1842
+ // Parse the default value based on its type
1843
+ if (defaultValue.includes('now()') || defaultValue.includes('now')) {
1844
+ appliedDefaults[field] = now.toISOString(); // Database expects ISO string
1845
+ } else if (defaultValue.includes('uuid()') || defaultValue.includes('uuid')) {
1846
+ appliedDefaults[field] = generateUUID();
1847
+ } else if (defaultValue.includes('cuid()') || defaultValue.includes('cuid')) {
1848
+ // Simple cuid-like implementation for client-side
1849
+ appliedDefaults[field] = 'c' + Math.random().toString(36).substring(2, 15);
1850
+ } else if (defaultValue.includes('true')) {
1851
+ appliedDefaults[field] = true;
1852
+ } else if (defaultValue.includes('false')) {
1853
+ appliedDefaults[field] = false;
1854
+ } else if (!isNaN(Number(defaultValue))) {
1855
+ // If it's a number
1856
+ appliedDefaults[field] = Number(defaultValue);
1857
+ } else {
1858
+ // String or other value, remove quotes if present
1859
+ const strValue = defaultValue.replace(/^["'](.*)["']$/, '$1');
1860
+ appliedDefaults[field] = strValue;
1861
+ }
1862
+ }
1863
+ }
1864
+
1865
+ const itemWithDefaults = convertDatesForDatabase({
1866
+ ...appliedDefaults, // Apply schema defaults first
1867
+ ...data, // Then user data (overrides defaults)
1868
+ // Use the actual field names from Prisma - convert Date to ISO string for database
1869
+ ...(hasCreatedAt ? { [createdAtField]: now.toISOString() } : {}),
1870
+ ...(hasUpdatedAt ? { [updatedAtField]: now.toISOString() } : {})
1871
+ });
1872
+
1873
+ const { data: result, error } = await supabase
1874
+ .from(tableName)
1875
+ .insert([itemWithDefaults])
1876
+ .select(selectString);
1877
+
1878
+ if (error) throw error;
1879
+
1880
+ // DO NOT UPDATE LOCAL STATE HERE - Let realtime INSERT event handle it
1881
+ console.log('✅ Created ' + tableName + ' record, waiting for realtime INSERT event to update state');
1882
+
1883
+ // Return created record
1884
+ return { data: result?.[0] as TWithRelations, error: null };
1885
+ } catch (err: any) {
1886
+ console.error('Error creating record:', err);
1887
+ setError(err);
1888
+ return { data: null, error: err };
1889
+ } finally {
1890
+ setLoading(false);
1891
+ }
1892
+ }, []);
1893
+
1894
+ /**
1895
+ * Update an existing record identified by a unique identifier.
1896
+ * NOTE: This operation does NOT immediately update the local state.
1897
+ * The state will be updated when the realtime UPDATE event is received.
1898
+ *
1899
+ * @param params - Object containing the identifier and update data
1900
+ * @returns A promise with the updated record or error
1901
+ *
1902
+ * @example
1903
+ * // Update a user's name
1904
+ * const result = await users.update({
1905
+ * where: { id: "123" },
1906
+ * data: { name: "New Name" }
1907
+ * });
1908
+ *
1909
+ * @example
1910
+ * // Update multiple fields
1911
+ * const result = await users.update({
1912
+ * where: { id: "123" },
1913
+ * data: {
1914
+ * name: "New Name",
1915
+ * active: false
1916
+ * }
1917
+ * });
1918
+ */
1919
+ const update = useCallback(async (params: {
1920
+ where: TWhereUniqueInput;
1921
+ data: TUpdateInput;
1922
+ }): ModelResult<TWithRelations> => {
1923
+ try {
1924
+ setLoading(true);
1925
+ setError(null);
1926
+
1927
+ // Find the primary field (usually 'id')
1928
+ // @ts-ignore: Supabase typing issue
1929
+ const primaryKey = Object.keys(params.where)[0];
1930
+ if (!primaryKey) {
1931
+ throw new Error('A unique identifier is required');
1932
+ }
1933
+
1934
+ const value = params.where[primaryKey as keyof typeof params.where];
1935
+ if (value === undefined) {
1936
+ throw new Error('A unique identifier is required');
1937
+ }
1938
+
1939
+ const now = new Date();
1940
+
1941
+ // Helper function to convert Date objects to ISO strings for database
1942
+ const convertDatesForDatabase = (obj: any): any => {
1943
+ const result: any = {};
1944
+ for (const [key, value] of Object.entries(obj)) {
1945
+ if (value instanceof Date) {
1946
+ result[key] = value.toISOString();
1947
+ } else {
1948
+ result[key] = value;
1949
+ }
1950
+ }
1951
+ return result;
1952
+ };
1953
+
1954
+ // We do not apply default values for updates
1955
+ // Default values are only for creation, not for updates,
1956
+ // as existing records already have these values set
1957
+
1958
+ const itemWithDefaults = convertDatesForDatabase({
1959
+ ...params.data,
1960
+ // Use the actual updatedAt field name from Prisma - convert Date to ISO string for database
1961
+ ...(hasUpdatedAt ? { [updatedAtField]: now.toISOString() } : {})
1962
+ });
1963
+
1964
+ const { data, error } = await supabase
1965
+ .from(tableName)
1966
+ .update(itemWithDefaults)
1967
+ .eq(primaryKey, value)
1968
+ .select(selectString);
1969
+
1970
+ if (error) throw error;
1971
+
1972
+ // DO NOT UPDATE LOCAL STATE HERE - Let realtime UPDATE event handle it
1973
+ console.log('✅ Updated ' + tableName + ' record, waiting for realtime UPDATE event to update state');
1974
+
1975
+ // Return updated record
1976
+ return { data: data?.[0] as TWithRelations, error: null };
1977
+ } catch (err: any) {
1978
+ console.error('Error updating record:', err);
1979
+ setError(err);
1980
+ return { data: null, error: err };
1981
+ } finally {
1982
+ setLoading(false);
1983
+ }
1984
+ }, []);
1985
+
1986
+ /**
1987
+ * Delete a record by its unique identifier.
1988
+ * NOTE: This operation does NOT immediately update the local state.
1989
+ * The state will be updated when the realtime DELETE event is received.
1990
+ *
1991
+ * @param where - The unique identifier to delete the record by
1992
+ * @returns A promise with the deleted record or error
1993
+ *
1994
+ * @example
1995
+ * // Delete a user by ID
1996
+ * const result = await users.delete({ id: "123" });
1997
+ * if (result.data) {
1998
+ * console.log("Deleted user:", result.data.name);
1999
+ * }
2000
+ */
2001
+ const deleteRecord = useCallback(async (
2002
+ where: TWhereUniqueInput
2003
+ ): ModelResult<TWithRelations> => {
2004
+ try {
2005
+ setLoading(true);
2006
+ setError(null);
2007
+
2008
+ // Find the primary field (usually 'id')
2009
+ // @ts-ignore: Supabase typing issue
2010
+ const primaryKey = Object.keys(where)[0];
2011
+ if (!primaryKey) {
2012
+ throw new Error('A unique identifier is required');
2013
+ }
2014
+
2015
+ const value = where[primaryKey as keyof typeof where];
2016
+ if (value === undefined) {
2017
+ throw new Error('A unique identifier is required');
2018
+ }
2019
+
2020
+ // First fetch the record to return it
2021
+ const { data: recordToDelete } = await supabase
2022
+ .from(tableName)
2023
+ .select(selectString)
2024
+ .eq(primaryKey, value)
2025
+ .maybeSingle();
2026
+
2027
+ if (!recordToDelete) {
2028
+ throw new Error('Record not found');
2029
+ }
2030
+
2031
+ // Then delete it
2032
+ const { error } = await supabase
2033
+ .from(tableName)
2034
+ .delete()
2035
+ .eq(primaryKey, value);
2036
+
2037
+ if (error) throw error;
2038
+
2039
+ // DO NOT UPDATE LOCAL STATE HERE - Let realtime DELETE event handle it
2040
+ console.log('✅ Deleted ' + tableName + ' record, waiting for realtime DELETE event to update state');
2041
+
2042
+ // Return the deleted record
2043
+ return { data: recordToDelete as TWithRelations, error: null };
2044
+ } catch (err: any) {
2045
+ console.error('Error deleting record:', err);
2046
+ setError(err);
2047
+ return { data: null, error: err };
2048
+ } finally {
2049
+ setLoading(false);
2050
+ }
2051
+ }, []);
2052
+
2053
+ /**
2054
+ * Delete multiple records matching the filter criteria.
2055
+ * NOTE: This operation does NOT immediately update the local state.
2056
+ * The state will be updated when realtime DELETE events are received for each record.
2057
+ *
2058
+ * @param params - Query parameters for filtering records to delete
2059
+ * @returns A promise with the count of deleted records or error
2060
+ *
2061
+ * @example
2062
+ * // Delete all inactive users
2063
+ * const result = await users.deleteMany({
2064
+ * where: { active: false }
2065
+ * });
2066
+ * console.log('Deleted ' + result.count + ' inactive users');
2067
+ *
2068
+ * @example
2069
+ * // Delete all records (use with caution!)
2070
+ * const result = await users.deleteMany();
2071
+ */
2072
+ const deleteMany = useCallback(async (params?: {
2073
+ where?: TWhereInput;
2074
+ }): Promise<{ count: number; error: Error | null }> => {
2075
+ try {
2076
+ setLoading(true);
2077
+ setError(null);
2078
+
2079
+ // First, get the records that will be deleted to count them
2080
+ let query = supabase.from(tableName).select('*');
2081
+
2082
+ // Apply where conditions if provided
2083
+ if (params?.where) {
2084
+ query = applyFilter(query, params.where);
2085
+ }
2086
+
2087
+ // Get records that will be deleted
2088
+ const { data: recordsToDelete, error: fetchError } = await query;
2089
+
2090
+ if (fetchError) throw fetchError;
2091
+
2092
+ if (!recordsToDelete?.length) {
2093
+ return { count: 0, error: null };
2094
+ }
2095
+
2096
+ // Build the delete query
2097
+ let deleteQuery = supabase.from(tableName).delete();
2098
+
2099
+ // Apply the same filter to the delete operation
2100
+ if (params?.where) {
2101
+ // @ts-ignore: Supabase typing issue
2102
+ deleteQuery = applyFilter(deleteQuery, params.where);
2103
+ }
2104
+
2105
+ // Perform the delete
2106
+ const { error: deleteError } = await deleteQuery;
2107
+
2108
+ if (deleteError) throw deleteError;
2109
+
2110
+ // DO NOT UPDATE LOCAL STATE HERE - Let realtime DELETE events handle it
2111
+ console.log('✅ Deleted ' + recordsToDelete.length + ' ' + tableName + ' records, waiting for realtime DELETE events to update state');
2112
+
2113
+ // Return the count of deleted records
2114
+ return { count: recordsToDelete.length, error: null };
2115
+ } catch (err: any) {
2116
+ console.error('Error deleting multiple records:', err);
2117
+ setError(err);
2118
+ return { count: 0, error: err };
2119
+ } finally {
2120
+ setLoading(false);
2121
+ }
2122
+ }, []);
2123
+
2124
+ /**
2125
+ * Find the first record matching the filter criteria.
2126
+ *
2127
+ * @param params - Query parameters for filtering and ordering
2128
+ * @returns A promise with the first matching record or error
2129
+ *
2130
+ * @example
2131
+ * // Find the first admin user
2132
+ * const result = await users.findFirst({
2133
+ * where: { role: 'admin' }
2134
+ * });
2135
+ *
2136
+ * @example
2137
+ * // Find the oldest post
2138
+ * const result = await posts.findFirst({
2139
+ * orderBy: { created_at: 'asc' }
2140
+ * });
2141
+ */
2142
+ const findFirst = useCallback(async (params?: {
2143
+ where?: TWhereInput;
2144
+ orderBy?: TOrderByInput;
2145
+ }): ModelResult<TWithRelations> => {
2146
+ try {
2147
+ const result = await findMany({
2148
+ ...params,
2149
+ take: 1
2150
+ });
2151
+
2152
+ if (result.error) return { data: null, error: result.error };
2153
+ if (!result.data.length) return { data: null, error: new Error('No records found') };
2154
+
2155
+ // @ts-ignore: Supabase typing issue
2156
+ return { data: result.data[0], error: null };
2157
+ } catch (err: any) {
2158
+ console.error('Error finding first record:', err);
2159
+ return { data: null, error: err };
2160
+ }
2161
+ }, [findMany]);
2162
+
2163
+ /**
2164
+ * Create a record if it doesn't exist, or update it if it does.
2165
+ *
2166
+ * @param params - Object containing the identifier, update data, and create data
2167
+ * @returns A promise with the created or updated record or error
2168
+ *
2169
+ * @example
2170
+ * // Upsert a user by ID
2171
+ * const result = await users.upsert({
2172
+ * where: { id: "123" },
2173
+ * update: { lastLogin: new Date().toISOString() },
2174
+ * create: {
2175
+ * id: "123",
2176
+ * name: "John Doe",
2177
+ * email: "john@example.com",
2178
+ * lastLogin: new Date().toISOString()
2179
+ * }
2180
+ * });
2181
+ */
2182
+ const upsert = useCallback(async (params: {
2183
+ where: TWhereUniqueInput;
2184
+ update: TUpdateInput;
2185
+ create: TCreateInput;
2186
+ }): ModelResult<TWithRelations> => {
2187
+ try {
2188
+ // Check if record exists
2189
+ const { data: existing } = await findUnique(params.where);
2190
+
2191
+ // Update if exists, otherwise create
2192
+ if (existing) {
2193
+ return update({ where: params.where, data: params.update });
2194
+ } else {
2195
+ return create(params.create);
2196
+ }
2197
+ } catch (err: any) {
2198
+ console.error('Error upserting record:', err);
2199
+ return { data: null, error: err };
2200
+ }
2201
+ }, [findUnique, update, create]);
2202
+
2203
+ /**
2204
+ * Count the number of records matching the filter criteria.
2205
+ * This is a manual method to get the count with a different filter
2206
+ * than the main hook's filter.
2207
+ *
2208
+ * @param params - Query parameters for filtering
2209
+ * @returns A promise with the count of matching records
2210
+ */
2211
+ const countFn = useCallback(async (params?: {
2212
+ where?: TWhereInput;
2213
+ }): Promise<number> => {
2214
+ try {
2215
+ let query = supabase.from(tableName).select('*', { count: 'exact', head: true });
2216
+
2217
+ // Use provided where filter, or fall back to the hook's original where filter
2218
+ const effectiveWhere = params?.where ?? where;
2219
+
2220
+ if (effectiveWhere) {
2221
+ query = applyFilter(query, effectiveWhere);
2222
+ }
2223
+
2224
+ const { count, error } = await query;
2225
+
2226
+ if (error) throw error;
2227
+
2228
+ return count || 0;
2229
+ } catch (err) {
2230
+ console.error('Error counting records:', err);
2231
+ return 0;
2232
+ }
2233
+ }, [where]);
2234
+
2235
+ /**
2236
+ * Manually refresh the data with current filter settings.
2237
+ * Useful after external operations or when realtime is disabled.
2238
+ *
2239
+ * @param params - Optional override parameters for this specific refresh
2240
+ * @returns A promise with the refreshed data or error
2241
+ *
2242
+ * @example
2243
+ * // Refresh with current filter settings
2244
+ * await users.refresh();
2245
+ *
2246
+ * @example
2247
+ * // Refresh with different filters for this call only
2248
+ * await users.refresh({
2249
+ * where: { active: true },
2250
+ * orderBy: { name: 'asc' }
2251
+ * });
2252
+ */
2253
+ const refresh = useCallback((params?: {
2254
+ where?: TWhereInput;
2255
+ orderBy?: TOrderByInput;
2256
+ take?: number;
2257
+ skip?: number;
2258
+ }) => {
2259
+ // If search is active, refresh search results
2260
+ if (isSearchingRef.current && searchQueries.length > 0) {
2261
+ executeSearch(searchQueries);
2262
+ return Promise.resolve({ data: data, error: null });
2263
+ }
2264
+
2265
+ // Otherwise, refresh normal data using original params if not explicitly overridden
2266
+ return findMany({
2267
+ where: params?.where ?? where,
2268
+ orderBy: params?.orderBy ?? orderBy,
2269
+ take: params?.take ?? limit,
2270
+ skip: params?.skip ?? offset
2271
+ });
2272
+ }, [findMany, data, searchQueries, where, orderBy, limit, offset]);
2273
+
2274
+ // Construct final hook API with or without search
2275
+ const api = {
2276
+ // State
2277
+ data,
2278
+ error,
2279
+ loading,
2280
+ count, // Now including count as a reactive state value
2281
+
2282
+ // Finder methods
2283
+ findUnique,
2284
+ findMany,
2285
+ findFirst,
2286
+
2287
+ // Mutation methods
2288
+ create,
2289
+ update,
2290
+ delete: deleteRecord,
2291
+ deleteMany,
2292
+ upsert,
2293
+
2294
+ // Manual refresh
2295
+ refresh
2296
+ };
2297
+
2298
+ // Add search object if searchable fields are present
2299
+ return searchFields.length > 0
2300
+ ? {
2301
+ ...api,
2302
+ search
2303
+ }
2304
+ : api;
2305
+ };
2306
+ }