nuxt-auto-crud 1.24.0 → 1.25.0

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,373 +1,43 @@
1
1
  # Nuxt Auto CRUD
2
2
 
3
- > **Note:** This module is production-ready and actively maintained. The core CRUD API is stable. Minor breaking changes may occasionally occur in advanced features or configuration. We recommend version pinning for production deployments.
3
+ **Nuxt Auto CRUD is a headless, zero-codegen CRUD engine that transforms Drizzle ORM schemas into fully functional RESTful APIs for Nuxt 4.**
4
4
 
5
- Auto-expose RESTful CRUD APIs for your **Nuxt** application based solely on your database schema. Minimal configuration required.
5
+ | Specification | Details |
6
+ | :--- | :--- |
7
+ | **Runtime** | Nuxt 4 (`app/` directory), Nitro |
8
+ | **Persistence** | SQLite / libSQL (Optimized for Cloudflare D1) |
9
+ | **ORM & SSOT** | Drizzle ORM (Schema-driven) |
10
+ | **Validation** | `drizzle-zod` (Dynamic derivation) |
6
11
 
7
- **Core Philosophy:**
8
- The main objective of this module is to **expose CRUD APIs without the need for writing code**. You define your database schema, and `nuxt-auto-crud` handles the rest.
12
+ ## 🛠 Architectural Logic: Zero-Codegen
13
+ NAC treats your Drizzle schema as the **Single Source of Truth (SSOT)**. Unlike traditional scaffolds, it does not generate physical files; it mounts dynamic Nitro handlers at runtime.
9
14
 
10
- You don't need to setup an extra server or database to create an MVP of an application. The Nuxt (Nitro) server and SQLite can save you time and money.
11
- And you don't need a separate Strapi or Supabase setup to automate your CRUD process. `nuxt-auto-crud` will help you with that and accelerate your development exponentially.
15
+ * **Dynamic Routing**: Automatically maps `GET|POST|PATCH|DELETE` to your Drizzle tables.
16
+ * **Agentic Compatibility**: Built with an MCP-friendly structure to allow AI Agents to interact directly with the schema-driven API.
12
17
 
13
- While this module exposes CRUD APIs, you are expected to build your own frontend application to consume them.
18
+ ## 🔐 RBAC & Permissions
19
+ Integrates with `nuxt-authorization` for database-driven Role-Based Access Control.
20
+ * **Ownership Logic**: Supports `update_own` and `delete_own` via `createdBy` column reflection.
21
+ * **Granular Scopes**: Fine-grained control over `list` vs `list_all` (drafts/soft-deleted).
14
22
 
15
- - [✨ Release Notes](/CHANGELOG.md)
16
- - [🗺️ Roadmap](/ROADMAP.md)
17
- - [🎮 Try the Playground](/playground)
23
+ ## 🌐 Endpoints
24
+ | Method | Endpoint | Description |
25
+ | :--- | :--- | :--- |
26
+ | `GET` | `/api/:model` | List with filtering/paging |
27
+ | `POST` | `/api/:model` | Validated creation |
28
+ | `GET` | `/api/:model/:id` | Single record retrieval |
29
+ | `PATCH` | `/api/:model/:id` | Partial validated update |
30
+ | `DELETE` | `/api/:model/:id` | Soft/Hard deletion |
18
31
 
19
- ## 🚀 CRUD APIs are ready to use without code
32
+ ---
20
33
 
