suparisma 0.0.4 → 1.0.1

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
@@ -1,49 +1,120 @@
1
1
  # Suparisma
2
-
3
- A React hook generator for Supabase that is driven by your Prisma schema, giving you type-safe, real-time enabled hooks to interact with your Supabase database.
4
-
5
- ## Why?
6
- CRUD typesafetey with Supabase should be easy, currently it is not with a lot of issues that can rise easily espeically if you're not using Prisma.
7
-
8
- Prisma solved supabase typesafely issue on the server, and TRPC helped making the client even more type safe but realtime capabilites from the DB to the browser was still lacking and that lead to a lot of unnecessary GET, POST requests if you just simply need to have realtime support.
9
-
10
- Supabase's rules are also powerful and if you're using TRPC or any server solution you're easily missing out on them.
11
-
12
- This package solves all this focusing, it lets you:
13
- - Create typesafe CRUD hooks for all your supabase tables.
14
- - Enables you to easily paginate, search and query in each table.
15
- - Uses Prisma and Supabase official SDKs.
16
- - Respects Supabase's auth rules enabling an easy way to secure your DB.
17
- - Works with any React env. like NextJS/Remix/Tanstack Start/Router/etc..
2
+ Supabase + Prisma!
3
+
4
+ ![Suparisma Logo](https://vg-bunny-cdn.b-cdn.net/random/suparisma-banner.png)
5
+
6
+ A powerful, typesafe React hook generator for Supabase, driven by your Prisma schema. Suparisma provides you with real-time enabled CRUD hooks to interact with your Supabase database without writing any boilerplate code.
7
+
8
+ [![npm version](https://img.shields.io/npm/v/suparisma.svg?style=flat-square)](https://www.npmjs.com/package/suparisma)
9
+ [![TypeScript](https://img.shields.io/badge/TypeScript-4.9+-blue?style=flat-square)](https://www.typescriptlang.org/)
10
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
11
+
12
+ ## Table of Contents
13
+
14
+ - [Why Suparisma?](#why-suparisma)
15
+ - [Features](#features)
16
+ - [Installation](#installation)
17
+ - [Quick Start](#quick-start)
18
+ - [Detailed Usage](#detailed-usage)
19
+ - [Basic CRUD Operations](#basic-crud-operations)
20
+ - [Realtime Updates](#realtime-updates)
21
+ - [Filtering Data](#filtering-data)
22
+ - [Sorting Data](#sorting-data)
23
+ - [Pagination](#pagination)
24
+ - [Search Functionality](#search-functionality)
25
+ - [Schema Annotations](#schema-annotations)
26
+ - [Building UI Components](#building-ui-components)
27
+ - [Table with Filtering, Sorting, and Pagination](#table-with-filtering-sorting-and-pagination)
28
+ - [Configuration](#configuration)
29
+ - [Environment Variables](#environment-variables)
30
+ - [CLI Commands](#cli-commands)
31
+ - [Advanced Usage](#advanced-usage)
32
+ - [Custom Hooks](#custom-hooks)
33
+ - [Error Handling](#error-handling)
34
+ - [Performance Optimization](#performance-optimization)
35
+ - [API Reference](#api-reference)
36
+ - [Troubleshooting](#troubleshooting)
37
+ - [Contributing](#contributing)
38
+ - [License](#license)
39
+
40
+ ## Why Suparisma?
41
+
42
+ I love Supabase and Prisma and use them extensively in my projects, but combining them effectively for frontend development has always been frustrating. The typical solutions involve either setting up tRPC with constant refetching or implementing complex websocket solutions that often feel like overkill.
43
+
44
+ While Prisma gives me perfect type safety on the server, and Supabase offers great realtime capabilities, bridging these two worlds has remained a pain point. I've struggled to maintain type safety across the stack while efficiently leveraging Supabase's realtime features without resorting to excessive API calls or overly complex architectures.
45
+
46
+ Suparisma bridges this gap by:
47
+
48
+ - Creating **typesafe CRUD hooks** for all your Supabase tables
49
+ - Enabling easy **pagination, filtering, and search** on your data
50
+ - Leveraging both **Prisma** and **Supabase** official SDKs
51
+ - Respecting **Supabase's auth rules** for secure database access
52
+ - Working seamlessly with any React environment (Next.js, Remix, Tanstack Router, etc.)
18
53
 
19
54
  ## Features
20
55
 
21
56
  - 🚀 **Auto-generated React hooks** based on your Prisma schema
22
- - 🔄 **Real-time updates by default** for all tables (opt-out available)
57
+ - 🔄 **Real-time updates by default** for all tables (with opt-out capability)
23
58
  - 🔒 **Type-safe interfaces** for all database operations
24
- - 🔍 **Full-text search** capabilities with optional annotations
25
- - 🔄 **Prisma-like API** that feels familiar if you use Prisma
59
+ - 🔍 **Full-text search** with configurable annotations
60
+ - 🔢 **Pagination and sorting** built into every hook
61
+ - 🧩 **Prisma-like API** that feels familiar if you already use Prisma
62
+ - 📱 **Works with any React framework** including Next.js, Remix, etc.
63
+ - 🛠️ **Simple CLI** to generate hooks with a single command
26
64
 
27
65
  ## Installation
28
66
 
29
67
  ```bash
68
+ # Using npm
30
69
  npm install suparisma
31
- # or
70
+
71
+ # Using yarn
32
72
  yarn add suparisma
33
- # or
73
+
74
+ # Using pnpm
34
75
  pnpm install suparisma
35
76
  ```
36
77
 
37
78
  ## Quick Start
38
79
 
39
- 1. **Add a Prisma schema**: Make sure you have a valid `prisma/schema.prisma` file in your project.
80
+ 1. **Add a Prisma schema**: Ensure you have a valid `prisma/schema.prisma` file in your project
81
+
82
+ ```prisma
83
+ // This is a sample Prisma schema file
84
+ generator client {
85
+ provider = "prisma-client-js"
86
+ }
87
+
88
+ datasource db {
89
+ provider = "postgresql"
90
+ url = env("DATABASE_URL")
91
+ }
92
+
93
+ // Realtime is enabled by default
94
+ model Thing {
95
+ id String @id @default(uuid())
96
+ name String? // @enableSearch
97
+ someNumber Int @default(0)
98
+ someEnum String @default("ONE")
99
+ createdAt DateTime @default(now())
100
+ updatedAt DateTime @updatedAt
101
+ }
102
+
103
+ // @disableRealtime - Opt out of realtime for this model
104
+ model AuditLog {
105
+ id String @id @default(uuid())
106
+ action String
107
+ details String?
108
+ createdAt DateTime @default(now())
109
+ }
110
+ ```
40
111
 
41
112
  2. **Set up required environment variables** in a `.env` file:
42
113
 
43
114
  ```
44
115
  # Required for Prisma and Supabase
45
116
  DATABASE_URL="postgresql://user:password@host:port/database"
46
- DIRECT_URL="postgresql://user:password@host:port/database" # Direct Postgres connection
117
+ DIRECT_URL="postgresql://user:password@host:port/database"
47
118
  NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
48
119
  NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key"
49
120
  ```
@@ -53,75 +124,475 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key"
53
124
  ```bash
54
125
  npx suparisma generate
55
126
  ```
56
-
57
- This will:
58
- - Read your Prisma schema from the current directory
59
- - Configure your database for realtime functionality and search
60
- - Generate type-safe React hooks in `src/suparisma/generated` (configurable)
127
+ Note: you can adjust the prisma schema path and the generated files output path with these ENV variables:
128
+ ```bash
129
+ SUPARISMA_PRISMA_SCHEMA_PATH="./prisma/schema.prisma"
130
+ SUPARISMA_OUTPUT_DIR="./src/suparisma/generated"
131
+ ```
132
+ Also make sure to not change any of these generated files directly as **they will always be overwritten**
61
133
 
62
134
  4. **Use the hooks** in your React components:
63
135
 
64
136
  ```tsx
65
137
  import useSuparisma from './src/suparisma/generated';
66
138
 
67
- function UserList() {
68
- const users = useSuparisma.user();
139
+ function ThingList() {
140
+ const {
141
+ data: things,
142
+ loading,
143
+ error,
144
+ create: createThing
145
+ } = useSuparisma.thing();
69
146
 
70
- if (users.loading) return <div>Loading...</div>;
71
- if (users.error) return <div>Error: {users.error.message}</div>;
147
+ if (loading) return <div>Loading...</div>;
148
+ if (error) return <div>Error: {error.message}</div>;
72
149
 
73
150
  return (
74
151
  <div>
75
- <h1>Users</h1>
152
+ <h1>Things</h1>
76
153
  <ul>
77
- {users.data?.map(user => (
78
- <li key={user.id}>{user.name}</li>
154
+ {things?.map(thing => (
155
+ <li key={thing.id}>{thing.name} (Number: {thing.someNumber})</li>
79
156
  ))}
80
157
  </ul>
81
158
 
82
- <button onClick={() => users.create({ name: "New User" })}>
83
- Add User
159
+ <button onClick={() => createThing({
160
+ name: "New Thing",
161
+ someNumber: Math.floor(Math.random() * 100)
162
+ })}>
163
+ Add Thing
84
164
  </button>
85
165
  </div>
86
166
  );
87
167
  }
88
168
  ```
89
169
 
90
- ## Annotations in Your Prisma Schema
170
+ ## Detailed Usage
171
+
172
+ ### Basic CRUD Operations
173
+
174
+ Every generated hook provides a complete set of CRUD operations:
175
+
176
+ ```tsx
177
+ const {
178
+ // State
179
+ data, // Array of records
180
+ loading, // Boolean loading state
181
+ error, // Error object if any
182
+
183
+ // Actions
184
+ create, // Create a new record
185
+ update, // Update existing record(s)
186
+ delete, // Delete a record
187
+ upsert, // Create or update a record
188
+
189
+ // Helpers
190
+ count, // Get count of records (respects where filters)
191
+ refresh, // Manually refresh data
192
+ } = useSuparisma.modelName();
193
+ ```
194
+
195
+ #### Creating Records
196
+
197
+ ```tsx
198
+ const { create: createThing } = useSuparisma.thing();
199
+
200
+ // Create a single record
201
+ await createThing({ name: "Cool Thing", someNumber: 42 });
202
+
203
+ // Create with nested data if your schema supports it
204
+ await createThing({
205
+ name: "Cool Thing",
206
+ someNumber: 42,
207
+ // If you had relations defined:
208
+ // tags: {
209
+ // create: [
210
+ // { name: "Important" },
211
+ // { name: "Featured" }
212
+ // ]
213
+ // }
214
+ });
215
+ ```
216
+
217
+ #### Reading Records
218
+
219
+ ```tsx
220
+ // Get all records with default pagination (first 10)
221
+ const { data: things } = useSuparisma.thing();
222
+
223
+ // With filtering
224
+ const { data: importantThings } = useSuparisma.thing({
225
+ where: { someEnum: "ONE" }
226
+ });
227
+
228
+ // With custom pagination
229
+ const { data: recentThings } = useSuparisma.thing({
230
+ orderBy: { createdAt: "desc" },
231
+ limit: 5
232
+ });
233
+ ```
234
+
235
+ #### Updating Records
236
+
237
+ ```tsx
238
+ const { update: updateThing } = useSuparisma.thing();
239
+
240
+ // Update by ID
241
+ await updateThing({
242
+ where: { id: "thing-id-123" },
243
+ data: { name: "Updated Thing" }
244
+ });
245
+
246
+ // Update many records matching a filter
247
+ await updateThing({
248
+ where: { someEnum: "ONE" },
249
+ data: { someEnum: "TWO" }
250
+ });
251
+ ```
252
+
253
+ #### Deleting Records
254
+
255
+ ```tsx
256
+ const { delete: deleteThing } = useSuparisma.thing();
257
+
258
+ // Delete by ID
259
+ await deleteThing({ id: "thing-id-123" });
260
+
261
+ // Delete with more complex filter
262
+ await deleteThing({ someNumber: 0 });
263
+ ```
264
+
265
+ ### Realtime Updates
266
+
267
+ Realtime updates are enabled by default for all models. The `data` will automatically update when changes occur in the database.
268
+
269
+ ```tsx
270
+ // Enable realtime (default)
271
+ const { data: things } = useSuparisma.thing({ realtime: true });
272
+
273
+ // Disable realtime for this particular hook instance
274
+ const { data: logsNoRealtime } = useSuparisma.auditLog({ realtime: false });
275
+ ```
276
+
277
+ ### Filtering Data
278
+
279
+ Filter data using Prisma-like syntax:
280
+
281
+ ```tsx
282
+ // Basic equality
283
+ const { data } = useSuparisma.thing({
284
+ where: { someEnum: "ONE" }
285
+ });
286
+
287
+ // Multiple conditions (AND)
288
+ const { data } = useSuparisma.thing({
289
+ where: {
290
+ someEnum: "ONE",
291
+ someNumber: { gt: 10 }
292
+ }
293
+ });
294
+
295
+ // Using operators
296
+ const { data } = useSuparisma.thing({
297
+ where: {
298
+ createdAt: { gte: new Date('2023-01-01') },
299
+ name: { contains: "cool" }
300
+ }
301
+ });
302
+ ```
303
+
304
+ ### Sorting Data
305
+
306
+ Sort data using Prisma-like ordering:
307
+
308
+ ```tsx
309
+ // Single field sort
310
+ const { data } = useSuparisma.thing({
311
+ orderBy: { createdAt: "desc" }
312
+ });
313
+
314
+ // Multiple field sort
315
+ const { data } = useSuparisma.thing({
316
+ orderBy: [
317
+ { someEnum: "asc" },
318
+ { createdAt: "desc" }
319
+ ]
320
+ });
321
+ ```
322
+
323
+ ### Pagination
324
+
325
+ Suparisma supports offset-based pagination:
326
+
327
+ ```tsx
328
+ // Offset-based pagination (page 1, 10 items per page)
329
+ const { data } = useSuparisma.thing({
330
+ offset: 0,
331
+ limit: 10
332
+ });
333
+
334
+ // Next page
335
+ const { data: page2 } = useSuparisma.thing({
336
+ offset: 10,
337
+ limit: 10
338
+ });
339
+
340
+ // Get total count
341
+ const { data, count } = useSuparisma.thing();
342
+ ```
343
+
344
+ ### Search Functionality
345
+
346
+ For fields annotated with `// @enableSearch`, you can use full-text search:
347
+
348
+ ```tsx
349
+ // Search things by name
350
+ const { data: searchResults } = useSuparisma.thing({
351
+ search: {
352
+ query: "cool",
353
+ fields: ["name"]
354
+ }
355
+ });
356
+ ```
357
+
358
+ ## Schema Annotations
91
359
 
92
- Add annotations directly in your Prisma schema as comments:
360
+ Suparisma uses comments in your Prisma schema to configure behavior:
93
361
 
94
362
  ```prisma
95
- // Realtime is enabled by default for this model
96
- model User {
97
- id String @id @default(uuid())
98
- email String @unique
99
- name String? // @enableSearch
100
- createdAt DateTime @default(now())
101
- updatedAt DateTime @updatedAt
102
- }
363
+ // Model level annotations
103
364
 
104
365
  // @disableRealtime - Opt out of realtime for this model
105
366
  model AuditLog {
106
- id String @id @default(uuid())
107
- action String
108
- details String?
109
- createdAt DateTime @default(now())
367
+ // ...fields
368
+ }
369
+
370
+ // Field level annotations
371
+
372
+ model Thing {
373
+ id String @id @default(uuid())
374
+ name String? // @enableSearch - Enable full-text search for this field
375
+ description String? // @enableSearch - Can add to multiple fields
376
+ someNumber Int
110
377
  }
111
378
  ```
112
379
 
113
- ## Stale Models Cleanup
380
+ Available annotations:
114
381
 
115
- When you delete a model from your Prisma schema and run the generation command, Suparisma automatically:
116
- - Detects changes to your schema
117
- - Deletes the entire generated directory
118
- - Regenerates all hooks and types based on your current schema
382
+ | Annotation | Description | Location |
383
+ |------------|-------------|----------|
384
+ | `@disableRealtime` | Disables real-time updates for this model | Model (before definition) |
385
+ | `@enableSearch` | Enables full-text search on this field | Field (after definition) |
119
386
 
120
- This ensures you don't have stale files lingering around for models that no longer exist in your schema.
387
+ ## Building UI Components
121
388
 
122
- ## CLI Commands
389
+ ### Table with Filtering, Sorting, and Pagination
123
390
 
124
- Suparisma provides a simple CLI with the following commands:
391
+ Here's a complete example of a data table with filtering, sorting, and pagination:
392
+
393
+ ```tsx
394
+ import { useState } from "react";
395
+ import useSuparisma from '../generated';
396
+
397
+ export default function ThingTable() {
398
+ const itemsPerPage = 10;
399
+ const [page, setPage] = useState(0);
400
+ const [enumFilter, setEnumFilter] = useState("");
401
+ const [sortField, setSortField] = useState("updatedAt");
402
+ const [sortDirection, setSortDirection] = useState("desc");
403
+
404
+ const {
405
+ data: things,
406
+ loading: isLoading,
407
+ error,
408
+ create: createThing,
409
+ update: updateThing,
410
+ delete: deleteThing,
411
+ count: thingCount,
412
+ } = useSuparisma.thing({
413
+ realtime: true,
414
+ limit: itemsPerPage,
415
+ offset: page * itemsPerPage,
416
+ where: enumFilter ? {
417
+ someEnum: enumFilter
418
+ } : undefined,
419
+ orderBy: {
420
+ [sortField]: sortDirection
421
+ }
422
+ });
423
+
424
+ if(error) {
425
+ return <div>Error: {error.message}</div>;
426
+ }
427
+
428
+ return (
429
+ <div className="container mx-auto p-4">
430
+ <div className="flex justify-between items-center mb-4">
431
+ <h1 className="text-2xl font-bold">Thing List</h1>
432
+ <button
433
+ onClick={() => createThing({
434
+ name: 'New Thing',
435
+ someNumber: Math.floor(Math.random() * 100)
436
+ })}
437
+ className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
438
+ >
439
+ Create New Thing
440
+ </button>
441
+ </div>
442
+
443
+ {/* Filter and Sort Controls */}
444
+ <div className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-4">
445
+ <div>
446
+ <label htmlFor="enumFilter" className="block text-sm font-medium mb-1">
447
+ Filter by Enum
448
+ </label>
449
+ <select
450
+ value={enumFilter}
451
+ onChange={(e) => {
452
+ setEnumFilter(e.target.value);
453
+ setPage(0); // Reset to first page when filter changes
454
+ }}
455
+ className="w-full p-2 border rounded-md"
456
+ >
457
+ <option value="">All</option>
458
+ <option value="ONE">ONE</option>
459
+ <option value="TWO">TWO</option>
460
+ <option value="THREE">THREE</option>
461
+ </select>
462
+ </div>
463
+
464
+ <div>
465
+ <label htmlFor="sortField" className="block text-sm font-medium mb-1">
466
+ Sort By
467
+ </label>
468
+ <select
469
+ value={sortField}
470
+ onChange={(e) => {
471
+ setSortField(e.target.value);
472
+ setPage(0);
473
+ }}
474
+ className="w-full p-2 border rounded-md"
475
+ >
476
+ <option value="createdAt">Created Date</option>
477
+ <option value="updatedAt">Updated Date</option>
478
+ <option value="someNumber">Number</option>
479
+ </select>
480
+ </div>
481
+
482
+ <div>
483
+ <label htmlFor="sortDirection" className="block text-sm font-medium mb-1">
484
+ Direction
485
+ </label>
486
+ <select
487
+ value={sortDirection}
488
+ onChange={(e) => {
489
+ setSortDirection(e.target.value);
490
+ setPage(0);
491
+ }}
492
+ className="w-full p-2 border rounded-md"
493
+ >
494
+ <option value="asc">Ascending</option>
495
+ <option value="desc">Descending</option>
496
+ </select>
497
+ </div>
498
+ </div>
499
+
500
+ {/* Data Table */}
501
+ <div className="overflow-x-auto">
502
+ <table className="min-w-full bg-white border">
503
+ <thead>
504
+ <tr className="bg-gray-100">
505
+ <th className="py-2 px-4 border-b text-left">Name</th>
506
+ <th className="py-2 px-4 border-b text-left">Number</th>
507
+ <th className="py-2 px-4 border-b text-left">Enum</th>
508
+ <th className="py-2 px-4 border-b text-left">ID</th>
509
+ <th className="py-2 px-4 border-b text-left">Actions</th>
510
+ </tr>
511
+ </thead>
512
+ <tbody>
513
+ {isLoading ? (
514
+ <tr>
515
+ <td colSpan={5} className="py-4 text-center">Loading...</td>
516
+ </tr>
517
+ ) : things?.length === 0 ? (
518
+ <tr>
519
+ <td colSpan={5} className="py-4 text-center text-gray-500">
520
+ No records found
521
+ </td>
522
+ </tr>
523
+ ) : (
524
+ things?.map((thing) => (
525
+ <tr key={thing.id} className="hover:bg-gray-50">
526
+ <td className="py-2 px-4 border-b">{thing.name}</td>
527
+ <td className="py-2 px-4 border-b">{thing.someNumber}</td>
528
+ <td className="py-2 px-4 border-b">{thing.someEnum}</td>
529
+ <td className="py-2 px-4 border-b">{thing.id}</td>
530
+ <td className="py-2 px-4 border-b">
531
+ <button
532
+ onClick={() => updateThing({
533
+ where: { id: thing.id },
534
+ data: { name: `Updated ${thing.name}` }
535
+ })}
536
+ className="bg-green-500 hover:bg-green-700 text-white py-1 px-2 rounded mr-2 text-sm"
537
+ >
538
+ Update
539
+ </button>
540
+ <button
541
+ onClick={() => deleteThing({ id: thing.id })}
542
+ className="bg-red-500 hover:bg-red-700 text-white py-1 px-2 rounded text-sm"
543
+ >
544
+ Delete
545
+ </button>
546
+ </td>
547
+ </tr>
548
+ ))
549
+ )}
550
+ </tbody>
551
+ </table>
552
+ </div>
553
+
554
+ {/* Pagination Controls */}
555
+ <div className="mt-4 flex justify-center items-center">
556
+ <button
557
+ onClick={() => setPage(prev => Math.max(0, prev - 1))}
558
+ disabled={page === 0}
559
+ className="bg-gray-300 hover:bg-gray-400 text-gray-800 py-2 px-4 rounded-l disabled:opacity-50"
560
+ >
561
+ Previous
562
+ </button>
563
+ <span className="py-2 px-4">
564
+ Page {page + 1}
565
+ {thingCount && ` of ${Math.ceil(Number(thingCount) / itemsPerPage)}`}
566
+ </span>
567
+ <button
568
+ onClick={() => setPage(prev => prev + 1)}
569
+ disabled={!things || things.length < itemsPerPage}
570
+ className="bg-gray-300 hover:bg-gray-400 text-gray-800 py-2 px-4 rounded-r disabled:opacity-50"
571
+ >
572
+ Next
573
+ </button>
574
+ </div>
575
+ </div>
576
+ );
577
+ }
578
+ ```
579
+
580
+ ## Configuration
581
+
582
+ ### Environment Variables
583
+
584
+ | Variable | Required | Description | Example |
585
+ |----------|----------|-------------|---------|
586
+ | `DATABASE_URL` | Yes | Postgres database URL used by Prisma | `postgresql://user:pass@host:port/db` |
587
+ | `DIRECT_URL` | Yes | Direct URL to Postgres DB for realtime setup | `postgresql://user:pass@host:port/db` |
588
+ | `NEXT_PUBLIC_SUPABASE_URL` | Yes | Your Supabase project URL | `https://xyz.supabase.co` |
589
+ | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anonymous key | `eyJh...` |
590
+ | `SUPARISMA_OUTPUT_DIR` | No | Custom output directory | `src/lib/suparisma` |
591
+ | `SUPARISMA_PRISMA_SCHEMA_PATH` | No | Custom schema path | `db/schema.prisma` |
592
+
593
+ ### CLI Commands
594
+
595
+ Suparisma provides a simple command-line interface:
125
596
 
126
597
  ```bash
127
598
  # Generate hooks based on your Prisma schema
@@ -131,26 +602,191 @@ npx suparisma generate
131
602
  npx suparisma help
132
603
  ```
133
604
 
134
- ## Configuration
605
+ ## Stale Models Cleanup
606
+
607
+ When you delete a model from your Prisma schema and run the generation command, Suparisma automatically:
608
+ - Detects changes to your schema
609
+ - Deletes the entire generated directory
610
+ - Regenerates all hooks and types based on your current schema
611
+
612
+ This ensures you never have stale files lingering around for models that no longer exist in your schema.
613
+
614
+ ## Advanced Usage
135
615
 
136
- You can customize the behavior using environment variables:
616
+ ### Custom Hooks
137
617
 
618
+ You can combine Suparisma hooks with your own custom hooks for advanced use cases:
619
+
620
+ ```tsx
621
+ import { useState } from 'react';
622
+ import useSuparisma from '../generated';
623
+
624
+ // Custom hook for managing important things
625
+ function useImportantThings() {
626
+ const [category, setCategory] = useState<string>("ONE");
627
+
628
+ const {
629
+ data: things,
630
+ loading,
631
+ error,
632
+ create: createThing,
633
+ update: updateThing,
634
+ } = useSuparisma.thing({
635
+ where: { someEnum: category },
636
+ orderBy: { someNumber: "desc" }
637
+ });
638
+
639
+ const addThing = (name: string, number: number) => {
640
+ return createThing({
641
+ name,
642
+ someNumber: number,
643
+ someEnum: category
644
+ });
645
+ };
646
+
647
+ const changeCategory = (newCategory: string) => {
648
+ setCategory(newCategory);
649
+ };
650
+
651
+ return {
652
+ things,
653
+ loading,
654
+ error,
655
+ addThing,
656
+ updateThing,
657
+ currentCategory: category,
658
+ changeCategory
659
+ };
660
+ }
661
+ ```
662
+
663
+ ### Error Handling
664
+
665
+ Handle errors gracefully with try/catch blocks:
666
+
667
+ ```tsx
668
+ const { create, error } = useSuparisma.thing();
669
+
670
+ async function handleSubmit(event) {
671
+ event.preventDefault();
672
+ try {
673
+ const result = await create({
674
+ name: formData.name,
675
+ someNumber: parseInt(formData.number)
676
+ });
677
+ console.log('Created!', result);
678
+ } catch (err) {
679
+ console.error('Failed to create thing:', err);
680
+ }
681
+ }
682
+ ```
683
+
684
+ ### Performance Optimization
685
+
686
+ Optimize performance by:
687
+
688
+ 1. Disabling realtime when not needed
689
+ 2. Using pagination to limit data size
690
+ 3. Using precise filter conditions
691
+
692
+ ```tsx
693
+ // Only get what you need
694
+ const { data } = useSuparisma.thing({
695
+ realtime: false, // Disable realtime if not needed
696
+ where: { someEnum: "ONE" }, // Only get specific items
697
+ select: { id: true, name: true, someNumber: true }, // Only select needed fields
698
+ limit: 10 // Limit results
699
+ });
138
700
  ```
139
- # Optional: Customize output directory
140
- SUPARISMA_OUTPUT_DIR="src/hooks/generated"
141
701
 
142
- # Optional: Specify custom schema path
143
- SUPARISMA_PRISMA_SCHEMA_PATH="path/to/schema.prisma"
702
+ ## API Reference
703
+
704
+ ### Hook Options
705
+
706
+ | Option | Type | Description |
707
+ |--------|------|-------------|
708
+ | `where` | `object` | Filter conditions for the query |
709
+ | `orderBy` | `object \| array` | Sorting options |
710
+ | `limit` | `number` | Maximum number of records to return |
711
+ | `offset` | `number` | Number of records to skip for pagination |
712
+ | `realtime` | `boolean` | Enable/disable real-time updates |
713
+ | `select` | `object` | Fields to include in the response |
714
+ | `include` | `object` | Related records to include |
715
+ | `search` | `object` | Full-text search configuration |
716
+
717
+ ### Hook Return Value
718
+
719
+ | Property | Type | Description |
720
+ |----------|------|-------------|
721
+ | `data` | `array` | Array of records matching the query |
722
+ | `loading` | `boolean` | Loading state |
723
+ | `error` | `Error \| null` | Error object if request failed |
724
+ | `create` | `function` | Create a new record |
725
+ | `update` | `function` | Update existing record(s) |
726
+ | `delete` | `function` | Delete a record |
727
+ | `upsert` | `function` | Create or update a record |
728
+ | `count` | `function` | Get count of records |
729
+ | `refresh` | `function` | Manually refresh data |
730
+
731
+ ## Troubleshooting
732
+
733
+ ### Common Issues
734
+
735
+ **Realtime not working**
736
+ - First, make sure you have ran npx suparisma generate as that will automatically add all your tables to the realtime supabase publication.
737
+ - Second, Make sure to have realtime: true in the hook usage and also in supabase go to tables > publications > supabase_realtime and there you must find the tables you created in prisma in there or the realtime is not working properly.
738
+
739
+ **No permissions/this table doesn't exist**
740
+ - If you ever run into such issue make sure the **anon** has suffiecient permissions to access your tables, this issue especially is common when you do prisma migrate.
741
+ ```sql
742
+ -- Replace YOUR_TABLE_NAME with the actual table names affected by your migration
743
+
744
+ -- Grant usage on the schema to anon
745
+ GRANT USAGE ON SCHEMA public TO anon;
746
+
747
+ -- Grant SELECT, INSERT, UPDATE, DELETE on tables to anon
748
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO anon;
749
+
750
+ -- Ensure future tables also grant these permissions
751
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
752
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO anon;
753
+
754
+ -- Grant usage on sequences to anon (if using auto-increment IDs)
755
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO anon;
756
+
757
+ -- Ensure future sequences also grant usage to anon
758
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
759
+ GRANT USAGE, SELECT ON SEQUENCES TO anon;
144
760
  ```
145
761
 
146
- ## Environment Variables
762
+ **"Unknown command: undefined"**
763
+
764
+ This happens when running the CLI without specifying a command. Use `npx suparisma generate` instead.
765
+
766
+ **"DIRECT_URL environment variable not found"**
767
+
768
+ You need to provide a direct PostgreSQL connection URL in your `.env` file for realtime functionality.
769
+
770
+ **"Table X was already in supabase_realtime publication"**
771
+
772
+ This is just an informational message, not an error. Your table is already configured for realtime updates.
773
+
774
+ **Hook data doesn't update in real-time**
775
+
776
+ Check:
777
+ 1. The model doesn't have `@disableRealtime` annotation
778
+ 2. The hook is called with `realtime: true` (default)
779
+ 3. Your Supabase project has realtime enabled in the dashboard
780
+
781
+ ## Contributing
147
782
 
148
- The following environment variables are required:
783
+ Contributions are welcome! Please feel free to submit a Pull Request.
149
784
 
150
- - `DATABASE_URL` - Your Postgres database URL (used by Prisma)
151
- - `DIRECT_URL` - Direct URL to your Postgres database (for setting up realtime)
152
- - `NEXT_PUBLIC_SUPABASE_URL` - Your Supabase project URL
153
- - `NEXT_PUBLIC_SUPABASE_ANON_KEY` - Your Supabase anonymous key
785
+ 1. Fork the repository
786
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
787
+ 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
788
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
789
+ 5. Open a Pull Request
154
790
 
155
791
  ## License
156
792
 
@@ -155,6 +155,37 @@ export type SearchState = {
155
155
  clearQueries: () => void;
156
156
  };
157
157
 
158
+ /**
159
+ * Compare two values for sorting with proper type handling
160
+ */
161
+ function compareValues(a: any, b: any, direction: 'asc' | 'desc'): number {
162
+ // Handle undefined/null values
163
+ if (a === undefined || a === null) return direction === 'asc' ? -1 : 1;
164
+ if (b === undefined || b === null) return direction === 'asc' ? 1 : -1;
165
+
166
+ // Handle numbers properly to ensure numeric comparison
167
+ if (typeof a === 'number' && typeof b === 'number') {
168
+ return direction === 'asc'
169
+ ? a - b
170
+ : b - a;
171
+ }
172
+
173
+ // Handle dates (convert to timestamps for comparison)
174
+ if (a instanceof Date && b instanceof Date) {
175
+ return direction === 'asc'
176
+ ? a.getTime() - b.getTime()
177
+ : b.getTime() - a.getTime();
178
+ }
179
+
180
+ // Handle strings or mixed types with string conversion
181
+ const aStr = String(a);
182
+ const bStr = String(b);
183
+
184
+ return direction === 'asc'
185
+ ? aStr.localeCompare(bStr)
186
+ : bStr.localeCompare(aStr);
187
+ }
188
+
158
189
  /**
159
190
  * Convert a type-safe where filter to Supabase filter string
160
191
  */
@@ -316,11 +347,17 @@ export function applyOrderBy<T>(
316
347
 
317
348
  // Apply each order by clause
318
349
  let orderedQuery = query;
319
- for (const [key, direction] of Object.entries(orderBy)) {
320
- // @ts-ignore: Supabase typing issue
321
- orderedQuery = orderedQuery.order(key, {
322
- ascending: direction === 'asc'
323
- });
350
+
351
+ // Handle orderBy as array or single object
352
+ const orderByArray = Array.isArray(orderBy) ? orderBy : [orderBy];
353
+
354
+ for (const orderByClause of orderByArray) {
355
+ for (const [key, direction] of Object.entries(orderByClause)) {
356
+ // @ts-ignore: Supabase typing issue
357
+ orderedQuery = orderedQuery.order(key, {
358
+ ascending: direction === 'asc'
359
+ });
360
+ }
324
361
  }
325
362
 
326
363
  return orderedQuery;
@@ -387,9 +424,29 @@ export function createSuparismaHook<
387
424
  offset,
388
425
  } = options;
389
426
 
390
- // Compute the actual filter string from the type-safe where object or use legacy filter
391
- const computedFilter = where ? buildFilterString(where) : realtimeFilter;
392
-
427
+ // Refs to store the latest options for realtime handlers
428
+ const whereRef = useRef(where);
429
+ const orderByRef = useRef(orderBy);
430
+ const limitRef = useRef(limit);
431
+ const offsetRef = useRef(offset);
432
+
433
+ // Update refs whenever options change
434
+ useEffect(() => {
435
+ whereRef.current = where;
436
+ }, [where]);
437
+
438
+ useEffect(() => {
439
+ orderByRef.current = orderBy;
440
+ }, [orderBy]);
441
+
442
+ useEffect(() => {
443
+ limitRef.current = limit;
444
+ }, [limit]);
445
+
446
+ useEffect(() => {
447
+ offsetRef.current = offset;
448
+ }, [offset]);
449
+
393
450
  // Single data collection for holding results
394
451
  const [data, setData] = useState<TWithRelations[]>([]);
395
452
  const [error, setError] = useState<Error | null>(null);
@@ -680,7 +737,7 @@ export function createSuparismaHook<
680
737
  }
681
738
 
682
739
  // Apply offset if provided (for pagination)
683
- if (params?.skip) {
740
+ if (params?.skip !== undefined && params.skip >= 0) {
684
741
  query = query.range(params.skip, params.skip + (params.take || 10) - 1);
685
742
  }
686
743
 
@@ -773,14 +830,11 @@ export function createSuparismaHook<
773
830
 
774
831
  const channelId = channelName || \`changes_to_\${tableName}_\${Math.random().toString(36).substring(2, 15)}\`;
775
832
 
776
- // Store the current filter and options for closure
777
- const currentFilter = computedFilter;
778
- const currentWhere = where;
779
- const currentOrderBy = orderBy;
780
- const currentLimit = limit;
781
- const currentOffset = offset;
782
-
783
- console.log(\`Setting up subscription for \${tableName} with filter: \${currentFilter}\`);
833
+ // Use a dynamic filter string builder inside the event handler or rely on Supabase
834
+ // For the subscription filter, we must use the initial computedFilter or a stable one.
835
+ // However, for client-side logic (sorting, adding/removing from list), we use refs.
836
+ const initialComputedFilter = where ? buildFilterString(where) : realtimeFilter;
837
+ console.log(\`Setting up subscription for \${tableName} with initial filter: \${initialComputedFilter}\`);
784
838
 
785
839
  const channel = supabase
786
840
  .channel(channelId)
@@ -790,11 +844,17 @@ export function createSuparismaHook<
790
844
  event: '*',
791
845
  schema: 'public',
792
846
  table: tableName,
793
- filter: currentFilter,
847
+ filter: initialComputedFilter, // Subscription filter uses initial state
794
848
  },
795
849
  (payload) => {
796
850
  console.log(\`Received \${payload.eventType} event for \${tableName}\`, payload);
797
851
 
852
+ // Access current options via refs inside the event handler
853
+ const currentWhere = whereRef.current;
854
+ const currentOrderBy = orderByRef.current;
855
+ const currentLimit = limitRef.current;
856
+ const currentOffset = offsetRef.current; // Not directly used in handlers but good for consistency
857
+
798
858
  // Skip realtime updates when search is active
799
859
  if (isSearchingRef.current) return;
800
860
 
@@ -806,7 +866,7 @@ export function createSuparismaHook<
806
866
  console.log(\`Processing INSERT for \${tableName}\`, { newRecord });
807
867
 
808
868
  // Check if this record matches our filter if we have one
809
- if (currentWhere) {
869
+ if (currentWhere) { // Use ref value
810
870
  let matchesFilter = true;
811
871
 
812
872
  // Check each filter condition
@@ -838,27 +898,43 @@ export function createSuparismaHook<
838
898
  }
839
899
 
840
900
  // Add the new record to the data
841
- let newData = [newRecord, ...prev];
901
+ let newData = [...prev, newRecord]; // Changed: Use spread on prev for immutability
842
902
 
843
903
  // Apply ordering if specified
844
- if (currentOrderBy) {
845
- const [orderField, direction] = Object.entries(currentOrderBy)[0] || [];
846
- if (orderField) {
847
- newData = [...newData].sort((a, b) => {
848
- const aValue = a[orderField as keyof typeof a] ?? '';
849
- const bValue = b[orderField as keyof typeof b] ?? '';
850
-
851
- if (direction === 'asc') {
852
- return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
853
- } else {
854
- return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
904
+ if (currentOrderBy) { // Use ref value
905
+ // Convert orderBy to array format for consistency if it's an object
906
+ const orderByArray = Array.isArray(currentOrderBy)
907
+ ? currentOrderBy
908
+ : [currentOrderBy];
909
+
910
+ // Apply each sort in sequence
911
+ newData = [...newData].sort((a, b) => {
912
+ // Check each orderBy clause in sequence
913
+ for (const orderByClause of orderByArray) {
914
+ for (const [field, direction] of Object.entries(orderByClause)) {
915
+ const aValue = a[field as keyof typeof a];
916
+ const bValue = b[field as keyof typeof b];
917
+
918
+ // Skip if values are equal and move to next criterion
919
+ if (aValue === bValue) continue;
920
+
921
+ // Use the compareValues function for proper type handling
922
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
855
923
  }
856
- });
857
- }
924
+ }
925
+ return 0; // Equal if all criteria match
926
+ });
927
+ } else if (hasCreatedAt) {
928
+ // Default sort by createdAt desc if no explicit sort but has timestamp
929
+ newData = [...newData].sort((a, b) => {
930
+ const aValue = a[createdAtField as keyof typeof a];
931
+ const bValue = b[createdAtField as keyof typeof b];
932
+ return compareValues(aValue, bValue, 'desc');
933
+ });
858
934
  }
859
935
 
860
936
  // Apply limit if specified
861
- if (currentLimit && currentLimit > 0) {
937
+ if (currentLimit && currentLimit > 0) { // Use ref value
862
938
  newData = newData.slice(0, currentLimit);
863
939
  }
864
940
 
@@ -874,6 +950,10 @@ export function createSuparismaHook<
874
950
  } else if (payload.eventType === 'UPDATE') {
875
951
  // Process update event
876
952
  setData((prev) => {
953
+ // Access current options via refs
954
+ const currentOrderBy = orderByRef.current;
955
+ const currentLimit = limitRef.current; // If needed for re-fetch logic on update
956
+
877
957
  // Skip if search is active
878
958
  if (isSearchingRef.current) {
879
959
  return prev;
@@ -886,15 +966,57 @@ export function createSuparismaHook<
886
966
  : item
887
967
  );
888
968
 
969
+ // Apply ordering again after update to ensure consistency
970
+ let sortedData = [...newData];
971
+
972
+ // Apply ordering if specified
973
+ if (currentOrderBy) { // Use ref value
974
+ // Convert orderBy to array format for consistency if it's an object
975
+ const orderByArray = Array.isArray(currentOrderBy)
976
+ ? currentOrderBy
977
+ : [currentOrderBy];
978
+
979
+ // Apply each sort in sequence
980
+ sortedData = sortedData.sort((a, b) => {
981
+ // Check each orderBy clause in sequence
982
+ for (const orderByClause of orderByArray) {
983
+ for (const [field, direction] of Object.entries(orderByClause)) {
984
+ const aValue = a[field as keyof typeof a];
985
+ const bValue = b[field as keyof typeof b];
986
+
987
+ // Skip if values are equal and move to next criterion
988
+ if (aValue === bValue) continue;
989
+
990
+ // Use the compareValues function for proper type handling
991
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
992
+ }
993
+ }
994
+ return 0; // Equal if all criteria match
995
+ });
996
+ } else if (hasCreatedAt) {
997
+ // Default sort by createdAt desc if no explicit sort but has timestamp
998
+ sortedData = sortedData.sort((a, b) => {
999
+ const aValue = a[createdAtField as keyof typeof a];
1000
+ const bValue = b[createdAtField as keyof typeof b];
1001
+ return compareValues(aValue, bValue, 'desc');
1002
+ });
1003
+ }
1004
+
889
1005
  // Fetch the updated count after the data changes
890
1006
  // For updates, the count might not change but we fetch anyway to be consistent
891
1007
  setTimeout(() => fetchTotalCount(), 0);
892
1008
 
893
- return newData;
1009
+ return sortedData;
894
1010
  });
895
1011
  } else if (payload.eventType === 'DELETE') {
896
1012
  // Process delete event
897
1013
  setData((prev) => {
1014
+ // Access current options via refs
1015
+ const currentWhere = whereRef.current;
1016
+ const currentOrderBy = orderByRef.current;
1017
+ const currentLimit = limitRef.current;
1018
+ const currentOffset = offsetRef.current;
1019
+
898
1020
  // Skip if search is active
899
1021
  if (isSearchingRef.current) {
900
1022
  return prev;
@@ -913,21 +1035,61 @@ export function createSuparismaHook<
913
1035
  setTimeout(() => fetchTotalCount(), 0);
914
1036
 
915
1037
  // If we need to maintain the size with a limit
916
- if (currentLimit && currentLimit > 0 && filteredData.length < currentSize && currentSize === currentLimit) {
1038
+ if (currentLimit && currentLimit > 0 && filteredData.length < currentSize && currentSize === currentLimit) { // Use ref value
917
1039
  console.log(\`Record deleted with limit \${currentLimit}, will fetch additional record to maintain size\`);
918
1040
 
919
1041
  // Use setTimeout to ensure this state update completes first
920
1042
  setTimeout(() => {
921
1043
  findMany({
922
- where: currentWhere,
923
- orderBy: currentOrderBy,
924
- take: currentLimit,
925
- skip: currentOffset
1044
+ where: currentWhere, // Use ref value
1045
+ orderBy: currentOrderBy, // Use ref value
1046
+ take: currentLimit, // Use ref value
1047
+ skip: currentOffset // Use ref value (passed as skip to findMany)
926
1048
  });
927
1049
  }, 0);
1050
+
1051
+ // Return the filtered data without resizing for now
1052
+ // The findMany call above will update the data later
1053
+ return filteredData;
1054
+ }
1055
+
1056
+ // Re-apply ordering to maintain consistency
1057
+ let sortedData = [...filteredData];
1058
+
1059
+ // Apply ordering if specified
1060
+ if (currentOrderBy) { // Use ref value
1061
+ // Convert orderBy to array format for consistency if it's an object
1062
+ const orderByArray = Array.isArray(currentOrderBy)
1063
+ ? currentOrderBy
1064
+ : [currentOrderBy];
1065
+
1066
+ // Apply each sort in sequence
1067
+ sortedData = sortedData.sort((a, b) => {
1068
+ // Check each orderBy clause in sequence
1069
+ for (const orderByClause of orderByArray) {
1070
+ for (const [field, direction] of Object.entries(orderByClause)) {
1071
+ const aValue = a[field as keyof typeof a];
1072
+ const bValue = b[field as keyof typeof b];
1073
+
1074
+ // Skip if values are equal and move to next criterion
1075
+ if (aValue === bValue) continue;
1076
+
1077
+ // Use the compareValues function for proper type handling
1078
+ return compareValues(aValue, bValue, direction as 'asc' | 'desc');
1079
+ }
1080
+ }
1081
+ return 0; // Equal if all criteria match
1082
+ });
1083
+ } else if (hasCreatedAt) {
1084
+ // Default sort by createdAt desc if no explicit sort but has timestamp
1085
+ sortedData = sortedData.sort((a, b) => {
1086
+ const aValue = a[createdAtField as keyof typeof a];
1087
+ const bValue = b[createdAtField as keyof typeof b];
1088
+ return compareValues(aValue, bValue, 'desc');
1089
+ });
928
1090
  }
929
1091
 
930
- return filteredData;
1092
+ return sortedData;
931
1093
  });
932
1094
  }
933
1095
  }
@@ -942,7 +1104,7 @@ export function createSuparismaHook<
942
1104
  return () => {
943
1105
  console.log(\`Unsubscribing from \${channelId}\`);
944
1106
  if (channelRef.current) {
945
- channelRef.current.unsubscribe();
1107
+ supabase.removeChannel(channelRef.current); // Correct way to remove channel
946
1108
  channelRef.current = null;
947
1109
  }
948
1110
 
@@ -951,7 +1113,7 @@ export function createSuparismaHook<
951
1113
  searchTimeoutRef.current = null;
952
1114
  }
953
1115
  };
954
- }, [realtime, channelName, computedFilter]);
1116
+ }, [realtime, channelName, tableName, initialLoadRef]); // Removed where, orderBy, limit, offset from deps
955
1117
 
956
1118
  // Create a memoized options object to prevent unnecessary re-renders
957
1119
  const optionsRef = useRef({ where, orderBy, limit, offset });
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "suparisma",
3
- "version": "0.0.4",
3
+ "version": "1.0.1",
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
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Moe03/suparisma"
9
+ },
6
10
  "scripts": {
7
11
  "build": "tsc",
8
12
  "start": "node dist/index.js",