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,2316 @@
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
+ relationMappings?: Record<string, string>
789
+ ): string {
790
+ const parts: string[] = [];
791
+
792
+ // Handle select - if provided, only return specified fields
793
+ if (select && typeof select === 'object') {
794
+ const selectedFields = Object.entries(select)
795
+ .filter(([_, value]) => value === true)
796
+ .map(([key]) => key);
797
+
798
+ if (selectedFields.length > 0) {
799
+ parts.push(...selectedFields);
800
+ }
801
+ }
802
+
803
+ // Handle include - add related records
804
+ if (include && typeof include === 'object') {
805
+ for (const [relationName, relationValue] of Object.entries(include)) {
806
+ const relatedTableName = relationMappings?.[relationName] || relationName;
807
+ // If mapping exists, use PostgREST alias syntax: alias:foreignTable(...)
808
+ const embedName =
809
+ relationMappings?.[relationName] && relatedTableName !== relationName
810
+ ? `${relationName}:${relatedTableName}`
811
+ : relationName;
812
+
813
+ if (relationValue === true) {
814
+ // Include all fields from the relation
815
+ parts.push(`${embedName}(*)`);
816
+ } else if (typeof relationValue === 'object' && relationValue !== null) {
817
+ // Include specific fields from the relation
818
+ const relationOptions = relationValue as { select?: Record<string, boolean> };
819
+ if (relationOptions.select) {
820
+ const relationFields = Object.entries(relationOptions.select)
821
+ .filter(([_, value]) => value === true)
822
+ .map(([key]) => key);
823
+
824
+ if (relationFields.length > 0) {
825
+ parts.push(`${embedName}(${relationFields.join(',')})`);
826
+ } else {
827
+ parts.push(`${embedName}(*)`);
828
+ }
829
+ } else {
830
+ parts.push(`${embedName}(*)`);
831
+ }
832
+ }
833
+ }
834
+ }
835
+
836
+ // If no select specified but include is, we need to include base table fields too
837
+ if (parts.length === 0) {
838
+ return '*';
839
+ }
840
+
841
+ // If only include was specified (no select), we need all base fields plus relations
842
+ if (!select && include) {
843
+ return '*,' + parts.join(',');
844
+ }
845
+
846
+ return parts.join(',');
847
+ }
848
+
849
+ /**
850
+ * Apply order by to the query builder
851
+ */
852
+ export function applyOrderBy<T>(
853
+ query: SupabaseQueryBuilder,
854
+ orderBy?: T,
855
+ hasCreatedAt?: boolean,
856
+ createdAtField: string = 'createdAt'
857
+ ): SupabaseQueryBuilder {
858
+ if (!orderBy) {
859
+ // By default, sort by createdAt if available, using the actual field name from Prisma
860
+ if (hasCreatedAt) {
861
+ // @ts-ignore: Supabase typing issue
862
+ return query.order(createdAtField, { ascending: false });
863
+ }
864
+ return query;
865
+ }
866
+
867
+ // Apply each order by clause
868
+ let orderedQuery = query;
869
+
870
+ // Handle orderBy as array or single object
871
+ const orderByArray = Array.isArray(orderBy) ? orderBy : [orderBy];
872
+
873
+ for (const orderByClause of orderByArray) {
874
+ for (const [key, direction] of Object.entries(orderByClause)) {
875
+ // @ts-ignore: Supabase typing issue
876
+ orderedQuery = orderedQuery.order(key, {
877
+ ascending: direction === 'asc'
878
+ });
879
+ }
880
+ }
881
+
882
+ return orderedQuery;
883
+ }
884
+
885
+ /**
886
+ * Core hook factory function that creates a type-safe realtime hook for a specific model.
887
+ * This is the foundation for all Suparisma hooks.
888
+ */
889
+ export function createSuparismaHook<
890
+ TModel,
891
+ TWithRelations,
892
+ TCreateInput,
893
+ TUpdateInput,
894
+ TWhereInput,
895
+ TWhereUniqueInput,
896
+ TOrderByInput
897
+ >(config: {
898
+ tableName: string;
899
+ hasCreatedAt: boolean;
900
+ hasUpdatedAt: boolean;
901
+ searchFields?: string[];
902
+ defaultValues?: Record<string, string>;
903
+ createdAtField?: string;
904
+ updatedAtField?: string;
905
+ relationMappings?: Record<string, string>;
906
+ }) {
907
+ const {
908
+ tableName,
909
+ hasCreatedAt,
910
+ hasUpdatedAt,
911
+ searchFields = [],
912
+ defaultValues = {},
913
+ createdAtField = 'createdAt',
914
+ updatedAtField = 'updatedAt',
915
+ relationMappings = {}
916
+ } = config;
917
+
918
+ /**
919
+ * The main hook function that provides all data access methods for a model.
920
+ *
921
+ * @param options - Optional configuration for data fetching, filtering, and realtime
922
+ *
923
+ * @returns An API object with data state and CRUD methods
924
+ *
925
+ * @example
926
+ * // Basic usage
927
+ * const users = useSuparismaUser();
928
+ * const { data, loading, error } = users;
929
+ *
930
+ * @example
931
+ * // With filtering
932
+ * const users = useSuparismaUser({
933
+ * where: { role: 'admin' },
934
+ * orderBy: { created_at: 'desc' }
935
+ * });
936
+ */
937
+ return function useSuparismaHook(options: SuparismaOptions<TWhereInput, TOrderByInput> = {}) {
938
+ const {
939
+ realtime = true,
940
+ channelName,
941
+ where,
942
+ realtimeFilter,
943
+ orderBy,
944
+ limit,
945
+ offset,
946
+ select,
947
+ include,
948
+ } = options;
949
+
950
+ // Build the select string once for reuse
951
+ const selectString = buildSelectString(select, include, relationMappings);
952
+
953
+ // Refs to store the latest options for realtime handlers
954
+ const whereRef = useRef(where);
955
+ const orderByRef = useRef(orderBy);
956
+ const limitRef = useRef(limit);
957
+ const offsetRef = useRef(offset);
958
+ const selectStringRef = useRef(selectString);
959
+
960
+ // Update refs whenever options change
961
+ useEffect(() => {
962
+ whereRef.current = where;
963
+ }, [where]);
964
+
965
+ useEffect(() => {
966
+ orderByRef.current = orderBy;
967
+ }, [orderBy]);
968
+
969
+ useEffect(() => {
970
+ limitRef.current = limit;
971
+ }, [limit]);
972
+
973
+ useEffect(() => {
974
+ offsetRef.current = offset;
975
+ }, [offset]);
976
+
977
+ useEffect(() => {
978
+ selectStringRef.current = selectString;
979
+ }, [selectString]);
980
+
981
+ // Single data collection for holding results
982
+ const [data, setData] = useState<TWithRelations[]>([]);
983
+ const [error, setError] = useState<Error | null>(null);
984
+ const [loading, setLoading] = useState<boolean>(true);
985
+
986
+ // This is the total count, unaffected by pagination limits
987
+ const [count, setCount] = useState<number>(0);
988
+
989
+ // Search state
990
+ const [searchQueries, setSearchQueries] = useState<SearchQuery[]>([]);
991
+ const [searchLoading, setSearchLoading] = useState<boolean>(false);
992
+
993
+ const initialLoadRef = useRef(false);
994
+ const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
995
+ const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
996
+ const isSearchingRef = useRef<boolean>(false);
997
+
998
+ // Function to fetch the total count from Supabase with current filters
999
+ const fetchTotalCount = useCallback(async () => {
1000
+ try {
1001
+ // Skip count updates during search
1002
+ if (isSearchingRef.current) return;
1003
+
1004
+ let countQuery = supabase.from(tableName).select('*', { count: 'exact', head: true });
1005
+
1006
+ // Apply where conditions if provided
1007
+ if (where) {
1008
+ countQuery = applyFilter(countQuery, where);
1009
+ }
1010
+
1011
+ const { count: totalCount, error: countError } = await countQuery;
1012
+
1013
+ if (!countError) {
1014
+ setCount(totalCount || 0);
1015
+ }
1016
+ } catch (err) {
1017
+ console.error(`Error fetching count for ${tableName}:`, err);
1018
+ }
1019
+ }, [where, tableName]);
1020
+
1021
+ // Update total count whenever where filter changes
1022
+ useEffect(() => {
1023
+ fetchTotalCount();
1024
+ }, [fetchTotalCount]);
1025
+
1026
+ // Create the search state object with all required methods
1027
+ const search: SearchState = {
1028
+ queries: searchQueries,
1029
+ loading: searchLoading,
1030
+
1031
+ // Set all search queries at once
1032
+ setQueries: useCallback((queries: SearchQuery[]) => {
1033
+ // Validate that all fields are searchable
1034
+ const validQueries = queries.filter(query =>
1035
+ searchFields.includes(query.field) && query.value.trim() !== ''
1036
+ );
1037
+
1038
+ setSearchQueries(validQueries);
1039
+
1040
+ // Execute search if there are valid queries
1041
+ if (validQueries.length > 0) {
1042
+ executeSearch(validQueries);
1043
+ } else {
1044
+ // If no valid queries, reset to normal data fetching
1045
+ isSearchingRef.current = false;
1046
+ findMany({ where, orderBy, take: limit, skip: offset });
1047
+ }
1048
+ }, [where, orderBy, limit, offset]),
1049
+
1050
+ // Add a single search query
1051
+ addQuery: useCallback((query: SearchQuery) => {
1052
+ // Validate that the field is searchable
1053
+ if (!searchFields.includes(query.field) || query.value.trim() === '') {
1054
+ return;
1055
+ }
1056
+
1057
+ setSearchQueries(prev => {
1058
+ // Replace if query for this field already exists, otherwise add
1059
+ const exists = prev.some(q => q.field === query.field);
1060
+ const newQueries = exists
1061
+ ? prev.map(q => q.field === query.field ? query : q)
1062
+ : [...prev, query];
1063
+
1064
+ // Execute search with updated queries
1065
+ executeSearch(newQueries);
1066
+
1067
+ return newQueries;
1068
+ });
1069
+ }, []),
1070
+
1071
+ // Remove a search query by field
1072
+ removeQuery: useCallback((field: string) => {
1073
+ setSearchQueries(prev => {
1074
+ const newQueries = prev.filter(q => q.field !== field);
1075
+
1076
+ // If we still have queries, execute search with remaining queries
1077
+ if (newQueries.length > 0) {
1078
+ executeSearch(newQueries);
1079
+ } else {
1080
+ // If no queries left, reset to normal data fetching
1081
+ isSearchingRef.current = false;
1082
+ findMany({ where, orderBy, take: limit, skip: offset });
1083
+ }
1084
+
1085
+ return newQueries;
1086
+ });
1087
+ }, [where, orderBy, limit, offset]),
1088
+
1089
+ // Clear all search queries
1090
+ clearQueries: useCallback(() => {
1091
+ setSearchQueries([]);
1092
+ isSearchingRef.current = false;
1093
+ findMany({ where, orderBy, take: limit, skip: offset });
1094
+ }, [where, orderBy, limit, offset]),
1095
+
1096
+ // Search across multiple fields (convenience method)
1097
+ searchMultiField: useCallback((value: string) => {
1098
+ if (searchFields.length <= 1) {
1099
+ console.warn('Multi-field search requires at least 2 searchable fields');
1100
+ return;
1101
+ }
1102
+
1103
+ setSearchQueries([{ field: 'multi', value }]);
1104
+ executeSearch([{ field: 'multi', value }]);
1105
+ }, [searchFields.length]),
1106
+
1107
+ // Search in a specific field (convenience method)
1108
+ searchField: useCallback((field: string, value: string) => {
1109
+ if (!searchFields.includes(field)) {
1110
+ console.warn(`Field "${field}" is not searchable. Available fields: ${searchFields.join(', ')}`);
1111
+ return;
1112
+ }
1113
+
1114
+ setSearchQueries([{ field, value }]);
1115
+ executeSearch([{ field, value }]);
1116
+ }, [searchFields]),
1117
+
1118
+ // Get current search terms for custom highlighting
1119
+ getCurrentSearchTerms: useCallback(() => {
1120
+ return searchQueries.map(q => q.value.trim());
1121
+ }, [searchQueries]),
1122
+
1123
+ // Safely escape regex special characters
1124
+ escapeRegex: useCallback((text: string) => {
1125
+ return escapeRegexCharacters(text);
1126
+ }, [])
1127
+ };
1128
+
1129
+ // Execute search based on queries
1130
+ const executeSearch = useCallback(async (queries: SearchQuery[]) => {
1131
+ // Clear the previous timeout
1132
+ if (searchTimeoutRef.current) {
1133
+ clearTimeout(searchTimeoutRef.current);
1134
+ }
1135
+
1136
+ // Skip if no searchable fields or no valid queries
1137
+ if (searchFields.length === 0 || queries.length === 0) {
1138
+ return;
1139
+ }
1140
+
1141
+ setSearchLoading(true);
1142
+ isSearchingRef.current = true;
1143
+
1144
+ // Use debounce to prevent rapid searches
1145
+ searchTimeoutRef.current = setTimeout(async () => {
1146
+ try {
1147
+ let results: TWithRelations[] = [];
1148
+
1149
+ // Validate search queries
1150
+ const validQueries = queries.filter(query => {
1151
+ if (!query.field || !query.value) {
1152
+ console.warn('Invalid search query - missing field or value:', query);
1153
+ return false;
1154
+ }
1155
+ // Allow "multi" as a special field for multi-field search
1156
+ if (query.field === 'multi' && searchFields.length > 1) {
1157
+ return true;
1158
+ }
1159
+ if (!searchFields.includes(query.field)) {
1160
+ console.warn(`Field "${query.field}" is not searchable. Available fields: ${searchFields.join(', ')}, or "multi" for multi-field search`);
1161
+ return false;
1162
+ }
1163
+ return true;
1164
+ });
1165
+
1166
+ if (validQueries.length === 0) {
1167
+ console.log('No valid search queries found');
1168
+ setData([]);
1169
+ setCount(0);
1170
+ return;
1171
+ }
1172
+
1173
+ // Execute RPC function for each query using Promise.all
1174
+ const searchPromises = validQueries.map(query => {
1175
+ // Build function name based on field type
1176
+ const functionName = query.field === 'multi'
1177
+ ? `search_${tableName.toLowerCase()}_multi_field`
1178
+ : `search_${tableName.toLowerCase()}_by_${query.field.toLowerCase()}_prefix`;
1179
+
1180
+ console.log(`🔍 Executing search: ${functionName}(search_prefix: "${query.value.trim()}")`);
1181
+
1182
+ // Call RPC function with proper error handling
1183
+ return Promise.resolve(supabase.rpc(functionName, { search_prefix: query.value.trim() }))
1184
+ .then((result: any) => ({
1185
+ ...result,
1186
+ queryField: query.field,
1187
+ queryValue: query.value
1188
+ }))
1189
+ .catch((error: any) => ({
1190
+ data: null,
1191
+ error: error,
1192
+ queryField: query.field,
1193
+ queryValue: query.value
1194
+ }));
1195
+ });
1196
+
1197
+ // Execute all search queries in parallel
1198
+ const searchResults = await Promise.all(searchPromises);
1199
+
1200
+ // Combine and deduplicate results
1201
+ const allResults: Record<string, TWithRelations> = {};
1202
+ let hasErrors = false;
1203
+
1204
+ // Process each search result
1205
+ searchResults.forEach((result: any, index: number) => {
1206
+ if (result.error) {
1207
+ console.error(`🔍 Search error for field "${result.queryField}" with value "${result.queryValue}":`, result.error);
1208
+ hasErrors = true;
1209
+ return;
1210
+ }
1211
+
1212
+ if (result.data && Array.isArray(result.data)) {
1213
+ console.log(`🔍 Search results for "${result.queryField}": ${result.data.length} items`);
1214
+
1215
+ // Add each result, using id as key to deduplicate
1216
+ for (const item of result.data as TWithRelations[]) {
1217
+ // @ts-ignore: Assume item has an id property
1218
+ if (item && typeof item === 'object' && 'id' in item && item.id) {
1219
+ // @ts-ignore: Add to results using id as key
1220
+ allResults[item.id] = item;
1221
+ }
1222
+ }
1223
+ } else if (result.data) {
1224
+ console.warn(`🔍 Unexpected search result format for "${result.queryField}":`, typeof result.data);
1225
+ }
1226
+ });
1227
+
1228
+ // Convert back to array
1229
+ results = Object.values(allResults);
1230
+ console.log(`🔍 Combined search results: ${results.length} unique items`);
1231
+
1232
+ // Apply any where conditions client-side (now using the proper filter function)
1233
+ if (where) {
1234
+ const originalCount = results.length;
1235
+ results = results.filter((item) => matchesFilter(item, where));
1236
+ console.log(`🔍 After applying where filter: ${results.length}/${originalCount} items`);
1237
+ }
1238
+
1239
+ // Set count directly for search results
1240
+ setCount(results.length);
1241
+
1242
+ // Apply ordering if needed (using the proper compare function)
1243
+ if (orderBy) {
1244
+ const orderByArray = Array.isArray(orderBy) ? orderBy : [orderBy];
1245
+ results = [...results].sort((a, b) => {
1246
+ for (const orderByClause of orderByArray) {
1247
+ for (const [field, direction] of Object.entries(orderByClause)) {
1248
+ const aValue = a[field as keyof typeof a];
1249
+ const bValue = b[field as keyof typeof b];
1250
+
1251
+ if (aValue === bValue) continue;
1252
+
1253
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
1254
+ }
1255
+ }
1256
+ return 0;
1257
+ });
1258
+ }
1259
+
1260
+ // Apply pagination if needed
1261
+ let paginatedResults = results;
1262
+ if (offset && offset > 0) {
1263
+ paginatedResults = paginatedResults.slice(offset);
1264
+ }
1265
+
1266
+ if (limit && limit > 0) {
1267
+ paginatedResults = paginatedResults.slice(0, limit);
1268
+ }
1269
+
1270
+ console.log(`🔍 Final search results: ${paginatedResults.length} items (total: ${results.length})`);
1271
+
1272
+ // Update data with search results
1273
+ setData(paginatedResults);
1274
+
1275
+ // Show error if there were issues but still show partial results
1276
+ if (hasErrors && results.length === 0) {
1277
+ setError(new Error('Search failed - please check if search functions are properly configured'));
1278
+ }
1279
+ } catch (err) {
1280
+ console.error('🔍 Search error:', err);
1281
+ setError(err as Error);
1282
+ setData([]);
1283
+ setCount(0);
1284
+ } finally {
1285
+ setSearchLoading(false);
1286
+ }
1287
+ }, 300); // 300ms debounce
1288
+ }, [tableName, searchFields, where, orderBy, limit, offset]);
1289
+
1290
+ /**
1291
+ * Fetch multiple records with support for filtering, sorting, and pagination.
1292
+ *
1293
+ * @param params - Query parameters for filtering and ordering records
1294
+ * @returns A promise with the fetched data or error
1295
+ *
1296
+ * @example
1297
+ * // Get all active users
1298
+ * const result = await users.findMany({
1299
+ * where: { active: true }
1300
+ * });
1301
+ *
1302
+ * @example
1303
+ * // Get 10 most recent posts with pagination
1304
+ * const page1 = await posts.findMany({
1305
+ * orderBy: { created_at: 'desc' },
1306
+ * take: 10,
1307
+ * skip: 0
1308
+ * });
1309
+ *
1310
+ * const page2 = await posts.findMany({
1311
+ * orderBy: { created_at: 'desc' },
1312
+ * take: 10,
1313
+ * skip: 10
1314
+ * });
1315
+ */
1316
+ const findMany = useCallback(async (params?: {
1317
+ where?: TWhereInput;
1318
+ orderBy?: TOrderByInput;
1319
+ take?: number;
1320
+ skip?: number;
1321
+ }): ModelResult<TWithRelations[]> => {
1322
+ try {
1323
+ setLoading(true);
1324
+ setError(null);
1325
+
1326
+ // Use selectString for field selection (includes relations if specified)
1327
+ let query = supabase.from(tableName).select(selectString);
1328
+
1329
+ // Apply where conditions if provided
1330
+ if (params?.where) {
1331
+ query = applyFilter(query, params.where);
1332
+ }
1333
+
1334
+ // Apply order by if provided
1335
+ if (params?.orderBy) {
1336
+ query = applyOrderBy(query, params.orderBy, hasCreatedAt, createdAtField);
1337
+ } else if (hasCreatedAt) {
1338
+ // Use the actual createdAt field name from Prisma
1339
+ // @ts-ignore: Supabase typing issue
1340
+ query = query.order(createdAtField, { ascending: false });
1341
+ }
1342
+
1343
+ // Apply limit if provided
1344
+ if (params?.take) {
1345
+ query = query.limit(params.take);
1346
+ }
1347
+
1348
+ // Apply offset if provided (for pagination)
1349
+ if (params?.skip !== undefined && params.skip >= 0) {
1350
+ query = query.range(params.skip, params.skip + (params.take || 10) - 1);
1351
+ }
1352
+
1353
+ const { data, error } = await query;
1354
+
1355
+ if (error) throw error;
1356
+
1357
+ const typedData = (data || []) as TWithRelations[];
1358
+
1359
+ // Only update data if not currently searching
1360
+ if (!isSearchingRef.current) {
1361
+ setData(typedData);
1362
+
1363
+ // If the where filter changed, update the total count
1364
+ if (JSON.stringify(params?.where) !== JSON.stringify(where)) {
1365
+ // Use our standard count fetching function instead of duplicating logic
1366
+ setTimeout(() => fetchTotalCount(), 0);
1367
+ }
1368
+ }
1369
+
1370
+ return { data: typedData, error: null };
1371
+ } catch (err: any) {
1372
+ console.error('Error finding records:', err);
1373
+ setError(err);
1374
+ return { data: null, error: err };
1375
+ } finally {
1376
+ setLoading(false);
1377
+ }
1378
+ }, [fetchTotalCount, where, tableName, hasCreatedAt, createdAtField]);
1379
+
1380
+ /**
1381
+ * Find a single record by its unique identifier (usually ID).
1382
+ *
1383
+ * @param where - The unique identifier to find the record by
1384
+ * @returns A promise with the found record or error
1385
+ *
1386
+ * @example
1387
+ * // Find user by ID
1388
+ * const result = await users.findUnique({ id: "123" });
1389
+ * if (result.data) {
1390
+ * console.log("Found user:", result.data.name);
1391
+ * }
1392
+ */
1393
+ const findUnique = useCallback(async (
1394
+ where: TWhereUniqueInput
1395
+ ): ModelResult<TWithRelations> => {
1396
+ try {
1397
+ setLoading(true);
1398
+ setError(null);
1399
+
1400
+ // Find the primary field (usually 'id')
1401
+ // @ts-ignore: Supabase typing issue
1402
+ const primaryKey = Object.keys(where)[0];
1403
+ if (!primaryKey) {
1404
+ throw new Error('A unique identifier is required');
1405
+ }
1406
+
1407
+ const value = where[primaryKey as keyof typeof where];
1408
+ if (value === undefined) {
1409
+ throw new Error('A unique identifier is required');
1410
+ }
1411
+
1412
+ const { data, error } = await supabase
1413
+ .from(tableName)
1414
+ .select(selectString)
1415
+ .eq(primaryKey, value)
1416
+ .maybeSingle();
1417
+
1418
+ if (error) throw error;
1419
+
1420
+ return { data: data as TWithRelations, error: null };
1421
+ } catch (err: any) {
1422
+ console.error('Error finding unique record:', err);
1423
+ setError(err);
1424
+ return { data: null, error: err };
1425
+ } finally {
1426
+ setLoading(false);
1427
+ }
1428
+ }, []);
1429
+
1430
+ // Set up realtime subscription for the list - ONCE and listen to ALL events
1431
+ useEffect(() => {
1432
+ if (!realtime) return;
1433
+
1434
+ // Clean up previous subscription if it exists
1435
+ if (channelRef.current) {
1436
+ channelRef.current.unsubscribe();
1437
+ channelRef.current = null;
1438
+ }
1439
+
1440
+ const channelId = channelName || `changes_to_${tableName}_${Math.random().toString(36).substring(2, 15)}`;
1441
+
1442
+ // ALWAYS listen to ALL events and filter client-side for maximum reliability
1443
+ let subscriptionConfig: any = {
1444
+ event: '*',
1445
+ schema: 'public',
1446
+ table: tableName,
1447
+ };
1448
+
1449
+ console.log(`Setting up subscription for ${tableName} - listening to ALL events (client-side filtering)`);
1450
+
1451
+ const channel = supabase
1452
+ .channel(channelId)
1453
+ .on(
1454
+ 'postgres_changes',
1455
+ subscriptionConfig,
1456
+ (payload) => {
1457
+ console.log(`🔥 REALTIME EVENT RECEIVED for ${tableName}:`, payload.eventType, payload);
1458
+
1459
+ // Access current options via refs inside the event handler
1460
+ const currentWhere = whereRef.current;
1461
+ const currentOrderBy = orderByRef.current;
1462
+ const currentLimit = limitRef.current;
1463
+ const currentOffset = offsetRef.current; // Not directly used in handlers but good for consistency
1464
+
1465
+ // Skip realtime updates when search is active
1466
+ if (isSearchingRef.current) {
1467
+ console.log('⏭️ Skipping realtime update - search is active');
1468
+ return;
1469
+ }
1470
+
1471
+ if (payload.eventType === 'INSERT') {
1472
+ // Process insert event
1473
+ setData((prev) => {
1474
+ try {
1475
+ const newRecord = payload.new as TWithRelations;
1476
+ console.log(`Processing INSERT for ${tableName}`, { newRecord });
1477
+
1478
+ // ALWAYS check if this record matches our filter client-side
1479
+ // This is especially important for complex OR/AND/array filters
1480
+ if (currentWhere && !matchesFilter(newRecord, currentWhere)) {
1481
+ console.log('New record does not match filter criteria, skipping');
1482
+ return prev;
1483
+ }
1484
+
1485
+ // Check if record already exists (avoid duplicates)
1486
+ const exists = prev.some(item =>
1487
+ // @ts-ignore: Supabase typing issue
1488
+ 'id' in item && 'id' in newRecord && item.id === newRecord.id
1489
+ );
1490
+
1491
+ if (exists) {
1492
+ console.log('Record already exists, skipping insertion');
1493
+ return prev;
1494
+ }
1495
+
1496
+ // Add the new record to the data
1497
+ let newData = [...prev, newRecord]; // Changed: Use spread on prev for immutability
1498
+
1499
+ // Apply ordering if specified
1500
+ if (currentOrderBy) { // Use ref value
1501
+ // Convert orderBy to array format for consistency if it's an object
1502
+ const orderByArray = Array.isArray(currentOrderBy)
1503
+ ? currentOrderBy
1504
+ : [currentOrderBy];
1505
+
1506
+ // Apply each sort in sequence
1507
+ newData = [...newData].sort((a, b) => {
1508
+ // Check each orderBy clause in sequence
1509
+ for (const orderByClause of orderByArray) {
1510
+ for (const [field, direction] of Object.entries(orderByClause)) {
1511
+ const aValue = a[field as keyof typeof a];
1512
+ const bValue = b[field as keyof typeof b];
1513
+
1514
+ // Skip if values are equal and move to next criterion
1515
+ if (aValue === bValue) continue;
1516
+
1517
+ // Use the compareValues function for proper type handling
1518
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
1519
+ }
1520
+ }
1521
+ return 0; // Equal if all criteria match
1522
+ });
1523
+ } else if (hasCreatedAt) {
1524
+ // Default sort by createdAt desc if no explicit sort but has timestamp
1525
+ newData = [...newData].sort((a, b) => {
1526
+ const aValue = a[createdAtField as keyof typeof a];
1527
+ const bValue = b[createdAtField as keyof typeof b];
1528
+ return compareValues(aValue, bValue, 'desc');
1529
+ });
1530
+ }
1531
+
1532
+ // Apply limit if specified
1533
+ if (currentLimit && currentLimit > 0) { // Use ref value
1534
+ newData = newData.slice(0, currentLimit);
1535
+ }
1536
+
1537
+ // Fetch the updated count after the data changes
1538
+ setTimeout(() => fetchTotalCount(), 0);
1539
+
1540
+ return newData;
1541
+ } catch (error) {
1542
+ console.error('Error processing INSERT event:', error);
1543
+ return prev;
1544
+ }
1545
+ });
1546
+ } else if (payload.eventType === 'UPDATE') {
1547
+ // Process update event
1548
+ setData((prev) => {
1549
+ // Access current options via refs
1550
+ const currentOrderBy = orderByRef.current;
1551
+ const currentLimit = limitRef.current; // If needed for re-fetch logic on update
1552
+ const currentWhere = whereRef.current;
1553
+
1554
+ // Skip if search is active
1555
+ if (isSearchingRef.current) {
1556
+ return prev;
1557
+ }
1558
+
1559
+ const updatedRecord = payload.new as TWithRelations;
1560
+
1561
+ // Check if the updated record still matches our current filter
1562
+ if (currentWhere && !matchesFilter(updatedRecord, currentWhere)) {
1563
+ console.log('Updated record no longer matches filter, removing from list');
1564
+ return prev.filter((item) =>
1565
+ // @ts-ignore: Supabase typing issue
1566
+ !('id' in item && 'id' in updatedRecord && item.id === updatedRecord.id)
1567
+ );
1568
+ }
1569
+
1570
+ const newData = prev.map((item) =>
1571
+ // @ts-ignore: Supabase typing issue
1572
+ 'id' in item && 'id' in payload.new && item.id === payload.new.id
1573
+ ? (payload.new as TWithRelations)
1574
+ : item
1575
+ );
1576
+
1577
+ // Apply ordering again after update to ensure consistency
1578
+ let sortedData = [...newData];
1579
+
1580
+ // Apply ordering if specified
1581
+ if (currentOrderBy) { // Use ref value
1582
+ // Convert orderBy to array format for consistency if it's an object
1583
+ const orderByArray = Array.isArray(currentOrderBy)
1584
+ ? currentOrderBy
1585
+ : [currentOrderBy];
1586
+
1587
+ // Apply each sort in sequence
1588
+ sortedData = sortedData.sort((a, b) => {
1589
+ // Check each orderBy clause in sequence
1590
+ for (const orderByClause of orderByArray) {
1591
+ for (const [field, direction] of Object.entries(orderByClause)) {
1592
+ const aValue = a[field as keyof typeof a];
1593
+ const bValue = b[field as keyof typeof b];
1594
+
1595
+ // Skip if values are equal and move to next criterion
1596
+ if (aValue === bValue) continue;
1597
+
1598
+ // Use the compareValues function for proper type handling
1599
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
1600
+ }
1601
+ }
1602
+ return 0; // Equal if all criteria match
1603
+ });
1604
+ } else if (hasCreatedAt) {
1605
+ // Default sort by createdAt desc if no explicit sort but has timestamp
1606
+ sortedData = sortedData.sort((a, b) => {
1607
+ const aValue = a[createdAtField as keyof typeof a];
1608
+ const bValue = b[createdAtField as keyof typeof b];
1609
+ return compareValues(aValue, bValue, 'desc');
1610
+ });
1611
+ }
1612
+
1613
+ // Fetch the updated count after the data changes
1614
+ // For updates, the count might not change but we fetch anyway to be consistent
1615
+ setTimeout(() => fetchTotalCount(), 0);
1616
+
1617
+ return sortedData;
1618
+ });
1619
+ } else if (payload.eventType === 'DELETE') {
1620
+ // Process delete event
1621
+ console.log('🗑️ Processing DELETE event for', tableName);
1622
+ setData((prev) => {
1623
+ console.log('🗑️ DELETE: Current data before deletion:', prev.length, 'items');
1624
+
1625
+ // Access current options via refs
1626
+ const currentWhere = whereRef.current;
1627
+ const currentOrderBy = orderByRef.current;
1628
+ const currentLimit = limitRef.current;
1629
+ const currentOffset = offsetRef.current;
1630
+
1631
+ // Skip if search is active
1632
+ if (isSearchingRef.current) {
1633
+ console.log('⏭️ DELETE: Skipping - search is active');
1634
+ return prev;
1635
+ }
1636
+
1637
+ // Save the current size before filtering
1638
+ const currentSize = prev.length;
1639
+
1640
+ // Filter out the deleted item
1641
+ const filteredData = prev.filter((item) => {
1642
+ // @ts-ignore: Supabase typing issue
1643
+ const shouldKeep = !('id' in item && 'id' in payload.old && item.id === payload.old.id);
1644
+ if (!shouldKeep) {
1645
+ console.log('🗑️ DELETE: Removing item with ID:', item.id);
1646
+ }
1647
+ return shouldKeep;
1648
+ });
1649
+
1650
+ console.log('🗑️ DELETE: Data after deletion:', filteredData.length, 'items (was', currentSize, ')');
1651
+
1652
+ // Fetch the updated count after the data changes
1653
+ setTimeout(() => fetchTotalCount(), 0);
1654
+
1655
+ // If we need to maintain the size with a limit
1656
+ if (currentLimit && currentLimit > 0 && filteredData.length < currentSize && currentSize === currentLimit) { // Use ref value
1657
+ console.log(`🗑️ DELETE: Record deleted with limit ${currentLimit}, will fetch additional record to maintain size`);
1658
+
1659
+ // Use setTimeout to ensure this state update completes first
1660
+ setTimeout(() => {
1661
+ findMany({
1662
+ where: currentWhere, // Use ref value
1663
+ orderBy: currentOrderBy, // Use ref value
1664
+ take: currentLimit, // Use ref value
1665
+ skip: currentOffset // Use ref value (passed as skip to findMany)
1666
+ });
1667
+ }, 0);
1668
+
1669
+ // Return the filtered data without resizing for now
1670
+ // The findMany call above will update the data later
1671
+ return filteredData;
1672
+ }
1673
+
1674
+ // Re-apply ordering to maintain consistency
1675
+ let sortedData = [...filteredData];
1676
+
1677
+ // Apply ordering if specified
1678
+ if (currentOrderBy) { // Use ref value
1679
+ // Convert orderBy to array format for consistency if it's an object
1680
+ const orderByArray = Array.isArray(currentOrderBy)
1681
+ ? currentOrderBy
1682
+ : [currentOrderBy];
1683
+
1684
+ // Apply each sort in sequence
1685
+ sortedData = sortedData.sort((a, b) => {
1686
+ // Check each orderBy clause in sequence
1687
+ for (const orderByClause of orderByArray) {
1688
+ for (const [field, direction] of Object.entries(orderByClause)) {
1689
+ const aValue = a[field as keyof typeof a];
1690
+ const bValue = b[field as keyof typeof b];
1691
+
1692
+ // Skip if values are equal and move to next criterion
1693
+ if (aValue === bValue) continue;
1694
+
1695
+ // Use the compareValues function for proper type handling
1696
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
1697
+ }
1698
+ }
1699
+ return 0; // Equal if all criteria match
1700
+ });
1701
+ } else if (hasCreatedAt) {
1702
+ // Default sort by createdAt desc if no explicit sort but has timestamp
1703
+ sortedData = sortedData.sort((a, b) => {
1704
+ const aValue = a[createdAtField as keyof typeof a];
1705
+ const bValue = b[createdAtField as keyof typeof b];
1706
+ return compareValues(aValue, bValue, 'desc');
1707
+ });
1708
+ }
1709
+
1710
+ return sortedData;
1711
+ });
1712
+ }
1713
+ }
1714
+ )
1715
+ .subscribe((status) => {
1716
+ console.log(`Subscription status for ${tableName}`, status);
1717
+ });
1718
+
1719
+ // Store the channel ref
1720
+ channelRef.current = channel;
1721
+
1722
+ return () => {
1723
+ console.log(`Unsubscribing from ${channelId}`);
1724
+ if (channelRef.current) {
1725
+ supabase.removeChannel(channelRef.current); // Correct way to remove channel
1726
+ channelRef.current = null;
1727
+ }
1728
+
1729
+ if (searchTimeoutRef.current) {
1730
+ clearTimeout(searchTimeoutRef.current);
1731
+ searchTimeoutRef.current = null;
1732
+ }
1733
+ };
1734
+ }, [realtime, channelName, tableName]); // NEVER include 'where' - subscription should persist
1735
+
1736
+ // Create a memoized options object to prevent unnecessary re-renders
1737
+ const optionsRef = useRef({ where, orderBy, limit, offset, selectString });
1738
+
1739
+ // Compare current options with previous options
1740
+ const optionsChanged = useCallback(() => {
1741
+ // Create stable string representations for deep comparison
1742
+ const whereStr = where ? JSON.stringify(where) : '';
1743
+ const orderByStr = orderBy ? JSON.stringify(orderBy) : '';
1744
+ const prevWhereStr = optionsRef.current.where ? JSON.stringify(optionsRef.current.where) : '';
1745
+ const prevOrderByStr = optionsRef.current.orderBy ? JSON.stringify(optionsRef.current.orderBy) : '';
1746
+
1747
+ // Compare the stable representations
1748
+ const hasChanged =
1749
+ whereStr !== prevWhereStr ||
1750
+ orderByStr !== prevOrderByStr ||
1751
+ limit !== optionsRef.current.limit ||
1752
+ offset !== optionsRef.current.offset ||
1753
+ selectString !== optionsRef.current.selectString;
1754
+
1755
+ if (hasChanged) {
1756
+ // Update the ref with the new values
1757
+ optionsRef.current = { where, orderBy, limit, offset, selectString };
1758
+ return true;
1759
+ }
1760
+
1761
+ return false;
1762
+ }, [where, orderBy, limit, offset, selectString]);
1763
+
1764
+ // Load initial data and refetch when options change (BUT NEVER TOUCH SUBSCRIPTION)
1765
+ useEffect(() => {
1766
+ // Skip if search is active
1767
+ if (isSearchingRef.current) return;
1768
+
1769
+ // Skip if we've already loaded or if no filter criteria are provided
1770
+ if (initialLoadRef.current) {
1771
+ // Only reload if options have changed significantly
1772
+ if (optionsChanged()) {
1773
+ console.log(`Options changed for ${tableName}, refetching data (subscription stays alive)`);
1774
+ findMany({
1775
+ where,
1776
+ orderBy,
1777
+ take: limit,
1778
+ skip: offset
1779
+ });
1780
+
1781
+ // Also update the total count
1782
+ fetchTotalCount();
1783
+ }
1784
+ return;
1785
+ }
1786
+
1787
+ // Initial load
1788
+ initialLoadRef.current = true;
1789
+ findMany({
1790
+ where,
1791
+ orderBy,
1792
+ take: limit,
1793
+ skip: offset
1794
+ });
1795
+
1796
+ // Initial count fetch
1797
+ fetchTotalCount();
1798
+ }, [findMany, where, orderBy, limit, offset, optionsChanged, fetchTotalCount]);
1799
+
1800
+ /**
1801
+ * Create a new record with the provided data.
1802
+ * Default values from the schema will be applied if not provided.
1803
+ * NOTE: This operation does NOT immediately update the local state.
1804
+ * The state will be updated when the realtime INSERT event is received.
1805
+ *
1806
+ * @param data - The data to create the record with
1807
+ * @returns A promise with the created record or error
1808
+ *
1809
+ * @example
1810
+ * // Create a new user
1811
+ * const result = await users.create({
1812
+ * name: "John Doe",
1813
+ * email: "john@example.com"
1814
+ * });
1815
+ *
1816
+ * @example
1817
+ * // Create with custom ID (overriding default)
1818
+ * const result = await users.create({
1819
+ * id: "custom-id-123",
1820
+ * name: "John Doe"
1821
+ * });
1822
+ */
1823
+ const create = useCallback(async (
1824
+ data: TCreateInput
1825
+ ): ModelResult<TWithRelations> => {
1826
+ try {
1827
+ setLoading(true);
1828
+ setError(null);
1829
+
1830
+ const now = new Date();
1831
+
1832
+ // Helper function to convert Date objects to ISO strings for database
1833
+ const convertDatesForDatabase = (obj: any): any => {
1834
+ const result: any = {};
1835
+ for (const [key, value] of Object.entries(obj)) {
1836
+ if (value instanceof Date) {
1837
+ result[key] = value.toISOString();
1838
+ } else {
1839
+ result[key] = value;
1840
+ }
1841
+ }
1842
+ return result;
1843
+ };
1844
+
1845
+ // Apply default values from schema
1846
+ const appliedDefaults: Record<string, any> = {};
1847
+
1848
+ // Apply all default values that aren't already in the data
1849
+ for (const [field, defaultValue] of Object.entries(defaultValues)) {
1850
+ // @ts-ignore: Supabase typing issue
1851
+ if (!(field in data)) {
1852
+ // Parse the default value based on its type
1853
+ if (defaultValue.includes('now()') || defaultValue.includes('now')) {
1854
+ appliedDefaults[field] = now.toISOString(); // Database expects ISO string
1855
+ } else if (defaultValue.includes('uuid()') || defaultValue.includes('uuid')) {
1856
+ appliedDefaults[field] = generateUUID();
1857
+ } else if (defaultValue.includes('cuid()') || defaultValue.includes('cuid')) {
1858
+ // Simple cuid-like implementation for client-side
1859
+ appliedDefaults[field] = 'c' + Math.random().toString(36).substring(2, 15);
1860
+ } else if (defaultValue.includes('true')) {
1861
+ appliedDefaults[field] = true;
1862
+ } else if (defaultValue.includes('false')) {
1863
+ appliedDefaults[field] = false;
1864
+ } else if (!isNaN(Number(defaultValue))) {
1865
+ // If it's a number
1866
+ appliedDefaults[field] = Number(defaultValue);
1867
+ } else {
1868
+ // String or other value, remove quotes if present
1869
+ const strValue = defaultValue.replace(/^["'](.*)["']$/, '$1');
1870
+ appliedDefaults[field] = strValue;
1871
+ }
1872
+ }
1873
+ }
1874
+
1875
+ const itemWithDefaults = convertDatesForDatabase({
1876
+ ...appliedDefaults, // Apply schema defaults first
1877
+ ...data, // Then user data (overrides defaults)
1878
+ // Use the actual field names from Prisma - convert Date to ISO string for database
1879
+ ...(hasCreatedAt ? { [createdAtField]: now.toISOString() } : {}),
1880
+ ...(hasUpdatedAt ? { [updatedAtField]: now.toISOString() } : {})
1881
+ });
1882
+
1883
+ const { data: result, error } = await supabase
1884
+ .from(tableName)
1885
+ .insert([itemWithDefaults])
1886
+ .select(selectString);
1887
+
1888
+ if (error) throw error;
1889
+
1890
+ // DO NOT UPDATE LOCAL STATE HERE - Let realtime INSERT event handle it
1891
+ console.log('✅ Created ' + tableName + ' record, waiting for realtime INSERT event to update state');
1892
+
1893
+ // Return created record
1894
+ return { data: result?.[0] as TWithRelations, error: null };
1895
+ } catch (err: any) {
1896
+ console.error('Error creating record:', err);
1897
+ setError(err);
1898
+ return { data: null, error: err };
1899
+ } finally {
1900
+ setLoading(false);
1901
+ }
1902
+ }, []);
1903
+
1904
+ /**
1905
+ * Update an existing record identified by a unique identifier.
1906
+ * NOTE: This operation does NOT immediately update the local state.
1907
+ * The state will be updated when the realtime UPDATE event is received.
1908
+ *
1909
+ * @param params - Object containing the identifier and update data
1910
+ * @returns A promise with the updated record or error
1911
+ *
1912
+ * @example
1913
+ * // Update a user's name
1914
+ * const result = await users.update({
1915
+ * where: { id: "123" },
1916
+ * data: { name: "New Name" }
1917
+ * });
1918
+ *
1919
+ * @example
1920
+ * // Update multiple fields
1921
+ * const result = await users.update({
1922
+ * where: { id: "123" },
1923
+ * data: {
1924
+ * name: "New Name",
1925
+ * active: false
1926
+ * }
1927
+ * });
1928
+ */
1929
+ const update = useCallback(async (params: {
1930
+ where: TWhereUniqueInput;
1931
+ data: TUpdateInput;
1932
+ }): ModelResult<TWithRelations> => {
1933
+ try {
1934
+ setLoading(true);
1935
+ setError(null);
1936
+
1937
+ // Find the primary field (usually 'id')
1938
+ // @ts-ignore: Supabase typing issue
1939
+ const primaryKey = Object.keys(params.where)[0];
1940
+ if (!primaryKey) {
1941
+ throw new Error('A unique identifier is required');
1942
+ }
1943
+
1944
+ const value = params.where[primaryKey as keyof typeof params.where];
1945
+ if (value === undefined) {
1946
+ throw new Error('A unique identifier is required');
1947
+ }
1948
+
1949
+ const now = new Date();
1950
+
1951
+ // Helper function to convert Date objects to ISO strings for database
1952
+ const convertDatesForDatabase = (obj: any): any => {
1953
+ const result: any = {};
1954
+ for (const [key, value] of Object.entries(obj)) {
1955
+ if (value instanceof Date) {
1956
+ result[key] = value.toISOString();
1957
+ } else {
1958
+ result[key] = value;
1959
+ }
1960
+ }
1961
+ return result;
1962
+ };
1963
+
1964
+ // We do not apply default values for updates
1965
+ // Default values are only for creation, not for updates,
1966
+ // as existing records already have these values set
1967
+
1968
+ const itemWithDefaults = convertDatesForDatabase({
1969
+ ...params.data,
1970
+ // Use the actual updatedAt field name from Prisma - convert Date to ISO string for database
1971
+ ...(hasUpdatedAt ? { [updatedAtField]: now.toISOString() } : {})
1972
+ });
1973
+
1974
+ const { data, error } = await supabase
1975
+ .from(tableName)
1976
+ .update(itemWithDefaults)
1977
+ .eq(primaryKey, value)
1978
+ .select(selectString);
1979
+
1980
+ if (error) throw error;
1981
+
1982
+ // DO NOT UPDATE LOCAL STATE HERE - Let realtime UPDATE event handle it
1983
+ console.log('✅ Updated ' + tableName + ' record, waiting for realtime UPDATE event to update state');
1984
+
1985
+ // Return updated record
1986
+ return { data: data?.[0] as TWithRelations, error: null };
1987
+ } catch (err: any) {
1988
+ console.error('Error updating record:', err);
1989
+ setError(err);
1990
+ return { data: null, error: err };
1991
+ } finally {
1992
+ setLoading(false);
1993
+ }
1994
+ }, []);
1995
+
1996
+ /**
1997
+ * Delete a record by its unique identifier.
1998
+ * NOTE: This operation does NOT immediately update the local state.
1999
+ * The state will be updated when the realtime DELETE event is received.
2000
+ *
2001
+ * @param where - The unique identifier to delete the record by
2002
+ * @returns A promise with the deleted record or error
2003
+ *
2004
+ * @example
2005
+ * // Delete a user by ID
2006
+ * const result = await users.delete({ id: "123" });
2007
+ * if (result.data) {
2008
+ * console.log("Deleted user:", result.data.name);
2009
+ * }
2010
+ */
2011
+ const deleteRecord = useCallback(async (
2012
+ where: TWhereUniqueInput
2013
+ ): ModelResult<TWithRelations> => {
2014
+ try {
2015
+ setLoading(true);
2016
+ setError(null);
2017
+
2018
+ // Find the primary field (usually 'id')
2019
+ // @ts-ignore: Supabase typing issue
2020
+ const primaryKey = Object.keys(where)[0];
2021
+ if (!primaryKey) {
2022
+ throw new Error('A unique identifier is required');
2023
+ }
2024
+
2025
+ const value = where[primaryKey as keyof typeof where];
2026
+ if (value === undefined) {
2027
+ throw new Error('A unique identifier is required');
2028
+ }
2029
+
2030
+ // First fetch the record to return it
2031
+ const { data: recordToDelete } = await supabase
2032
+ .from(tableName)
2033
+ .select(selectString)
2034
+ .eq(primaryKey, value)
2035
+ .maybeSingle();
2036
+
2037
+ if (!recordToDelete) {
2038
+ throw new Error('Record not found');
2039
+ }
2040
+
2041
+ // Then delete it
2042
+ const { error } = await supabase
2043
+ .from(tableName)
2044
+ .delete()
2045
+ .eq(primaryKey, value);
2046
+
2047
+ if (error) throw error;
2048
+
2049
+ // DO NOT UPDATE LOCAL STATE HERE - Let realtime DELETE event handle it
2050
+ console.log('✅ Deleted ' + tableName + ' record, waiting for realtime DELETE event to update state');
2051
+
2052
+ // Return the deleted record
2053
+ return { data: recordToDelete as TWithRelations, error: null };
2054
+ } catch (err: any) {
2055
+ console.error('Error deleting record:', err);
2056
+ setError(err);
2057
+ return { data: null, error: err };
2058
+ } finally {
2059
+ setLoading(false);
2060
+ }
2061
+ }, []);
2062
+
2063
+ /**
2064
+ * Delete multiple records matching the filter criteria.
2065
+ * NOTE: This operation does NOT immediately update the local state.
2066
+ * The state will be updated when realtime DELETE events are received for each record.
2067
+ *
2068
+ * @param params - Query parameters for filtering records to delete
2069
+ * @returns A promise with the count of deleted records or error
2070
+ *
2071
+ * @example
2072
+ * // Delete all inactive users
2073
+ * const result = await users.deleteMany({
2074
+ * where: { active: false }
2075
+ * });
2076
+ * console.log('Deleted ' + result.count + ' inactive users');
2077
+ *
2078
+ * @example
2079
+ * // Delete all records (use with caution!)
2080
+ * const result = await users.deleteMany();
2081
+ */
2082
+ const deleteMany = useCallback(async (params?: {
2083
+ where?: TWhereInput;
2084
+ }): Promise<{ count: number; error: Error | null }> => {
2085
+ try {
2086
+ setLoading(true);
2087
+ setError(null);
2088
+
2089
+ // First, get the records that will be deleted to count them
2090
+ let query = supabase.from(tableName).select('*');
2091
+
2092
+ // Apply where conditions if provided
2093
+ if (params?.where) {
2094
+ query = applyFilter(query, params.where);
2095
+ }
2096
+
2097
+ // Get records that will be deleted
2098
+ const { data: recordsToDelete, error: fetchError } = await query;
2099
+
2100
+ if (fetchError) throw fetchError;
2101
+
2102
+ if (!recordsToDelete?.length) {
2103
+ return { count: 0, error: null };
2104
+ }
2105
+
2106
+ // Build the delete query
2107
+ let deleteQuery = supabase.from(tableName).delete();
2108
+
2109
+ // Apply the same filter to the delete operation
2110
+ if (params?.where) {
2111
+ // @ts-ignore: Supabase typing issue
2112
+ deleteQuery = applyFilter(deleteQuery, params.where);
2113
+ }
2114
+
2115
+ // Perform the delete
2116
+ const { error: deleteError } = await deleteQuery;
2117
+
2118
+ if (deleteError) throw deleteError;
2119
+
2120
+ // DO NOT UPDATE LOCAL STATE HERE - Let realtime DELETE events handle it
2121
+ console.log('✅ Deleted ' + recordsToDelete.length + ' ' + tableName + ' records, waiting for realtime DELETE events to update state');
2122
+
2123
+ // Return the count of deleted records
2124
+ return { count: recordsToDelete.length, error: null };
2125
+ } catch (err: any) {
2126
+ console.error('Error deleting multiple records:', err);
2127
+ setError(err);
2128
+ return { count: 0, error: err };
2129
+ } finally {
2130
+ setLoading(false);
2131
+ }
2132
+ }, []);
2133
+
2134
+ /**
2135
+ * Find the first record matching the filter criteria.
2136
+ *
2137
+ * @param params - Query parameters for filtering and ordering
2138
+ * @returns A promise with the first matching record or error
2139
+ *
2140
+ * @example
2141
+ * // Find the first admin user
2142
+ * const result = await users.findFirst({
2143
+ * where: { role: 'admin' }
2144
+ * });
2145
+ *
2146
+ * @example
2147
+ * // Find the oldest post
2148
+ * const result = await posts.findFirst({
2149
+ * orderBy: { created_at: 'asc' }
2150
+ * });
2151
+ */
2152
+ const findFirst = useCallback(async (params?: {
2153
+ where?: TWhereInput;
2154
+ orderBy?: TOrderByInput;
2155
+ }): ModelResult<TWithRelations> => {
2156
+ try {
2157
+ const result = await findMany({
2158
+ ...params,
2159
+ take: 1
2160
+ });
2161
+
2162
+ if (result.error) return { data: null, error: result.error };
2163
+ if (!result.data.length) return { data: null, error: new Error('No records found') };
2164
+
2165
+ // @ts-ignore: Supabase typing issue
2166
+ return { data: result.data[0], error: null };
2167
+ } catch (err: any) {
2168
+ console.error('Error finding first record:', err);
2169
+ return { data: null, error: err };
2170
+ }
2171
+ }, [findMany]);
2172
+
2173
+ /**
2174
+ * Create a record if it doesn't exist, or update it if it does.
2175
+ *
2176
+ * @param params - Object containing the identifier, update data, and create data
2177
+ * @returns A promise with the created or updated record or error
2178
+ *
2179
+ * @example
2180
+ * // Upsert a user by ID
2181
+ * const result = await users.upsert({
2182
+ * where: { id: "123" },
2183
+ * update: { lastLogin: new Date().toISOString() },
2184
+ * create: {
2185
+ * id: "123",
2186
+ * name: "John Doe",
2187
+ * email: "john@example.com",
2188
+ * lastLogin: new Date().toISOString()
2189
+ * }
2190
+ * });
2191
+ */
2192
+ const upsert = useCallback(async (params: {
2193
+ where: TWhereUniqueInput;
2194
+ update: TUpdateInput;
2195
+ create: TCreateInput;
2196
+ }): ModelResult<TWithRelations> => {
2197
+ try {
2198
+ // Check if record exists
2199
+ const { data: existing } = await findUnique(params.where);
2200
+
2201
+ // Update if exists, otherwise create
2202
+ if (existing) {
2203
+ return update({ where: params.where, data: params.update });
2204
+ } else {
2205
+ return create(params.create);
2206
+ }
2207
+ } catch (err: any) {
2208
+ console.error('Error upserting record:', err);
2209
+ return { data: null, error: err };
2210
+ }
2211
+ }, [findUnique, update, create]);
2212
+
2213
+ /**
2214
+ * Count the number of records matching the filter criteria.
2215
+ * This is a manual method to get the count with a different filter
2216
+ * than the main hook's filter.
2217
+ *
2218
+ * @param params - Query parameters for filtering
2219
+ * @returns A promise with the count of matching records
2220
+ */
2221
+ const countFn = useCallback(async (params?: {
2222
+ where?: TWhereInput;
2223
+ }): Promise<number> => {
2224
+ try {
2225
+ let query = supabase.from(tableName).select('*', { count: 'exact', head: true });
2226
+
2227
+ // Use provided where filter, or fall back to the hook's original where filter
2228
+ const effectiveWhere = params?.where ?? where;
2229
+
2230
+ if (effectiveWhere) {
2231
+ query = applyFilter(query, effectiveWhere);
2232
+ }
2233
+
2234
+ const { count, error } = await query;
2235
+
2236
+ if (error) throw error;
2237
+
2238
+ return count || 0;
2239
+ } catch (err) {
2240
+ console.error('Error counting records:', err);
2241
+ return 0;
2242
+ }
2243
+ }, [where]);
2244
+
2245
+ /**
2246
+ * Manually refresh the data with current filter settings.
2247
+ * Useful after external operations or when realtime is disabled.
2248
+ *
2249
+ * @param params - Optional override parameters for this specific refresh
2250
+ * @returns A promise with the refreshed data or error
2251
+ *
2252
+ * @example
2253
+ * // Refresh with current filter settings
2254
+ * await users.refresh();
2255
+ *
2256
+ * @example
2257
+ * // Refresh with different filters for this call only
2258
+ * await users.refresh({
2259
+ * where: { active: true },
2260
+ * orderBy: { name: 'asc' }
2261
+ * });
2262
+ */
2263
+ const refresh = useCallback((params?: {
2264
+ where?: TWhereInput;
2265
+ orderBy?: TOrderByInput;
2266
+ take?: number;
2267
+ skip?: number;
2268
+ }) => {
2269
+ // If search is active, refresh search results
2270
+ if (isSearchingRef.current && searchQueries.length > 0) {
2271
+ executeSearch(searchQueries);
2272
+ return Promise.resolve({ data: data, error: null });
2273
+ }
2274
+
2275
+ // Otherwise, refresh normal data using original params if not explicitly overridden
2276
+ return findMany({
2277
+ where: params?.where ?? where,
2278
+ orderBy: params?.orderBy ?? orderBy,
2279
+ take: params?.take ?? limit,
2280
+ skip: params?.skip ?? offset
2281
+ });
2282
+ }, [findMany, data, searchQueries, where, orderBy, limit, offset]);
2283
+
2284
+ // Construct final hook API with or without search
2285
+ const api = {
2286
+ // State
2287
+ data,
2288
+ error,
2289
+ loading,
2290
+ count, // Now including count as a reactive state value
2291
+
2292
+ // Finder methods
2293
+ findUnique,
2294
+ findMany,
2295
+ findFirst,
2296
+
2297
+ // Mutation methods
2298
+ create,
2299
+ update,
2300
+ delete: deleteRecord,
2301
+ deleteMany,
2302
+ upsert,
2303
+
2304
+ // Manual refresh
2305
+ refresh
2306
+ };
2307
+
2308
+ // Add search object if searchable fields are present
2309
+ return searchFields.length > 0
2310
+ ? {
2311
+ ...api,
2312
+ search
2313
+ }
2314
+ : api;
2315
+ };
2316
+ }