suparisma 1.0.4 → 1.0.5

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 CHANGED
@@ -21,6 +21,8 @@ A powerful, typesafe React hook generator for Supabase, driven by your Prisma sc
21
21
  - [Basic CRUD Operations](#basic-crud-operations)
22
22
  - [Realtime Updates](#realtime-updates)
23
23
  - [Filtering Data](#filtering-data)
24
+ - [⚠️ IMPORTANT: Using Dynamic Filters with React](#️-important-using-dynamic-filters-with-react)
25
+ - [Array Filtering](#array-filtering)
24
26
  - [Sorting Data](#sorting-data)
25
27
  - [Pagination](#pagination)
26
28
  - [Search Functionality](#search-functionality)
@@ -58,7 +60,7 @@ Suparisma bridges this gap by:
58
60
  - 🚀 **Auto-generated React hooks** based on your Prisma schema
59
61
  - 🔄 **Real-time updates by default** for all tables (with opt-out capability)
60
62
  - 🔒 **Type-safe interfaces** for all database operations
61
- - 🔍 **Full-text search** with configurable annotations
63
+ - 🔍 **Full-text search** with configurable annotations *(currently under maintenance)*
62
64
  - 🔢 **Pagination and sorting** built into every hook
63
65
  - 🧩 **Prisma-like API** that feels familiar if you already use Prisma
64
66
  - 📱 **Works with any React framework** including Next.js, Remix, etc.
@@ -303,6 +305,83 @@ const { data } = useSuparisma.thing({
303
305
  });
304
306
  ```
305
307
 
308
+ ### ⚠️ IMPORTANT: Using Dynamic Filters with React
309
+
310
+ **You MUST use `useMemo` for dynamic where filters to prevent constant re-subscriptions!**
311
+
312
+ When creating `where` filters based on state variables, React will create a new object reference on every render, causing the realtime subscription to restart constantly and **breaking realtime updates**.
313
+
314
+ ❌ **WRONG - This breaks realtime:**
315
+ ```tsx
316
+ function MyComponent() {
317
+ const [filter, setFilter] = useState("active");
318
+
319
+ const { data } = useSuparisma.thing({
320
+ where: filter ? { status: filter } : undefined // ❌ New object every render!
321
+ });
322
+ }
323
+ ```
324
+
325
+ ✅ **CORRECT - Use useMemo:**
326
+ ```tsx
327
+ import { useMemo } from 'react';
328
+
329
+ function MyComponent() {
330
+ const [filter, setFilter] = useState("active");
331
+ const [arrayFilter, setArrayFilter] = useState(["item1"]);
332
+
333
+ // Create stable object reference that only changes when dependencies change
334
+ const whereFilter = useMemo(() => {
335
+ if (filter) {
336
+ return { status: filter };
337
+ }
338
+ return undefined;
339
+ }, [filter]); // Only recreate when filter actually changes
340
+
341
+ const { data } = useSuparisma.thing({
342
+ where: whereFilter // ✅ Stable reference!
343
+ });
344
+ }
345
+ ```
346
+
347
+ ✅ **Complex example with multiple filters:**
348
+ ```tsx
349
+ const whereFilter = useMemo(() => {
350
+ if (arrayFilterValue && arrayOperator) {
351
+ return {
352
+ tags: arrayOperator === 'has'
353
+ ? { has: [arrayFilterValue] }
354
+ : arrayOperator === 'hasEvery'
355
+ ? { hasEvery: ["required", "tag", arrayFilterValue] }
356
+ : { isEmpty: false }
357
+ };
358
+ } else if (statusFilter) {
359
+ return { status: statusFilter };
360
+ }
361
+ return undefined;
362
+ }, [arrayFilterValue, arrayOperator, statusFilter]); // Dependencies
363
+
364
+ const { data } = useSuparisma.thing({ where: whereFilter });
365
+ ```
366
+
367
+ **Why this matters:**
368
+ - Without `useMemo`, the subscription restarts on EVERY render
369
+ - This causes realtime events to be lost during reconnection
370
+ - You'll see constant "Unsubscribing/Subscribing" messages in the console
371
+ - Realtime updates will appear to be broken
372
+
373
+ **The same applies to `orderBy` if it's dynamic:**
374
+ ```tsx
375
+ const orderByConfig = useMemo(() => ({
376
+ [sortField]: sortDirection
377
+ }), [sortField, sortDirection]);
378
+
379
+ const { data } = useSuparisma.thing({
380
+ where: whereFilter,
381
+ orderBy: orderByConfig
382
+ });
383
+ ```
384
+
306
385
  ### Array Filtering
307
386
 
308
387
  Suparisma provides powerful operators for filtering array fields (e.g., `String[]`, `Int[]`, etc.):
@@ -543,6 +622,8 @@ const { data, count } = useSuparisma.thing();
543
622
 
544
623
  ### Search Functionality
545
624
 
625
+ > ⚠️ **MAINTENANCE NOTICE**: Search functionality is currently under maintenance and may not work as expected. We're working on improvements and will update the documentation once it's fully operational.
626
+
546
627
  For fields annotated with `// @enableSearch`, you can use full-text search:
547
628
 
548
629
  ```tsx
@@ -912,24 +912,52 @@ export function createSuparismaHook<
912
912
 
913
913
  const channelId = channelName || \`changes_to_\${tableName}_\${Math.random().toString(36).substring(2, 15)}\`;
914
914
 
915
- // Use a dynamic filter string builder inside the event handler or rely on Supabase
916
- // For the subscription filter, we must use the initial computedFilter or a stable one.
917
- // However, for client-side logic (sorting, adding/removing from list), we use refs.
918
- const initialComputedFilter = where ? buildFilterString(where) : realtimeFilter;
919
- console.log(\`Setting up subscription for \${tableName} with initial filter: \${initialComputedFilter}\`);
915
+ // Check if we have complex array filters that should be handled client-side only
916
+ let hasComplexArrayFilters = false;
917
+ if (where) {
918
+ for (const [key, value] of Object.entries(where)) {
919
+ if (typeof value === 'object' && value !== null) {
920
+ const advancedOps = value as any;
921
+ // Check for complex array operators
922
+ if ('has' in advancedOps || 'hasEvery' in advancedOps || 'hasSome' in advancedOps || 'isEmpty' in advancedOps) {
923
+ hasComplexArrayFilters = true;
924
+ break;
925
+ }
926
+ }
927
+ }
928
+ }
929
+
930
+ // For complex array filters, use no database filter and rely on client-side filtering
931
+ // For simple filters, use database-level filtering
932
+ let subscriptionConfig: any = {
933
+ event: '*',
934
+ schema: 'public',
935
+ table: tableName,
936
+ };
937
+
938
+ if (hasComplexArrayFilters) {
939
+ // Don't include filter at all for complex array operations
940
+ console.log(\`Setting up subscription for \${tableName} with NO FILTER (complex array filters detected) - will receive ALL events\`);
941
+ } else if (where) {
942
+ // Include filter for simple operations
943
+ const filter = buildFilterString(where);
944
+ if (filter) {
945
+ subscriptionConfig.filter = filter;
946
+ }
947
+ console.log(\`Setting up subscription for \${tableName} with database filter: \${filter}\`);
948
+ } else if (realtimeFilter) {
949
+ // Use custom realtime filter if provided
950
+ subscriptionConfig.filter = realtimeFilter;
951
+ console.log(\`Setting up subscription for \${tableName} with custom filter: \${realtimeFilter}\`);
952
+ }
920
953
 
921
954
  const channel = supabase
922
955
  .channel(channelId)
923
956
  .on(
924
957
  'postgres_changes',
925
- {
926
- event: '*',
927
- schema: 'public',
928
- table: tableName,
929
- filter: initialComputedFilter, // Subscription filter uses initial state
930
- },
958
+ subscriptionConfig,
931
959
  (payload) => {
932
- console.log(\`Received \${payload.eventType} event for \${tableName}\`, payload);
960
+ console.log(\`🔥 REALTIME EVENT RECEIVED for \${tableName}:\`, payload.eventType, payload);
933
961
 
934
962
  // Access current options via refs inside the event handler
935
963
  const currentWhere = whereRef.current;
@@ -938,7 +966,10 @@ export function createSuparismaHook<
938
966
  const currentOffset = offsetRef.current; // Not directly used in handlers but good for consistency
939
967
 
940
968
  // Skip realtime updates when search is active
941
- if (isSearchingRef.current) return;
969
+ if (isSearchingRef.current) {
970
+ console.log('⏭️ Skipping realtime update - search is active');
971
+ return;
972
+ }
942
973
 
943
974
  if (payload.eventType === 'INSERT') {
944
975
  // Process insert event
@@ -947,14 +978,46 @@ export function createSuparismaHook<
947
978
  const newRecord = payload.new as TWithRelations;
948
979
  console.log(\`Processing INSERT for \${tableName}\`, { newRecord });
949
980
 
950
- // Check if this record matches our filter if we have one
981
+ // ALWAYS check if this record matches our filter client-side
982
+ // This is especially important for complex array filters
951
983
  if (currentWhere) { // Use ref value
952
984
  let matchesFilter = true;
953
985
 
954
- // Check each filter condition
986
+ // Check each filter condition client-side for complex filters
955
987
  for (const [key, value] of Object.entries(currentWhere)) {
956
988
  if (typeof value === 'object' && value !== null) {
957
- // Complex filter - this is handled by Supabase, assume it matches
989
+ // Handle complex array filters client-side
990
+ const advancedOps = value as any;
991
+ const recordValue = newRecord[key as keyof typeof newRecord] as any;
992
+
993
+ // Array-specific operators validation
994
+ if ('has' in advancedOps && advancedOps.has !== undefined) {
995
+ // Array contains ANY of the specified items
996
+ if (!Array.isArray(recordValue) || !advancedOps.has.some((item: any) => recordValue.includes(item))) {
997
+ matchesFilter = false;
998
+ break;
999
+ }
1000
+ } else if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
1001
+ // Array contains ALL of the specified items
1002
+ if (!Array.isArray(recordValue) || !advancedOps.hasEvery.every((item: any) => recordValue.includes(item))) {
1003
+ matchesFilter = false;
1004
+ break;
1005
+ }
1006
+ } else if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
1007
+ // Array contains ANY of the specified items
1008
+ if (!Array.isArray(recordValue) || !advancedOps.hasSome.some((item: any) => recordValue.includes(item))) {
1009
+ matchesFilter = false;
1010
+ break;
1011
+ }
1012
+ } else if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
1013
+ // Array is empty or not empty
1014
+ const isEmpty = !Array.isArray(recordValue) || recordValue.length === 0;
1015
+ if (isEmpty !== advancedOps.isEmpty) {
1016
+ matchesFilter = false;
1017
+ break;
1018
+ }
1019
+ }
1020
+ // Add other complex filter validations as needed
958
1021
  } else if (newRecord[key as keyof typeof newRecord] !== value) {
959
1022
  matchesFilter = false;
960
1023
  console.log(\`Filter mismatch on \${key}\`, { expected: value, actual: newRecord[key as keyof typeof newRecord] });
@@ -1035,12 +1098,68 @@ export function createSuparismaHook<
1035
1098
  // Access current options via refs
1036
1099
  const currentOrderBy = orderByRef.current;
1037
1100
  const currentLimit = limitRef.current; // If needed for re-fetch logic on update
1101
+ const currentWhere = whereRef.current;
1038
1102
 
1039
1103
  // Skip if search is active
1040
1104
  if (isSearchingRef.current) {
1041
1105
  return prev;
1042
1106
  }
1043
1107
 
1108
+ const updatedRecord = payload.new as TWithRelations;
1109
+
1110
+ // Check if the updated record still matches our current filter
1111
+ if (currentWhere) {
1112
+ let matchesFilter = true;
1113
+
1114
+ for (const [key, value] of Object.entries(currentWhere)) {
1115
+ if (typeof value === 'object' && value !== null) {
1116
+ // Handle complex array filters client-side
1117
+ const advancedOps = value as any;
1118
+ const recordValue = updatedRecord[key as keyof typeof updatedRecord] as any;
1119
+
1120
+ // Array-specific operators validation
1121
+ if ('has' in advancedOps && advancedOps.has !== undefined) {
1122
+ // Array contains ANY of the specified items
1123
+ if (!Array.isArray(recordValue) || !advancedOps.has.some((item: any) => recordValue.includes(item))) {
1124
+ matchesFilter = false;
1125
+ break;
1126
+ }
1127
+ } else if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
1128
+ // Array contains ALL of the specified items
1129
+ if (!Array.isArray(recordValue) || !advancedOps.hasEvery.every((item: any) => recordValue.includes(item))) {
1130
+ matchesFilter = false;
1131
+ break;
1132
+ }
1133
+ } else if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
1134
+ // Array contains ANY of the specified items
1135
+ if (!Array.isArray(recordValue) || !advancedOps.hasSome.some((item: any) => recordValue.includes(item))) {
1136
+ matchesFilter = false;
1137
+ break;
1138
+ }
1139
+ } else if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
1140
+ // Array is empty or not empty
1141
+ const isEmpty = !Array.isArray(recordValue) || recordValue.length === 0;
1142
+ if (isEmpty !== advancedOps.isEmpty) {
1143
+ matchesFilter = false;
1144
+ break;
1145
+ }
1146
+ }
1147
+ } else if (updatedRecord[key as keyof typeof updatedRecord] !== value) {
1148
+ matchesFilter = false;
1149
+ break;
1150
+ }
1151
+ }
1152
+
1153
+ // If the updated record doesn't match the filter, remove it from the list
1154
+ if (!matchesFilter) {
1155
+ console.log('Updated record no longer matches filter, removing from list');
1156
+ return prev.filter((item) =>
1157
+ // @ts-ignore: Supabase typing issue
1158
+ !('id' in item && 'id' in updatedRecord && item.id === updatedRecord.id)
1159
+ );
1160
+ }
1161
+ }
1162
+
1044
1163
  const newData = prev.map((item) =>
1045
1164
  // @ts-ignore: Supabase typing issue
1046
1165
  'id' in item && 'id' in payload.new && item.id === payload.new.id
@@ -1092,7 +1211,10 @@ export function createSuparismaHook<
1092
1211
  });
1093
1212
  } else if (payload.eventType === 'DELETE') {
1094
1213
  // Process delete event
1214
+ console.log('🗑️ Processing DELETE event for', tableName);
1095
1215
  setData((prev) => {
1216
+ console.log('🗑️ DELETE: Current data before deletion:', prev.length, 'items');
1217
+
1096
1218
  // Access current options via refs
1097
1219
  const currentWhere = whereRef.current;
1098
1220
  const currentOrderBy = orderByRef.current;
@@ -1101,6 +1223,7 @@ export function createSuparismaHook<
1101
1223
 
1102
1224
  // Skip if search is active
1103
1225
  if (isSearchingRef.current) {
1226
+ console.log('⏭️ DELETE: Skipping - search is active');
1104
1227
  return prev;
1105
1228
  }
1106
1229
 
@@ -1110,15 +1233,21 @@ export function createSuparismaHook<
1110
1233
  // Filter out the deleted item
1111
1234
  const filteredData = prev.filter((item) => {
1112
1235
  // @ts-ignore: Supabase typing issue
1113
- return !('id' in item && 'id' in payload.old && item.id === payload.old.id);
1236
+ const shouldKeep = !('id' in item && 'id' in payload.old && item.id === payload.old.id);
1237
+ if (!shouldKeep) {
1238
+ console.log('🗑️ DELETE: Removing item with ID:', item.id);
1239
+ }
1240
+ return shouldKeep;
1114
1241
  });
1115
1242
 
1243
+ console.log('🗑️ DELETE: Data after deletion:', filteredData.length, 'items (was', currentSize, ')');
1244
+
1116
1245
  // Fetch the updated count after the data changes
1117
1246
  setTimeout(() => fetchTotalCount(), 0);
1118
1247
 
1119
1248
  // If we need to maintain the size with a limit
1120
1249
  if (currentLimit && currentLimit > 0 && filteredData.length < currentSize && currentSize === currentLimit) { // Use ref value
1121
- console.log(\`Record deleted with limit \${currentLimit}, will fetch additional record to maintain size\`);
1250
+ console.log(\`🗑️ DELETE: Record deleted with limit \${currentLimit}, will fetch additional record to maintain size\`);
1122
1251
 
1123
1252
  // Use setTimeout to ensure this state update completes first
1124
1253
  setTimeout(() => {
@@ -1195,7 +1324,7 @@ export function createSuparismaHook<
1195
1324
  searchTimeoutRef.current = null;
1196
1325
  }
1197
1326
  };
1198
- }, [realtime, channelName, tableName, initialLoadRef]); // Removed where, orderBy, limit, offset from deps
1327
+ }, [realtime, channelName, tableName, where, initialLoadRef]); // Added 'where' back so subscription updates when filter changes
1199
1328
 
1200
1329
  // Create a memoized options object to prevent unnecessary re-renders
1201
1330
  const optionsRef = useRef({ where, orderBy, limit, offset });
package/dist/parser.js CHANGED
@@ -12,6 +12,16 @@ function parsePrismaSchema(schemaPath) {
12
12
  const schema = fs_1.default.readFileSync(schemaPath, 'utf-8');
13
13
  const modelRegex = /model\s+(\w+)\s+{([^}]*)}/gs;
14
14
  const models = [];
15
+ // Extract enum names from the schema
16
+ const enumRegex = /enum\s+(\w+)\s+{[^}]*}/gs;
17
+ const enumNames = [];
18
+ let enumMatch;
19
+ while ((enumMatch = enumRegex.exec(schema)) !== null) {
20
+ const enumName = enumMatch[1];
21
+ if (enumName) {
22
+ enumNames.push(enumName);
23
+ }
24
+ }
15
25
  let match;
16
26
  while ((match = modelRegex.exec(schema)) !== null) {
17
27
  const modelName = match[1] || '';
@@ -45,7 +55,7 @@ function parsePrismaSchema(schemaPath) {
45
55
  continue;
46
56
  }
47
57
  // Parse field definition - Updated to handle array types
48
- const fieldMatch = line.match(/\s*(\w+)\s+(\w+)(\[\])?\??(\?)?\s*(?:@[^)]+)?/);
58
+ const fieldMatch = line.match(/\s*(\w+)\s+(\w+)(\[\])?(\?)?\s*(?:@[^)]+)?/);
49
59
  if (fieldMatch) {
50
60
  const fieldName = fieldMatch[1];
51
61
  const baseFieldType = fieldMatch[2]; // e.g., "String" from "String[]"
@@ -67,9 +77,13 @@ function parsePrismaSchema(schemaPath) {
67
77
  defaultValue = defaultMatch[1];
68
78
  }
69
79
  }
80
+ // Improved relation detection
81
+ const primitiveTypes = ['String', 'Int', 'Float', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Decimal', 'BigInt'];
70
82
  const isRelation = line.includes('@relation') ||
71
83
  (!!fieldName &&
72
- (fieldName.endsWith('_id') || fieldName === 'userId' || fieldName === 'user_id'));
84
+ (fieldName.endsWith('_id') || fieldName === 'userId' || fieldName === 'user_id')) ||
85
+ // Also detect relation fields by checking if the type is not a primitive type and not an enum
86
+ (!!baseFieldType && !primitiveTypes.includes(baseFieldType) && !enumNames.includes(baseFieldType));
73
87
  // Check for inline @enableSearch comment
74
88
  if (line.includes('// @enableSearch')) {
75
89
  searchFields.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suparisma",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Opinionated typesafe React realtime CRUD hooks generator for all your Supabase tables, powered by Prisma.",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
@@ -19,6 +19,14 @@ enum SomeEnum {
19
19
  THREE
20
20
  }
21
21
 
22
+ model User {
23
+ id String @id @default(uuid())
24
+ name String
25
+ email String @unique
26
+ things Thing[] // One-to-many relation
27
+ createdAt DateTime @default(now())
28
+ updatedAt DateTime @updatedAt
29
+ }
22
30
 
23
31
  model Thing {
24
32
  id String @id @default(uuid())
@@ -27,6 +35,8 @@ model Thing {
27
35
  stringArray String[]
28
36
  someEnum SomeEnum @default(ONE)
29
37
  someNumber Int?
38
+ userId String?
39
+ user User? @relation(fields: [userId], references: [id])
30
40
  createdAt DateTime @default(now())
31
41
  updatedAt DateTime @updatedAt
32
42
  }