suparisma 1.0.4 → 1.0.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 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 });
@@ -41,12 +41,25 @@ function generateModelTypesFile(model) {
41
41
  // Find the actual field names for createdAt and updatedAt
42
42
  const createdAtField = model.fields.find(field => field.isCreatedAt)?.name || 'createdAt';
43
43
  const updatedAtField = model.fields.find(field => field.isUpdatedAt)?.name || 'updatedAt';
44
- // Create a manual property list for WithRelations interface
45
- const withRelationsProps = model.fields
46
- .filter((field) => !relationObjectFields.includes(field.name) && !foreignKeyFields.includes(field.name))
47
- .map((field) => {
48
- const isOptional = field.isOptional;
44
+ // Helper function to get TypeScript type for a field, considering zod directives
45
+ const getFieldType = (field) => {
49
46
  let baseType;
47
+ // Check if field has a custom zod directive
48
+ if (field.zodDirective && field.type === 'Json') {
49
+ // For Json fields with zod directives, we need to infer the type from the zod schema
50
+ // This is a simplified approach - you might want to make this more sophisticated
51
+ if (field.zodDirective.includes('z.array(') && field.zodDirective.includes('LLMNodeSchema')) {
52
+ // Handle z.array(LLMNodeSchema).nullable() case
53
+ return field.zodDirective.includes('.nullable()') ? 'LLMNode[] | null' : 'LLMNode[]';
54
+ }
55
+ else if (field.zodDirective.includes('LLMNodeSchema')) {
56
+ // Handle single LLMNodeSchema case
57
+ return field.zodDirective.includes('.nullable()') ? 'LLMNode | null' : 'LLMNode';
58
+ }
59
+ // For other custom zod directives on Json fields, default to any for now
60
+ return 'any';
61
+ }
62
+ // Standard type mapping
50
63
  switch (field.type) {
51
64
  case 'Int':
52
65
  case 'Float':
@@ -65,7 +78,14 @@ function generateModelTypesFile(model) {
65
78
  // Covers String, Enum names (e.g., "SomeEnum"), Bytes, Decimal, etc.
66
79
  baseType = 'string';
67
80
  }
68
- const finalType = field.isList ? `${baseType}[]` : baseType;
81
+ return field.isList ? `${baseType}[]` : baseType;
82
+ };
83
+ // Create a manual property list for WithRelations interface
84
+ const withRelationsProps = model.fields
85
+ .filter((field) => !relationObjectFields.includes(field.name) && !foreignKeyFields.includes(field.name))
86
+ .map((field) => {
87
+ const isOptional = field.isOptional;
88
+ const finalType = getFieldType(field);
69
89
  return ` ${field.name}${isOptional ? '?' : ''}: ${finalType};`;
70
90
  });
71
91
  // Add foreign key fields
@@ -83,26 +103,7 @@ function generateModelTypesFile(model) {
83
103
  .map((field) => {
84
104
  // Make fields with default values optional in CreateInput
85
105
  const isOptional = field.isOptional || defaultValueFields.includes(field.name);
86
- let baseType;
87
- switch (field.type) {
88
- case 'Int':
89
- case 'Float':
90
- baseType = 'number';
91
- break;
92
- case 'Boolean':
93
- baseType = 'boolean';
94
- break;
95
- case 'DateTime':
96
- baseType = 'string'; // ISO date string
97
- break;
98
- case 'Json':
99
- baseType = 'any'; // Or a more specific structured type if available
100
- break;
101
- default:
102
- // Covers String, Enum names (e.g., "SomeEnum"), Bytes, Decimal, etc.
103
- baseType = 'string';
104
- }
105
- const finalType = field.isList ? `${baseType}[]` : baseType;
106
+ const finalType = getFieldType(field);
106
107
  return ` ${field.name}${isOptional ? '?' : ''}: ${finalType};`;
107
108
  });
108
109
  // Add foreign key fields to CreateInput
@@ -114,11 +115,50 @@ function generateModelTypesFile(model) {
114
115
  createInputProps.push(` ${field}${isOptional ? '?' : ''}: ${fieldInfo.type === 'Int' ? 'number' : 'string'};`);
115
116
  }
116
117
  });
118
+ // Generate imports section for zod custom types
119
+ let customImports = '';
120
+ if (model.zodImports && model.zodImports.length > 0) {
121
+ // Add zod import for type inference
122
+ customImports = 'import { z } from \'zod\';\n';
123
+ // Get the zod schemas file path from environment variable
124
+ const zodSchemasPath = process.env.ZOD_SCHEMAS_FILE_PATH || '../commonTypes';
125
+ // Add custom imports with environment variable path
126
+ customImports += model.zodImports
127
+ .map(zodImport => {
128
+ // Extract the types from the original import statement
129
+ const typeMatch = zodImport.importStatement.match(/import\s+{\s*([^}]+)\s*}\s+from/);
130
+ if (typeMatch) {
131
+ const types = typeMatch[1].trim();
132
+ return `import { ${types} } from '${zodSchemasPath}'`;
133
+ }
134
+ // Fallback to original import if parsing fails
135
+ return zodImport.importStatement;
136
+ })
137
+ .join('\n') + '\n\n';
138
+ // Add type definitions for imported zod schemas if needed
139
+ // This is a simplified approach - you might want to make this more sophisticated
140
+ const customTypeDefinitions = model.zodImports
141
+ .flatMap(zodImport => zodImport.types)
142
+ .filter((type, index, array) => array.indexOf(type) === index) // Remove duplicates
143
+ .map(type => {
144
+ // If it ends with 'Schema', create a corresponding type
145
+ if (type.endsWith('Schema')) {
146
+ const typeName = type.replace('Schema', '');
147
+ return `export type ${typeName} = z.infer<typeof ${type}>;`;
148
+ }
149
+ return '';
150
+ })
151
+ .filter(Boolean)
152
+ .join('\n');
153
+ if (customTypeDefinitions) {
154
+ customImports += customTypeDefinitions + '\n\n';
155
+ }
156
+ }
117
157
  // Generate the type content with TSDoc comments
118
158
  const typeContent = `// THIS FILE IS AUTO-GENERATED - DO NOT EDIT DIRECTLY
119
159
  // Edit the generator script instead
120
160
 
121
- import type { ${modelName} } from '@prisma/client';
161
+ ${customImports}import type { ${modelName} } from '@prisma/client';
122
162
  import type { ModelResult, SuparismaOptions, SearchQuery, SearchState, FilterOperators } from '../utils/core';
123
163
 
124
164
  /**
@@ -230,26 +270,7 @@ ${model.fields
230
270
  .filter((field) => !relationObjectFields.includes(field.name) && !foreignKeyFields.includes(field.name))
231
271
  .map((field) => {
232
272
  const isOptional = true; // All where fields are optional
233
- let baseType;
234
- switch (field.type) {
235
- case 'Int':
236
- case 'Float':
237
- baseType = 'number';
238
- break;
239
- case 'Boolean':
240
- baseType = 'boolean';
241
- break;
242
- case 'DateTime':
243
- baseType = 'string'; // ISO date string
244
- break;
245
- case 'Json':
246
- baseType = 'any'; // Or a more specific structured type if available
247
- break;
248
- default:
249
- // Covers String, Enum names (e.g., "SomeEnum"), Bytes, Decimal, etc.
250
- baseType = 'string';
251
- }
252
- const finalType = field.isList ? `${baseType}[]` : baseType;
273
+ const finalType = getFieldType(field);
253
274
  const filterType = `${finalType} | FilterOperators<${finalType}>`;
254
275
  return ` ${field.name}${isOptional ? '?' : ''}: ${filterType};`;
255
276
  })
@@ -643,6 +664,7 @@ ${createInputProps
643
664
  searchFields,
644
665
  defaultValues: Object.keys(defaultValues).length > 0 ? defaultValues : undefined,
645
666
  createdAtField,
646
- updatedAtField
667
+ updatedAtField,
668
+ zodImports: model.zodImports // Pass through zod imports
647
669
  };
648
670
  }
package/dist/parser.js CHANGED
@@ -6,12 +6,82 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.parsePrismaSchema = parsePrismaSchema;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  /**
9
- * Parse Prisma schema to extract model information including search annotations
9
+ * Parse zod directive from comment
10
+ * Supports formats like:
11
+ * /// @zod.custom.use(z.string().refine(...))
12
+ * /// @zod.string.min(3).max(10)
13
+ */
14
+ function parseZodDirective(comment) {
15
+ // Remove leading /// and whitespace
16
+ const cleanComment = comment.replace(/^\/\/\/?\s*/, '').trim();
17
+ // Look for @zod.custom.use() format
18
+ const customUseMatch = cleanComment.match(/@zod\.custom\.use\((.+)\)$/);
19
+ if (customUseMatch) {
20
+ return customUseMatch[1].trim();
21
+ }
22
+ // Look for other @zod patterns like @zod.string.min(3)
23
+ const zodMatch = cleanComment.match(/@zod\.(.+)$/);
24
+ if (zodMatch) {
25
+ const zodChain = zodMatch[1].trim();
26
+ // Convert to actual zod syntax - this is a basic implementation
27
+ // For more complex cases, you might want to build a more sophisticated parser
28
+ return `z.${zodChain}`;
29
+ }
30
+ return undefined;
31
+ }
32
+ /**
33
+ * Parse zod import from comment
34
+ * Supports format like:
35
+ * /// @zod.import(["import { LLMNodeSchema } from '../commonTypes'"])
36
+ */
37
+ function parseZodImport(comment) {
38
+ const cleanComment = comment.replace(/^\/\/\/?\s*/, '').trim();
39
+ // Look for @zod.import([...]) format
40
+ const importMatch = cleanComment.match(/@zod\.import\(\[(.*)\]\)/);
41
+ if (!importMatch) {
42
+ return [];
43
+ }
44
+ const importsString = importMatch[1];
45
+ const imports = [];
46
+ // Parse individual import statements within the array
47
+ // Handle nested quotes properly - look for quoted strings that start with "import"
48
+ const importRegex = /"(import\s+[^"]+)"|'(import\s+[^']+)'/g;
49
+ let match;
50
+ while ((match = importRegex.exec(importsString)) !== null) {
51
+ // Get the import statement from either the double-quoted or single-quoted group
52
+ const importStatement = match[1] || match[2];
53
+ // Extract types from import statement
54
+ // e.g., "import { LLMNodeSchema, AnotherType } from '../commonTypes'"
55
+ const typeMatch = importStatement.match(/import\s+{\s*([^}]+)\s*}\s+from/);
56
+ const types = [];
57
+ if (typeMatch) {
58
+ // Split by comma and clean up whitespace
59
+ types.push(...typeMatch[1].split(',').map(t => t.trim()));
60
+ }
61
+ imports.push({
62
+ importStatement,
63
+ types
64
+ });
65
+ }
66
+ return imports;
67
+ }
68
+ /**
69
+ * Parse Prisma schema to extract model information including search annotations and zod directives
10
70
  */
11
71
  function parsePrismaSchema(schemaPath) {
12
72
  const schema = fs_1.default.readFileSync(schemaPath, 'utf-8');
13
73
  const modelRegex = /model\s+(\w+)\s+{([^}]*)}/gs;
14
74
  const models = [];
75
+ // Extract enum names from the schema
76
+ const enumRegex = /enum\s+(\w+)\s+{[^}]*}/gs;
77
+ const enumNames = [];
78
+ let enumMatch;
79
+ while ((enumMatch = enumRegex.exec(schema)) !== null) {
80
+ const enumName = enumMatch[1];
81
+ if (enumName) {
82
+ enumNames.push(enumName);
83
+ }
84
+ }
15
85
  let match;
16
86
  while ((match = modelRegex.exec(schema)) !== null) {
17
87
  const modelName = match[1] || '';
@@ -23,9 +93,12 @@ function parsePrismaSchema(schemaPath) {
23
93
  const fields = [];
24
94
  // Track fields with @enableSearch annotation
25
95
  const searchFields = [];
96
+ // Track zod imports at model level
97
+ const zodImports = [];
26
98
  const lines = modelBody.split('\n');
27
99
  let lastFieldName = '';
28
100
  let lastFieldType = '';
101
+ let pendingZodDirective;
29
102
  for (let i = 0; i < lines.length; i++) {
30
103
  const line = lines[i]?.trim();
31
104
  // Skip blank lines and non-field lines
@@ -42,10 +115,18 @@ function parsePrismaSchema(schemaPath) {
42
115
  }
43
116
  // Check if line is a comment
44
117
  if (line.startsWith('//')) {
118
+ // Parse zod directives from comments
119
+ const zodDirective = parseZodDirective(line);
120
+ if (zodDirective) {
121
+ pendingZodDirective = zodDirective;
122
+ }
123
+ // Parse zod imports from comments
124
+ const zodImportInfos = parseZodImport(line);
125
+ zodImports.push(...zodImportInfos);
45
126
  continue;
46
127
  }
47
128
  // Parse field definition - Updated to handle array types
48
- const fieldMatch = line.match(/\s*(\w+)\s+(\w+)(\[\])?\??(\?)?\s*(?:@[^)]+)?/);
129
+ const fieldMatch = line.match(/\s*(\w+)\s+(\w+)(\[\])?(\?)?\s*(?:@[^)]+)?/);
49
130
  if (fieldMatch) {
50
131
  const fieldName = fieldMatch[1];
51
132
  const baseFieldType = fieldMatch[2]; // e.g., "String" from "String[]"
@@ -67,9 +148,13 @@ function parsePrismaSchema(schemaPath) {
67
148
  defaultValue = defaultMatch[1];
68
149
  }
69
150
  }
151
+ // Improved relation detection
152
+ const primitiveTypes = ['String', 'Int', 'Float', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Decimal', 'BigInt'];
70
153
  const isRelation = line.includes('@relation') ||
71
154
  (!!fieldName &&
72
- (fieldName.endsWith('_id') || fieldName === 'userId' || fieldName === 'user_id'));
155
+ (fieldName.endsWith('_id') || fieldName === 'userId' || fieldName === 'user_id')) ||
156
+ // Also detect relation fields by checking if the type is not a primitive type and not an enum
157
+ (!!baseFieldType && !primitiveTypes.includes(baseFieldType) && !enumNames.includes(baseFieldType));
73
158
  // Check for inline @enableSearch comment
74
159
  if (line.includes('// @enableSearch')) {
75
160
  searchFields.push({
@@ -77,6 +162,14 @@ function parsePrismaSchema(schemaPath) {
77
162
  type: baseFieldType || '',
78
163
  });
79
164
  }
165
+ // Check for inline zod directive
166
+ let fieldZodDirective = pendingZodDirective;
167
+ if (line.includes('/// @zod.')) {
168
+ const inlineZodDirective = parseZodDirective(line);
169
+ if (inlineZodDirective) {
170
+ fieldZodDirective = inlineZodDirective;
171
+ }
172
+ }
80
173
  if (fieldName && baseFieldType) {
81
174
  fields.push({
82
175
  name: fieldName,
@@ -91,8 +184,11 @@ function parsePrismaSchema(schemaPath) {
91
184
  defaultValue, // Add the extracted default value
92
185
  isRelation,
93
186
  isList: isArray, // Add the isList property
187
+ zodDirective: fieldZodDirective, // Add zod directive
94
188
  });
95
189
  }
190
+ // Clear pending zod directive after using it
191
+ pendingZodDirective = undefined;
96
192
  }
97
193
  }
98
194
  // Check for model-level @enableSearch before the model definition
@@ -108,11 +204,30 @@ function parsePrismaSchema(schemaPath) {
108
204
  }
109
205
  });
110
206
  }
207
+ // Also check for model-level zod imports before the model definition
208
+ const modelStartIndex = schema.indexOf(`model ${modelName}`);
209
+ if (modelStartIndex !== -1) {
210
+ // Look backwards for any /// @zod.import directives before this model
211
+ const beforeModel = schema.substring(0, modelStartIndex);
212
+ const lines = beforeModel.split('\n').reverse(); // Start from model and go backwards
213
+ for (const line of lines) {
214
+ const trimmedLine = line.trim();
215
+ if (trimmedLine.startsWith('///') && trimmedLine.includes('@zod.import')) {
216
+ const modelLevelImports = parseZodImport(trimmedLine);
217
+ zodImports.push(...modelLevelImports);
218
+ }
219
+ else if (trimmedLine && !trimmedLine.startsWith('///') && !trimmedLine.startsWith('//')) {
220
+ // Stop if we hit a non-comment line (another model or other content)
221
+ break;
222
+ }
223
+ }
224
+ }
111
225
  models.push({
112
226
  name: modelName,
113
227
  mappedName: mappedName || '',
114
228
  fields,
115
229
  searchFields: searchFields.length > 0 ? searchFields : undefined,
230
+ zodImports: zodImports.length > 0 ? zodImports : undefined,
116
231
  });
117
232
  }
118
233
  return models;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suparisma",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
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,7 +19,16 @@ 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
 
31
+ /// @zod.import(["import { LLMNodeSchema } from '../commonTypes'"])
23
32
  model Thing {
24
33
  id String @id @default(uuid())
25
34
  // @enableSearch
@@ -27,6 +36,11 @@ model Thing {
27
36
  stringArray String[]
28
37
  someEnum SomeEnum @default(ONE)
29
38
  someNumber Int?
39
+
40
+ /// [Json]
41
+ someJson Json? /// @zod.custom.use(z.array(LLMNodeSchema).nullable())
42
+ userId String?
43
+ user User? @relation(fields: [userId], references: [id])
30
44
  createdAt DateTime @default(now())
31
45
  updatedAt DateTime @updatedAt
32
46
  }