add tanstack db generator.

This commit is contained in:
Ivan Vasilov committed 2026-03-02 18:25:32 +01:00
1 parent fc06ffc901
commit c69d96fbd8
18 files changed
+1401

No files matched your search

@@ -0,0 +1,64 @@
import { OpenAPIDefinition } from '../types'
import { findPrimaryKeys, toCamelCase } from '../utils'
// Generate db.ts content from OpenAPI definitions
export function generateDbContent(definitions: Record<string, OpenAPIDefinition>): string {
const tableNames = Object.keys(definitions).filter((name) => !name.startsWith('_'))
const lines: string[] = [
"import { createSupabaseCollection } from 'supabase-collection'",
"import { createClient } from '@/lib/supabase/client'",
"import { getQueryClient } from '@/lib/query-client'",
'import {',
...tableNames.map((name) => ` ${toCamelCase(name)}Schema,`),
"} from './schemas'",
'',
'const supabase = createClient()',
'',
]
for (const [tableName, definition] of Object.entries(definitions)) {
// Skip internal PostgREST tables
if (tableName.startsWith('_')) continue
const collectionName = `${toCamelCase(tableName)}Collection`
const schemaName = `${toCamelCase(tableName)}Schema`
const properties = definition.properties || {}
const primaryKeys = findPrimaryKeys(properties)
// Generate getKey function
let getKeyFn: string
if (primaryKeys.length === 1) {
getKeyFn = `(item) => item.${primaryKeys[0]}`
} else if (primaryKeys.length > 1) {
const keyParts = primaryKeys.map((k) => `\${item.${k}}`).join('-')
getKeyFn = `(item) => \`${keyParts}\``
} else {
// Fallback to JSON stringify if no keys found
getKeyFn = '(item) => JSON.stringify(item)'
}
// Generate where function
let whereFn: string
if (primaryKeys.length === 1) {
whereFn = `(query, item) => query.eq('${primaryKeys[0]}', item.${primaryKeys[0]})`
} else if (primaryKeys.length > 1) {
const whereParts = primaryKeys.map((k) => `.eq('${k}', item.${k})`).join('')
whereFn = `(query, item) => query${whereParts}`
} else {
whereFn = '(query, item) => query'
}
lines.push(`export const ${collectionName} = createSupabaseCollection({`)
lines.push(` tableName: '${tableName}',`)
lines.push(` getKey: ${getKeyFn},`)
lines.push(` where: ${whereFn},`)
lines.push(` schema: ${schemaName},`)
lines.push(' queryClient: getQueryClient(),')
lines.push(' supabase: supabase as any,')
lines.push('})')
lines.push('')
}
return lines.join('\n')
}
@@ -0,0 +1,5 @@
export { generateSchemasContent } from './schemas'
export { generateDbContent } from './db'
export { generatePageContent } from './page'
export { generateSheetContent } from './sheet'
export { generateListContent } from './list'
@@ -0,0 +1,187 @@
import { OpenAPIDefinition } from '../types'
import { findPrimaryKeys, toCamelCase, toPascalCase, toSingular } from '../utils'
// Generate list component content
export function generateListContent(tableName: string, definition: OpenAPIDefinition): string {
const pascalName = toPascalCase(tableName)
const singularName = toSingular(tableName)
const singularPascal = toPascalCase(singularName)
const camelName = toCamelCase(tableName)
const collectionName = `${camelName}Collection`
const listComponentName = `${pascalName}List`
const sheetComponentName = `${singularPascal}Sheet`
const properties = definition.properties || {}
const primaryKeys = findPrimaryKeys(properties)
const primaryKey = primaryKeys[0] || 'id'
// Get display fields (non-system fields, first 2 for display)
const editableFields = Object.entries(properties).filter(([name]) => {
const isSystemField =
name === primaryKey || name === 'created_at' || name === 'updated_at' || name.endsWith('_at')
return !isSystemField
})
const displayField = editableFields[0]?.[0] || primaryKey
const secondaryField = editableFields[1]?.[0]
// Get all fields for select
const allFieldNames = Object.keys(properties)
const selectFields = allFieldNames.map((name) => ` ${name}: item.${name},`).join('\n')
// Determine if we have created_at for display
const hasCreatedAt = properties['created_at'] !== undefined
// Generate secondary info display
let secondaryInfo = ''
if (secondaryField) {
secondaryInfo = `
{item.${secondaryField} && (
<span className="truncate">{item.${secondaryField}}</span>
)}
${hasCreatedAt ? `<span>•</span>` : ''}`
}
return `'use client'
import { useState } from 'react'
import { useLiveQuery } from '@tanstack/react-db'
import { Pencil, Plus, Trash2, FileText } from 'lucide-react'
import { ${collectionName} } from '@/lib/db'
import { ${pascalName} } from '@/lib/schemas'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { ${sheetComponentName} } from './${singularName}-sheet'
export function ${listComponentName}() {
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false)
const [editingItem, setEditingItem] = useState<${pascalName} | null>(null)
const { data: items } = useLiveQuery(
(q) =>
q
.from({ item: ${collectionName} })
.orderBy(({ item }) => item.${displayField}, 'asc')
.select(({ item }) => ({
${selectFields}
})),
[]
)
const handleDelete = (itemId: string) => {
${collectionName}.delete([itemId])
}
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-border/50 border-b bg-linear-to-r from-primary/5 to-transparent p-6 rounded-t-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<FileText className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="font-bold text-2xl tracking-tight">${pascalName}</h1>
<p className="text-muted-foreground text-sm">
{items.length} ${singularName.replace(/_/g, ' ')}{items.length !== 1 ? 's' : ''} in database
</p>
</div>
</div>
<Button className="gap-2" onClick={() => setIsCreateSheetOpen(true)}>
<Plus className="h-4 w-4" />
Add ${singularPascal}
</Button>
</div>
</div>
{/* Items list */}
<div className="flex-1 overflow-y-auto p-4">
{items.length > 0 ? (
<div className="grid gap-3">
{items.map((item) => (
<div
className={cn(
'flex items-center justify-between rounded-lg p-4',
'border border-border/50 bg-card shadow-sm',
'transition-all duration-150 hover:shadow-md hover:border-border'
)}
key={item.${primaryKey}}
>
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-linear-to-br from-chart-1/20 to-chart-2/20 shrink-0">
<FileText className="h-5 w-5 text-foreground/70" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
{item.${displayField} || 'Untitled'}
</p>
<div className="flex items-center gap-3 text-muted-foreground text-xs">${secondaryInfo}${hasCreatedAt ? `
<span>
Created {new Date(item.created_at).toLocaleDateString()}
</span>` : `
<span className="font-mono truncate max-w-[120px]" title={item.${primaryKey}}>
{item.${primaryKey}.slice(0, 8)}...
</span>`}
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
className="text-muted-foreground hover:text-primary"
onClick={() => setEditingItem(item)}
size="sm"
variant="ghost"
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<Button
className="text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(item.${primaryKey})}
size="sm"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</div>
</div>
))}
</div>
) : (
<div className="flex h-64 flex-col items-center justify-center text-muted-foreground border border-dashed border-border/50 rounded-lg">
<FileText className="mb-3 h-12 w-12 opacity-30" />
<p className="font-medium">No ${tableName.replace(/_/g, ' ')} yet</p>
<p className="text-sm mb-4">Create your first ${singularName.replace(/_/g, ' ')} to get started</p>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => setIsCreateSheetOpen(true)}
>
<Plus className="h-4 w-4" />
Add ${singularPascal}
</Button>
</div>
)}
</div>
{/* Create Sheet */}
<${sheetComponentName}
open={isCreateSheetOpen}
onOpenChange={setIsCreateSheetOpen}
mode="create"
/>
{/* Edit Sheet */}
<${sheetComponentName}
open={!!editingItem}
onOpenChange={(open) => !open && setEditingItem(null)}
mode="edit"
item={editingItem}
/>
</div>
)
}
`
}
@@ -0,0 +1,32 @@
import { toCamelCase, toPascalCase } from '../utils'
// Generate page.tsx content
export function generatePageContent(tableName: string): string {
const pascalName = toPascalCase(tableName)
const camelName = toCamelCase(tableName)
const collectionName = `${camelName}Collection`
const listComponentName = `${pascalName}List`
return `import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { ${collectionName} } from '@/lib/db'
import { getQueryClient } from '@/lib/query-client'
import { ${listComponentName} } from './${tableName}-list'
export default async function ${pascalName}Page() {
const queryClient = getQueryClient()
// Prefetch ${camelName} on the server
await queryClient.prefetchQuery(${collectionName}.listQueryOptions())
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div className="container mx-auto max-w-4xl py-8">
<${listComponentName} />
</div>
</HydrationBoundary>
)
}
`
}
@@ -0,0 +1,33 @@
import { OpenAPIDefinition } from '../types'
import { openApiTypeToZod, toCamelCase, toPascalCase } from '../utils'
// Generate schemas.ts content from OpenAPI definitions
export function generateSchemasContent(definitions: Record<string, OpenAPIDefinition>): string {
const lines: string[] = ["import { z } from 'zod'", '']
for (const [tableName, definition] of Object.entries(definitions)) {
// Skip internal PostgREST tables
if (tableName.startsWith('_')) continue
const typeName = toPascalCase(tableName)
const schemaName = `${toCamelCase(tableName)}Schema`
const properties = definition.properties || {}
const required = definition.required || []
lines.push(`// ${typeName} schema`)
lines.push(`export const ${schemaName} = z.object({`)
for (const [propName, prop] of Object.entries(properties)) {
const isRequired = required.includes(propName)
const zodType = openApiTypeToZod(prop, isRequired)
lines.push(` ${propName}: ${zodType},`)
}
lines.push('})')
lines.push('')
lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>`)
lines.push('')
}
return lines.join('\n')
}
@@ -0,0 +1,312 @@
import { OpenAPIDefinition } from '../types'
import { findPrimaryKeys, toCamelCase, toLabel, toPascalCase, toSingular } from '../utils'
// Generate sheet component content
export function generateSheetContent(tableName: string, definition: OpenAPIDefinition): string {
const pascalName = toPascalCase(tableName)
const singularName = toSingular(tableName)
const singularPascal = toPascalCase(singularName)
const camelName = toCamelCase(tableName)
const collectionName = `${camelName}Collection`
const sheetComponentName = `${singularPascal}Sheet`
const properties = definition.properties || {}
const required = definition.required || []
const primaryKeys = findPrimaryKeys(properties)
const primaryKey = primaryKeys[0] || 'id'
// Separate fields into editable and read-only
const allFields = Object.entries(properties)
const editableFields = allFields.filter(([name]) => {
const isSystemField =
name === primaryKey || name === 'created_at' || name === 'updated_at' || name.endsWith('_at')
return !isSystemField
})
const readOnlyFields = allFields.filter(([name]) => {
return name === primaryKey || name === 'created_at' || name === 'updated_at' || name.endsWith('_at')
})
// Generate state declarations
const stateDeclarations = editableFields
.map(([name, prop]) => {
const stateName = toCamelCase(name)
if (prop.type === 'boolean') {
return ` const [${stateName}, set${toPascalCase(name)}] = useState(false)`
}
if (prop.type === 'integer' || prop.type === 'number') {
return ` const [${stateName}, set${toPascalCase(name)}] = useState<number | ''>(0)`
}
return ` const [${stateName}, set${toPascalCase(name)}] = useState('')`
})
.join('\n')
// Generate useEffect reset
const useEffectReset = editableFields
.map(([name, prop]) => {
const stateName = toCamelCase(name)
const setterName = `set${toPascalCase(name)}`
if (prop.type === 'boolean') {
return ` ${setterName}(item?.${name} ?? false)`
}
if (prop.type === 'integer' || prop.type === 'number') {
return ` ${setterName}(item?.${name} ?? 0)`
}
return ` ${setterName}(item?.${name} ?? '')`
})
.join('\n')
// Generate form fields for editable fields
const editableFormFields = editableFields
.map(([name, prop]) => {
const stateName = toCamelCase(name)
const setterName = `set${toPascalCase(name)}`
const label = toLabel(name)
const isRequired = required.includes(name)
if (prop.type === 'boolean') {
return ` {/* ${label} Field */}
<div className="flex items-center gap-3">
<input
id="${singularName}-${name}"
type="checkbox"
checked={${stateName}}
onChange={(e) => ${setterName}(e.target.checked)}
className="h-4 w-4 rounded border"
/>
<Label htmlFor="${singularName}-${name}">${label}</Label>
</div>`
}
if (prop.type === 'integer' || prop.type === 'number') {
return ` {/* ${label} Field */}
<div className="flex flex-col gap-2">
<Label htmlFor="${singularName}-${name}">
${label}${isRequired ? ' <span className="text-destructive">*</span>' : ''}
</Label>
<Input
id="${singularName}-${name}"
type="number"
value={${stateName}}
onChange={(e) => ${setterName}(e.target.value ? Number(e.target.value) : '')}
placeholder="Enter ${label.toLowerCase()}"
/>
</div>`
}
if (prop.enum && prop.enum.length > 0) {
const options = prop.enum
.map((e) => ` <option value="${e}">${toLabel(e)}</option>`)
.join('\n')
return ` {/* ${label} Field */}
<div className="flex flex-col gap-2">
<Label htmlFor="${singularName}-${name}">
${label}${isRequired ? ' <span className="text-destructive">*</span>' : ''}
</Label>
<select
id="${singularName}-${name}"
value={${stateName}}
onChange={(e) => ${setterName}(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">Select ${label.toLowerCase()}</option>
${options}
</select>
</div>`
}
return ` {/* ${label} Field */}
<div className="flex flex-col gap-2">
<Label htmlFor="${singularName}-${name}">
${label}${isRequired ? ' <span className="text-destructive">*</span>' : ''}
</Label>
<Input
id="${singularName}-${name}"
value={${stateName}}
onChange={(e) => ${setterName}(e.target.value)}
placeholder="Enter ${label.toLowerCase()}"
/>
</div>`
})
.join('\n\n')
// Generate read-only fields for edit mode
const readOnlyFormFields = readOnlyFields
.map(([name]) => {
const label = toLabel(name)
const isTimestamp = name.endsWith('_at')
const displayValue = isTimestamp ? `new Date(item.${name}).toLocaleString()` : `item.${name}`
return ` {/* ${label} - Read Only */}
<div className="flex flex-col gap-2">
<Label htmlFor="${singularName}-${name}" className="text-muted-foreground">
${label}
</Label>
<Input
id="${singularName}-${name}"
value={${displayValue}}
disabled
className="${name === primaryKey ? 'font-mono text-sm ' : ''}bg-muted"
/>
</div>`
})
.join('\n\n')
// Generate insert data
const insertData = [
` ${primaryKey}: crypto.randomUUID(),`,
...editableFields.map(([name, prop]) => {
const stateName = toCamelCase(name)
if (prop.type === 'boolean') {
return ` ${name}: ${stateName},`
}
if (prop.type === 'integer' || prop.type === 'number') {
return ` ${name}: ${stateName} || 0,`
}
return ` ${name}: ${stateName},`
}),
// Add timestamps if they exist
...readOnlyFields
.filter(([name]) => name === 'created_at')
.map(() => ` created_at: new Date().toISOString(),`),
].join('\n')
// Generate update data
const updateData = editableFields
.map(([name, prop]) => {
const stateName = toCamelCase(name)
if (prop.type === 'boolean') {
return ` ${name}: ${stateName},`
}
if (prop.type === 'integer' || prop.type === 'number') {
return ` ${name}: ${stateName} || 0,`
}
return ` ${name}: ${stateName},`
})
.join('\n')
// Generate reset states
const resetStates = editableFields
.map(([name, prop]) => {
const setterName = `set${toPascalCase(name)}`
if (prop.type === 'boolean') {
return ` ${setterName}(false)`
}
if (prop.type === 'integer' || prop.type === 'number') {
return ` ${setterName}(0)`
}
return ` ${setterName}('')`
})
.join('\n')
// Generate validation (check required fields)
const requiredEditableFields = editableFields.filter(([name]) => required.includes(name))
const validationCheck =
requiredEditableFields.length > 0
? requiredEditableFields
.map(([name, prop]) => {
const stateName = toCamelCase(name)
if (prop.type === 'boolean') return null
if (prop.type === 'integer' || prop.type === 'number') {
return `${stateName} === ''`
}
return `!${stateName}.trim()`
})
.filter(Boolean)
.join(' || ')
: null
return `'use client'
import { useState, useEffect } from 'react'
import { ${collectionName} } from '@/lib/db'
import { ${pascalName} } from '@/lib/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
interface ${sheetComponentName}Props {
open: boolean
onOpenChange: (open: boolean) => void
mode: 'create' | 'edit'
item?: ${pascalName} | null
}
export function ${sheetComponentName}({ open, onOpenChange, mode, item }: ${sheetComponentName}Props) {
${stateDeclarations}
const isCreate = mode === 'create'
const title = isCreate ? 'Create New ${singularPascal}' : 'Edit ${singularPascal}'
const description = isCreate
? 'Add a new ${singularName.replace(/_/g, ' ')} to your database.'
: 'Update the ${singularName.replace(/_/g, ' ')} information.'
const submitLabel = isCreate ? 'Create ${singularPascal}' : 'Save Changes'
useEffect(() => {
if (open) {
${useEffectReset}
}
}, [open, item])
const handleSubmit = () => {
${validationCheck ? ` if (${validationCheck}) return\n` : ''}
if (isCreate) {
${collectionName}.insert({
${insertData}
})
} else if (item) {
${collectionName}.update([item.${primaryKey}], (prev) => ({
...prev,
${updateData}
}))
}
handleClose()
}
const handleClose = () => {
${resetStates}
onOpenChange(false)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-6 py-6">
{/* Read-only fields (only in edit mode) */}
{!isCreate && item && (
<>
${readOnlyFormFields}
</>
)}
{/* Editable fields */}
${editableFormFields}
</div>
<SheetFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit}>
{submitLabel}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
`
}
@@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server'
import path from 'path'
import { RegistryItem } from 'shadcn/schema'
import fs from 'fs/promises'
import { OpenAPISchema, RegistryFile } from './types'
import { toSingular } from './utils'
import {
generateSchemasContent,
generateDbContent,
generatePageContent,
generateSheetContent,
generateListContent,
} from './generators'
// Read the base registry JSON
async function readBaseRegistry(): Promise<RegistryItem> {
const fullPath = path.join(process.cwd(), 'public/r/tanstack-db-nextjs.json')
const content = await fs.readFile(fullPath, 'utf-8')
return JSON.parse(content) as RegistryItem
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const ref = searchParams.get('ref')
const anonKey = searchParams.get('anonKey')
if (!ref || !anonKey) {
return NextResponse.json(
{ error: 'Missing required parameters: ref and anonKey' },
{ status: 400 }
)
}
try {
// Fetch OpenAPI spec from Supabase
const openApiUrl = `https://${ref}.supabase.co/rest/v1/`
const response = await fetch(openApiUrl, {
headers: {
apikey: anonKey,
},
})
if (!response.ok) {
return NextResponse.json(
{ error: `Failed to fetch OpenAPI spec: ${response.statusText}` },
{ status: response.status }
)
}
const openApiSpec: OpenAPISchema = await response.json()
if (!openApiSpec.definitions) {
return NextResponse.json(
{ error: 'No table definitions found in OpenAPI spec' },
{ status: 400 }
)
}
// Generate dynamic file contents
const schemasContent = generateSchemasContent(openApiSpec.definitions)
const dbContent = generateDbContent(openApiSpec.definitions)
// Get the first table for the example CRUD page
const tableNames = Object.keys(openApiSpec.definitions).filter((name) => !name.startsWith('_'))
const firstTableName = tableNames[0]
const firstTableDefinition = firstTableName ? openApiSpec.definitions[firstTableName] : null
// Read the base registry JSON
const baseRegistry = await readBaseRegistry()
// Create dynamic files
const dynamicFiles: RegistryFile[] = [
{
path: 'lib/schemas.ts',
content: schemasContent,
type: 'registry:lib',
},
{
path: 'lib/db.ts',
content: dbContent,
type: 'registry:lib',
},
]
// Add example CRUD pages for the first table
if (firstTableName && firstTableDefinition) {
const singularTableName = toSingular(firstTableName)
dynamicFiles.push(
{
path: `app/${firstTableName}/page.tsx`,
content: generatePageContent(firstTableName),
type: 'registry:page',
target: `app/${firstTableName}/page.tsx`,
},
{
path: `app/${firstTableName}/${singularTableName}-sheet.tsx`,
content: generateSheetContent(firstTableName, firstTableDefinition),
type: 'registry:component',
target: `app/${firstTableName}/${singularTableName}-sheet.tsx`,
},
{
path: `app/${firstTableName}/${firstTableName}-list.tsx`,
content: generateListContent(firstTableName, firstTableDefinition),
type: 'registry:component',
target: `app/${firstTableName}/${firstTableName}-list.tsx`,
}
)
}
// Combine dynamic files with base registry files
const registryResponse: RegistryItem = {
...baseRegistry,
files: [...dynamicFiles, ...(baseRegistry.files || [])],
envVars: {
NEXT_PUBLIC_SUPABASE_URL: `https://${ref}.supabase.co`,
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY: anonKey,
},
}
return NextResponse.json(registryResponse)
} catch (error) {
console.error('Error generating tanstack-db block:', error)
return NextResponse.json(
{ error: 'Failed to generate block. Please check your project ref and anon key.' },
{ status: 500 }
)
}
}
@@ -0,0 +1,23 @@
import { RegistryItem } from 'shadcn/schema'
export interface OpenAPISchema {
definitions?: Record<string, OpenAPIDefinition>
}
export interface OpenAPIDefinition {
type: string
properties?: Record<string, OpenAPIProperty>
required?: string[]
}
export interface OpenAPIProperty {
type?: string
format?: string
description?: string
default?: unknown
maxLength?: number
enum?: string[]
items?: { type?: string; format?: string }
}
export type RegistryFile = NonNullable<RegistryItem['files']>[number]
@@ -0,0 +1,105 @@
import { OpenAPIProperty } from './types'
// Convert OpenAPI type to Zod type
export function openApiTypeToZod(prop: OpenAPIProperty, isRequired: boolean): string {
let zodType: string
switch (prop.type) {
case 'string':
if (prop.format === 'uuid') {
zodType = 'z.string().uuid()'
} else if (prop.format === 'date-time' || prop.format === 'timestamp with time zone') {
zodType = 'z.string()'
} else if (prop.enum && prop.enum.length > 0) {
const enumValues = prop.enum.map((e) => `'${e}'`).join(', ')
zodType = `z.enum([${enumValues}])`
} else {
zodType = 'z.string()'
}
break
case 'integer':
zodType = 'z.number().int()'
break
case 'number':
zodType = 'z.number()'
break
case 'boolean':
zodType = 'z.boolean()'
break
case 'array':
if (prop.items) {
const itemType = openApiTypeToZod(prop.items as OpenAPIProperty, true)
zodType = `z.array(${itemType})`
} else {
zodType = 'z.array(z.unknown())'
}
break
case 'object':
zodType = 'z.record(z.unknown())'
break
default:
zodType = 'z.unknown()'
}
// Add nullable if not required
if (!isRequired) {
zodType += '.nullable()'
}
return zodType
}
// Convert table name to PascalCase for type names
export function toPascalCase(str: string): string {
return str
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join('')
}
// Convert table name to camelCase for variable names
export function toCamelCase(str: string): string {
const pascal = toPascalCase(str)
return pascal.charAt(0).toLowerCase() + pascal.slice(1)
}
// Convert field name to human-readable label
export function toLabel(fieldName: string): string {
return fieldName
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
// Get singular form of table name (simple heuristic)
export function toSingular(tableName: string): string {
if (tableName.endsWith('ies')) {
return tableName.slice(0, -3) + 'y'
}
if (tableName.endsWith('ses') || tableName.endsWith('xes') || tableName.endsWith('zes')) {
return tableName.slice(0, -2)
}
if (tableName.endsWith('s') && !tableName.endsWith('ss')) {
return tableName.slice(0, -1)
}
return tableName
}
// Find primary key columns from properties
export function findPrimaryKeys(properties: Record<string, OpenAPIProperty>): string[] {
const primaryKeys: string[] = []
for (const [name, prop] of Object.entries(properties)) {
// Check if the description mentions primary key
if (prop.description?.toLowerCase().includes('primary key')) {
primaryKeys.push(name)
}
}
// If no primary keys found, default to 'id' if it exists
if (primaryKeys.length === 0 && properties['id']) {
primaryKeys.push('id')
}
return primaryKeys
}
@@ -17,6 +17,7 @@ import { DualRealtimeChat } from './dual-realtime-chat'
import { RegistryBlock } from './registry-block'
import { StyleWrapper } from './style-wrapper'
import TanStackBeta from './tanstack-beta'
import { TanstackDBGenerator } from './tanstack-db-generator'
import type { Style } from '@/registry/styles'
const components = {
@@ -181,6 +182,7 @@ const components = {
BlockItem,
BlockPreview,
DualRealtimeChat,
TanstackDBGenerator,
}
interface MdxProps {
@@ -0,0 +1,212 @@
'use client'
import { Check, Copy, Loader2 } from 'lucide-react'
import { useState } from 'react'
import {
Button,
Input_Shadcn_ as Input,
Label_Shadcn_ as Label,
Tabs_Shadcn_ as Tabs,
TabsContent_Shadcn_ as TabsContent,
TabsList_Shadcn_ as TabsList,
TabsTrigger_Shadcn_ as TabsTrigger,
cn,
copyToClipboard,
} from 'ui'
import { useLocalStorage } from './use-local-storage'
type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
const LOCAL_STORAGE_KEY = 'package-manager-tanstack-db'
const getBaseUrl = () => {
if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'production') {
return 'https://supabase.com/ui'
} else if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'preview') {
return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
} else {
return 'http://localhost:3004/ui'
}
}
export function TanstackDBGenerator() {
const [projectRef, setProjectRef] = useState('')
const [anonKey, setAnonKey] = useState('')
const [isValidating, setIsValidating] = useState(false)
const [validationError, setValidationError] = useState<string | null>(null)
const [isGenerated, setIsGenerated] = useState(false)
const [hasCopied, setHasCopied] = useState(false)
const [packageManager, setPackageManager] = useLocalStorage(LOCAL_STORAGE_KEY, 'npm')
const baseUrl = getBaseUrl()
const apiUrl = `${baseUrl}/api/registry/tanstack-db?ref=${encodeURIComponent(projectRef)}&anonKey=${encodeURIComponent(anonKey)}`
const commands: Record<PackageManager, string> = {
npm: `npx shadcn@latest add "${apiUrl}"`,
pnpm: `pnpm dlx shadcn@latest add "${apiUrl}"`,
yarn: `yarn dlx shadcn@latest add "${apiUrl}"`,
bun: `bunx --bun shadcn@latest add "${apiUrl}"`,
}
const handleValidate = async () => {
if (!projectRef.trim() || !anonKey.trim()) {
setValidationError('Please enter both Project Ref and Anon Key')
return
}
setIsValidating(true)
setValidationError(null)
try {
// Test the connection by fetching the OpenAPI spec
const response = await fetch(`https://${projectRef}.supabase.co/rest/v1/`, {
headers: {
apikey: anonKey,
},
})
if (!response.ok) {
throw new Error(`Invalid credentials or project not found (${response.status})`)
}
const data = await response.json()
if (!data.definitions || Object.keys(data.definitions).length === 0) {
setValidationError('No tables found in your database. Create some tables first.')
return
}
setIsGenerated(true)
} catch (error) {
setValidationError(
error instanceof Error ? error.message : 'Failed to connect to your Supabase project'
)
} finally {
setIsValidating(false)
}
}
const handleCopy = (command: string) => {
copyToClipboard(command)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
}
const handleReset = () => {
setIsGenerated(false)
setProjectRef('')
setAnonKey('')
setValidationError(null)
}
return (
<div className="mt-6 space-y-4">
{!isGenerated ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="project-ref">Project Ref</Label>
<Input
id="project-ref"
placeholder="your-project-ref"
value={projectRef}
onChange={(e) => setProjectRef(e.target.value)}
className="font-mono"
/>
<p className="text-xs text-muted-foreground">
Found in your project settings or URL (e.g., abcdefghijklmnop)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="anon-key">Anon Key</Label>
<Input
id="anon-key"
placeholder="eyJhbGciOiJIUzI1NiIs..."
value={anonKey}
onChange={(e) => setAnonKey(e.target.value)}
className="font-mono"
type="password"
/>
<p className="text-xs text-muted-foreground">
Found in your project&apos;s API settings
</p>
</div>
</div>
{validationError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 text-sm text-destructive">
{validationError}
</div>
)}
<Button
onClick={handleValidate}
disabled={isValidating || !projectRef.trim() || !anonKey.trim()}
className="w-full sm:w-auto"
>
{isValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Validating...
</>
) : (
'Generate Installation Command'
)}
</Button>
</>
) : (
<>
<div className="rounded-md bg-brand-400/10 border border-brand-400/20 p-3 text-sm text-brand-600">
Successfully connected to your Supabase project. Copy the command below to install the
block.
</div>
<Tabs value={packageManager} onValueChange={setPackageManager} className="w-full">
<div className="w-full group relative rounded-lg bg-surface-200 dark:bg-surface-100 px-4 py-2 overflow-hidden">
<div className="flex flex-col">
<TabsList className="gap-2 relative mb-2 z-10">
{(Object.keys(commands) as PackageManager[]).map((manager) => (
<TabsTrigger key={manager} value={manager} className="text-xs">
{manager}
</TabsTrigger>
))}
</TabsList>
{(Object.keys(commands) as PackageManager[]).map((manager) => (
<TabsContent key={manager} value={manager} className="m-0">
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-sm text-foreground relative z-10 overflow-x-auto">
<span className="mr-2 text-[#888] select-none">$</span>
<span className="whitespace-nowrap">{commands[manager]}</span>
</div>
<Button
size="small"
type="outline"
className={cn(
'relative z-10 h-6 w-6 text-foreground-muted hover:bg-surface-100 hover:text-foreground p-0 shrink-0'
)}
onClick={() => handleCopy(commands[manager])}
>
<span className="sr-only">Copy</span>
{hasCopied ? (
<Check className="h-3 w-3 text-brand-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</TabsContent>
))}
</div>
</div>
</Tabs>
<Button type="outline" onClick={handleReset} className="w-full sm:w-auto">
Generate for a different project
</Button>
</>
)}
</div>
)
}
@@ -0,0 +1,120 @@
---
title: TanStack DB
description: Type-safe database collections with TanStack Query and Supabase
---
This block provides TanStack Query integration with Supabase collections for type-safe database operations. It generates Zod schemas and collection configurations based on your database schema.
## Installation
Enter your Supabase project credentials to generate a personalized installation command. The block will include schemas and collections for all tables in your database.
<TanstackDBGenerator />
<Callout type="info">
Your credentials are only used to fetch your database schema. They are not stored or sent to any
server other than your Supabase project.
</Callout>
## What's included
This block installs the following files:
| File | Description |
| ---------------------------- | ---------------------------------------------------- |
| `lib/schemas.ts` | Zod schemas for all your database tables |
| `lib/db.ts` | Supabase collections for type-safe CRUD operations |
| `lib/query-client.ts` | TanStack Query client configuration with SSR support |
| `app/providers.tsx` | React Query provider component |
| `lib/supabase/client.ts` | Browser Supabase client |
| `lib/supabase/server.ts` | Server-side Supabase client |
| `lib/supabase/middleware.ts` | Middleware for session handling |
## Getting started
After installing the block, add the required environment variables to your `.env.local` file:
```env
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY=your-anon-key
```
## Usage
### Wrap your app with the Providers component
Update your root layout to include the Providers component:
```tsx
import { Providers } from '@/app/providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
```
### Using collections in your components
Each table in your database gets a corresponding collection. Here's how to use them:
```tsx
'use client'
import { usersCollection } from '@/lib/db'
export function UserList() {
const { data: users, isLoading } = usersCollection.useList()
if (isLoading) return <div>Loading...</div>
return <ul>{users?.map((user) => <li key={user.id}>{user.name}</li>)}</ul>
}
```
### Available collection methods
Each collection provides the following methods:
| Method | Description |
| ------------- | -------------------------- |
| `useList()` | Fetch all items |
| `useGet(key)` | Fetch a single item by key |
| `useCreate()` | Create a new item |
| `useUpdate()` | Update an existing item |
| `useDelete()` | Delete an item |
### Server-side data fetching
For server components, you can prefetch data using the query client:
```tsx
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { usersCollection } from '@/lib/db'
import { getQueryClient } from '@/lib/query-client'
export default async function UsersPage() {
const queryClient = getQueryClient()
// Prefetch users on the server
await queryClient.prefetchQuery(usersCollection.listQueryOptions())
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList />
</HydrationBoundary>
)
}
```
## Further reading
- [TanStack Query documentation](https://tanstack.com/query/latest)
- [Supabase JavaScript client](https://supabase.com/docs/reference/javascript)
- [Generating TypeScript types](https://supabase.com/docs/guides/api/rest/generating-types)
+43
View File
@@ -1417,6 +1417,49 @@
}
]
},
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "tanstack-db-nextjs",
"type": "registry:block",
"title": "TanStack DB for Next.js and Supabase",
"description": "TanStack Query integration with Supabase collections for type-safe database operations",
"registryDependencies": [],
"dependencies": [
"@tanstack/react-query@latest",
"github:ivasilov/supabase-collection",
"zod@latest",
"@supabase/ssr@latest",
"@supabase/supabase-js@latest"
],
"files": [
{
"path": "registry/default/blocks/tanstack-db/lib/query-client.ts",
"type": "registry:lib"
},
{
"path": "registry/default/blocks/tanstack-db/app/providers.tsx",
"type": "registry:component",
"target": "app/providers.tsx"
},
{
"path": "registry/default/clients/nextjs/lib/supabase/client.ts",
"type": "registry:lib"
},
{
"path": "registry/default/clients/nextjs/lib/supabase/middleware.ts",
"type": "registry:lib"
},
{
"path": "registry/default/clients/nextjs/lib/supabase/server.ts",
"type": "registry:lib"
}
],
"docs": "You'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY`.",
"envVars": {
"NEXT_PUBLIC_SUPABASE_URL": "",
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
}
},
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "supabase-client-nextjs",
@@ -0,0 +1,48 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "tanstack-db-nextjs",
"type": "registry:block",
"title": "TanStack DB for Next.js and Supabase",
"description": "TanStack Query integration with Supabase collections for type-safe database operations",
"dependencies": [
"@tanstack/react-query@latest",
"github:ivasilov/supabase-collection",
"zod@latest",
"@supabase/ssr@latest",
"@supabase/supabase-js@latest"
],
"registryDependencies": [],
"files": [
{
"path": "registry/default/blocks/tanstack-db/lib/query-client.ts",
"content": "import { QueryClient, defaultShouldDehydrateQuery, isServer } from '@tanstack/react-query'\nimport { cache } from 'react'\n\nfunction makeQueryClient() {\n return new QueryClient({\n defaultOptions: {\n queries: {\n staleTime: 60 * 1000,\n },\n dehydrate: {\n // include pending queries in dehydration\n shouldDehydrateQuery: (query) =>\n defaultShouldDehydrateQuery(query) || query.state.status === 'pending',\n shouldRedactErrors: (error) => {\n // We should not catch Next.js server errors\n // as that's how Next.js detects dynamic pages\n // so we cannot redact them.\n // Next.js also automatically redacts errors for us\n // with better digests.\n return false\n },\n },\n },\n })\n}\n\nlet browserQueryClient: QueryClient | undefined = undefined\nconst serverQueryClient = cache(() => makeQueryClient())\n\nexport function getQueryClient() {\n if (isServer) {\n return serverQueryClient()\n } else {\n // Browser: make a new query client if we don't already have one\n // This is very important, so we don't re-make a new client if React\n // suspends during the initial render. This may not be needed if we\n // have a suspense boundary BELOW the creation of the query client\n if (!browserQueryClient) browserQueryClient = makeQueryClient()\n return browserQueryClient\n }\n}\n",
"type": "registry:lib"
},
{
"path": "registry/default/blocks/tanstack-db/app/providers.tsx",
"content": "'use client'\n\nimport { QueryClientProvider } from '@tanstack/react-query'\n\nimport { getQueryClient } from '@/registry/default/blocks/tanstack-db/lib/query-client'\n\nexport function Providers({ children }: { children: React.ReactNode }) {\n // NOTE: Avoid useState when initializing the query client if you don't\n // have a suspense boundary between this and the code that may\n // suspend because React will throw away the client on the initial\n // render if it suspends and there is no boundary\n const queryClient = getQueryClient()\n\n return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>\n}\n",
"type": "registry:component",
"target": "app/providers.tsx"
},
{
"path": "registry/default/clients/nextjs/lib/supabase/client.ts",
"content": "import { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!\n )\n}\n",
"type": "registry:lib"
},
{
"path": "registry/default/clients/nextjs/lib/supabase/middleware.ts",
"content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n",
"type": "registry:lib"
},
{
"path": "registry/default/clients/nextjs/lib/supabase/server.ts",
"content": "import { createServerClient } from '@supabase/ssr'\nimport { cookies } from 'next/headers'\n\n/**\n * If using Fluid compute: Don't put this client in a global variable. Always create a new client within each\n * function when using it.\n */\nexport async function createClient() {\n const cookieStore = await cookies()\n\n return createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return cookieStore.getAll()\n },\n setAll(cookiesToSet) {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options)\n )\n } catch {\n // The `setAll` method was called from a Server Component.\n // This can be ignored if you have middleware refreshing\n // user sessions.\n }\n },\n },\n }\n )\n}\n",
"type": "registry:lib"
}
],
"envVars": {
"NEXT_PUBLIC_SUPABASE_URL": "",
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
},
"docs": "You'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY`."
}
+4
View File
@@ -15,6 +15,7 @@ import socialAuthNextjs from './default/blocks/social-auth-nextjs/registry-item.
import socialAuthReactRouter from './default/blocks/social-auth-react-router/registry-item.json' with { type: 'json' }
import socialAuthReact from './default/blocks/social-auth-react/registry-item.json' with { type: 'json' }
import socialAuthTanstack from './default/blocks/social-auth-tanstack/registry-item.json' with { type: 'json' }
import tanstackDbNextjs from './default/blocks/tanstack-db/registry-item.json' with { type: 'json' }
import { registryItemAppend } from './utils'
const combine = (component: RegistryItem) => {
@@ -52,4 +53,7 @@ export const blocks = [
...combine(realtimeChat as RegistryItem),
// infinite query hook is intentionally not combined with the clients since it depends on clients having database types.
infiniteQueryHook as RegistryItem,
// tanstack-db is served dynamically via API route, but we register it here for the static build
registryItemAppend(tanstackDbNextjs as RegistryItem, [nextjsClient!]),
] as RegistryItem[]
@@ -0,0 +1,15 @@
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from '@/registry/default/blocks/tanstack-db/lib/query-client'
export function Providers({ children }: { children: React.ReactNode }) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient()
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
@@ -0,0 +1,41 @@
import { QueryClient, defaultShouldDehydrateQuery, isServer } from '@tanstack/react-query'
import { cache } from 'react'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
// include pending queries in dehydration
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
shouldRedactErrors: (error) => {
// We should not catch Next.js server errors
// as that's how Next.js detects dynamic pages
// so we cannot redact them.
// Next.js also automatically redacts errors for us
// with better digests.
return false
},
},
},
})
}
let browserQueryClient: QueryClient | undefined = undefined
const serverQueryClient = cache(() => makeQueryClient())
export function getQueryClient() {
if (isServer) {
return serverQueryClient()
} else {
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "tanstack-db-nextjs",
"type": "registry:block",
"title": "TanStack DB for Next.js and Supabase",
"description": "TanStack Query integration with Supabase collections for type-safe database operations",
"registryDependencies": [],
"dependencies": [
"@tanstack/react-query@latest",
"github:ivasilov/supabase-collection",
"zod@latest"
],
"files": [
{
"path": "registry/default/blocks/tanstack-db/lib/query-client.ts",
"type": "registry:lib"
},
{
"path": "registry/default/blocks/tanstack-db/app/providers.tsx",
"type": "registry:component",
"target": "app/providers.tsx"
}
],
"docs": "This block provides TanStack Query integration with Supabase collections. The schemas and collections are generated based on your database schema."
}