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