21
- Once installed, your database tables' CRUD APIs are exposed in a controlled manner:
34
+ ## Installation
35
+ It is highly recommended to use the [Template](https://auto-crud.clifland.in/docs/auto-crud) for new installations.
22
36
 
23
- - `GET /api/:model` - List all records
24
- - `POST /api/:model` - Create a new record
25
- - `GET /api/:model/:id` - Get record by ID
26
- - `PATCH /api/:model/:id` - Update record
27
- - `DELETE /api/:model/:id` - Delete record
37
+ If you are adding it to an existing application, refer to the [Manual Installation](https://auto-crud.clifland.in/docs/manual-installation) guide.
28
38
 
29
- ## 📦 How to install
39
+ [YouTube Walkthrough](https://www.youtube.com/watch?v=_o0cddJUU50&list=PLnbvxcojhIixqM1J08Tnm7vmMdx2wsy4B)
30
40
 
31
- ### 1. Fullstack Template (Recommended)
41
+ [NPM Package](https://www.npmjs.com/package/nuxt-auto-crud)
32
42
 
33
- Start a new project with everything pre-configured using our template:
34
-
35
- ```bash
36
- npx nuxi init -t gh:clifordpereira/nuxt-auto-crud_template <project-name>
37
- cd <project-name>
38
- bun install
39
- bun db:generate
40
- bun run dev
41
- ```
42
-
43
- **Template Usage Modes:**
44
-
45
- 1. **Fullstack App**: The template includes the `nuxt-auto-crud` module, providing both the backend APIs and the frontend UI. [Watch Demo](https://youtu.be/M9-koXmhB9k)
46
- 2. **Frontend Only**: You can use the template just for the frontend. In this case, you don't need to install the module in the frontend app. Instead, you would install `nuxt-auto-crud` in a separate backend setup (e.g., another Nuxt project acting as the API).
47
-
48
- Detailed instructions can be found in [https://auto-crud.clifland.in/docs/auto-crud](https://auto-crud.clifland.in/docs/auto-crud)
49
-
50
- ### 2. Manual Setup (Existing Project)
51
-
52
- If you want to add `nuxt-auto-crud` to an existing project, follow these steps:
53
-
54
- > **Note:** These instructions have been simplified for NuxtHub.
55
-
56
- #### Install dependencies
57
-
58
- ```bash
59
- npm install nuxt-auto-crud @nuxthub/core@^0.10.0 drizzle-orm
60
- npm install nuxt-auth-utils nuxt-authorization # Optional: for authentication
61
- npm install --save-dev wrangler drizzle-kit
62
- ```
63
-
64
- > You can also use `bun` or `pnpm` instead of `npm`.
65
-
66
- #### Configure Nuxt
67
-
68
- Add the modules to your `nuxt.config.ts`:
69
-
70
- ```typescript
71
- // nuxt.config.ts
72
- export default defineNuxtConfig({
73
- modules: ['@nuxthub/core', 'nuxt-auto-crud'],
74
-
75
- hub: {
76
- db: 'sqlite',
77
- },
78
-
79
- autoCrud: {
80
- schemaPath: 'server/db/schema',
81
- // auth: false,
82
- auth: {
83
- type: 'session', // for Normal Authentication with nuxt-auth-utils
84
- authentication: true,
85
- authorization: true,
86
- },
87
- },
88
- })
89
- ```
90
-
91
- #### Configure Drizzle
92
-
93
- Add the generation script to your `package.json`:
94
-
95
- ```json
96
- {
97
- "scripts": {
98
- "db:generate": "nuxt db generate"
99
- }
100
- // ...
101
- }
102
- ```
103
-
104
- Create `drizzle.config.ts` in your project root:
105
-
106
- ```typescript
107
- // drizzle.config.ts
108
- import { defineConfig } from 'drizzle-kit'
109
-
110
- export default defineConfig({
111
- dialect: 'sqlite',
112
- schema: './server/db/schema/index.ts', // Point to your schema index file
113
- out: './server/db/migrations'
114
- })
115
- ```
116
-
117
-
118
-
119
- #### Define your database schema
120
-
121
- Create your schema files in `server/db/schema/`. For example, `server/db/schema/users.ts`:
122
-
123
- ```typescript
124
- // server/db/schema/users.ts
125
- import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
126
-
127
- export const users = sqliteTable('users', {
128
- id: integer('id').primaryKey({ autoIncrement: true }),
129
- name: text('name').notNull(),
130
- email: text('email').notNull().unique(),
131
- password: text('password').notNull(),
132
- avatar: text('avatar').notNull(),
133
- createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
134
- })
135
- ```
136
- #### Run the project
137
-
138
- ```bash
139
- cd <project-name>
140
- bun db:generate
141
- bun run dev
142
- ```
143
-
144
- That's it! 🎉 Your CRUD APIs are now available at `/api/users`.
145
-
146
-
147
- #### Adding New Schemas
148
-
149
- To add a new table (e.g., `posts`), simply create a new file in your schema directory:
150
-
151
- ```typescript
152
- // server/db/schema/posts.ts
153
- import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
154
- import { users } from './users'
155
-
156
- export const posts = sqliteTable('posts', {
157
- id: integer('id').primaryKey({ autoIncrement: true }),
158
- title: text('title').notNull(),
159
- content: text('content').notNull(),
160
- authorId: integer('author_id').references(() => users.id),
161
- createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
162
- })
163
- ```
164
-
165
- Then, ensure it is exported in your `server/db/schema/index.ts` (if you are using an index file) or that your `drizzle.config.ts` is pointing to the correct location.
166
-
167
- ```typescript
168
- // server/db/schema/index.ts
169
- export * from './users'
170
- export * from './posts'
171
- ```
172
-
173
- After adding the file, run the generation script:
174
-
175
- ```bash
176
- bun db:generate
177
- ```
178
-
179
- The new API endpoints (e.g., `/api/posts`) will be automatically available. [Watch Demo](https://youtu.be/7gW0KW1KtN0)
180
-
181
-
182
- > **Note:** The `organization.ts` and `cms.ts` files you might see in the playground are just examples and are commented out by default. You should implement a robust schema tailored to your production needs.
183
-
184
- ### 3. Backend-only App (API Mode)
185
-
186
- If you are using Nuxt as a backend for a separate client application (e.g., mobile app, SPA), you can use this module to quickly expose REST APIs.
187
-
188
- In this case, you might handle authentication differently (e.g., validating tokens in middleware) or disable the built-in auth checks if you have a global auth middleware.
189
-
190
- ```typescript
191
- // nuxt.config.ts
192
- export default defineNuxtConfig({
193
- modules: ['nuxt-auto-crud'],
194
- autoCrud: {
195
- schemaPath: 'server/db/schema',
196
- // auth: false, // Uncomment this line for testing APIs without auth
197
- auth: {
198
- type: 'jwt', // for app providing backend apis only
199
- authentication: true,
200
- authorization: true,
201
- jwtSecret: process.env.NUXT_JWT_SECRET || 'test-secret-key-123',
202
- },
203
- },
204
- })
205
- ```
206
-
207
- **Note:** Remember to add your `NUXT_JWT_SECRET` in `.env`.
208
-
209
- You should also configure `drizzle.config.ts` correctly:
210
-
211
- ```typescript
212
- // drizzle.config.ts
213
- import { defineConfig } from 'drizzle-kit'
214
-
215
- export default defineConfig({
216
- dialect: 'sqlite',
217
- schema: './server/db/schema/index.ts',
218
- out: './server/db/migrations',
219
- tablesFilter: ['!_hub_migrations'],
220
- })
221
- ```
222
-
223
- ## 🔐 Authentication
224
-
225
- Authentication is enabled by default using **Session Auth** (requires `nuxt-auth-utils` and `nuxt-authorization`).
226
-
227
- To disable auth for testing:
228
- ```typescript
229
- autoCrud: { auth: false }
230
- ```
231
-
232
- For **JWT Auth** (backend-only apps) or advanced configuration, see the [Authentication docs](https://auto-crud.clifland.in/docs/configuration/authentication).
233
-
234
-
235
-
236
- ## 🛡️ Public View Configuration (Field Visibility)
237
-
238
- You can define which fields are visible to unauthenticated (guest) users in your `nuxt.config.ts`.
239
- > **Note:** Access control (RBAC) - determining *who* can access *what* - is expected to be handled by your database/permissions system (using `nuxt-authorization`). This configuration only controls the *serialization* of the response for guests.
240
-
241
- ```typescript
242
- // nuxt.config.ts
243
- export default defineNuxtConfig({
244
- autoCrud: {
245
- resources: {
246
- // Guest View: Only these columns are visible to unauthenticated users.
247
- // Access control (who can list/read) is managed by your DB permissions.
248
- users: ['id', 'name', 'avatar'],
249
- },
250
- },
251
- })
252
- ```
253
-
254
-
255
- ## 👤 Owner-based Permissions (RBAC)
256
-
257
- In addition to standard `create`, `read`, `update`, and `delete` permissions, you can assign **Ownership Permissions**:
258
-
259
- - `list`: Allows a user to view a list of active records (status='active').
260
- - `list_all`: Allows a user to view **all** records, including inactive ones (e.g., status='inactive', 'draft').
261
- - `update_own`: Allows a user to update a record **only if they created it**.
262
- - `delete_own`: Allows a user to delete a record **only if they created it**.
263
-
264
- **How it works:**
265
- The module checks for ownership using the following logic:
266
- 1. **Standard Tables:** Checks if the record has a `createdBy` (or `userId`) column that matches the logged-in user's ID.
267
- 2. **Users Table:** Checks if the record being accessed is the user's own profile (`id` matches).
268
-
269
- **Prerequisites:**
270
- Ensure your schema includes a `createdBy` field for resources where you want this behavior:
271
-
272
- ```typescript
273
- export const posts = sqliteTable('posts', {
274
- // ...
275
- createdBy: integer('created_by'), // Recommended
276
- })
277
- ```
278
-
279
- ## 🎮 Try the Playground
280
-
281
- Want to see it in action? Clone this repo and try the playground:
282
-
283
- ```bash
284
- # Clone the repository
285
- git clone https://github.com/clifordpereira/nuxt-auto-crud.git
286
- cd nuxt-auto-crud
287
-
288
- # Install dependencies (parent folder)
289
- bun install
290
-
291
- # Run the playground (Fullstack)
292
- cd playground
293
- bun install
294
- bun db:generate
295
- bun run dev
296
- ```
297
-
298
- ## 📖 Usage Examples
299
-
300
- ### Create a Record
301
-
302
- ```typescript
303
- const user = await $fetch("/api/users", {
304
- method: "POST",
305
- body: {
306
- name: "Cliford Pereira",
307
- email: "clifordpereira@gmail.com",
308
- bio: "Full-Stack Developer",
309
- },
310
- });
311
- ```
312
-
313
- ### Get All Records
314
-
315
- ```typescript
316
- const users = await $fetch("/api/users");
317
- ```
318
-
319
- ### Get Record by ID
320
-
321
- ```typescript
322
- const user = await $fetch("/api/users/1");
323
- ```
324
-
325
- ### Update a Record
326
-
327
- ```typescript
328
- const updated = await $fetch("/api/users/1", {
329
- method: "PATCH",
330
- body: {
331
- bio: "Updated bio",
332
- },
333
- });
334
- ```
335
-
336
- ### Delete a Record
337
-
338
- ```typescript
339
- await $fetch("/api/users/1", {
340
- method: "DELETE",
341
- });
342
- ```
343
-
344
- > **Note:** If authentication is enabled (default):
345
- > - **Fullstack App:** The module integrates with `nuxt-auth-utils`, so session cookies are handled automatically.
346
- > - **Backend-only App:** You must include the `Authorization: Bearer <token>` header in your requests.
347
-
348
-
349
-
350
- ## 🔧 Requirements
351
-
352
- - Nuxt 3 or 4
353
- - Drizzle ORM (SQLite)
354
- - NuxtHub >= 0.10.0
355
-
356
- ## 🔗 Links
357
-
358
- - **📚 Documentation:** [auto-crud.clifland.in](https://auto-crud.clifland.in/docs/auto-crud)
359
- - **🎬 Video Tutorials:** [YouTube Channel](https://www.youtube.com/@ClifordPereira)
360
- - **💬 Community:** [Discord](https://discord.gg/FBkQQfRFJM) • [GitHub Discussions](https://github.com/clifordpereira/nuxt-auto-crud/discussions/1)
361
- - **📦 npm:** [nuxt-auto-crud](https://www.npmjs.com/package/nuxt-auto-crud)
362
-
363
- ## 🤝 Contributing
364
-
365
- Contributions are welcome! Please check out the [contribution guide](/CONTRIBUTING.md).
366
-
367
- ## 📝 License
368
-
369
- [MIT License](./LICENSE)
370
-
371
- ## 👨‍💻 Author
372
-
373
- Made with ❤️ by [Cliford Pereira](https://github.com/clifordpereira)
43
+ [Creator: Clifland](https://www.clifland.in/)
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-auto-crud",
3
3
  "configKey": "autoCrud",
4
- "version": "1.24.0",
4
+ "version": "1.25.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -67,6 +67,11 @@ const module$1 = defineNuxtModule({
67
67
  method: "get",
68
68
  handler: resolver.resolve(apiDir, "_relations.get")
69
69
  });
70
+ addServerHandler({
71
+ route: "/api/_meta",
72
+ method: "get",
73
+ handler: resolver.resolve(apiDir, "_meta.get")
74
+ });
70
75
  addServerHandler({
71
76
  route: "/api/:model",
72
77
  method: "get",
@@ -1,7 +1,7 @@
1
1
  import { eventHandler, getRouterParams, readBody } from "h3";
2
2
  import { getUserSession } from "#imports";
3
3
  import { eq } from "drizzle-orm";
4
- import { getTableForModel, filterUpdatableFields } from "../../utils/modelMapper.js";
4
+ import { getTableForModel, getZodSchema } from "../../utils/modelMapper.js";
5
5
  import { db } from "hub:db";
6
6
  import { ensureResourceAccess, formatResourceResult, hashPayloadFields } from "../../utils/handler.js";
7
7
  import { RecordNotFoundError } from "../../exceptions.js";
@@ -10,7 +10,8 @@ export default eventHandler(async (event) => {
10
10
  const isAdmin = await ensureResourceAccess(event, model, "update", { id });
11
11
  const table = getTableForModel(model);
12
12
  const body = await readBody(event);
13
- const payload = filterUpdatableFields(model, body);
13
+ const schema = getZodSchema(model, "patch");
14
+ const payload = await schema.parseAsync(body);
14
15
  if ("status" in payload) {
15
16
  const { checkAdminAccess } = await import("../../utils/auth.js");
16
17
  const hasStatusPermission = await checkAdminAccess(event, model, "update_status", { id });
@@ -1,6 +1,6 @@
1
1
  import { eventHandler, getRouterParams, readBody } from "h3";
2
2
  import { getUserSession } from "#imports";
3
- import { getTableForModel, filterUpdatableFields } from "../../utils/modelMapper.js";
3
+ import { getTableForModel, getZodSchema } from "../../utils/modelMapper.js";
4
4
  import { db } from "hub:db";
5
5
  import { ensureResourceAccess, formatResourceResult, hashPayloadFields } from "../../utils/handler.js";
6
6
  export default eventHandler(async (event) => {
@@ -8,7 +8,8 @@ export default eventHandler(async (event) => {
8
8
  const isAdmin = await ensureResourceAccess(event, model, "create");
9
9
  const table = getTableForModel(model);
10
10
  const body = await readBody(event);
11
- const payload = filterUpdatableFields(model, body);
11
+ const schema = getZodSchema(model, "insert");
12
+ const payload = await schema.parseAsync(body);
12
13
  if ("status" in payload) {
13
14
  const { checkAdminAccess } = await import("../../utils/auth.js");
14
15
  const hasStatusPermission = await checkAdminAccess(event, model, "update_status");
@@ -0,0 +1,19 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
+ architecture: string;
3
+ version: string;
4
+ resources: ({
5
+ resource: string;
6
+ endpoint: string;
7
+ labelField: string;
8
+ fields: {
9
+ name: string;
10
+ type: any;
11
+ required: boolean;
12
+ isEnum: boolean;
13
+ options: any;
14
+ references: any;
15
+ isRelation: boolean;
16
+ }[];
17
+ } | null)[];
18
+ }>>;
19
+ export default _default;
@@ -0,0 +1,55 @@
1
+ import { eventHandler } from "h3";
2
+ import { getTableForModel, getAvailableModels } from "../utils/modelMapper.js";
3
+ import { getTableColumns as getDrizzleTableColumns } from "drizzle-orm";
4
+ import { getTableConfig } from "drizzle-orm/sqlite-core";
5
+ import { PROTECTED_FIELDS, HIDDEN_FIELDS } from "../utils/constants.js";
6
+ import { db } from "hub:db";
7
+ import { ensureAuthenticated } from "../utils/auth.js";
8
+ export default eventHandler(async (event) => {
9
+ await ensureAuthenticated(event);
10
+ const models = getAvailableModels().length > 0 ? getAvailableModels() : Object.keys(db?.query || {});
11
+ const resources = models.map((model) => {
12
+ try {
13
+ const table = getTableForModel(model);
14
+ const columns = getDrizzleTableColumns(table);
15
+ const config = getTableConfig(table);
16
+ const fields = Object.entries(columns).filter(([name]) => !PROTECTED_FIELDS.includes(name) && !HIDDEN_FIELDS.includes(name)).map(([name, col]) => {
17
+ let references = null;
18
+ const fk = config?.foreignKeys.find(
19
+ (f) => f.reference().columns[0].name === col.name
20
+ );
21
+ if (fk) {
22
+ references = fk.reference().foreignTable[Symbol.for("drizzle:Name")];
23
+ } else if (col.referenceConfig?.foreignTable) {
24
+ const foreignTable = col.referenceConfig.foreignTable;
25
+ references = foreignTable[Symbol.for("drizzle:Name")] || foreignTable.name;
26
+ }
27
+ const semanticType = col.columnType.toLowerCase().replace("sqlite", "");
28
+ return {
29
+ name,
30
+ type: semanticType,
31
+ required: col.notNull || false,
32
+ isEnum: !!col.enumValues,
33
+ options: col.enumValues || null,
34
+ references,
35
+ isRelation: !!references
36
+ };
37
+ });
38
+ const fieldNames = fields.map((f) => f.name);
39
+ const labelField = fieldNames.find((n) => n === "name") || fieldNames.find((n) => n === "title") || fieldNames.find((n) => n === "email") || "id";
40
+ return {
41
+ resource: model,
42
+ endpoint: `/api/${model}`,
43
+ labelField,
44
+ fields
45
+ };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }).filter(Boolean);
50
+ return {
51
+ architecture: "Clifland-NAC",
52
+ version: "1.0.0-agentic",
53
+ resources
54
+ };
55
+ });
@@ -1,5 +1,6 @@
1
1
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
2
  resource: string;
3
+ labelField: string;
3
4
  fields: import("../../utils/schema.js").Field[];
4
5
  }>>;
5
6
  export default _default;
@@ -0,0 +1,2 @@
1
+ export declare const PROTECTED_FIELDS: string[];
2
+ export declare const HIDDEN_FIELDS: string[];
@@ -0,0 +1,36 @@
1
+ export const PROTECTED_FIELDS = [
2
+ "id",
3
+ "createdAt",
4
+ "updatedAt",
5
+ "deletedAt",
6
+ "createdBy",
7
+ "updatedBy",
8
+ "deletedBy",
9
+ "created_at",
10
+ "updated_at",
11
+ "deleted_at",
12
+ "created_by",
13
+ "updated_by",
14
+ "deleted_by"
15
+ ];
16
+ export const HIDDEN_FIELDS = [
17
+ // Sensitive Auth
18
+ "password",
19
+ "resetToken",
20
+ "reset_token",
21
+ "resetExpires",
22
+ "reset_expires",
23
+ "githubId",
24
+ "github_id",
25
+ "googleId",
26
+ "google_id",
27
+ "secret",
28
+ "token",
29
+ // System Fields (Leakage prevention)
30
+ "deletedAt",
31
+ "createdBy",
32
+ "updatedBy",
33
+ "deleted_at",
34
+ "created_by",
35
+ "updated_by"
36
+ ];
@@ -1,86 +1,28 @@
1
1
  import type { SQLiteTable } from 'drizzle-orm/sqlite-core';
2
- /**
3
- * Custom updatable fields configuration (optional)
4
- * Only define here if you want to override the auto-detection
5
- *
6
- * Example:
7
- * export const customUpdatableFields: Record<string, string[]> = {
8
- * users: ['name', 'avatar'], // Only these fields can be updated
9
- * }
10
- */
2
+ import type { z } from 'zod';
11
3
  export declare const customUpdatableFields: Record<string, string[]>;
12
- /**
13
- * Custom hidden fields configuration (optional)
14
- * Only define here if you want to override the default hidden fields
15
- */
16
4
  export declare const customHiddenFields: Record<string, string[]>;
17
- /**
18
- * Auto-generated model table map
19
- * Automatically includes all tables from schema
20
- */
21
5
  export declare const modelTableMap: Record<string, unknown>;
22
6
  /**
23
- * Gets the table for a given model name
24
- * @param modelName - The name of the model (e.g., 'users', 'products')
25
- * @returns The corresponding database table
26
- * @throws Error if model is not found
7
+ * @throws 404 if modelName is not found in tableMap.
27
8
  */
28
9
  export declare function getTableForModel(modelName: string): SQLiteTable;
29
- /**
30
- * Gets the updatable fields for a model
31
- * @param modelName - The name of the model
32
- * @returns Array of field names that can be updated
33
- */
34
10
  export declare function getUpdatableFields(modelName: string): string[];
35
11
  /**
36
- * Filters an object to only include updatable fields for a model
37
- * @param modelName - The name of the model
38
- * @param data - The data object to filter
39
- * @returns Filtered object with only updatable fields
12
+ * Filters and coerces data for updates, handling timestamp conversion.
40
13
  */
41
14
  export declare function filterUpdatableFields(modelName: string, data: Record<string, unknown>): Record<string, unknown>;
42
- /**
43
- * Gets the singular name for a model (for error messages)
44
- * Uses pluralize library for accurate singular/plural conversion
45
- * @param modelName - The plural model name
46
- * @returns The singular name in PascalCase
47
- */
48
15
  export declare function getModelSingularName(modelName: string): string;
49
- /**
50
- * Gets the plural name for a model
51
- * @param modelName - The model name (singular or plural)
52
- * @returns The plural name
53
- * @return The plural name
54
- */
55
16
  export declare function getModelPluralName(modelName: string): string;
56
- /**
57
- * Lists all available models
58
- * @returns Array of model names
59
- */
60
17
  export declare function getAvailableModels(): string[];
61
- /**
62
- * Gets the hidden fields for a model
63
- * @param modelName - The name of the model
64
- * @returns Array of field names that should be hidden
65
- */
66
18
  export declare function getHiddenFields(modelName: string): string[];
67
- /**
68
- * Gets the public columns for a model
69
- * @param modelName - The name of the model
70
- * @returns Array of field names that are public (or undefined if all are public)
71
- */
72
19
  export declare function getPublicColumns(modelName: string): string[] | undefined;
73
20
  /**
74
- * Filters an object to only include public columns (if configured)
75
- * @param modelName - The name of the model
76
- * @param data - The data object to filter
77
- * @returns Filtered object
21
+ * Restricts payload to runtimeConfig resource whitelist and filters hidden fields.
78
22
  */
79
23
  export declare function filterPublicColumns(modelName: string, data: Record<string, unknown>): Record<string, unknown>;
24
+ export declare function filterHiddenFields(modelName: string, data: Record<string, unknown>): Record<string, unknown>;
80
25
  /**
81
- * Filters an object to exclude hidden fields
82
- * @param modelName - The name of the model
83
- * @param data - The data object to filter
84
- * @returns Filtered object without hidden fields
26
+ * Generates Zod schema via drizzle-zod, omitting server-managed and protected fields.
85
27
  */
86
- export declare function filterHiddenFields(modelName: string, data: Record<string, unknown>): Record<string, unknown>;
28
+ export declare function getZodSchema(modelName: string, type?: 'insert' | 'patch'): z.ZodObject<any, any>;
@@ -4,15 +4,10 @@ import { pascalCase } from "scule";
4
4
  import { getTableColumns as getDrizzleTableColumns, getTableName } from "drizzle-orm";
5
5
  import { createError } from "h3";
6
6
  import { useRuntimeConfig } from "#imports";
7
- const PROTECTED_FIELDS = ["id", "created_at", "updated_at", "createdAt", "updatedAt"];
8
- const HIDDEN_FIELDS = ["password", "secret", "token"];
9
- export const customUpdatableFields = {
10
- // Add custom field restrictions here if needed
11
- // By default, all fields except PROTECTED_FIELDS are updatable
12
- };
13
- export const customHiddenFields = {
14
- // Add custom hidden fields here if needed
15
- };
7
+ import { createInsertSchema } from "drizzle-zod";
8
+ import { PROTECTED_FIELDS, HIDDEN_FIELDS } from "./constants.js";
9
+ export const customUpdatableFields = {};
10
+ export const customHiddenFields = {};
16
11
  function buildModelTableMap() {
17
12
  const tableMap = {};
18
13
  for (const [key, value] of Object.entries(schema)) {
@@ -56,7 +51,7 @@ export function getUpdatableFields(modelName) {
56
51
  const table = modelTableMap[modelName];
57
52
  if (!table) return [];
58
53
  const allColumns = getTableColumns(table);
59
- return allColumns.filter((col) => !PROTECTED_FIELDS.includes(col));
54
+ return allColumns.filter((col) => !PROTECTED_FIELDS.includes(col) && !HIDDEN_FIELDS.includes(col));
60
55
  }
61
56
  export function filterUpdatableFields(modelName, data) {
62
57
  const allowedFields = getUpdatableFields(modelName);
@@ -86,10 +81,7 @@ export function getAvailableModels() {
86
81
  return Object.keys(modelTableMap);
87
82
  }
88
83
  export function getHiddenFields(modelName) {
89
- if (customHiddenFields[modelName]) {
90
- return customHiddenFields[modelName];
91
- }
92
- return HIDDEN_FIELDS;
84
+ return customHiddenFields[modelName] ?? HIDDEN_FIELDS;
93
85
  }
94
86
  export function getPublicColumns(modelName) {
95
87
  const { resources } = useRuntimeConfig().autoCrud;
@@ -101,8 +93,9 @@ export function filterPublicColumns(modelName, data) {
101
93
  return filterHiddenFields(modelName, data);
102
94
  }
103
95
  const filtered = {};
96
+ const hidden = getHiddenFields(modelName);
104
97
  for (const [key, value] of Object.entries(data)) {
105
- if (publicColumns.includes(key) && !getHiddenFields(modelName).includes(key)) {
98
+ if (publicColumns.includes(key) && !hidden.includes(key)) {
106
99
  filtered[key] = value;
107
100
  }
108
101
  }
@@ -118,3 +111,22 @@ export function filterHiddenFields(modelName, data) {
118
111
  }
119
112
  return filtered;
120
113
  }
114
+ export function getZodSchema(modelName, type = "insert") {
115
+ const table = getTableForModel(modelName);
116
+ const schema2 = createInsertSchema(table);
117
+ if (type === "patch") {
118
+ return schema2.partial();
119
+ }
120
+ const OMIT_ON_CREATE = [
121
+ ...PROTECTED_FIELDS,
122
+ ...HIDDEN_FIELDS
123
+ ];
124
+ const columns = getDrizzleTableColumns(table);
125
+ const fieldsToOmit = {};
126
+ OMIT_ON_CREATE.forEach((field) => {
127
+ if (columns[field]) {
128
+ fieldsToOmit[field] = true;
129
+ }
130
+ });
131
+ return schema2.omit(fieldsToOmit);
132
+ }
@@ -7,11 +7,13 @@ export interface Field {
7
7
  }
8
8
  export declare function drizzleTableToFields(table: any, resourceName: string): {
9
9
  resource: string;
10
+ labelField: string;
10
11
  fields: Field[];
11
12
  };
12
13
  export declare function getRelations(): Promise<Record<string, Record<string, string>>>;
13
14
  export declare function getAllSchemas(): Promise<Record<string, any>>;
14
15
  export declare function getSchema(tableName: string): Promise<{
15
16
  resource: string;
17
+ labelField: string;
16
18
  fields: Field[];
17
19
  } | undefined>;
@@ -15,6 +15,8 @@ export function drizzleTableToFields(table, resourceName) {
15
15
  selectOptions
16
16
  });
17
17
  }
18
+ const fieldNames = fields.map((f) => f.name);
19
+ const labelField = fieldNames.find((n) => n === "name") || fieldNames.find((n) => n === "title") || fieldNames.find((n) => n === "email") || "id";
18
20
  try {
19
21
  const config = getTableConfig(table);
20
22
  config.foreignKeys.forEach((fk) => {
@@ -32,6 +34,8 @@ export function drizzleTableToFields(table, resourceName) {
32
34
  }
33
35
  return {
34
36
  resource: resourceName,
37
+ labelField,
38
+ // metadata point
35
39
  fields
36
40
  };
37
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-auto-crud",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "description": "Exposes RESTful CRUD APIs for your Nuxt app based solely on your database migrations.",
5
5
  "author": "Cliford Pereira",
6
6
  "license": "MIT",
@@ -51,6 +51,7 @@
51
51
  "@nuxt/scripts": "^0.13.0",
52
52
  "@types/pluralize": "^0.0.33",
53
53
  "c12": "^2.0.1",
54
+ "drizzle-zod": "^0.8.3",
54
55
  "jose": "^5.9.6",
55
56
  "pluralize": "^8.0.0",
56
57
  "scule": "^1.0.0"
@@ -78,7 +79,7 @@
78
79
  "drizzle-orm": "^0.38.3",
79
80
  "eslint": "^9.39.1",
80
81
  "nuxt": "^4.2.1",
81
- "nuxt-auth-utils": "^0.5.26",
82
+ "nuxt-auth-utils": "^0.5.27",
82
83
  "nuxt-authorization": "^0.3.5",
83
84
  "typescript": "~5.9.3",
84
85
  "vitest": "^4.0.13",
@@ -4,7 +4,7 @@ import type { H3Event } from 'h3'
4
4
  // @ts-expect-error - #imports is a virtual alias
5
5
  import { getUserSession } from '#imports'
6
6
  import { eq } from 'drizzle-orm'
7
- import { getTableForModel, filterUpdatableFields } from '../../utils/modelMapper'
7
+ import { getTableForModel, getZodSchema } from '../../utils/modelMapper'
8
8
  import type { TableWithId } from '../../types'
9
9
 
10
10
  // @ts-expect-error - hub:db is a virtual alias
@@ -20,7 +20,8 @@ export default eventHandler(async (event) => {
20
20
  const table = getTableForModel(model) as TableWithId
21
21
 
22
22
  const body = await readBody(event)
23
- const payload = filterUpdatableFields(model, body)
23
+ const schema = getZodSchema(model, 'patch')
24
+ const payload = await schema.parseAsync(body)
24
25
 
25
26
  // Custom check for status update permission
26
27
  if ('status' in payload) {
@@ -1,10 +1,10 @@
1
1
  // server/api/[model]/index.post.ts
2
2
  import { eventHandler, getRouterParams, readBody } from 'h3'
3
3
  import type { H3Event } from 'h3'
4
- // @ts-expect-error - #imports is a virtual alias
4
+ // @ts-expect-error - '#imports' is a virtual alias
5
5
  import { getUserSession } from '#imports'
6
- import { getTableForModel, filterUpdatableFields } from '../../utils/modelMapper'
7
- // @ts-expect-error - hub:db is a virtual alias
6
+ import { getTableForModel, getZodSchema } from '../../utils/modelMapper'
7
+ // @ts-expect-error - 'hub:db' is a virtual alias
8
8
  import { db } from 'hub:db'
9
9
  import { ensureResourceAccess, formatResourceResult, hashPayloadFields } from '../../utils/handler'
10
10
 
@@ -15,7 +15,8 @@ export default eventHandler(async (event) => {
15
15
  const table = getTableForModel(model)
16
16
 
17
17
  const body = await readBody(event)
18
- const payload = filterUpdatableFields(model, body)
18
+ const schema = getZodSchema(model, 'insert')
19
+ const payload = await schema.parseAsync(body)
19
20
 
20
21
  // Custom check for status update permission (or just remove it during creation as per requirement)
21
22
  if ('status' in payload) {
@@ -0,0 +1,86 @@
1
+ // server/api/_meta.get.ts
2
+ import { eventHandler } from 'h3'
3
+ import { getTableForModel, getAvailableModels } from '../utils/modelMapper'
4
+ import { getTableColumns as getDrizzleTableColumns } from 'drizzle-orm'
5
+ import { getTableConfig } from 'drizzle-orm/sqlite-core'
6
+ import { PROTECTED_FIELDS, HIDDEN_FIELDS } from '../utils/constants'
7
+ // @ts-expect-error - 'hub:db' is a virtual alias
8
+ import { db } from 'hub:db'
9
+ import { ensureAuthenticated } from '../utils/auth'
10
+
11
+ export default eventHandler(async (event) => {
12
+ await ensureAuthenticated(event)
13
+
14
+ const models = getAvailableModels().length > 0
15
+ ? getAvailableModels()
16
+ : Object.keys(db?.query || {})
17
+
18
+ const resources = models.map((model) => {
19
+ try {
20
+ const table = getTableForModel(model)
21
+ const columns = getDrizzleTableColumns(table)
22
+ const config = getTableConfig(table)
23
+
24
+ // Map columns to fields
25
+ const fields = Object.entries(columns)
26
+ .filter(([name]) => !PROTECTED_FIELDS.includes(name) && !HIDDEN_FIELDS.includes(name))
27
+ .map(([name, col]) => {
28
+ let references = null
29
+
30
+ // 1. Check for Foreign Keys via getTableConfig (Robust Drizzle Reflection)
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ const fk = config?.foreignKeys.find((f: any) =>
33
+ f.reference().columns[0].name === col.name,
34
+ )
35
+
36
+ if (fk) {
37
+ // @ts-expect-error - Drizzle internals
38
+ references = fk.reference().foreignTable[Symbol.for('drizzle:Name')]
39
+ }
40
+ // 2. Fallback to inline reference config if symbol lookup fails
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ else if ((col as any).referenceConfig?.foreignTable) {
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ const foreignTable = (col as any).referenceConfig.foreignTable
45
+ references = foreignTable[Symbol.for('drizzle:Name')] || foreignTable.name
46
+ }
47
+
48
+ // Semantic Normalization
49
+ const semanticType = col.columnType.toLowerCase().replace('sqlite', '')
50
+
51
+ return {
52
+ name,
53
+ type: semanticType,
54
+ required: col.notNull || false,
55
+ isEnum: !!col.enumValues,
56
+ options: col.enumValues || null,
57
+ references,
58
+ isRelation: !!references,
59
+ }
60
+ })
61
+
62
+ // 3. Implement Clifland Label Heuristic (name > title > email > id)
63
+ const fieldNames = fields.map(f => f.name)
64
+ const labelField = fieldNames.find(n => n === 'name')
65
+ || fieldNames.find(n => n === 'title')
66
+ || fieldNames.find(n => n === 'email')
67
+ || 'id'
68
+
69
+ return {
70
+ resource: model,
71
+ endpoint: `/api/${model}`,
72
+ labelField,
73
+ fields,
74
+ }
75
+ }
76
+ catch {
77
+ return null
78
+ }
79
+ }).filter(Boolean)
80
+
81
+ return {
82
+ architecture: 'Clifland-NAC',
83
+ version: '1.0.0-agentic',
84
+ resources,
85
+ }
86
+ })
@@ -0,0 +1,25 @@
1
+ export const PROTECTED_FIELDS = [
2
+ 'id',
3
+ 'createdAt', 'updatedAt', 'deletedAt',
4
+ 'createdBy', 'updatedBy', 'deletedBy',
5
+ 'created_at', 'updated_at', 'deleted_at',
6
+ 'created_by', 'updated_by', 'deleted_by',
7
+ ]
8
+
9
+ export const HIDDEN_FIELDS = [
10
+ // Sensitive Auth
11
+ 'password',
12
+ 'resetToken', 'reset_token',
13
+ 'resetExpires', 'reset_expires',
14
+ 'githubId', 'github_id',
15
+ 'googleId', 'google_id',
16
+ 'secret',
17
+ 'token',
18
+ // System Fields (Leakage prevention)
19
+ 'deletedAt',
20
+ 'createdBy',
21
+ 'updatedBy',
22
+ 'deleted_at',
23
+ 'created_by',
24
+ 'updated_by',
25
+ ]
@@ -7,53 +7,23 @@ import { getTableColumns as getDrizzleTableColumns, getTableName } from 'drizzle
7
7
  import type { SQLiteTable } from 'drizzle-orm/sqlite-core'
8
8
  import { createError } from 'h3'
9
9
  import { useRuntimeConfig } from '#imports'
10
+ import { createInsertSchema } from 'drizzle-zod'
11
+ import type { z } from 'zod'
10
12
 
11
- /**
12
- * Fields that should never be updatable via PATCH requests
13
- */
14
- const PROTECTED_FIELDS = ['id', 'created_at', 'updated_at', 'createdAt', 'updatedAt']
15
-
16
- /**
17
- * Fields that should never be returned in API responses
18
- */
19
- const HIDDEN_FIELDS = ['password', 'secret', 'token']
20
-
21
- /**
22
- * Custom updatable fields configuration (optional)
23
- * Only define here if you want to override the auto-detection
24
- *
25
- * Example:
26
- * export const customUpdatableFields: Record<string, string[]> = {
27
- * users: ['name', 'avatar'], // Only these fields can be updated
28
- * }
29
- */
30
- export const customUpdatableFields: Record<string, string[]> = {
31
- // Add custom field restrictions here if needed
32
- // By default, all fields except PROTECTED_FIELDS are updatable
33
- }
13
+ import { PROTECTED_FIELDS, HIDDEN_FIELDS } from './constants'
34
14
 
35
- /**
36
- * Custom hidden fields configuration (optional)
37
- * Only define here if you want to override the default hidden fields
38
- */
39
- export const customHiddenFields: Record<string, string[]> = {
40
- // Add custom hidden fields here if needed
41
- }
15
+ export const customUpdatableFields: Record<string, string[]> = {}
16
+ export const customHiddenFields: Record<string, string[]> = {}
42
17
 
43
18
  /**
44
- * Automatically builds a map of all exported tables from the schema
45
- * No manual configuration needed!
19
+ * Builds a map of all exported Drizzle tables from the schema.
46
20
  */
47
21
  function buildModelTableMap(): Record<string, unknown> {
48
22
  const tableMap: Record<string, unknown> = {}
49
23
 
50
- // Iterate through all exports from schema
51
24
  for (const [key, value] of Object.entries(schema)) {
52
- // Check if it's a Drizzle table
53
25
  if (value && typeof value === 'object') {
54
26
  try {
55
- // getTableName returns the table name for valid tables, and undefined/null for others (like relations)
56
- // This is a more robust check than checking for properties
57
27
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
28
  const tableName = getTableName(value as any)
59
29
  if (tableName) {
@@ -61,7 +31,7 @@ function buildModelTableMap(): Record<string, unknown> {
61
31
  }
62
32
  }
63
33
  catch {
64
- // Ignore if it throws (not a table)
34
+ // Not a table
65
35
  }
66
36
  }
67
37
  }
@@ -69,17 +39,10 @@ function buildModelTableMap(): Record<string, unknown> {
69
39
  return tableMap
70
40
  }
71
41
 
72
- /**
73
- * Auto-generated model table map
74
- * Automatically includes all tables from schema
75
- */
76
42
  export const modelTableMap = buildModelTableMap()
77
43
 
78
44
  /**
79
- * Gets the table for a given model name
80
- * @param modelName - The name of the model (e.g., 'users', 'products')
81
- * @returns The corresponding database table
82
- * @throws Error if model is not found
45
+ * @throws 404 if modelName is not found in tableMap.
83
46
  */
84
47
  export function getTableForModel(modelName: string): SQLiteTable {
85
48
  const table = modelTableMap[modelName]
@@ -95,10 +58,6 @@ export function getTableForModel(modelName: string): SQLiteTable {
95
58
  return table as SQLiteTable
96
59
  }
97
60
 
98
- /**
99
- * Auto-detects updatable fields for a table
100
- * Returns all fields except protected ones (id, createdAt, etc.)
101
- */
102
61
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
62
  function getTableColumns(table: any): string[] {
104
63
  try {
@@ -111,33 +70,20 @@ function getTableColumns(table: any): string[] {
111
70
  }
112
71
  }
113
72
 
114
- /**
115
- * Gets the updatable fields for a model
116
- * @param modelName - The name of the model
117
- * @returns Array of field names that can be updated
118
- */
119
73
  export function getUpdatableFields(modelName: string): string[] {
120
- // Check if custom fields are defined for this model
121
74
  if (customUpdatableFields[modelName]) {
122
75
  return customUpdatableFields[modelName]
123
76
  }
124
77
 
125
- // Auto-detect from table schema
126
78
  const table = modelTableMap[modelName]
127
-
128
79
  if (!table) return []
129
80
 
130
81
  const allColumns = getTableColumns(table)
131
-
132
- // Filter out protected fields
133
- return allColumns.filter(col => !PROTECTED_FIELDS.includes(col))
82
+ return allColumns.filter(col => !PROTECTED_FIELDS.includes(col) && !HIDDEN_FIELDS.includes(col))
134
83
  }
135
84
 
136
85
  /**
137
- * Filters an object to only include updatable fields for a model
138
- * @param modelName - The name of the model
139
- * @param data - The data object to filter
140
- * @returns Filtered object with only updatable fields
86
+ * Filters and coerces data for updates, handling timestamp conversion.
141
87
  */
142
88
  export function filterUpdatableFields(modelName: string, data: Record<string, unknown>): Record<string, unknown> {
143
89
  const allowedFields = getUpdatableFields(modelName)
@@ -151,7 +97,6 @@ export function filterUpdatableFields(modelName: string, data: Record<string, un
151
97
  let value = data[field]
152
98
  const column = columns[field]
153
99
 
154
- // Coerce timestamp fields to Date objects if they are strings
155
100
  if (column && column.mode === 'timestamp' && typeof value === 'string') {
156
101
  value = new Date(value)
157
102
  }
@@ -163,79 +108,43 @@ export function filterUpdatableFields(modelName: string, data: Record<string, un
163
108
  return filtered
164
109
  }
165
110
 
166
- /**
167
- * Gets the singular name for a model (for error messages)
168
- * Uses pluralize library for accurate singular/plural conversion
169
- * @param modelName - The plural model name
170
- * @returns The singular name in PascalCase
171
- */
172
111
  export function getModelSingularName(modelName: string): string {
173
112
  const singular = pluralize.singular(modelName)
174
113
  return pascalCase(singular)
175
114
  }
176
115
 
177
- /**
178
- * Gets the plural name for a model
179
- * @param modelName - The model name (singular or plural)
180
- * @returns The plural name
181
- * @return The plural name
182
- */
183
116
  export function getModelPluralName(modelName: string): string {
184
117
  return pluralize.plural(modelName)
185
118
  }
186
119
 
187
- /**
188
- * Lists all available models
189
- * @returns Array of model names
190
- */
191
120
  export function getAvailableModels(): string[] {
192
121
  return Object.keys(modelTableMap)
193
122
  }
194
123
 
195
- /**
196
- * Gets the hidden fields for a model
197
- * @param modelName - The name of the model
198
- * @returns Array of field names that should be hidden
199
- */
200
124
  export function getHiddenFields(modelName: string): string[] {
201
- // Check if custom hidden fields are defined for this model
202
- if (customHiddenFields[modelName]) {
203
- return customHiddenFields[modelName]
204
- }
205
-
206
- return HIDDEN_FIELDS
125
+ return customHiddenFields[modelName] ?? HIDDEN_FIELDS
207
126
  }
208
127
 
209
- /**
210
- * Gets the public columns for a model
211
- * @param modelName - The name of the model
212
- * @returns Array of field names that are public (or undefined if all are public)
213
- */
214
128
  export function getPublicColumns(modelName: string): string[] | undefined {
215
129
  const { resources } = useRuntimeConfig().autoCrud
216
- // Runtime config structure now matches simple key-value
217
130
  return resources?.[modelName]
218
131
  }
219
132
 
220
133
  /**
221
- * Filters an object to only include public columns (if configured)
222
- * @param modelName - The name of the model
223
- * @param data - The data object to filter
224
- * @returns Filtered object
134
+ * Restricts payload to runtimeConfig resource whitelist and filters hidden fields.
225
135
  */
226
136
  export function filterPublicColumns(modelName: string, data: Record<string, unknown>): Record<string, unknown> {
227
137
  const publicColumns = getPublicColumns(modelName)
228
138
 
229
- // If no public columns configured, return all (except hidden)
230
139
  if (!publicColumns) {
231
140
  return filterHiddenFields(modelName, data)
232
141
  }
233
142
 
234
143
  const filtered: Record<string, unknown> = {}
144
+ const hidden = getHiddenFields(modelName)
235
145
 
236
146
  for (const [key, value] of Object.entries(data)) {
237
- // Must be in publicColumns AND not in hidden fields (double safety)
238
- if (publicColumns.includes(key) && !getHiddenFields(modelName).includes(key)) {
147
+ if (publicColumns.includes(key) && !hidden.includes(key)) {
239
148
  filtered[key] = value
240
149
  }
241
150
  }
@@ -243,12 +152,6 @@ export function filterPublicColumns(modelName: string, data: Record<string, unkn
243
152
  return filtered
244
153
  }
245
154
 
246
- /**
247
- * Filters an object to exclude hidden fields
248
- * @param modelName - The name of the model
249
- * @param data - The data object to filter
250
- * @returns Filtered object without hidden fields
251
- */
252
155
  export function filterHiddenFields(modelName: string, data: Record<string, unknown>): Record<string, unknown> {
253
156
  const hiddenFields = getHiddenFields(modelName)
254
157
  const filtered: Record<string, unknown> = {}
@@ -261,3 +164,34 @@ export function filterHiddenFields(modelName: string, data: Record<string, unkno
261
164
 
262
165
  return filtered
263
166
  }
167
+
168
+ /**
169
+ * Generates Zod schema via drizzle-zod, omitting server-managed and protected fields.
170
+ */
171
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
+ export function getZodSchema(modelName: string, type: 'insert' | 'patch' = 'insert'): z.ZodObject<any, any> {
173
+ const table = getTableForModel(modelName)
174
+ const schema = createInsertSchema(table)
175
+
176
+ if (type === 'patch') {
177
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
+ return schema.partial() as z.ZodObject<any, any>
179
+ }
180
+
181
+ const OMIT_ON_CREATE = [
182
+ ...PROTECTED_FIELDS,
183
+ ...HIDDEN_FIELDS,
184
+ ]
185
+
186
+ const columns = getDrizzleTableColumns(table)
187
+ const fieldsToOmit: Record<string, true> = {}
188
+
189
+ OMIT_ON_CREATE.forEach((field) => {
190
+ if (columns[field]) {
191
+ fieldsToOmit[field] = true
192
+ }
193
+ })
194
+
195
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
196
+ return (schema as any).omit(fieldsToOmit)
197
+ }
@@ -30,6 +30,13 @@ export function drizzleTableToFields(table: any, resourceName: string) {
30
30
  })
31
31
  }
32
32
 
33
+ // Clifland Heuristic: Auto-detect the primary label for the resource
34
+ const fieldNames = fields.map(f => f.name)
35
+ const labelField = fieldNames.find(n => n === 'name')
36
+ || fieldNames.find(n => n === 'title')
37
+ || fieldNames.find(n => n === 'email')
38
+ || 'id'
39
+
33
40
  try {
34
41
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
42
  const config = getTableConfig(table as any)
@@ -57,6 +64,7 @@ export function drizzleTableToFields(table: any, resourceName: string) {
57
64
 
58
65
  return {
59
66
  resource: resourceName,
67
+ labelField, // metadata point
60
68
  fields,
61
69
  }
62
70
  }