.claude/skills/entity-system/SKILL.md
Config-driven entity system for creating automatic CRUDs. Includes: config, fields, types, service, messages (i18n), migrations. Use this skill to create, modify, or understand system entities.
npx skillsauth add NextSpark-js/nextspark entity-systemInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Config-driven system for defining entities with automatic CRUDs, similar to WordPress Custom Post Types.
┌──────────────────────────────────────────────────────────────────────────┐
│ ENTITY CONFIGURATION │
│ │
│ contents/themes/{theme}/entities/{entity}/ │
│ ├── {entity}.config.ts ← Main configuration │
│ ├── {entity}.fields.ts ← Field definitions │
│ ├── {entity}.types.ts ← TypeScript types │
│ ├── {entity}.service.ts ← Optional but recommended │
│ ├── messages/ │
│ │ ├── en.json ← English translations │
│ │ ├── es.json ← Spanish translations │
│ │ └── {locale}.json ← Additional locales if project requires │
│ └── migrations/ │
│ ├── 001_{entity}_table.sql │
│ ├── 002_{entity}_metas.sql (if metadata enabled) │
│ └── sample_data.json ← Sample data for seeding │
└──────────────────────────────────────────────────────────────────────────┘
📍 Context-Aware Paths: Entity configs go in
contents/themes/{theme}/entities/in both contexts. Core entities are read-only in consumer projects. Seecore-theme-responsibilitiesskill for complete rules.
interface EntityConfig {
// 1. BASIC IDENTIFICATION
slug: string // URL/table name (single source of truth)
enabled: boolean
names: { singular: string, plural: string }
icon: LucideIcon
// 2. ACCESS AND SCOPE
access: {
public: boolean // Accessible without auth
api: boolean // Has external API endpoints
metadata: boolean // Supports key-value metadata
shared: boolean // Shared among team members (no userId filter)
}
// 3. UI/UX FEATURES
ui: {
dashboard: {
showInMenu: boolean
showInTopbar: boolean
filters?: EntityFilterConfig[]
}
public: {
hasArchivePage: boolean
hasSinglePage: boolean
}
features: {
searchable: boolean
sortable: boolean
filterable: boolean
bulkOperations: boolean
importExport: boolean
}
}
// 4. INTERNATIONALIZATION
i18n: {
fallbackLocale: 'en' | 'es'
loaders: Record<'en' | 'es', () => Promise<object>>
}
// FIELDS (imported separately)
fields: EntityField[]
}
Note: Permissions are defined centrally in
config/permissions.config.ts, not in entity config.
type EntityFieldType =
// Basic
| 'text' | 'textarea' | 'number' | 'boolean' | 'date' | 'datetime'
| 'email' | 'url' | 'json'
// Selection
| 'select' | 'multiselect' | 'radio' | 'buttongroup' | 'tags' | 'combobox'
// Media
| 'file' | 'image' | 'video' | 'audio' | 'media-library'
// Specialized
| 'phone' | 'rating' | 'range' | 'doublerange'
| 'markdown' | 'richtext' | 'code'
| 'timezone' | 'currency' | 'country' | 'address'
// Relations
| 'relation' | 'relation-multi' | 'relation-prop' | 'relation-prop-multi'
| 'reference' | 'user'
interface EntityField {
name: string
type: EntityFieldType
required: boolean
defaultValue?: unknown
validation?: ZodSchema
display: {
label: string
description?: string
placeholder?: string
showInList: boolean
showInDetail: boolean
showInForm: boolean
order: number
columnWidth?: number // 1-12 grid
}
api: {
searchable: boolean
sortable: boolean
filterable?: boolean
readOnly: boolean
}
// For select/multiselect
options?: { value: string, label: string }[]
// For relations
relation?: {
entity: string
titleField?: string
parentId?: string
userFiltered?: boolean
}
}
From an EntityConfig, the system automatically provides:
/api/v1/{slug} with CRUD operationsServer Actions permiten ejecutar operaciones CRUD desde Client Components sin pasar por HTTP.
┌─────────────────────────────────────────────────────────────────────┐
│ SERVER ACTIONS FLOW │
│ │
│ Client Component Server Action GenericEntityService
│ ───────────────── ───────────── ────────────────────
│ │ │ │
│ │ createEntity(slug,data) │ │
│ │ ─────────────────────► │ │
│ │ │ │
│ │ ┌─────┴─────┐ │
│ │ │ 1. Auth │ │
│ │ │ (session) │ │
│ │ ├───────────┤ │
│ │ │ 2. Perms │ │
│ │ │ (registry)│ │
│ │ └─────┬─────┘ │
│ │ │ │
│ │ │ create(slug,uid,tid,data)
│ │ │ ─────────────────────► │
│ │ │ ┌────┴────┐
│ │ │ │ Validate│
│ │ │ │ Hooks │
│ │ │ │ SQL+RLS │
│ │ │ └────┬────┘
│ │ │ ◄───────────────────── │
│ │ ◄───────────────────── │ │
│ │ EntityActionResult │ │
└─────────────────────────────────────────────────────────────────────┘
| Escenario | Server Actions | API HTTP | |-----------|----------------|----------| | Client Component mutations | Recommended | No | | Server Component data fetching | No | Yes | | External integrations | No | Yes | | Webhooks | No | Yes | | Cache revalidation automática | Yes | Manual |
| Function | Required Permission | Description |
|----------|---------------------|-------------|
| createEntity(slug, data, config?) | {slug}.create | Create entity |
| updateEntity(slug, id, data, config?) | {slug}.update | Update entity |
| deleteEntity(slug, id, config?) | {slug}.delete | Delete one |
| deleteEntities(slug, ids, config?) | {slug}.delete | Delete many |
| getEntity(slug, id) | {slug}.read | Get by ID |
| listEntities(slug, options?) | {slug}.list | List with filters |
| entityExists(slug, id) | {slug}.read | Check existence |
| countEntities(slug, where?) | {slug}.list | Count records |
'use client'
import { createEntity, updateEntity, deleteEntity } from '@nextsparkjs/core/actions'
// CREATE - Auth and permissions verified automatically
async function handleCreate(data: FormData) {
const result = await createEntity('schools', {
name: data.get('name'),
status: 'active'
})
if (result.success) {
console.log('Created:', result.data)
} else {
console.error('Error:', result.error)
}
}
// UPDATE with custom revalidation
async function handleUpdate(id: string, data: Partial<School>) {
const result = await updateEntity('schools', id, data, {
revalidatePaths: ['/dashboard/overview'],
revalidateTags: ['school-stats'],
})
}
// DELETE with redirect
async function handleDelete(id: string) {
await deleteEntity('schools', id, {
redirectTo: '/dashboard/schools'
})
}
// For operations that return data
type EntityActionResult<T> =
| { success: true; data: T }
| { success: false; error: string }
// For void operations (delete)
type EntityActionVoidResult =
| { success: true }
| { success: false; error: string }
// List result
interface ListEntityResult<T> {
data: T[]
total: number
limit: number
offset: number
}
interface ActionConfig {
revalidatePaths?: string[] // Paths to revalidate
revalidateTags?: string[] // Cache tags
redirectTo?: string // Redirect after action
}
getTypedSession() (server-side)activeTeamIdpermissions.config.tsUsers can belong to multiple teams. Server Actions automatically filter by the active team cookie to prevent accidental cross-team data access:
User belongs to: TeamA, TeamB
Active team cookie: TeamA
getEntity('campaigns', 'id-from-teamB') → Returns null (filtered out)
updateEntity('campaigns', 'id-from-teamB', data) → Error: not found
deleteEntity('campaigns', 'id-from-teamB') → Error: not found
This isolation is automatic and cannot be bypassed from client code.
// NEVER: Ignore the result
await createEntity('schools', data) // Without checking success
// CORRECT: Always check result
const result = await createEntity('schools', data)
if (!result.success) {
toast.error(result.error)
}
// NEVER: Use for Server Components (no 'use client')
// Server Actions are for CLIENT Components only
// CORRECT: In Server Components use service directly
import { GenericEntityService } from '@nextsparkjs/core/services'
const data = await GenericEntityService.list('schools', userId, { teamId })
'use client'
import { listEntities } from '@nextsparkjs/core/actions'
async function loadCampaigns() {
const result = await listEntities<Campaign>('campaigns', {
where: { status: 'active' },
orderBy: 'createdAt',
orderDir: 'desc',
limit: 20,
offset: 0,
search: 'marketing', // Full-text search on searchable fields
})
if (result.success) {
const { data, total, limit, offset } = result.data
console.log(`Showing ${data.length} of ${total} campaigns`)
}
}
| File | Purpose |
|------|---------|
| core/lib/actions/entity.actions.ts | Server Actions (entry points) |
| core/lib/actions/types.ts | TypeScript types |
| core/lib/services/generic-entity.service.ts | Business logic |
| core/lib/permissions/check.ts | Permission verification |
When access.metadata: true is set in the entity config, the entity supports dynamic key-value metadata.
A separate migration creates the {entity}_metas table:
-- migrations/002_{entity}_metas.sql
CREATE TABLE IF NOT EXISTS "{entity}_metas" (
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"{entity}Id" TEXT NOT NULL REFERENCES "{entity}"(id) ON DELETE CASCADE,
"metaKey" TEXT NOT NULL,
"metaValue" JSONB NOT NULL DEFAULT '{}'::jsonb,
"dataType" TEXT, -- Optional: string, number, boolean, json
"isPublic" BOOLEAN NOT NULL DEFAULT FALSE, -- Visible without auth
"isSearchable" BOOLEAN NOT NULL DEFAULT FALSE, -- Indexed for search
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT {entity}_metas_unique_key UNIQUE ("{entity}Id", "metaKey")
);
-- Indexes
CREATE INDEX "idx_{entity}_metas_entity_id" ON "{entity}_metas"("{entity}Id");
CREATE INDEX "idx_{entity}_metas_key" ON "{entity}_metas"("metaKey");
CREATE INDEX "idx_{entity}_metas_value_gin" ON "{entity}_metas" USING GIN ("metaValue");
| Use Case | Fields | Metas | |----------|--------|-------| | Structured, validated data | ✅ | ❌ | | Shown in forms/lists | ✅ | ❌ | | Searchable/sortable | ✅ | ❌ | | Dynamic/extensible data | ❌ | ✅ | | Plugin-specific data | ❌ | ✅ | | User preferences/settings | ❌ | ✅ | | Third-party integrations | ❌ | ✅ |
Metas are accessed via query parameter (covered in entity-api skill):
GET /api/v1/products/123?metas=all
GET /api/v1/products/123?metas=category,tags
Child entities are 1:N relationships where the child only exists in the context of its parent (e.g., order items, post comments).
export const orderEntityConfig: EntityConfig = {
slug: 'orders',
// ... other config
childEntities: {
'items': {
table: 'order_items', // Convention: {parent}_{child}
showInParentView: true, // Show in parent detail view
hasOwnRoutes: false, // Only via /orders/{id}/child/items
fields: [
{
name: 'productName',
type: 'text',
required: true,
display: { label: 'Product' }
},
{
name: 'quantity',
type: 'number',
required: true,
display: { label: 'Qty' }
}
],
display: {
title: 'Order Items',
description: 'Products in this order',
mode: 'table' // 'table' | 'cards' | 'list'
}
}
}
}
interface ChildEntityDefinition {
table: string // Database table name
fields: ChildEntityField[] // Child entity fields
showInParentView: boolean // Show in parent view
hasOwnRoutes: boolean // Has independent routes?
// Note: Permissions are defined centrally in permissions.config.ts
display: {
title: string
description?: string
mode: 'table' | 'cards' | 'list'
}
}
-- migrations/002_{parent}_{child}.sql
CREATE TABLE "order_items" (
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"parentId" TEXT NOT NULL REFERENCES "orders"(id) ON DELETE CASCADE,
"productName" VARCHAR(255) NOT NULL,
"quantity" INTEGER NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX "idx_order_items_parentId" ON "order_items"("parentId");
-- RLS: Access via parent ownership
ALTER TABLE "order_items" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "order_items_via_parent" ON "order_items"
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM "orders"
WHERE "orders".id = "parentId"
AND "orders"."userId" = public.get_auth_user_id()
)
);
| Property | Required | Description |
|----------|----------|-------------|
| table | Yes | Table name. Convention: {parent}_{child} |
| fields | Yes | Child entity fields (same structure as entity fields) |
| showInParentView | Yes | Display in parent detail view |
| hasOwnRoutes | Yes | If true: also /api/v1/{child}. If false: only via parent |
| display.mode | Yes | table (rows), cards (cards), list (simple) |
Child entity endpoints (covered in entity-api skill):
GET /api/v1/orders/{id}/child/items
POST /api/v1/orders/{id}/child/items
GET /api/v1/orders/{id}/child/items/{itemId}
PATCH /api/v1/orders/{id}/child/items/{itemId}
DELETE /api/v1/orders/{id}/child/items/{itemId}
python .claude/skills/entity-system/scripts/scaffold-entity.py --entity products --theme default
python .claude/skills/entity-system/scripts/generate-migration.py --entity products --theme default
python .claude/skills/entity-system/scripts/generate-metas-migration.py --entity products --theme default
python .claude/skills/entity-system/scripts/generate-child-migration.py --parent orders --child items --theme default
python .claude/skills/entity-system/scripts/generate-sample-data.py --entity products --count 10
| File | Purpose |
|------|---------|
| core/lib/entities/types.ts | All entity type definitions |
| core/lib/entities/migration-helper.ts | Migration SQL generation |
| core/lib/entities/registry.ts | Entity registry (auto-generated) |
| core/lib/entities/queries.ts | Database query helpers |
| core/lib/entities/helpers.ts | Utility functions |
tasks, blog-posts)tasks, blog_posts)/api/v1/{slug}{singular}.config.ts (task.config.ts){slug}.fields.ts (tasks.fields.ts)// ❌ NEVER: Add system fields manually
fields: [
{ name: 'id', type: 'text', ... }, // Auto-included
{ name: 'teamId', type: 'text', ... }, // Auto-included (team-mode entities)
{ name: 'createdAt', type: 'datetime' }, // Auto-included
{ name: 'updatedAt', type: 'datetime' }, // Auto-included
]
// ❌ NEVER: Use dynamic imports for entity configs
const config = await import(`@/contents/entities/${slug}`)
// ❌ NEVER: Define tableName explicitly (derived from slug)
tableName: 'my_custom_table'
// ✅ CORRECT: Only declare business fields
fields: [
{ name: 'title', type: 'text', ... },
{ name: 'status', type: 'select', ... },
]
{entity}.config.ts with all 5 sections{entity}.fields.ts with field definitions{entity}.types.ts with TypeScript interfacesmessages/ with required locales (en.json, es.json, + others if project defines)migrations/001_{entity}_table.sql_metas migration if access.metadata: truenode core/scripts/build/registry.mjsreferences/entity-types.md for complete type definitionsreferences/field-examples.md for field configuration examplesreferences/migration-patterns.md for SQL patternsdevelopment
Zod validation patterns for this Next.js application. Covers schema definition, API validation, form integration, error formatting, and type inference. Use this skill when implementing validation for APIs, forms, or entity schemas.
development
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
testing
Test coverage metrics and registry system for this Next.js application. Covers FEATURE_REGISTRY, FLOW_REGISTRY, TAGS_REGISTRY, and coverage metrics interpretation. Use this skill when evaluating test coverage, identifying gaps, or planning testing priorities.
development
TanStack Query (React Query) patterns for data fetching in this Next.js application. Covers useQuery, useMutation, optimistic updates, cache invalidation, and anti-patterns. Use this skill when implementing data fetching or state management with server data.